Repository navigation
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 |
…refusing defineStack applied, beside the refusal (objectstack-ai#20926) Fixes objectstack-ai#20583 Clause-②: no ## What this lands: location 2's CLI half A `defineStack` (or `composeStacks`) call that converts an ADR-0087 D2 spelling and then refuses now reports both on `os validate --json`, `os build --json` and `os lint --json`: the refusal's `error` and `code`, and the conversions the producer applied before it refused, in `conversions`. The spec half landed in PR objectstack-ai#20651 (objectstack-ai#20618): a refusing producer stamps the notices it applied on the ADR-0112 refusal it throws, and `stackConversionsOf(error)` reads them. This PR is the fold the seat answer `5886671384` kept on this card: one line in each of the three catch-alls. - `validate.ts`, `compile.ts` (`os build` inherits it) and `lint.ts`: inside the catch-all's `--json` branch, `conversionNotices.push(...stackConversionsOf(error))` runs before `emitJson`. The payload still reads the one shared sink (`conversions: conversionNotices`), which the nightly source-scan pins require of every exit. - **Folded, never recomputed.** There is no second conversion pass over the authored source and no reading of the producer's stderr line. Both were declined as consumer-side reconstructions (`5886671384`). - **Exactly once, by construction.** None of the three commands calls a stack producer itself (`git grep` over `packages/cli/src`: every `defineStack(` / `composeStacks(` hit is prose or scaffold text; the door dependencies `packages/lint/src` and `packages/objectql/src` hit only comments). So only the config module's load can throw a stamped refusal, and a throwing load comes before both other fillers of the list (step 1b's `loaded.stackConversions` fold and step 2's own pass). `stackConversionsOf` answers `[]` for every other throw: a plain `Error`, this CLI's own refusals (`refuseUnbuiltStack`, `ConfigRefusalError`) and non-refusal throws. - **`loadConfig` passes the refusal through untouched,** so `packages/cli/src/utils/config.ts` is not edited. Measured at `165c1d49e3` by calling `loadConfig` from source on the fixture below. The caught error is a `StackCapabilityUnknownError` (`code` `STACK_CAPABILITY_UNKNOWN`, `instanceof Error`) that carries the own symbol `objectstack.stack.conversions`, and `stackConversionsOf(error)` answers the one `page-header-subtitle-alias` notice. The canonical control answers `[]`. - The text face does not change. The fold sits inside the `--json` branch, and the producer's own stderr line is unchanged. - The comments that said "a throw at load reports `[]`" now name the one exception. `validate.ts`'s hoist note counts three fillers. - `test/validate-build-gate-parity.test.ts`: `stackConversionsOf` is now a bare call site in both `compile.ts` and `validate.ts`, so the closed call-site ledger needed a row for it. It gets its own `NOT_A_GATE` reason ("carries the conversion record a stack producer stamped on the refusal it threw ... refuses nothing"), not the nearest bucket. `os lint` still does not take the one-authoring-shape rule. An unbuilt default export is neither built nor refused by a producer, so nothing moves for it (objectstack-ai#20367's OQ3 stays unopened). ## Measurements: the three doors through `bin/run-dev.js` (CLI from source) The fixture is a strict `defineStack` whose `page:header` authors `description` (the live conversion `page-header-subtitle-alias`) and which then declares `requires: ['no-such-capability']`. The control is the same config with `subtitle`. | run | before (`165c1d49e3`) | after (fold applied) | |:--|:--|:--| | `os validate --json`, convert then refuse | exit 1, `STACK_CAPABILITY_UNKNOWN`, `conversions: []`, 1 producer stderr line | exit 1, `STACK_CAPABILITY_UNKNOWN`, `conversions` = the one notice, 1 stderr line | | `os build --json`, the same config | exit 1, `STACK_CAPABILITY_UNKNOWN`, `conversions: []`, 1 stderr line | exit 1, `STACK_CAPABILITY_UNKNOWN`, the one notice, 1 stderr line | | `os lint --json`, the same config | exit 1, `STACK_CAPABILITY_UNKNOWN`, `conversions: []`, 1 stderr line | exit 1, `STACK_CAPABILITY_UNKNOWN`, the one notice, 1 stderr line | | all three, the canonical control | exit 1, `STACK_CAPABILITY_UNKNOWN`, `conversions: []`, 0 stderr lines | unchanged | The notice is `page-header-subtitle-alias` at `pages[0].regions[0].components[0].properties.subtitle`, `description` to `subtitle`, `retiresIn` 18. The "after" column was also read at the final head: at `dd6fa55374` all six rows answer exactly as in the table. ## Tests - **The pins,** in `packages/cli/test/stack-conversion-record-door.test.ts` (the per-PR `integration` tier; `*.e2e.*` files run nightly only, which is why the objectstack-ai#20617 lint rows live here too). A new block drives triage's convert-then-refuse fixture through each door. Each row asserts exit 1, `code` `STACK_CAPABILITY_UNKNOWN`, the catch-all's `error` string plus the door's own verdict key (`valid: false` / `success: false`), and `conversions` equal to exactly the one notice. The control (canonical then refuse) asserts the same envelope with `conversions: []`. That is 6 new rows, and the file now holds 21. - At the final head `dd6fa55374`, through `scripts/pm/os-verify-lock.sh`: - `pnpm --filter @objectstack/cli exec vitest run --project unit --maxWorkers=2`: `Test Files 238 passed (238)` / `Tests 3382 passed (3382)`. - `vitest run --project integration --maxWorkers=2 test/stack-conversion-record-door.test.ts`: `Test Files 1 passed (1)` / `Tests 21 passed (21)`. That is the 15 existing rows plus the 6 new ones. - `pnpm --filter @objectstack/cli typecheck`: exit 0, `check:test-typecheck: OK`. The door file is in the test-layer program (`tsc -p tsconfig.test.json --listFilesOnly`, 1 hit). - The nightly pins that read these catch-alls were run too, because the diff edits exactly what they assert. At `dd6fa55374`, `OS_TEST_TIERS=nightly vitest run` over `validate-json-failure-conversions.e2e.test.ts`, `build-json-failure-conversions.e2e.test.ts` and `lint-conversion-notices.e2e.test.ts` gave `Test Files 3 passed (3)` / `Tests 35 passed (35)`. That covers every exit still reading the one sink, the load-throw rows (a plain `Error`) still answering `[]`, and the normalize call still below `loadConfig`. - The first unit run, at `649236e024`, failed one test: `validate-build-gate-parity.test.ts` found the new call site unclassified. `dd6fa55374` adds the ledger row, and the run above is green. ## Ablation (reverse verification) Run from the committed state `a34e868fa4`. The fold line was deleted in all three doors at once: three nested WRAP legs of `node scripts/ablation-replace.mjs`, plus a shell `trap` restoring the three absolute paths with `git checkout HEAD --`. The CLI loads these files from `src/` through tsx, so no `dist/` sits on the measured path and no rebuild was needed. - **Landed on disk:** the anchor `conversionNotices.push(...stackConversionsOf(error));` went from 1 to 0 in each file. The blobs moved: `validate.ts` `11b7ea3e79e2` to `f744edd59909`, `compile.ts` `1e716a6e53fa` to `3f653b1a82ba`, `lint.ts` `4837e2246a13` to `5df926e26a3e`. - **Direction observed: red, and exactly where expected.** The result was `Tests 3 failed | 18 passed (21)`. The three red rows are the convert-then-refuse rows on `validate`, `build` and `lint`, each failing on `conversions`: `expected [] to deeply equal [ { …(5) } ]`. Their exit and `code` assertions held. The three controls and all 15 earlier rows stayed green. - **Restore proven:** each file's blob equals HEAD again (`11b7ea3e79e2`, `1e716a6e53fa`, `4837e2246a13`), the anchor count is 1 in each, and `git diff HEAD` over the three paths is 0 bytes. ## Gates At the final head `dd6fa55374`, in this worktree: - `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` derived 63 commands. I ran each one and captured its exit code before any pipe. - 61 exited 0 on the first run. - `check:dual-build-cjs-loads` and `check:i18n-coverage` first answered PREREQUISITE NOT MET (exit 3: no `dist/` for packages outside the CLI closure). After `pnpm turbo run build --filter=!@objectstack/docs`, both exited 0. `check:i18n-coverage` printed `OK (13 config(s), 621 baselined untranslated string(s), none new)`. - `--ran` reconciliation: `Run reconciliation — 63 derived, 63 run, 0 NOT-MEASURED, 0 UNRUN`, a derived zero (all 63 carry an exit code). - `pnpm lint` (`eslint . --no-inline-config`, the whole repo): exit 0 at `dd6fa55374`. - Remote CI: not waited on. ## Acceptance notes - No hand-written docs page is touched. A grep of `content/docs` finds no page describing the `conversions` field of a failure payload, so no doc sentence was made false. - The macOS `TMPDIR` note on PR objectstack-ai#20617 does not apply here: this run is on Linux. --- _Generated by [Claude Code](https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
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