Skip to content

fix(spec): a refusing defineStack / composeStacks carries the ADR-0087 conversions it applied on its refusal — stackConversionsOf(error) reads them - #20651

Merged
objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-20618-refusal-carries-conversions
Sep 29, 2026
Merged

objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-20618-refusal-carries-conversions

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #20618
Clause-②: no

What this lands

This is the packages/spec half of #20583 (its location 2). #20583 keeps the CLI fold in the three catch-alls; this PR does not touch packages/cli.

A defineStack call that converts an old spelling and then refuses now carries the conversions it applied on the ADR-0112 refusal it throws. The record uses the same Symbol.for('objectstack.stack.conversions') key and the same properties as the record on a built stack: a frozen ConversionNotice[], non-enumerable, non-writable, non-configurable. It is the producer's own array as it stands at the throw. There is no second conversion pass, and nothing reads the warn-once stderr line.

  • stack.zod.ts: defineStack is now a thin wrapper around its unchanged body (buildDefinedStack). The wrapper holds the appliedConversions array that the conversion pass pushes to, and its one catch stamps that array on any StackRefusalError before it rethrows the same object. composeStacks gets the same wrapper, and its record formula moves into one helper (composedConversions) shared by the return and the refusal. One rule for which throw is stamped (withRefusalConversions): members of the StackRefusalError family only. Anything else is rethrown untouched.
  • stack-provenance.ts: markRefusalConversions is the refusing half of markStackProvenance: same writer, no mark, first stamp wins. It is module-internal and not re-exported. stackConversionsOf reads the record off a marked stack, as before, or off an Error that carries it as an own property. A plain object that carries the key without the mark is still not read, and neither is a record inherited through a prototype. The module header gains a section on the refusal record, and the reader's TSDoc section "What it cannot hold" is amended: the refusing-defineStack boundary is gone, and the non-refusal-throw boundary is stated.
  • One changeset, @objectstack/spec: patch.

Each refusal keeps its code, status, name, message and issues byte-for-byte, and hasStackProvenance(error) still answers false.

How a door reads it (for #20583's CLI fold)

} catch (error) {
  conversions.push(...stackConversionsOf(error)); // readonly ConversionNotice[]
  • The return is frozen. Each element is the whole ConversionNotice (code, conversionId, surface, from, to, path, toMajor, retiresIn, message), the same element LoadedConfig.stackConversions carries. path is relative to the refusing defineStack call.
  • It answers [] for a refusal whose source needed no conversion, for a plain Error, and for any throw that is not a producer refusal (the CLI's own "throw at load" fixture is one of these).
  • The reader never uses instanceof on the refusal class. It keys on Symbol.for plus instanceof Error, so a CLI and a config that resolve two copies of this package still agree, as long as they run in one realm.
  • A door's own step-2 pass never runs on the refusal path, so folding the record cannot double-count.

Coverage: every throw between the first conversion and the return (hypothesis 1, measured by reading)

The conversion pass itself (normalizeStackInput into applyConversions) is documented never to throw. After it, defineStack reaches exactly 10 throw sites, all StackRefusalError subclasses:

  • The 7 in the strict tail:
    • the schema parse (STACK_SCHEMA_INVALID);
    • capability, cross-reference, namespace prefix, single app, hierarchy-scope capability and trigger capability.
  • The 3 in mergeActionsIntoObjects, all STACK_SCHEMA_INVALID: objects is not an array, an objects entry is not an object, and an actions shape is wrong. The merge ends both modes, so these 3 are reachable after a conversion under strict: false. This is a refusal the triage's coverage clause names ("every refusal thrown after a conversion") that sits outside the strict tail.

The wrapper's single catch covers all 10. The census in the tests drives each site with a converting page and asserts the record: 10 rows, 7 distinct codes.

No non-refusal throw is reachable by construction:

  • warnUnknownAuthoringKeys, the six validate* helpers and warnEmailTemplateLocaleFloor contain no throw, directly or through the modules they call.
  • safeParse returns its failure instead of throwing it.

The residue is a throw from inside a zod refinement or transform, or from an author's own getter or proxy. Such a throw is not a producer refusal, so it is rethrown untouched and carries no record. The TSDoc states this, and the tests pin it through composeStacks' options parse.

composeStacks: extended in place (the same defect class)

A composeStacks refusal is thrown after its inputs' defineStack calls converted, and before this change it carried nothing, which is the same loss. All four in-place conditions hold:

The guard wraps the whole body, so every throw in its call tree passes through the one catch.

  • What is stamped: 13 refusal sites: 10 in its own call tree (provenance, the concat shape, the artifact cross-reference, the action-key collision, the two mergeObjects refusals, the two function conflicts, the key conflict and the collection conflict) plus the 3 in mergeActionsIntoObjects.
  • What is not stamped: the two non-refusal throws, which are the ComposeStacksOptionsSchema.parse zod error and the internal-invariant Error.
  • Pinned: the provenance refusal (step 0), an object conflict (step 2), the empty-record control, and the options-parse non-refusal.

The claim's file-surface parenthetical reads "defineStack's strict tail". The seat may amend it to cover the strict: false merge refusals and composeStacks.

Where a refusing inner defineStack surfaces (hypothesis 3, measured)

In composeStacks([defineStack(A), defineStack(B)]), B's refusal is thrown while the array literal is being evaluated, and composeStacks never runs. The test records that only A was built. The error's record is B's own notice: exactly one, not A's object (checked by identity), and B printed 0 stderr header lines because the warn-once set already had the key.

API surface (hypothesis 2)

stackConversionsOf(value: unknown) already accepted the error, but its body returned [] for anything without the provenance mark. It is reused, with one arm added. No export is added or changed:

  • check:api-surface: exit 0.
  • check:export-origins: exit 0.
  • check:generated: all 15 artifacts up to date against a fresh build.

Hence Clause-②: no and a patch changeset.

Verification record (HEAD e6595835ae)

  • Build:
    • pnpm --filter @objectstack/spec build: exit 0.
    • After the restart: turbo run build --filter='./packages/*' --filter='./packages/*/*', 71/71 tasks successful.
  • Spec tests (--project local), in 4 shards, all passing: 144 + 144 + 144 + 143 files, 4582 + 4133 (1 todo) + 3721 + 4502 tests.
  • Spec tests (--project repo): the 8 repo-project files that reference the stack producers, 142 tests, passed.
  • Typecheck: pnpm --filter @objectstack/spec typecheck exit 0. check:test-typecheck answered OK, with the debt ledger unchanged.
  • The record test file: 39 tests, 16 pre-existing and 23 new.
  • Ablation A (committed state 9deca56296, through scripts/ablation-replace.mjs in wrap mode with a trap). It deletes the stamp call in withRefusalConversions.
    • On disk: anchor x1 to x0, blob f916adad1fe4 to 8aac6e049c3c.
    • Result: 19 failed and 20 passed. The red rows are every refusal-record row. The 16 pre-existing rows, the "otherwise the same refusal" row, the census-count row, the non-refusal row and the plain-Error row stayed green.
    • Restore: blob equals HEAD, and git diff HEAD is empty.
  • Ablation B (the same method). It removes the reader's refusal arm.
    • On disk: anchor x1 to x0, blob 89278c468c95 to ed6b5a82b5b3.
    • Result: 17 failed and 22 passed. The two stamped-empty-record pins stayed green because they read the property descriptor directly.
    • Restore proven the same way.
    • Both ablations turned red, the expected direction. The tests import src/ directly, so no dist/ was on the measured path.
  • Gates:
    • dispatch-gates --commands derived 83 commands, all exit 0.
    • --ran reconciliation: "83 derived, 83 run, 0 NOT-MEASURED, 0 UNRUN" (a derived zero).
    • Four gates first answered PREREQUISITE NOT MET (exit 3): check:doc-formula-expressions, check:dual-build-cjs-loads, check:lean-entry-closure and check:type-check-debt. All four were re-run green after the full build.
  • Lint (a proven narrowing, not the repo run):
    • eslint --no-inline-config --format json over the 3 changed .ts files reports 3 files, 0 errors and 0 warnings.
    • The population is eslint.config.mjs's **/*.{ts,…} block.
    • The config has no parserOptions.project and no typed rules, so the diff cannot move any untouched file's verdict.
    • pnpm lint over the repo is CI's.
  • Full-repo pin sweep: zero pins of the old semantics. By grep:
    • no test deep-equals a stack refusal;
    • no test reads a stack refusal's own symbol keys;
    • no test asserts that stackConversionsOf answers [] for a thrown refusal;
    • the boundary prose lived only in stack-provenance.ts.
      The CLI's "throw at load" control is a plain Error thrown before any producer, and stays [] by the new rule. Refusal assertions for illegal shapes are untouched.

Acceptance notes

  • The strict: false merge refusals and composeStacks are covered beyond the claim's "strict tail" wording. The reasons are above.
  • Not stamped, by decision: non-refusal throws.
    • Inside defineStack, none is reachable by construction.
    • Inside composeStacks, two are: the options-parse zod error (an authored-options mistake) and the internal-invariant Error.
    • A door therefore answers conversions: [] for those. Stamping arbitrary thrown values would hand a producer record to errors the producer did not construct, including frozen or primitive ones.
  • The record on a composed refusal is the whole composition's (all inputs, in input order), not only the inputs involved in the conflict. This is the same record the artifact would have carried.
  • origin/main moved after the merge (0cb72cfc72 at report time). None of its commits touch these files, so the branch was not re-merged. CI's merge ref re-verifies the combination.

Generated by Claude Code

…ions it applied on its refusal

defineStack stamps the ADR-0087 conversion record, as it stood at the throw,
on every ADR-0112 refusal it throws after its conversion pass (both modes);
composeStacks stamps its inputs' records on its refusals. Same
Symbol.for('objectstack.stack.conversions') key; stackConversionsOf reads it
off a caught refusal. No second conversion pass, no stderr capture.

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

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/spec, touching 11 documentable anchor(s).

2 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/getting-started/examples.mdx (via composeStacks (symbol, a top-level function), composeStacks (literal, a string literal in composeBuiltStacks; a string literal in composeStacks))
  • content/docs/getting-started/glossary.mdx (via composeStacks (symbol, a top-level function), composeStacks (literal, a string literal in composeBuiltStacks; a string literal in composeStacks))

⛔ 4 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17/17-3.mdx (via composeStacks (symbol, a top-level function), composeStacks (literal, a string literal in composeBuiltStacks; a string literal in composeStacks))
  • content/docs/releases/v17/17-4.mdx (via composeStacks (symbol, a top-level function), composeStacks (literal, a string literal in composeBuiltStacks; a string literal in composeStacks))
  • content/docs/releases/v17/17-5.mdx (via composeStacks (symbol, a top-level function), composeStacks (literal, a string literal in composeBuiltStacks; a string literal in composeStacks))
  • content/docs/releases/v17/index.mdx (via composeStacks (symbol, a top-level function), composeStacks (literal, a string literal in composeBuiltStacks; a string literal in composeStacks))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 1 anchor(s) matched too much of the corpus to be a work list: defineStack (symbol, 63 pages)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 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; 97 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 — 137 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 c6b37cd08d0eb622d4f59b79effdc954f86dad9f → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json c6b37cd08d0eb622d4f59b79effdc954f86dad9f

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

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs c6b37cd08d0eb622d4f59b79effdc954f86dad9f → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: e6595835ae2c97bba163d8548364c4ddffddc5be
Local-runs: none

Read-only, adversarial: the net diff origin/main...e6595835ae (4 files, +478/-32), card #20618 (body; triage 5888053426; claim 5889219571; os-dev-report 5891640607), #20583 (body; triage 5884692257), PR #20651 (body, its one bot comment, file list), and the head's check-runs read once at 2026-09-29T13:52Z. Every count below is my own reading of the head's stack.zod.ts, stack-provenance.ts and the test file, not the dev's prose.

① Derived judgments

  1. defineStack accept-set: unchanged — right. The old body moved verbatim into buildDefinedStack (the hunk touches only comment lines and hoists the appliedConversions declaration into the wrapper); the wrapper's one catch rethrows the SAME object. Every input that built before builds the same stack with the same record; every input that refused refuses with the same class, code, status, message and issues (pinned by the "otherwise the same refusal" row). Object.keys, JSON and equality readers cannot see the new own property (non-enumerable symbol), and hasStackProvenance(error) stays false (no mark is written).
  2. Coverage of the refusal record: the count is 10 and I confirm it from the throw census — right. The try wraps the whole buildDefinedStack call. The conversion pass cannot throw: normalizeStackInput (shared/metadata-collection.zod.ts:229) has no throw and delegates to applyConversions, whose contract reads "Never throws" (conversions/apply.ts:124). After it, the reachable throw statements in the file are exactly: the strict tail's 7 (stack.zod.ts 3687 schema, 3703 capability, 3718 cross-reference, 3727 namespace prefix, 3734 single app, 3741 hierarchy-scope, 3748 trigger) plus mergeActionsIntoObjects's 3 (3070, 3094, 3152, all StackSchemaInvalidError), which runs in BOTH modes (3671 under strict: false, 3766 strict). All 10 construct subclasses of the abstract StackRefusalError (2172, status = 422). The six validate* helpers, warnUnknownAuthoringKeys, warnEmailTemplateLocaleFloor, warnConversionNotice (3426) and formatZodError contain no throw; safeParse returns its failure. The residue (a non-Zod throw from inside a refinement or transform, or an author's getter or proxy) is not a refusal and is rethrown untouched by withRefusalConversions (3630), which stamps on instanceof StackRefusalError only. The census test drives every one of the 10 sites with a converting page and asserts the record; the "seven distinct codes" row keeps the census honest.
  3. The stamp is the producer's own array as it stood at the throw, with no second pass — right. withRefusalConversions(error, ...) hands the very appliedConversions array the onConversionNotice callback pushed into (3656); markRefusalConversions writes it through stampConversionRecord, the ONE writer markStackProvenance also uses (frozen copy, notices frozen in place, NO_CONVERSIONS sentinel when empty; non-enumerable, non-writable, non-configurable). Nothing on the refusal path calls normalizeStackInput a second time, and nothing reads stderr. The "applied so far" pin (a built stack handed straight back, and its spread-copy control) shows the record is carried, not reconstructed.
  4. The reader arm does not widen the published surface — Clause-②: no is right. stackConversionsOf(value: unknown) keeps its signature; the new arm isStampedRefusal requires instanceof Error AND an OWN property under the Symbol.for key, then still requires Array.isArray. A prototype-inherited record is not read (pinned: Object.create(refusal)), a plain object carrying the key without the mark is not read (pre-existing pin), a plain Error answers [] (pinned), a non-array under the key answers []. The only value the arm reads that a producer did not stamp is an Error on which some actor deliberately wrote that global-registry symbol as an own array: that key IS the module's declared cross-copy contract (the same trust the mark places in Symbol.for('objectstack.stack.provenance')), so it is the contract, not a misread. No export moves: stack.zod.ts:3776 re-exports only hasStackProvenance, stackConversionsOf; index.ts:109's export * cannot re-export an import, so markRefusalConversions (module-level export, imported at stack.zod.ts:16) is shipped bytes unreachable from the entry, not published surface; packages/spec/api-surface/*.json and api-surface-signatures.json are untouched by the diff; the defineStack and composeStacks headers are byte-identical. None of the four widening tells (new Zod key, new closed-set member, new export row, new registration) appears in the added lines.
  5. composeStacks extended in place: inside the claim, and its return is unchanged for every input — right, no split owed. The claim's file surface is stack.zod.ts under triage's clause "every refusal thrown after a conversion carries the record"; a composition refusal is thrown after its inputs' conversions and, before this diff, lost them by the same mechanism. On the code: composedConversions is the byte-same formula the return used ([...new Set(stacks.flatMap(stackConversionsOf))]), and on the return path every input already passed step 0 (marked), so the reader takes the mark arm exactly as before; stacks.length === 0 and === 1 short-circuits are untouched. The try wraps the whole composeBuiltStacks call, and I count its refusal sites as 13: 4 direct (5210 provenance, 5268 concat shape, 5320 artifact cross-reference, 5377 action-key collision) + mergeObjects 2 (4492, 4520; called 5240) + composeFunctions 2 (4001, 4018; called 5324) + composeSingleValue 1 (3948; called 5365) + refuseUnmergeableCollections 1 (4411; called from mergeObjects 4536) + mergeActionsIntoObjects 3 (called 5388). The two non-refusal throws — ComposeStacksOptionsSchema.parse (5224) and the internal-invariant Error (4643) — are rethrown untouched (the options parse is pinned). The lazy composedConversions(stacks) uses the same flatMap step 0 already used on the same value, so the catch cannot itself throw for any input that reached a refusal. Four pins cover step 0, step 2, the empty-record control and the non-refusal. The claim's parenthetical ("defineStack's strict tail") is narrower than triage's clause; the seat should amend the wording in its own records, not the diff.
  6. Hypothesis 3 — B's refusal carries exactly B's own notice — right, and the pin is the right shape. In composeStacks([defineStack(A), defineStack(B)]) the array literal evaluates its elements before the call, so B's defineStack throws inside ITS wrapper with ITS fresh appliedConversions (a new array per call, seeded from the input's own record), and composeStacks never runs. The push at 3656 precedes warnConversionNotice at 3657 unconditionally, so the warn-once set (3427–3429) suppresses B's stderr line and nothing else. The pin asserts one notice, identity-distinct from A's, built of length 1, and zero header lines from B.
  7. "How a door reads it" is sound for [finding] the conversions defineStack applies still miss two door paths after PR #20579: os lint --json never reads the record, and a defineStack that converts then refuses drops them from --json #20583's fold — right. At the head, validate.ts declares conversionNotices above its try (194), and loadConfig (220) precedes both the 1b fold (238) and step 2's own pass (255); a refusal thrown while the config module evaluates reaches the catch-all (902) before either ran, so conversions.push(...stackConversionsOf(error)) adds only the producer's record — no double count. The reader keys on Symbol.for plus instanceof Error, never the refusal class, so two package copies in one realm agree. One boundary for [finding] the conversions defineStack applies still miss two door paths after PR #20579: os lint --json never reads the record, and a defineStack that converts then refuses drops them from --json #20583's door pin, outside this diff: the config loader must deliver the SAME error object to the catch-all; a loader that wrapped the throw would drop the own property, and only the door's pin can measure that.
  8. Defensive guards in markRefusalConversions (first stamp wins; non-extensible error returned as-is) — right, and unreachable by the two producers today (neither producer body calls a producer), so they cannot change any refusal's record.

② Semver level

@objectstack/spec: patch, PR body and changeset both Clause-②: no; no skip-changeset (the labels are documentation, size/l, tests, tooling); Check Changeset concluded success on this head. Right: the card is graded bug (triage 5888053426), and a bug fix in a released package takes patch; the accept-set of authored metadata does not move (①1, ①5), no export is added (①4), and the one behavioural extension is a reader answering the record off a value its unknown parameter already accepted — triage's and the claim's own reading ("if the existing reader is reused, the api surface does not move"). The changeset body states what a consumer reads on a caught refusal and that nothing is accepted or refused differently, which is the migration text a --json door author needs. Not minor: no sibling reader was exported.

Clause-②: no

③ Boundary flags

  • Dev deviation (a), strict: false merge refusals covered beyond "strict tail": answered — inside triage's clause; ①2.
  • Dev deviation (b), composeStacks extended in place; split offered: answered — inside the claim and the clause, return unchanged for every input, no new gate family; no split; the seat amends the claim's parenthetical in its own records (①5).
  • Dev deviation: origin/main moved to 0cb72cfc72 after the merge, branch not re-merged: answered — the three-dot diff holds 4 files; the head's check-runs run on the merge ref (81bd3a64d0 per the drift bot), which re-verifies the combination.
  • Dev deviation: container restart; record-file run and both ablations at 9deca56296: answered — I confirm from git that the four PR files are byte-identical between 9deca56296 and the head (the merge commit changes only origin/main's own files). The ablation results themselves are the dev's measurements; this record judges the code, and ①2–①5 stand without them.
  • Dev deviations: lock queue-timeouts; commit trailers in AGENTS.md's model-free pair; worktree cleanup after the report; zero label writes: answered — process notes; trailers read Claude-Session plus Co-authored-by: Claude on all three commits; nothing bears on the diff.
  • open_questions: none declared. No hidden one found.
  • out_of_scope_findings 1 ([finding] the conversions defineStack applies still miss two door paths after PR #20579: os lint --json never reads the record, and a defineStack that converts then refuses drops them from --json #20583 keeps the CLI fold): answered — right; nothing under packages/cli is in the diff.
  • Reviewer's own flag, for [finding] the conversions defineStack applies still miss two door paths after PR #20579: os lint --json never reads the record, and a defineStack that converts then refuses drops them from --json #20583's pin, not this PR: the loader-identity boundary in ①7.
  • Gate coverage on this head, read once at 2026-09-29T13:52Z — 32 check-runs, 0 failure. Concluded success (13): Check Changeset, Type Check · source gates, Spec property liveness, Governed Surface Queue Guard, The card this PR closes must claim this branch, No other open PR may claim the same issue, No other open PR may claim the same single-writer path, Part-of PR must not also close its card, Check PR Size, Check Documentation Links, Flag docs affected by code changes, Auto Label, filter. Skipped (3): Build Docs, Console Pin Gate, Packed-tarball smoke (opt-in). Not concluded at read time (16), named rather than presumed: Lint & Repo Gates (carries check:api-surface, check:export-origins, check:generated, check:adr-0087-registration — the derived family that answers ①4 mechanically), Test Core (1/6)–(6/6) (the spec test family, the record test file included), Type Check · workspace, Type Check · consumer gates, Type Check · debt ledger, Build Core, Dogfood Regression Gate (1/3)–(3/3), Dogfood Verify CLI, Temporal Conformance (live PG + MySQL). The verdict below is on the diff; landing still waits for every check to conclude green, per the Tier S rule. No governed surface is touched (file list: .changeset/, packages/spec/src/ x3).

Implemented-by: claude/issue-20618-refusal-carries-conversions
Reviewed-by: session_014EJ1ED8X4MMrT18BhVx4tx

VERDICT: PASS


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/l tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

spec(stack): a refusing defineStack carries the conversions it applied on its StackRefusalError, so the doors can report them (the spec half of #20583)

2 participants