Skip to content

fix(spec): defineStack strict:false refuses a non-array objects with an ADR-0112 envelope - #19798

Merged
objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/issue-19785-merge-actions-shape-refusal
Sep 23, 2026
Merged

objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/issue-19785-merge-actions-shape-refusal

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Sep 23, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #19785

Clause-②: no (narrowing)

defineStack(config, { strict: false }) now refuses a non-array objects, and an objects array holding a non-object entry, with the ADR-0112 envelope: StackSchemaInvalidError, code: 'STACK_SCHEMA_INVALID', status: 422, and the zod issue at path: ['objects'] (non-array) or one per entry at path: ['objects', index] with expected: 'object' (non-object entry). Before, a number or a string raised a bare TypeError: config.objects.map is not a function from mergeActionsIntoObjects, and a null entry raised one reading actions off it. No new error code; the error class stays module-local.

Refuse or skip: the card's first question

Refuse. Here is what DefineStackOptions.strict declares for false: "validation is skipped for maximum flexibility (e.g., when views reference objects provided by other plugins). Use this ONLY when you need to bypass validation for advanced use cases." It promises that validation is skipped: cross-references and schema detail. It does not promise to accept, or quietly skip, a shape the action merge cannot read. No ADR is cited there. So this is not a contract-direction question, and the #18239 ruling (5690859601) applies one door earlier: 「an ADR-0112 envelope error (closed error.code, status), ⛔ not a bare TypeError and ⛔ not a skip」.

What changed

packages/spec/src/stack.zod.ts, inside mergeActionsIntoObjects only:

  1. Array guard. Any objects that is present and not an array is refused. undefined is the only non-array that is not malformed, because the key is absent. This is the line fix(spec): composeStacks refuses a non-array objects with an ADR-0112 envelope #19783 drew for mergeObjects. It includes the falsy values null, '', 0 and false, which used to be handed on untouched and then refused one call later by composeStacks with this same code. Refusing 5 while passing 0 would repeat the silent-skip row fix(spec): composeStacks refuses a non-array objects with an ADR-0112 envelope #19783 removed. The map form ({ name: { ... } }) is normalized to an array before the merge and is still accepted.
  2. Non-object entries (rework round 1). An entry that is not an object (null, 7, a string, an array) is refused with the same envelope: one zod issue per such entry at ['objects', index], expected: 'object' (read from z.array(z.looseObject({})), re-rooted at objects, which is the strict parse's own answer for the shape). The message names each position. Round 0 handed such an entry on untouched. The contract review showed that this widens the accept set under a (narrowing) declaration: the card's own [null, obj] repro threw before and would have succeeded. It would also let the next consumer, plugin-object registration in packages/objectql/src/engine.ts (one loop in one warn-level catch), silently drop every object after the bad entry. Now every row narrows.

Measured on the base 3f9e2eaa1c, against the built dist:

objects under strict: false before after
5, 'abc' TypeError: config.objects.map is not a function, code/status undefined STACK_SCHEMA_INVALID, 422
null, '', 0, false returned untouched STACK_SCHEMA_INVALID, 422
[null, obj] TypeError: Cannot read properties of null (reading 'actions') STACK_SCHEMA_INVALID, 422, issue at ['objects', 0]
[obj, 7] returned with the entry in place STACK_SCHEMA_INVALID, 422, issue at ['objects', 1]
map form, array, absent accepted accepted (unchanged)

Tests

  • New file: packages/spec/src/define-stack-non-strict-objects-shape-refusal.test.ts. It has 6 non-array refusal rows asserting code + status + the issue (path, invalid_type, expected: 'array'). Two entry refusal rows cover [null, obj, 7], with issues exactly at ['objects', 0] and ['objects', 2], all invalid_type / expected: 'object', plus [null, obj]. Controls: array, map form and absent objects are all accepted with the bound action merged, and the strict door raises the same code for the same shape.
  • packages/spec/src/compose-stacks-objects-shape-refusal.test.ts (fix(spec): composeStacks refuses a non-array objects with an ADR-0112 envelope #19783's file): its four falsy rows reached composition through strict: false. That door now refuses those shapes itself, so the rows now reach composition as hand-built stacks. They keep the same values and the same assertions. This is a fixture re-route, and it is outside the claim's declared file surface. I am declaring it here; the contract review accepted it.
  • Ablation, round 0 (commit d3959b7b79): stack.zod.ts restored from the base, with the new file run against it: Tests 7 failed | 4 passed (11). The refusal rows failed with expected undefined to be 'STACK_SCHEMA_INVALID' and the entry row with a bare TypeError. Restored, git diff HEAD empty.
  • Ablation, round 1 (commit c5a865c769): scripts/ablation-replace.mjs replaced the entry guard's condition. The anchor went x1 to x0 and the blob 88f36c9846 became ac5fbf58be. Result: Tests 2 failed | 10 passed (12). Both entry rows failed with expected undefined to be 'STACK_SCHEMA_INVALID' (a bare TypeError reached the assertion), and every other row stayed green. Restore: blob back to the HEAD blob 88f36c9846, git diff HEAD empty.
  • At c5a865c769 (merge of origin/main fae870352e):
    • pnpm --filter @objectstack/spec build + typecheck: exit 0. The test layer compiles, and test-typecheck-debt.json held.
    • The two targeted files: 37/37.
    • Full spec suite: Test Files 552 passed (552), Tests 15690 passed | 1 todo.
    • eslint --no-inline-config over the 3 touched TS files: 3 files, 0 errors, 0 warnings. Type-aware linting is not enabled in eslint.config.mjs, so this diff cannot move the verdict on any untouched file.
  • dispatch-gates --commands derived 82 commands (the same list as round 0). 80 exit 0. 2 are NOT MEASURED with exit 3 (they need the whole-workspace build): check:dual-build-cjs-loads and check:type-check-debt. --ran reconcile: 82 accounted, 0 UNRUN.
  • Round 1b, at ce893bbf31 (merge of origin/main 0b83e01627, which includes fix(spec): composeStacks refuses a non-array concatenated collection instead of dropping its content #19794, then the helper routing):

Acceptance notes


Generated by Claude Code

…the ADR-0112 envelope

mergeActionsIntoObjects now refuses every non-array `objects` except an
absent one with StackSchemaInvalidError (STACK_SCHEMA_INVALID, 422, zod
issue at ['objects']) instead of raising a bare TypeError from `.map`,
and hands a non-object entry on untouched 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

github-actions Bot commented Sep 23, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

1 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 0b83e01627dbf2dce05cb38148fecd3cff430696 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from c0088ebf985296094a91c552e1484b708f76473d — the merge of head ce893bbf31e7068187fb1b0ad4677f2d20771dfe into base 0b83e01627dbf2dce05cb38148fecd3cff430696, 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 c0088ebf985296094a91c552e1484b708f76473d && git checkout c0088ebf985296094a91c552e1484b708f76473d
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 0b83e01627dbf2dce05cb38148fecd3cff430696 ce893bbf31e7068187fb1b0ad4677f2d20771dfe && git checkout -B drift-repro 0b83e01627dbf2dce05cb38148fecd3cff430696 && git merge --no-ff ce893bbf31e7068187fb1b0ad4677f2d20771dfe

node scripts/docs-audit/affected-docs.mjs --json 0b83e01627dbf2dce05cb38148fecd3cff430696

⚠️ 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: 5c5d1db68672c2e0ee6c0af1d5ed543040dc24b6

① Derived judgments

  • Triage question (refuse vs skip) — the dev's answer is RIGHT: DefineStackOptions.strict says for false only that 「validation is skipped」 (cross-references its example), cites no ADR, promises no acceptance or skip of a shape the merge cannot read ⇒ refusing is not a contract direction.
  • Top-level objects guard in mergeActionsIntoObjects — RIGHT: every present non-array (5, 'abc', null, '', 0, false) refused with STACK_SCHEMA_INVALID / 422, issue at ['objects']; undefined passes; no new code, no export; pure narrowing for these rows.
  • Non-object ENTRIES — WRONG. if (!isRecord(obj as unknown)) return obj; (head :3007) turns the card's own third repro (objects: [null, obj] under strict: false, a throw before) into a silent success:
    • an accept-set widening at a public root export, while the PR and changeset declare Clause-②: no (narrowing);
    • inconsistent with the PR's own principle (the merge cannot read null.actions) and with the strict parse, which refuses [null] at ['objects', 0];
    • not ruling B's entry shape either (skip + warn, as fix(spec): composeStacks refuses a non-array objects with an ADR-0112 envelope #19783 did in mergeObjects) — here it is neither refused, skipped nor warned;
    • the returned value breaks the declared return type (objects[0] pinned toBeNull());
    • next consumer, traced and re-read by the seat: packages/objectql/src/engine.ts (~:5711) registers plugin.objects in one loop inside one try … catch → logger.warn('Failed to register plugin objects') — a null entry throws on the first property read and every object AFTER it in that stack is silently not registered: 「success with a stack's objects silently missing」, the shape ruling 5690859601 forbids, relocated to plugin load.
  • fix(spec): composeStacks refuses a non-array objects with an ADR-0112 envelope #19783's coverage stays intact: the four re-routed rows keep values and assertions; strict: false is still exercised in that file (control + absent rows).

② Semver level

minor + BREAKING + ADR-0087 marker — right for the top-level refusal. The changeset's third row documents the widening under a (narrowing) declaration ⇒ inconsistent until ① is fixed.

③ Boundary flags

Implemented-by: claude/issue-19785-merge-actions-shape-refusal
Reviewed-by: session_01VWsFyWDp8Rjb2Ma6a3Cyo8

VERDICT: FAIL

Minimal fixes: (1) refuse a non-object entry with the same envelope (STACK_SCHEMA_INVALID, 422, issue at ['objects', index], expected: 'object'); (2) flip the new test's entry row to assert that refusal (code / status / path), keeping the [obj] control; (3) correct the changeset's third row and the PR body paragraph — then Clause-②: no (narrowing) is true for every row. (4, non-blocking) route the kind/issues block through #19794's helper once it lands.

Rendered by an isolated at-tier review subagent, adopted by domain:spec seat 4 after re-reading the :3007 line and the engine registration loop. Checks at review time: 13 success, 3 skips, 16 in progress, 0 failed.


Generated by Claude Code

…y too

A non-object entry in `objects` is now refused with the same envelope
(STACK_SCHEMA_INVALID, 422, one zod issue per entry at ['objects', index],
expected 'object') instead of being handed on untouched, which widened the
accept set and let plugin-object registration drop every later object.

Claude-Session: https://claude.ai/code/session_01VWsFyWDp8Rjb2Ma6a3Cyo8
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: ce893bbf31e7068187fb1b0ad4677f2d20771dfe

① Derived judgments

② Semver level

minor + BREAKING + adr-0087: not-required (no-migration-prescription) — matches ruling 5690859601; Clause-②: no (narrowing) holds for every row.

③ Boundary flags

Implemented-by: claude/issue-19785-merge-actions-shape-refusal
Reviewed-by: session_01VWsFyWDp8Rjb2Ma6a3Cyo8

VERDICT: PASS

Round-1 re-review by an isolated at-tier subagent, adopted by domain:spec seat 4 after re-reading the two fix sites on the head. Checks at review time: 37 success, 5 skips, 0 failed.


Generated by Claude Code

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