Skip to content

fix(spec): composeStacks refuses a non-array objects with an ADR-0112 envelope - #19783

Merged
objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-18239-merge-objects-refusal
Sep 23, 2026
Merged

objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-18239-merge-objects-refusal

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #18239

Clause-②: no (narrowing)

Implements ruling 5690859601 (batch #139 item 2, letter B): composeStacks step 2 (mergeObjects) refuses a stack whose objects is not an array, with an ADR-0112 envelope. It no longer raises a bare TypeError, and it no longer skips the stack. A non-object entry inside an array objects is skipped and reported through warnMalformedCollectionKey, the way step 3 handles a malformed collection.

What changed

packages/spec/src/stack.zod.ts, three hunks, none of them inside the regions open PR #19666 edits (ObjectStackDefinitionSchema at ~1408, ComposeStacksOptionsSchema, preservePackageEntries, the composeStacks docblock and body). I read that PR's hunk list before editing.

  1. mergeObjects, the ruled change. If objects is undefined, the key is absent and the stack is skipped as before. Any other non-array value throws StackSchemaInvalidError: code: 'STACK_SCHEMA_INVALID', status: 422. issues carries one real zod issue (z.array(z.unknown()).safeParse of the value, with path prefixed ['objects'], so code: 'invalid_type' and expected: 'array'). The message starts composeStacks validation failed:, names the stack by manifest id and position, and names the key 'objects'. A non-object entry is skipped and reported through warnMalformedCollectionKey('objects', 'entry').
  2. warnMalformedCollectionKey gains an optional shape argument. The default 'value' path is unchanged byte for byte. The new 'entry' path prints an accurate sentence ("an entry in it that is not an object"), because the existing sentence says "a non-array value", which is false for an entry. The two shapes are deduplicated separately.
  3. collectObjectNames skips a non-object entry. Without this, step 3b (collectArtifactCrossReferenceErrors) reads the raw input's objects and still raises TypeError: Cannot read properties of null (reading 'name') on an entry that step 2 had just skipped. The ruling's entry half cannot hold end to end without it. This is one line outside the mergeObjects body that the claim fenced: same file, same defect class, not in feat(spec)!: a multi-package artifact carries its metadata once, in packages[] (#14512) #19666's hunks.

Why STACK_SCHEMA_INVALID and not a new code. The ruling asks for a closed error.code. The strict defineStack parse already refuses this exact authored mistake with STACK_SCHEMA_INVALID (a number in objects raises it; the new test pins that). Reusing it gives one code for one defect, whichever door catches it. The header names the pass and the code names the rule, the same split STACK_CROSS_REFERENCE_INVALID already makes across its defineStack and composeStacks raise sites. It also adds no ledger row and no ErrorCode member, which keeps the claim's Clause-②: no true: the public face does not grow, only the accept set narrows. A STACK_COMPOSE_* spelling would also be wrong by that family's own docblock, which reserves it for disagreements between stacks.

Red before the guard, green after

The fixture is packages/spec/src/compose-stacks-objects-shape-refusal.test.ts (25 cases). The subject is imported from ./stack.zod (source, not dist), so the ablation needs no rebuild.

  • Red, on the base code (test commit 22c2465f before the fix existed): Tests 23 failed | 2 passed (25).
  • Green, after the fix (862bc7bb): Tests 25 passed (25).
  • Ablation on the committed fix, through node scripts/ablation-replace.mjs. It swapped the anchor if (!Array.isArray(declared)) { for the base's if (!declared) continue; behaviour (anchor 1 to 0, blob 66d0db3fe4cc to 486f0a54245a). Result: Tests 21 failed | 4 passed (25). The 21 are exactly the per-row refusal cases. The tool then restored the file: blob equals HEAD 66d0db3fe4cc, git diff HEAD empty.

What the same composition (a well-formed stack plus a second stack whose objects is X) did at the ablated state compared with the fix, from a direct probe:

X before after
a map, or 5 TypeError ("… is not iterable"), code and status undefined STACK_SCHEMA_INVALID, 422
null, '', 0, false ACCEPTED, objects = ["a_item"] (the stack's objects silently absent) STACK_SCHEMA_INVALID, 422
a Set of objects ACCEPTED, composed as if it were an array STACK_SCHEMA_INVALID, 422

The strict: false path is exercised: four rows go through defineStack(config, { strict: false }).

The ruling's measurement: does skipping a malformed permissions / data change the composed artifact's content?

Yes, for a non-array value; no, for a non-object entry. Probed at HEAD. Stack A has permissions: [{ name: 'pa' }], and stack B has a hand-built non-array permissions: { name: 'pb', … }. The result is composed.permissions = ["pa"]: B's grant is absent from the artifact, and only the #5005 warning mentions it. data behaves the same way (composed.data = ["a_item"], B's dataset absent). The loss happens in step 3, the concat pass (CONCAT_ARRAY_FIELDS), not in step 3b's collectors: #18212's skip there only affects validation. A non-object entry (permissions: [null, {…}]) is carried into the artifact as is ([null, {"name":"pb",…}]), so its content is unchanged. The ruling says this answer goes back to the card for the spec lane to file C. I have not widened anything here.

Verification

Run at HEAD 927ea9bfa6. That commit only adds the changeset on top of 862bc7bb, so no source changed after the test runs. Heavy runs went through scripts/pm/os-verify-lock.sh, and each result below is its VERDICT command-exit line.

  • pnpm --filter @objectstack/spec test + typecheck (which includes check:test-typecheck over the test layer): exit 0. Test Files 516 passed | 1 skipped (517), Tests 15079 passed | 1 skipped | 1 todo, and check:test-typecheck: OK — 53 file(s) / 257 error(s) / 142 pinned signature(s) held. The new test file compiles with no new debt.
  • pnpm --filter @objectstack/spec build, then check:generated: exit 0, All 15 generated artifacts are up to date.
  • node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derived 82 commands. All were run and exit codes recorded before any pipe. --ran reconciliation: 82 derived famil(ies) accounted for — 80 run, 2 NOT-MEASURED.
    • NOT MEASURED: check:dual-build-cjs-loads and check:type-check-debt. Both exit 3 (PREREQUISITE NOT MET) because they need every workspace package's dist, which means a full pnpm build. That is left to CI.
    • Three gates first exited 3 on a prerequisite and are green after I supplied it: check-plugin-teardown-shape --self-test (fetched its pinned control commit), check:doc-formula-expressions (built formula and lint), check:lean-entry-closure (built objectql).
  • check-adr-0087-registration: the changeset is detected as [BREAKING+clause-②-narrowing] with disposition not-required (no-migration-prescription).
  • Lint: targeted, not a proven narrowing. eslint --no-inline-config --format json on the 2 changed .ts files gives files 2, errors 0, warnings 0. eslint.config.mjs enables no type-aware linting (no parserOptions.project), so this diff cannot change any verdict on an untouched file. The repo-wide pnpm lint is CI's.

Changeset

.changeset/18239-merge-objects-refusal.md: minor, with the BREAKING banner (a public root export now refuses a class of input), a before/after table, the Clause-②: no (narrowing) line, and the ADR-0087 disposition not-required (no-migration-prescription). No authorable key, export or stored shape moves, and the strict parse already refused every input this refuses.

Acceptance notes

  • Boundary, unchanged: composeStacks([oneStack]) returns stacks[0] untouched, so a single input is never refused here. This is the same declared boundary the artifact cross-reference pass states.
  • Observation, not filed: defineStack(config, { strict: false }) with objects set to a Set returns without throwing. The map-form normalizer reads the Set as a map with no keys. Contrived, and no author writes it.
  • Finding (reproducible, reported on the card for the seat to file): the strict: false door crashes before composition is reached, in mergeActionsIntoObjects. defineStack({ …, objects: 5 }, { strict: false }) gives TypeError: config.objects.map is not a function. objects: [null, …] gives TypeError: Cannot read properties of null (reading 'actions'). Both have code and status undefined. It is the same family, but a different function and a different door, so it is out of this card's scope.

Generated by Claude Code

… envelope

mergeObjects iterated stack.objects unguarded: a truthy non-iterable raised
a bare TypeError, a falsy non-array was skipped in silence, and a non-array
iterable composed as if it were an array. It now refuses every non-array
objects with STACK_SCHEMA_INVALID (status 422, the zod issue on issues with
path objects), the code the strict parse raises for the same defect. A
non-object entry inside an array objects is skipped and reported through the
shared malformed-collection warning, and the artifact pass's object-name
collector skips it too instead of dereferencing it.

Claude-Session: https://claude.ai/code/session_01VWsFyWDp8Rjb2Ma6a3Cyo8
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Sep 23, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

3 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

What this run could not see
  • the SDK route bridge reached 60 of 215 client-bound route-ledger rows — the other 155 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 155: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 100 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 136 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 43e17b89053495cd30a7a2e86809e6772dcb64c4 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from aa11e97468317d2b337213ace2dc9eab7555bebc — the merge of head 927ea9bfa6a7f9780acb2327bf7bcc638c50b8c1 into base 43e17b89053495cd30a7a2e86809e6772dcb64c4, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin aa11e97468317d2b337213ace2dc9eab7555bebc && git checkout aa11e97468317d2b337213ace2dc9eab7555bebc
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 43e17b89053495cd30a7a2e86809e6772dcb64c4 927ea9bfa6a7f9780acb2327bf7bcc638c50b8c1 && git checkout -B drift-repro 43e17b89053495cd30a7a2e86809e6772dcb64c4 && git merge --no-ff 927ea9bfa6a7f9780acb2327bf7bcc638c50b8c1

node scripts/docs-audit/affected-docs.mjs --json 43e17b89053495cd30a7a2e86809e6772dcb64c4

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 927ea9bfa6a7f9780acb2327bf7bcc638c50b8c1

① Derived judgments

  • Non-array objects at mergeObjects — undefined passes (key absent, as before); every other non-array throws StackSchemaInvalidError (STACK_SCHEMA_INVALID, 422, one zod issue at path ['objects'], message names stack and key). Matches ruling B (5690859601): refuse loudly with an ADR-0112 envelope, ⛔ not a bare TypeError, ⛔ not a skip.
  • Refusing null / '' / 0 / false — RIGHT: base if (!stack.objects) continue; skipped them silently, the concealment shape the ruling forbids; the strict parse already refuses them; strict: false passes null through, so the population is real (4 strict: false fixture rows).
  • Set — previously composed as an array; now refused. The ruling's predicate is 「not an array」 ⇒ ruled behaviour.
  • Non-object entry in an array objects — skip + warnMalformedCollectionKey('objects','entry'): step 3's shape, as ruled.
  • permissions / data and the step-3 concat pass — untouched (no hunk).
  • Code reuse STACK_SCHEMA_INVALID — registered (error-code-ledger.zod.ts:1308, @objectstack/spec); no new literal, no ledger row, no export ⇒ Clause-②: no holds. Precedent: STACK_CROSS_REFERENCE_INVALID raised from both doors in the same file.
  • warnMalformedCollectionKey(key, shape = 'value') — default path behaviour-identical (same dedup key, same text); existing callers pass one argument.
  • collectObjectNames skip — necessary: step 3b reads raw inputs and would otherwise crash on the entry one step later.
  • Accept-set widening — NONE; every change narrows or is identical.
  • Red/green independently reproduced in an isolated worktree: pre-fix commit 22c2465f3b ⇒ 23 failed / 2 passed (25); head ⇒ this fixture + compose-stacks-refusal-envelopes.test.ts + stack-artifact-crossref.test.ts 85 passed.

② Semver level

@objectstack/spec: minor + BREAKING banner + Clause-②: no (narrowing) + adr-0087: not-required (no-migration-prescription) — exactly what the ruling and AGENTS.md demand; Check Changeset green. CHANGELOG prose checked claim by claim against the code: before/after table, 「no export changes」, 「error class stays module-local」, the adr-0087 line — all TRUE.

③ Boundary flags

Implemented-by: claude/issue-18239-merge-objects-refusal
Reviewed-by: session_01VWsFyWDp8Rjb2Ma6a3Cyo8

VERDICT: PASS

Rendered by an isolated at-tier review subagent (fed the card, the ruling, the PR and the head's check-runs — not the dispatch order), adopted by domain:spec seat 4 after re-reading two of its readings on the head. Checks at review time: 0 failures, 16 in progress — landing waits for every check green.


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review September 23, 2026 04:33
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Sep 23, 2026
Merged via the queue into main with commit 55095cc Sep 23, 2026
37 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-18239-merge-objects-refusal branch September 23, 2026 04:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

1 participant