fix(spec): a refusing defineStack / composeStacks carries the ADR-0087 conversions it applied on its refusal — stackConversionsOf(error) reads them - #20651
Conversation
…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>
Claude-Session: https://claude.ai/code/session_014EJ1ED8X4MMrT18BhVx4tx Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014EJ1ED8X4MMrT18BhVx4tx Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 2 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 4 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 137 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # 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
|
Contract reviewServed-tier: Read-only, adversarial: the net diff ① Derived judgments
② Semver level
Clause-②: no ③ Boundary flags
Implemented-by: VERDICT: PASS Generated by Claude Code |
Fixes #20618
Clause-②: no
What this lands
This is the
packages/spechalf of #20583 (its location 2). #20583 keeps the CLI fold in the three catch-alls; this PR does not touchpackages/cli.A
defineStackcall 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 sameSymbol.for('objectstack.stack.conversions')key and the same properties as the record on a built stack: a frozenConversionNotice[], 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:defineStackis now a thin wrapper around its unchanged body (buildDefinedStack). The wrapper holds theappliedConversionsarray that the conversion pass pushes to, and its onecatchstamps that array on anyStackRefusalErrorbefore it rethrows the same object.composeStacksgets 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 theStackRefusalErrorfamily only. Anything else is rethrown untouched.stack-provenance.ts:markRefusalConversionsis the refusing half ofmarkStackProvenance: same writer, no mark, first stamp wins. It is module-internal and not re-exported.stackConversionsOfreads the record off a marked stack, as before, or off anErrorthat 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-defineStackboundary is gone, and the non-refusal-throw boundary is stated.@objectstack/spec: patch.Each refusal keeps its
code,status,name, message andissuesbyte-for-byte, andhasStackProvenance(error)still answersfalse.How a door reads it (for #20583's CLI fold)
ConversionNotice(code,conversionId,surface,from,to,path,toMajor,retiresIn,message), the same elementLoadedConfig.stackConversionscarries.pathis relative to the refusingdefineStackcall.[]for a refusal whose source needed no conversion, for a plainError, and for any throw that is not a producer refusal (the CLI's own "throw at load" fixture is one of these).instanceofon the refusal class. It keys onSymbol.forplusinstanceof Error, so a CLI and a config that resolve two copies of this package still agree, as long as they run in one realm.Coverage: every throw between the first conversion and the return (hypothesis 1, measured by reading)
The conversion pass itself (
normalizeStackInputintoapplyConversions) is documented never to throw. After it,defineStackreaches exactly 10 throw sites, allStackRefusalErrorsubclasses:STACK_SCHEMA_INVALID);mergeActionsIntoObjects, allSTACK_SCHEMA_INVALID:objectsis not an array, anobjectsentry is not an object, and anactionsshape is wrong. The merge ends both modes, so these 3 are reachable after a conversion understrict: 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
catchcovers 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 sixvalidate*helpers andwarnEmailTemplateLocaleFloorcontain nothrow, directly or through the modules they call.safeParsereturns 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
composeStacksrefusal is thrown after its inputs'defineStackcalls 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.mergeObjectsrefusals, the two function conflicts, the key conflict and the collection conflict) plus the 3 inmergeActionsIntoObjects.ComposeStacksOptionsSchema.parsezod error and the internal-invariantError.The claim's file-surface parenthetical reads "defineStack's strict tail". The seat may amend it to cover the
strict: falsemerge refusals andcomposeStacks.Where a refusing inner
defineStacksurfaces (hypothesis 3, measured)In
composeStacks([defineStack(A), defineStack(B)]), B's refusal is thrown while the array literal is being evaluated, andcomposeStacksnever 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-②: noand apatchchangeset.Verification record (HEAD
e6595835ae)pnpm --filter @objectstack/spec build: exit 0.turbo run build --filter='./packages/*' --filter='./packages/*/*', 71/71 tasks successful.--project local), in 4 shards, all passing: 144 + 144 + 144 + 143 files, 4582 + 4133 (1 todo) + 3721 + 4502 tests.--project repo): the 8 repo-project files that reference the stack producers, 142 tests, passed.pnpm --filter @objectstack/spec typecheckexit 0.check:test-typecheckanswered OK, with the debt ledger unchanged.9deca56296, throughscripts/ablation-replace.mjsin wrap mode with a trap). It deletes the stamp call inwithRefusalConversions.f916adad1fe4to8aac6e049c3c.Errorrow stayed green.git diff HEADis empty.89278c468c95toed6b5a82b5b3.src/directly, so nodist/was on the measured path.dispatch-gates --commandsderived 83 commands, all exit 0.--ranreconciliation: "83 derived, 83 run, 0 NOT-MEASURED, 0 UNRUN" (a derived zero).check:doc-formula-expressions,check:dual-build-cjs-loads,check:lean-entry-closureandcheck:type-check-debt. All four were re-run green after the full build.eslint --no-inline-config --format jsonover the 3 changed.tsfiles reports 3 files, 0 errors and 0 warnings.eslint.config.mjs's**/*.{ts,…}block.parserOptions.projectand no typed rules, so the diff cannot move any untouched file's verdict.pnpm lintover the repo is CI's.stackConversionsOfanswers[]for a thrown refusal;stack-provenance.ts.The CLI's "throw at load" control is a plain
Errorthrown before any producer, and stays[]by the new rule. Refusal assertions for illegal shapes are untouched.Acceptance notes
strict: falsemerge refusals andcomposeStacksare covered beyond the claim's "strict tail" wording. The reasons are above.defineStack, none is reachable by construction.composeStacks, two are: the options-parse zod error (an authored-options mistake) and the internal-invariantError.conversions: []for those. Stamping arbitrary thrown values would hand a producer record to errors the producer did not construct, including frozen or primitive ones.origin/mainmoved after the merge (0cb72cfc72at 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