fix(spec,cli): os validate / os build read the ADR-0087 conversions defineStack applied — --json conversions and --strict see the producer's record - #20579
Conversation
…ions they applied defineStack (both modes) records every D2 conversion notice its load-time pass applied on the stack it returns, beside the provenance mark and stamped in the same act: non-enumerable, frozen, invisible to the schema and to JSON. composeStacks records its inputs' records in input order, one application once. One exported reader, stackConversionsOf, answers [] for any value no producer returned. Claude-Session: https://claude.ai/code/session_014EJ1ED8X4MMrT18BhVx4tx Co-authored-by: Claude <noreply@anthropic.com>
…d into conversions and --strict loadConfig reads the ADR-0087 conversion record off the default export beside the provenance mark, before its named-export spread drops it. os validate and os build fold it into their one conversions sink right after the provenance refusal, so --json lists what defineStack converted and os validate --strict fails on it. The doors' own step-2 pass stays: it still converts keys merged from named exports, which the producer never saw. Claude-Session: https://claude.ai/code/session_014EJ1ED8X4MMrT18BhVx4tx Co-authored-by: Claude <noreply@anthropic.com>
…cord, not its loss The three pins that recorded conversions: [] beside the producer's stderr line now assert the one notice in the --json payload (identity, site, direction, expiry) and exactly one producer line on stderr; the conversions-only cell of the strict-exit matrix exits 1 on both faces again, with a no --strict control. A new integration-tier door test covers the record across loadConfig's named-export spread, a composeStacks record, the doors' own pass over a named-export key, and the canonical control. Claude-Session: https://claude.ai/code/session_014EJ1ED8X4MMrT18BhVx4tx Co-authored-by: Claude <noreply@anthropic.com>
…ty, forgery and arity pins Claude-Session: https://claude.ai/code/session_014EJ1ED8X4MMrT18BhVx4tx Co-authored-by: Claude <noreply@anthropic.com>
…onversionsOf; changeset Claude-Session: https://claude.ai/code/session_014EJ1ED8X4MMrT18BhVx4tx Co-authored-by: Claude <noreply@anthropic.com>
…fine-stack-conversions-reach-doors
…fine-stack-conversions-reach-doors
📓 Docs Drift CheckThis PR changes 2 package(s): 18 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 7 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 143 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 88c3ad4f1249cc63c67a42140dab18bd9da49cf2 && git checkout 88c3ad4f1249cc63c67a42140dab18bd9da49cf2
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin f11b5f20a2ee22698c6647c2fb76aa67e99a7a53 9de090d5fae2bb114d4c1e2714f9866c84f68ea2 && git checkout -B drift-repro f11b5f20a2ee22698c6647c2fb76aa67e99a7a53 && git merge --no-ff 9de090d5fae2bb114d4c1e2714f9866c84f68ea2
node scripts/docs-audit/affected-docs.mjs --json f11b5f20a2ee22698c6647c2fb76aa67e99a7a53
|
Contract reviewServed-tier: Inputs, and nothing else: card #20476 (body; triage ① Derived judgmentsEvery accept-set and public-surface change the diff implies, judged from the code:
Pins assert the substance, not the absence of an old assertion. ② Semver levelChangeset
③ Boundary flags
Implemented-by: VERDICT: PASS Generated by Claude Code |
|
Generated by Claude Code |
…fine-stack-conversions-reach-doors
…/src remainder to the commits and ADRs that decided them (stage 6) (objectstack-ai#20606) Part of objectstack-ai#20234 Clause-②: no Stage 6 of the staged sweep: the `packages/spec/src` remainder outside `migrations/` and the held files. Its claim is `5884233505`, with 22 files named there. Every comment or docblock line in those files that cited a tracker number answering 404 now cites what decided its rule, in ruling C+D's form C. That is the commit on `main` that decided the rule, or, for one number, the ADR amendment that records the ruling. Each line says in its own words what was decided. Comments only: 66 lines out, 66 in, across 21 files. No code token, string literal, `describe()` text or message-catalog string moves. Two dead sites stay byte-identical, because a test reads each one by literal. The census is the gate's own `node scripts/check-issue-citations.mjs --census --json`, filtered to the 22 paths. Before: base `c876a7426d`, board enumerated (185 pages, frontier objectstack-ai#20590). After: head `9d63cb6548`, frontier objectstack-ai#20604. ## Measurement | file (under `packages/spec/src/`) | dead before | after | numbers, then anchor | |---|---:|---:|---| | `api/rest-server.zod.ts` | 11 | 0 | objectstack-ai#14691 ×9 to `b3a63d32c`; objectstack-ai#14369 ×2 to `a3d5724c8` | | `system/i18n-resolver.ts` | 14 | 0 | objectstack-ai#12961 ×6 to `901355c3b`; objectstack-ai#13218 ×4 to `c45d8e6b4`; objectstack-ai#8460 ×2 to ADR-0029 D9.2a; objectstack-ai#10926 to `d173125fb`; objectstack-ai#13109 to `8b236c826` | | `system/operation-message.ts` | 4 | 0 | objectstack-ai#12493 ×4 to `aa5994e17` (docblock lines only) | | `system/translation.zod.ts` | 2 | 0 | objectstack-ai#10926 ×2 to `d173125fb` | | `system/core-services.zod.ts` | 1 | 0 | objectstack-ai#6604 to `d127ff002` | | `system/dev-login.zod.ts` | 1 | 0 | objectstack-ai#17081 to `24d622b94` (objectstack-ai#17556 stays, 200) | | `system/environment-artifact.zod.ts` | 1 | 0 | objectstack-ai#11333 to `e58ea8b38` (objectstack-ai#14865 and objectstack-ai#13457 stay, 200) | | `shared/identifiers.zod.ts` | 7 | 0 | objectstack-ai#12245 ×2 to `c41b42e8d`; objectstack-ai#12144 ×2 to `3a04b0125`; objectstack-ai#12194 and objectstack-ai#12176 (:140-141) to `311433f6b`; objectstack-ai#12176 (:189) to `7986d973f` | | `shared/metadata-collection.zod.ts` | 1 | 0 | objectstack-ai#10485 to `35ad101bc` | | `index.ts` (package root) | 6 | 0 | objectstack-ai#11350 ×5 to `ece4dad31` (objectstack-ai#11709 stays, 200); objectstack-ai#10485 to `35ad101bc` | | `automation/control-flow.zod.ts` | 1 | 0 | objectstack-ai#14419 to `c5a7448d5` (objectstack-ai#14954 stays, 200) | | `automation/execution.zod.ts` | 1 | 0 | objectstack-ai#13681 to `18d816a50` | | `automation/index.ts` | 1 | 0 | objectstack-ai#16659 to `ecdfc9411` | | `automation/schedule-organization.zod.ts` | 1 | 0 | objectstack-ai#16659 to `ecdfc9411` | | `ai/index.ts` | 1 | 0 | objectstack-ai#11350 to `ece4dad31` | | `identity/identity.zod.ts` | 1 | **1** | objectstack-ai#8715 kept at :230 (a test reads it); `2c86fe3ea` added on :231 | | `security/explain.zod.ts` | 1 | 0 | objectstack-ai#8714 to `42b05af89` | | `security/public-form.ts` | 1 | 0 | objectstack-ai#6640 to `2ab1257c9` | | `data/api-derivation.ts` | 1 | **1** | objectstack-ai#6259 kept at :163 (two tests read it); `6968885ef` already on :164; file untouched | | `data/driver/turso.zod.ts` | 2 | 0 | objectstack-ai#6345 ×2 to `e2798fab7` | | `conversions/walk.ts` | 1 | 0 | objectstack-ai#13031 to `b799ac553` | | `meta-spelling/metadata-url-spelling.ts` | 2 | 0 | objectstack-ai#10485 ×2 to `35ad101bc` | | **22 files** | **62** | **2** | 26 numbers, 24 removed: 24 distinct shas and 1 ADR | Per-file counts at base equal the claim's (census `5884031174` at `f11b5f20a2`) in all 22 files. A second instrument agrees site for site: every `#N` in the 22 files, classified by the TypeScript parser, and each of 201 distinct numbers probed by REST `issues/N` without redirects. It found 484 sites, all in comments and none in a string, 26 dead numbers and 62 dead sites. Its string-class positive control found 19 string sites in `api/rest-server.test.ts`. Head: 424 sites and 177 numbers, 175 answer 200 (the same 175), and 2 answer 404 (the two kept sites). Lit controls objectstack-ai#16862, objectstack-ai#16847 and objectstack-ai#17698 answered 200 at every checkpoint (4 at base, 3 at head); dead controls objectstack-ai#16714, objectstack-ai#16715 and objectstack-ai#16697 answered 404 at every checkpoint. ## Why each anchor decides its line Each sha resolves uniquely, is an ancestor of `origin/main` (and of the base), has one parent, and names the number it replaces in its own message or diff. Each was read for the rule its line states. - **objectstack-ai#14691 to `b3a63d32c`**: the retirement of the ten inert `RestServerConfig` keys under ADR-0049 enforce-or-remove. Its own `rest-server.zod.ts` diff wrote all nine `objectstack-ai#14691` lines: the tombstones, the dropped `CrudEndpointPatternSchema` and the `routes` block. - **objectstack-ai#14369 to `a3d5724c8`**: seeded the four `RestServerConfig` liveness ledgers "from the census filed with objectstack-ai#14369" (its changeset heading names the number). It records both facts the two lines state: every CRUD route is mounted from hard-coded method/path pairs, and `routes` is parsed, defaulted and normalized, then never read. - **objectstack-ai#12961 to `901355c3b`**: "Ruled 2026-08-29 (option A)". `translatePage` descends into declared `properties.children`; on an id collision a region-level component wins outright, and among nested matches document order decides. Its diff wrote the `objectstack-ai#12961` lines being replaced. - **objectstack-ai#13218 to `c45d8e6b4`**: exports `walkAddressedPageComponents` and consumes it from both sides; its changeset reads "(objectstack-ai#13218, ruled 2026-08-30)". - **objectstack-ai#13109 to `8b236c826`**: its changeset says the extractor OMITTED keys the resolver reads, and "This matches the second half". The line now says the second half went live and this commit repaired it. - **objectstack-ai#8460 to ADR-0029 D9.2a**: ruling C's first rung. The amendment "D9.2a — AMENDMENT (2026-08-13)" records the option-A ruling: an extender's scalar applies only while the fold's base still carries the packaged owner's value. It also records that the mechanism is deliberately the same comparison-based one the catalog uses one layer up. The heading marker `[objectstack-ai#8460]` becomes `[ADR-0029 D9.2a]`. - **objectstack-ai#10926 to `d173125fb`**: "Option A per the maintainer ruling on objectstack-ai#10926 (2026-08-22): drop the key", the `submitLabel` retirement all three lines describe. - **objectstack-ai#12493 to `aa5994e17`**: adds `record_write_denied` and `approval_recall_not_submitter` ahead of their emitters, with no placeholders. Its diff wrote the four docblock lines. The catalog's rendered strings are untouched, per hypothesis 5. - **objectstack-ai#6604 to `d127ff002`**: "Per the maintainer's 2026-08-08 Option-B ruling the kernel side takes the domain-specific name", matching `KernelServiceMapSchema`. - **objectstack-ai#17081 to `24d622b94`**: lands objectstack-ai#17556 as "Suggestion 1 of objectstack-ai#17081". The line keeps objectstack-ai#17556 and names the parent card in words. - **objectstack-ai#11333 to `e58ea8b38`**: declares `grantedPermissions`, described by the commit itself as "the artifact-contract half of objectstack-ai#11333 option A / the objectstack-ai#13457 batch ruling". The line keeps objectstack-ai#14865 and objectstack-ai#13457 and states the ruled option in words. - **objectstack-ai#12245 to `c41b42e8d`**: rewrote this docblock from "the per-surface census (its os-dev-report comment, measured on origin/main @ e2debee)" and carries the 1218-values measurement. The report comment lived on the deleted card, so the commit is now the record, and the line says so. - **objectstack-ai#12144 to `3a04b0125`**: wrote the storage-owned length-ceiling note and its storage-column pin; its changeset heads "(objectstack-ai#12144)". - **objectstack-ai#12194 / objectstack-ai#12176 to `311433f6b`** (:140-141): stage 1, the item-name grammar declared and refused at the publish door; its message reads "Stage 1 of objectstack-ai#12176". **objectstack-ai#12176 to `7986d973f`** (:189): "Retire compound-name metadata addressing", stage 3 of the maintainer-ruled retirement. - **objectstack-ai#10485 to `35ad101bc`**: retires the `themes` carrier, `ThemeSchema` and the `PLURAL_TO_SINGULAR` fold ("Ruled B"). Its own diff wrote all four lines. - **objectstack-ai#11350 to `ece4dad31`**: "Invariant recorded (maintainer ruling 2026-08-23)". It also points the premise-delta note at objectstack-ai#11709, which is what `index.ts:148` now says. This is stage 1's wording for `kernel/index.ts:53`. - **objectstack-ai#14419 to `c5a7448d5`**: `create_record` surfaces the engine's `DUPLICATE_RECORD` code and the engine binds it on `$error`, the founding case the line names. Its message names objectstack-ai#14419 as the card it lands. - **objectstack-ai#13681 to `18d816a50`**: declares the run-level `FlowRunSummary.failed`, the spec half of the contained-failure contract. - **objectstack-ai#16659 to `ecdfc9411`**: declares the start-node `config.organization` key and "the one refusal sentence every enforcement point says". Its sub-commits pin "the three objectstack-ai#16659 consequences", so it is also the commit that closed the defect the second line describes. - **objectstack-ai#8714 to `42b05af89`**: "ONE closed contributor-state enumeration", maintainer-ruled 2026-08-18. - **objectstack-ai#6640 to `2ab1257c9`**: `preserveAudit` is UPDATE-only (stage 1's anchor for the same rule). - **objectstack-ai#6345 to `e2798fab7`**: its own `turso.zod.ts` diff wrote both lines ("The maintainer's objectstack-ai#6345 ruling closes it…", "(objectstack-ai#6345 fork 2)"). The wording is stage 3's in `config-registry.zod.ts`. - **objectstack-ai#13031 to `b799ac553`**: adds `mapViewPayloads` to `walk.ts`, the centralized walk the heading describes. Its message names objectstack-ai#13031 as the card it lands. - **objectstack-ai#8715, kept**: `2c86fe3ea` ("Maintainer ruling 2026-08-15 (disposition B: delete)"; its diff wrote :230) now sits on :231. ## Mechanical proof - **Token guard** (my `tokcmp.mjs`: TypeScript 6.0.3 leaf tokens, JSDoc kinds excluded, controls mutate the head text in memory only). Base `c876a7426d` against the head, 21 files, 37,539 base tokens: - Real run: 0 files with a token change (exit 0). - Comment-insertion control (`ai/index.ts`): 0 (exit 0). - Code-insertion positive control (`system/i18n-resolver.ts`): DIFFER at token 14452 (exit 1). - String positive control (a real `StringLiteral` in `system/operation-message.ts`, found by the parser): DIFFER at token 5 (exit 1). - The first string-control attempt matched a quoted fragment inside a comment and did not fire. It was a vacuous control, not a measurement, and the parser-located control above replaced it. - **Line balance**: every file is +N/−N (66/66 across 21 files); every line count is equal at base and head. - **Tracker numbers**: added-not-removed is empty in every file, and no `PR #N` is on an added line. Net-removed: 60 sites, 24 numbers. - **Shas**: 24 distinct on added lines, 0 on removed lines. - `rev-parse --disambiguate` answers 1 object for each. - `merge-base --is-ancestor` exits 0 against `origin/main` `e666636fd9` and against the base. - Each is single-parent; the repository is not shallow; the control leg `e9584681a4` exits 0. - Each commit's own message (17 of 24) or diff (all 24) names the number it replaces. - **Literal readers**: every string literal in the repository that carries one of the 26 numbers was matched against the 22 files' text. Three hits read these files: - `data/api-derivation.test.ts:236` splits on `[objectstack-ai#6259]`; - `packages/runtime/src/api-exposure.test.ts:152` splits on `objectstack-ai#6259`; - `identity/api-key-retirement.test.ts:118` asserts `are NOT declared here (objectstack-ai#8715`. - Those lines are the two kept sites. No test or script matches any rewritten line by pattern. ## Tests and gates (at head `9d63cb6548`) - `pnpm exec turbo run build --concurrency=2 --filter=./packages/* --filter=./packages/*/*` under `os-verify-lock`: Tasks 71 successful, 71 total, VERDICT command-exit 0. - `pnpm --filter @objectstack/spec check:generated`: exit 1, `check:docs` stale (1 of 15). `check:generated --fix` then regenerated exactly that artifact: 2 pages, 3 lines, each its docblock line verbatim. - `content/docs/references/automation/schedule-organization.mdx` - `content/docs/references/data/driver-turso.mdx` - The re-check in the gate run below is exit 0. - `vitest run --maxWorkers=2` over the touched areas (`src/api/rest-server.test.ts`, `src/api/rest-api-config-dead-keys-retirement.test.ts`, `src/system`, `src/shared`, `src/automation`, `src/ai`, `src/identity`, `src/security`, `src/data/driver`, `src/conversions`, `src/meta-spelling`): Test Files 159 passed (159), Tests 5163 passed (5163). - The 23 spec suites outside those areas that read a touched file's source text or name it: Test Files 23 passed (23), Tests 540 passed (540). - `scripts/{tombstoned-row-status,strictness-ledger,file-description,skill-map-guards,root-index,export-origins,category-title,split-entries,dist-freshness,dist-freshness-adoption,root-entry-type-nameability.pin}` tests; - `src/type-alias-convention.pin`, `src/contracts/{automation-result-status.pin,automation-service,scoped-context}`, `src/api/{export-job-family-retirement,api-entry-graph.pin}`, `src/eager-entry-import`, `src/integration/connector-author-shape`, `src/ui/{interaction-config-retirement,notification,strictness-batch14}`, `src/migrations/migrations`. - `scripts/build-schemas-check-mode.test.ts` is left to CI: it imports rather than reads, and rebuilds schemas in a temp tree. - `pnpm --filter @objectstack/spec typecheck`: exit 0; `check:test-typecheck` OK (53 files / 251 errors / 138 pinned signatures held). - Lint, a proven narrowing: `eslint --no-inline-config --format json` over the 21 touched `.ts` files gives 21 files, 0 errors, 0 warnings. - `isPathIgnored` is false for all 21, read through eslint's API. - `eslint.config.mjs:327-328` says type-aware linting is never enabled, so a comment edit cannot move an untouched file's verdict. - The repo-wide `pnpm lint` is CI's. - `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands`: 108 families derived and run, every one exit 0. `--ran` reads "108 derived, 108 run, 0 NOT-MEASURED, 0 UNRUN". Among them: - `pnpm check:issue-citations` plus the live diff-scoped `node scripts/check-issue-citations.mjs` judged 7 citations across 21 files, 7 resolve: the live numbers kept beside the anchors (objectstack-ai#9249, objectstack-ai#14954, objectstack-ai#17556, objectstack-ai#14865, objectstack-ai#13457, and objectstack-ai#11709 twice). - `pnpm check:doc-authoring`: 16,765 customer-facing strings across 1,175 spec sources clean; the sibling baseline holds. - Changeset: `patch` for `@objectstack/spec`. 13 of the 21 touched sources are `src/**/*.zod.ts`, which `files[]` ships verbatim, and the rewritten docblocks reach `dist`. For example, `ruled collision arbitration (commit 901355c)` is in 1 `.d.ts`, and the unchanged neighbouring sentence in the same exported docblock (the positive control) is in 1 `.d.ts`. - Merge probe: a no-driver `merge-tree` of the head onto `origin/main` `7a1faf1a5d`, from a bare shared clone, exits 0. The 3 commits `main` gained since the base touch none of this diff's files. No merge was made, as stages 1–4 did. - No ablation or reverse verification: the change is comment-only, so there is no behaviour to invert. ## Hypotheses (measured first) 1. **Holds.** The population is exactly the claim's 22 files: 62 dead sites at the tip, equal per file to the census at `f11b5f20a2`. 2. **Holds, with nothing to respell.** No sibling-qualified pair occurs among the 62 sites; no `pre-#N` spelling is dead here. 3. **Holds.** Re-read at 2026-09-29T06:23Z, after the last push and before this PR was opened: all 14 open PRs' full file lists (1,116 files in the version PR alone), and the newest `Claim:` on all 15 `pm:dispatched` cards. None names any of this PR's 24 paths. The five exclusions stay excluded. 4. **Holds.** The two projected pages were regenerated by the generator, never by hand. No other page under `content/docs/references/` carries any of the 26 numbers. 5. **Holds.** No `#N` in the 22 files is inside a string; the two literal-read sites stay as tokens. In `system/i18n-resolver.ts` and `system/operation-message.ts` only docblock and line-comment lines moved. ## Deviations - The file surface is 21 of the 22 named files. `data/api-derivation.ts` is untouched, because its one dead site is read by literal and its commit already stands on the next line (stage 3's disposition). - Six changed lines held no dead number. Each is the other half of a rewritten sentence: `rest-server.zod.ts:756`, `identifiers.zod.ts:19`, `index.ts:133`, `environment-artifact.zod.ts:137`, `schedule-organization.zod.ts:82`, and `identity.zod.ts:231` (the commit placed beside the kept :230). - Commit trailers follow AGENTS.md's model-free pair (`Claude-Session` plus `Co-authored-by: Claude`); the pre-push trailer check passed on every push. ## Acceptance notes **What stays for later stages.** At the tip `7a1faf1a5d` with this PR applied, `packages/spec/src` holds **260** dead sites (34 numbers). This is the gate's census on this head, with the four spec sources `main` changed since the base re-extracted and re-probed at the tip. By area: - `migrations/` **233**: objectstack-ai#20233 edits the same entry files; the author-shown fields are its form D. - `conversions/registry.ts` **12**: held by PRs objectstack-ai#20570 and objectstack-ai#20458. - `stack.zod.ts` **9**: free now; PR objectstack-ai#20579 landed as `7a1faf1a5d` at 06:02Z, after the claim, so it stayed excluded here. - `data/analytics.zod.ts` **3**: PR objectstack-ai#20458. - `integration/connector.zod.ts` **1**: objectstack-ai#20287, PR objectstack-ai#20587. - `data/api-derivation.ts:163` (objectstack-ai#6259) and `identity/identity.zod.ts:230` (objectstack-ai#8715), **1** each: kept because `api-derivation.test.ts:236`, `packages/runtime/src/api-exposure.test.ts:152` and `api-key-retirement.test.ts:118` read them by literal. Removing them is a test-string change, form D, outside this card's comment-only scope. **Carried from earlier stages, outside the gate's census** (which blanks strings and defers test files): the dead-number test-title strings, the two `why` strings, the `PROVENANCE_WAIVERS` reason, the two `AGGREGATION_CASES` notes, and the `liveness/**` notes. **Outside `packages/spec/src`** (objectstack-ai#20556's lane): `packages/spec/scripts/check-entry-nameability.ts` cites objectstack-ai#11350 in its header (:14) and PRINTS "recorded on objectstack-ai#11350" in its failure text (:727); `packages/spec/scripts/root-entry-type-nameability.pin.test.ts` cites it too. Commit `ece4dad31` is the anchor, already verified here. **Rung.** Five of the anchored retirements also have ADR-0087 D3/D2 entries: `identity-api-key-schema-retired`, `metadata-item-name-grammar-enforced`, `rest-server-config-dead-keys-retired`, `stack-themes-carrier-retired` and `translation-component-submit-label-retired`. This PR takes the commit rung, as stages 1–5 did. The D3 id is the more durable in-repo record if the ruling's first rung is later read to include those entries. **Wording, each true of its commit.** - `dev-login.zod.ts:10` names objectstack-ai#17081 in words ("suggestion 1 of its parent card"). - `environment-artifact.zod.ts:136-137` states objectstack-ai#11333's option A as "the option the objectstack-ai#13457 batch ruling chose". - `identifiers.zod.ts:18` drops "its `os-dev-report` comment is the measurement of record", since that comment went with the card, and names the commit as the record. **Observation, not filed** (a pre-existing live citation, not a tracker number; carrier: none): `environment-artifact.zod.ts:137` and `packages/runtime/src/security/artifact-granted-permissions.ts:6` cite "ADR-0025 §3.5 step 2" for the granted set. At the tip, §3.5's numbered step 2 is "Compatibility" and "Permission consent" is step 3. --- _Generated by [Claude Code](https://claude.ai/code/session_014EJ1ED8X4MMrT18BhVx4tx)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
… applied (objectstack-ai#20617) Part of objectstack-ai#20583 Clause-②: no ## What this lands: location 1 only `os lint --json` now folds `LoadedConfig.stackConversions` into its `conversions` list, right after `loadConfig`: the same one-line fold `validate.ts` / `compile.ts` make at their step 1b since PR objectstack-ai#20579. `defineStack` converts at load, so the config `os lint` received was already canonical and its own `normalizeStackInput` pass found nothing of the default export to convert. The notice reached stderr alone. - `os lint` keeps accepting exactly what it accepts today. There is no `refuseUnbuiltStack` here and none is implied; the one-shape rule is not extended to this command. An unbuilt default export carries no record, so `stackConversionsOf` answers an empty list for it and the command's own pass converts it as before. - One conversion is listed once. The producer's output is canonical wherever it converted, so the pass finds only what no producer saw: an unbuilt export, or a key merged from a named export. Pinned by the exactly-one rows below. - No new `--json` key, and `passed`, `issues`, the counts and the exit code do not move. The text face prints the folded notice in its warning block, as `os build` does. ## What this leaves: location 2 is a fork, not shipped A `defineStack` that converts and then refuses still reports `conversions: []` on `os validate --json`, `os build --json` and `os lint --json` (the third door, measured below). The dispatch's hypothesis was that the CLI can recover those conversions without a spec edit. Measured, it cannot recover them; it can only reconstruct them: 1. **Capturing the producer's stderr lines during `loadConfig` loses notices.** `warnConversionNotice` is warn-once per process, keyed on conversion id, path, from and to. The record is not subject to that warn-once. - Measured at `eb4b17c346`: `composeStacks([defineStack(A), defineStack(B)])`, where both carry `page:header` `description` at `pages[0]`. The record carries 2 notices, and stderr carries 1 line. - In the refusing variant (B adds `requires: ['no-such-capability']`), the one stderr line belongs to A, whose `defineStack` did not refuse. B's own notice was suppressed. - The line also lacks the `surface`, `toMajor`, `message` and `code` fields, so the structured notice would have to be re-derived from prose. The conversion types document that prose as derived, never the source of truth. 2. **Recomputing through `normalizeStackInput` on the authored argument is a second conversion pass.** The CLI would shim `defineStack` in every config load, or re-load the module in authored-source mode, and rerun the pass when the call refuses. The `stackConversionsOf` TSDoc rules this out: a door never runs a second pass to reconstruct the record. It would also copy the producer's record formula (input record plus pass notices) into the CLI, where it drifts. 3. The spec exposes no other channel: `warnConversionNotice` and its warn-once set are module-private, and the refusal errors (`StackRefusalError` subclasses) carry `issues` only. So the only channel that is not a workaround is a spec change: the refusal carries the notices it applied. `packages/spec` belongs to the spec seat under this dispatch, so no spec edit is made here. The dev report carries the fork, with options. ## Measurements (CLI from source, `bin/run-dev.js`) | run | before (`eb4b17c346`) | after (`ca74de14aa`) | |:--|:--|:--| | `os lint --json`, the card's `page:header` `description` case | exit 0, `conversions: []`, 1 stderr line | exit 0, `conversions` = the one `page-header-subtitle-alias` notice, 1 stderr line | | `os validate --json`, convert-then-refuse (`requires: ['no-such-capability']`) | exit 1, `STACK_CAPABILITY_UNKNOWN`, `conversions: []` | unchanged (location 2) | | `os build --json`, the same config | exit 1, `STACK_CAPABILITY_UNKNOWN`, `conversions: []` | unchanged (location 2) | | `os lint --json`, the same config | not measured | exit 1, `STACK_CAPABILITY_UNKNOWN`, `conversions: []` (location 2, third door) | ## Tests - `packages/cli/test/stack-conversion-record-door.test.ts` gains an `os lint --json` block. It covers the card's plain case, the record across the named-export spread, `composeStacks`, a key merged from a named export (the pass converts it once, with no producer stderr line), and the canonical control. Every non-empty row asserts exactly one entry. This file is in the per-PR `integration` tier. The existing `lint-conversion-notices.e2e.test.ts` is `*.e2e.*` and runs nightly only, which is why the new rows are not in it. - At `ca74de14aa`: the `unit` tier passed 234 of 234 files (3342 tests), and the door file passed 15 of 15 tests. `pnpm --filter @objectstack/cli typecheck` exits 0, and the door file is in the test-layer program (`--listFilesOnly`). `lint-conversion-notices.e2e.test.ts` under `OS_TEST_TIERS=nightly` passed 6 of 6 at `a80b61dad0`. Its unbuilt `export default` fixtures still lint and convert through the pass. - Ablation, run from the committed state `a80b61dad0` through `scripts/ablation-replace.mjs` (WRAP mode, with a trap). Deleting the fold line took the anchor count from 1 to 0 on disk (blob `37bf1203bff7` to `95dcca7f308a`). - Exactly the three record-dependent lint rows went red (plain, record across spread, composeStacks). The named-export-pass row, the lint control and all 10 validate / build / strict rows stayed green: 3 failed, 12 passed. - Restore was proven: the blob equals HEAD (`37bf1203bff7`) and `git diff HEAD` is empty. `lint.ts` is loaded from `src/` by the child, so no `dist/` sits on the measured path. ## Gates (at `ca74de14aa`, after merging `origin/main`) - `dispatch-gates --commands`: 63 commands, all exit 0. `dispatch-gates --ran` reconciles 63 derived, 63 run, 0 not measured, 0 unrun, each with its exit code. The first runs of `check:dual-build-cjs-loads` and `check:i18n-coverage` answered PREREQUISITE NOT MET (exit 3) before a full build. Both were rerun green after `pnpm turbo run build --filter=!@objectstack/docs`. - The artifact-roster rows: 36 of 39 exit 0. `check-closing-target-claim`, `check-partof-closing-keyword` and `check-single-claim-paths` need PR context and are rerun against this PR. - `pnpm lint` (the repo-wide `eslint . --no-inline-config`) exits 0 in 29s. - `node scripts/check-issue-citations.mjs --base origin/main` exits 0 (1 citation, resolves). **Declared narrowing — verification ran UNLOCKED.** `scripts/pm/os-verify-lock.sh` could not take the shared verify lock on this host: no usable `flock`. The shared verify lock is declared Linux-only (`flock` is util-linux, and a stock macOS does not ship it), so the command below was run directly, without the lock — a declared narrowing, not a silent one. No serialization guarantee held for this run, nor for any sibling agent in this container while it ran. ## Acceptance notes - **Location 2 stays open on the card.** This PR says `Part of`, so merging it leaves the card open for the fork above. - **`os lint`'s text face now prints the producer's notice** in its warning block for a `defineStack` config with a retiring spelling, in the same wording as `os build`. `check:i18n-coverage` runs `os lint` over the 13 example configs and stays green. - **Host-only reading, not a defect:** on macOS, `test/published-subpath-console.pin.test.ts` and `test/published-subpath-hook-body.pin.test.ts` fail 5 assertions when `TMPDIR` is the `/var/folders/...` symlink. The resolver answers the `/private/var/...` realpath. With `TMPDIR` set to its realpath, both pass 29 of 29. CI runs on Linux. Carrier: none. - Carried over from the card, not filed: `content/docs/deployment/cli.mdx`'s "Warnings checked" list for `os validate` names no conversion notices. --- _Generated by [Claude Code](https://claude.ai/code/session_local_1d2a197c-c20e-4e90-9be8-413d4d432289)_ --------- Co-authored-by: Jack Zhuang <50353452+hotlong@users.noreply.github.com> Co-authored-by: Claude <noreply@anthropic.com>
…data/analytics.zod.ts to the commits that decided them (stage 7) (objectstack-ai#20616) Part of objectstack-ai#20234 Clause-②: no Stage 7 of the staged sweep: `packages/spec/src/stack.zod.ts` and `packages/spec/src/data/analytics.zod.ts`, both freed by landings (PR objectstack-ai#20579 and PR objectstack-ai#20458). Its claim is `5885635758`. Every comment or docblock line in those two files that cited a tracker number answering 404 now cites the commit on `main` that decided its rule, in ruling C+D's form C, and says in its own words what was decided. Comments only: 12 lines out, 12 in, across 2 files. No code token, string literal or `describe()` text moves. No dead site stays: none of the 12 is read by literal. The census is the gate's own `node scripts/check-issue-citations.mjs --census --json`, filtered to the two paths. Before: base `0f6dcac5e9`, board enumerated (185 pages, frontier objectstack-ai#20611). After: head `cc0580d404`, board enumerated (185 pages, frontier objectstack-ai#20615). ## Measurement | file (under `packages/spec/src/`) | dead before | after | numbers, then anchor | |---|---:|---:|---| | `stack.zod.ts` | 9 | 0 | objectstack-ai#10485 ×2 (`:415`, `:1023`) to `35ad101bc`; objectstack-ai#6238 (`:633`) to `c8d6f6e08`; objectstack-ai#14192 (`:1233`) to `4d0d9445a`; objectstack-ai#14686 ×2 (`:3037`, `:3194`) to `279431e7a`; objectstack-ai#14662 ×3 (`:4510`, `:5070`, `:5293`) to `35dffeace` | | `data/analytics.zod.ts` | 3 | 0 | objectstack-ai#10194 ×3 (`:404`, `:407`, `:485`) to `2306a765c` | | **2 files** | **12** | **0** | 6 numbers removed, 6 distinct shas | Per-file counts at base equal the claim's (9 and 3, from stage 6's census). A second instrument agrees site for site: every `#N` in the two files, classified by the TypeScript parser, and each of the 84 distinct numbers of 100 or more probed by REST `issues/N` without following redirects (the other 3 are the ordinals `Prime Directive objectstack-ai#12`, `batch objectstack-ai#23`, `batch objectstack-ai#57`). - Base: 244 sites, all in comments (0 strings, 0 code). 78 numbers answer 200 and 6 answer 404: the same 6 numbers and the same 12 sites as the gate. - Its string-class positive control found 11 string sites in `kernel/manifest-unknown-keys.test.ts` and `packages/cli/src/utils/lower-callables.test.ts`. - Head: 232 sites, 78 numbers, all 78 answer 200 (the same 78), none answers 404. - Lit controls objectstack-ai#16862, objectstack-ai#16847 and objectstack-ai#17698 answered 200 at every checkpoint (3 at base, 3 at head); dead controls objectstack-ai#16714, objectstack-ai#16715 and objectstack-ai#16697 answered 404 at every checkpoint. ## Why each anchor decides its line Each sha resolves uniquely, is an ancestor of `origin/main` (and of the base), and has one parent. No file under `docs/adr/**`, `docs/NORTH-STAR.md` or `scripts/adr-anchors/` names any of the six numbers or records these rules, so each takes the commit rung, as stages 1–6 did. - **objectstack-ai#10485 to `35ad101bc`** (`:415`, `:1023`): retires the `themes` carrier key and `ThemeSchema` under ADR-0049. Its message records the ruling, "Ruled B (退役授权面, 2026-08-21)", and its own `stack.zod.ts` diff wrote both lines. `:415` keeps ADR-0049 and the ruling in its words; the D3 entry `stack-themes-carrier-retired` it names on `:423` is unchanged. This is the anchor stages 1, 5 and 6 used for the same retirement. - **objectstack-ai#6238 to `c8d6f6e08`** (`:633`): widens the array member of `functions` so its `handler` also takes the lowered string ref, which is the fix for `objectstack build` refusing its own array output. Its message names objectstack-ai#6238, and its own diff wrote the line. objectstack-ai#4343 and objectstack-ai#4976 on the same line stay (both 200). - **objectstack-ai#14192 to `4d0d9445a`** (`:1233`): turns `ManifestSchema` and its nested blocks into `strictObject` and flips the assembled-body strip pin to a refusal pin; each of its sub-commits names objectstack-ai#14192. The line itself was written later by `c78c9180de`, whose own message says "objectstack-ai#14192 closed ManifestSchema with strictObject", so the commit that closed it is the anchor. - **objectstack-ai#14686 to `279431e7a`** (`:3037`, `:3194`): "defineStack refuses two actions that resolve to one scope-qualified runtime key". Its subject names objectstack-ai#14686, and its diff adds `collectDuplicateActionKeyErrors` and the changeset for that refusal. Both lines were written later by `773a99960a` (PR objectstack-ai#15022), whose message describes the same "same-key rule, which runs before the merge". - **objectstack-ai#14662 to `35dffeace`** (`:4510`, `:5070`, `:5293`): "composeStacks refuses two stacks whose actions resolve to one scope-qualified runtime key". It checks the composed set with the rule `defineStack` applies within one stack, with no `actionConflict` option (maintainer ruling 2026-09-03). Its message does not name objectstack-ai#14662; its own `stack.zod.ts` diff wrote all three `(objectstack-ai#14662)` lines. - **objectstack-ai#10194 to `2306a765c`** (`analytics.zod.ts:404`, `:407`, `:485`): binds `analytics_cube` (and `theme`) in `UNREGISTERED_KIND_SCHEMAS`, so `PUT /meta/analytics_cube/:name` parses through `CubeSchema`, and gives `CubeSchema` the `...MetadataProtectionFields` spread. Its message names objectstack-ai#10194, and its own diff wrote all three lines. The `[objectstack-ai#10194]` markers become `[commit 2306a76]`, the spelling stages 1 and 5 already use in `kernel/metadata-type-schemas.ts`. ## Mechanical proof - **Token guard** (my `tokcmp.mjs`: TypeScript 6.0.3 leaf tokens, JSDoc kinds excluded, controls mutate the head text in memory only). Base `0f6dcac5e9` against the head, 2 files, 17,249 base tokens: - Real run: 0 files with a token change (exit 0). - Comment-insertion control (`data/analytics.zod.ts`): 0 (exit 0). - Code-insertion positive control (`stack.zod.ts`, a declaration appended): DIFFER at token 15388 (exit 1). - String positive control (the first `StringLiteral` the parser locates in each file): DIFFER at token 5 (exit 1), once per file. - `describe()` positive control (the first `.describe()` string argument the parser locates: `stack.zod.ts:133`, `analytics.zod.ts:244`): DIFFER at tokens 507 and 442 (exit 1). - **Line balance**: `stack.zod.ts` +9/−9, `data/analytics.zod.ts` +3/−3; line counts equal at base and head (5344 and 853). - **Tracker numbers**: added-not-removed is empty in both files, and no `PR #N` is on an added line. Net-removed: 12 sites, 6 numbers. The only numbers on added lines are objectstack-ai#4343 and objectstack-ai#4976, which stay on `:633`. - **Shas**: 6 distinct on added lines, 0 on removed lines. - `rev-parse --disambiguate` answers 1 object for each. - `merge-base --is-ancestor` exits 0 for each, against `origin/main` `7510663c87` and against the base; each is single-parent; the repository is not shallow. - **Literal readers**: all 26 string, template and regex literals in the repository that carry one of the six numbers (42 code files) were matched against the two files' base text: 0 occur there. Each removed line was also cut into 4-word windows (96) and searched across the tree: the 9 hits inside string literals are other files' own test titles sharing a phrase ("the ADR-0010 protection envelope", "an assembled body is"), and none reads either file. The source-text readers of the two files read code, not these comments: `compose-stacks-refusal-envelopes.test.ts` counts `throw new Error(`, and `check-stack-collection-maps.mjs` and `check-skill-top-level-keys.mjs` read the declared collections and keys. ## Tests and gates (at head `cc0580d404`) - `pnpm exec turbo run build --concurrency=2 --filter=./packages/* --filter=./packages/*/*` under `os-verify-lock`: Tasks 71 successful, 71 total, VERDICT command-exit 0. - `pnpm --filter @objectstack/spec check:generated` under the lock: all 15 generated artifacts up to date, `check:docs` over `content/docs/references/**` included; VERDICT command-exit 0. No reference page projects any of the 12 lines, so none is regenerated. - `vitest run --maxWorkers=2` under the lock over the two files' own suites (`src/stack*`, `src/compose-stacks*`, `src/define-stack*`, `src/assembled-package-body`, `src/data/analytics*`, `src/data/cube*`): Test Files 35 passed (35), Tests 976 passed (976). - The 37 spec suites that read source text across `src/`, or carry one of these numbers, under the lock: Test Files 37 passed (37), Tests 759 passed (759). - `scripts/{category-title,dist-freshness,dist-freshness-adoption,file-description,strictness-ledger,strictness-ledger-doc,root-index,skill-map-guards,export-origins,split-entries,root-entry-type-nameability.pin}`, `scripts/liveness/{evidence,tombstoned-row-status}`; - `src/type-alias-convention.pin`, `src/eager-entry-import`, `src/api/{api-entry-graph.pin,auth,export-job-family-retirement}`, `src/ai/tool-confirmation-prescription-tense.pin`, `src/data/{currency-mode-family-closure.pin,external-lookup-retirement}`, `src/identity/position-delegatable-enforcer.pin`, `src/integration/{connector-connection-timeout-retirement,connector-resilience-keys-retirement}`, `src/security/rls-tags-retirement`, `src/shared/{alias-integrity,retired-key-migrate-sentence}`, `src/system/{compliance-families-retirement,constants/platform-object-names,email-template-floor-locale-parity.pin,message-queue-retirement}`, `src/ui/{action-requires-confirmation-docblock.pin,i18n,interaction-config-retirement,strictness-batch14}`, `src/kernel/{manifest-unknown-keys,metadata-type-schemas}`. - Left to CI: `scripts/{build-schemas-check-mode,def-key-collisions,openapi-self-consistency}` (each rebuilds artifacts in a temp tree) and `scripts/{check-generated-ledger,check-generated-fix-rebuild.pin}` (read the ledger and `dist`). None reads comment text. - `pnpm --filter @objectstack/spec typecheck` under the lock: exit 0; `check:test-typecheck` OK (53 files / 251 errors / 138 pinned signatures held). - Lint, a proven narrowing: `eslint --no-inline-config --format json` over the 2 files gives 2 files, 0 errors, 0 warnings. - `isPathIgnored` is false for both, read through eslint's API. - `eslint.config.mjs:327-328` says type-aware linting is never enabled, so a comment edit cannot move an untouched file's verdict. - The repo-wide `pnpm lint` is CI's. - `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands`: 79 families derived and run, every one exit 0. `--ran` reads "79 derived, 79 run, 0 NOT-MEASURED, 0 UNRUN". Among them: - `pnpm check:issue-citations` (self-test, 114 cases in 8 batteries) and the live diff-scoped `node scripts/check-issue-citations.mjs`: it judged the 2 citations on added lines, objectstack-ai#4343 and objectstack-ai#4976, and both are live issues. - `pnpm check:doc-authoring`: 16,804 customer-facing strings across 1,179 spec sources clean; the sibling baseline holds. - `pnpm check:stack-collection-maps`: 8 enumerations reconciled against 31 declared collections. - Changeset: `patch` for `@objectstack/spec`. Both files are `src/**/*.zod.ts`, which `files[]` ships verbatim, and the rewritten docblocks reach `dist`: "posture: commit 4d0d944 closed" and "[commit 2306a76] This docblock used to say" are each in 2 `.d.ts`, their old spellings in 0. Positive control: the unchanged neighbouring sentence "BY INHERITANCE — an undeclared key on one is REFUSED" is in the same 2 `.d.ts`. - Merge probe: a no-driver `merge-tree` of the head onto `origin/main` `7510663c87`, from a bare shared clone, exits 0. The 3 commits `main` gained since the base touch neither file nor the citation or derivation scripts, and a re-derivation prints the same 79 commands. No merge was made. - No ablation or reverse verification: the change is comment-only, so there is no behaviour to invert. ## Hypotheses (measured first) 1. **Holds.** 12 dead sites at the tip, 9 in `stack.zod.ts` and 3 in `data/analytics.zod.ts`, equal per file to stage 6's census. 2. **Holds.** Read at 2026-09-29T07:36Z and again at 08:16Z, after the last push and before this PR was opened: all open PRs' full file lists (9 PRs, 166 files at the second read) and the newest `Claim:` on all 11 `pm:dispatched` cards. None names either file, except this card's own claim. 3. **Holds, with nothing to keep.** All 12 sites are comments. No test string, exported string or `describe()` text carries one, and no test or script reads any of them by literal. 4. **Holds.** No generated reference page projects these lines; `check:docs` is green with no regeneration. ## Deviations - None to the file surface: the 12 claimed lines and one changeset, no generated page needed. - Commit trailers follow AGENTS.md's model-free pair (`Claude-Session` plus `Co-authored-by: Claude`); the pre-push trailer check passed on every push. ## Acceptance notes **What stays for later stages.** The gate's census at this PR's head (base `0f6dcac5e9` plus this PR) reads **248** dead sites (29 numbers) in `packages/spec/src`. The only `packages/spec/src` change `main` has made since the base (objectstack-ai#20610's migrations entry and registry) adds four live numbers and removes none, so 248 also stands at the tip `7510663c87` plus this PR: - `migrations/` **233**: objectstack-ai#20233 edits the same entry files (PR objectstack-ai#20607 holds `migrations/registry.ts`). - `conversions/registry.ts` **12**: PRs objectstack-ai#20570 and objectstack-ai#20587 hold it. - `integration/connector.zod.ts` **1**: PR objectstack-ai#20587 (objectstack-ai#20287). - `data/api-derivation.ts:163` (objectstack-ai#6259) and `identity/identity.zod.ts:230` (objectstack-ai#8715), **1** each: kept because tests read them by literal, so removing them is form D. **Outside the gate's census: test files.** The gate defers `*.test.ts`. The same six dead numbers still stand at 15 comment sites and 10 test-title strings in `packages/spec/src` test files: - `data/analytics-strictness-batchd.test.ts:96` (comment, objectstack-ai#10194) and its title `:93`. This file is in the `analytics*` set stages 3 and 4 excluded while PR objectstack-ai#20458 held it; `analytics-date-range-two-bound-window.test.ts` and `cube-member-inner-name-retirement.test.ts` were in that set too and are not re-measured here. - The package root: `compose-stacks-action-echo.test.ts:20`, `:34`, `:200` (objectstack-ai#14686) and titles `:176`, `:224`; `compose-stacks-action-key-collision.test.ts:3` (objectstack-ai#14662); `stack-top-level-strict.test.ts:103` (objectstack-ai#10485) and title `:128`; `type-alias-convention.pin.test.ts:257`, `:1572`, `:1937` (objectstack-ai#10485). - `shared/`: `metadata-collection.test.ts:250`, `metadata-url-spelling.test.ts:51`, `:72`, `:168` (objectstack-ai#10485), `:257` (objectstack-ai#10194), title `:254`. `automation/sync-retirement.test.ts:207` (objectstack-ai#10485). - `kernel/`: `manifest-unknown-keys.test.ts`, four titles (objectstack-ai#14192); `metadata-type-schemas.test.ts:422`, a title (objectstack-ai#10194). - Stage 6 took the package root, `shared/` and `automation/` through the gate's census, which never lists a test file, so test-file comment lines there may carry other dead numbers as well. That wider population is not measured here. **Outside `packages/spec/src`.** The same six numbers stand at 44 more sites (`packages/{metadata-protocol,objectql,rest,runtime,cli,core,metadata,qa}`, `examples/`, `scripts/`, `packages/spec/scripts/`), and at 19 sites in `migrations/` (the objectstack-ai#20233 area). **Rung.** The objectstack-ai#10485 retirement also has the ADR-0087 D3 entry `stack-themes-carrier-retired`, which `:423` already names. This PR takes the commit rung, as stages 1–6 did. **Wording, each true of its commit.** `:3037` and `:3194` now read "commit 279431e's same-key refusal": the refusal that commit added, in lines `773a99960a` wrote. `:1233` reads "commit 4d0d944 closed `ManifestSchema`", in a line `c78c9180de` wrote. --- _Generated by [Claude Code](https://claude.ai/code/session_014EJ1ED8X4MMrT18BhVx4tx)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…7 conversions it applied on its refusal — stackConversionsOf(error) reads them (objectstack-ai#20651) Fixes objectstack-ai#20618 Clause-②: no ## What this lands This is the `packages/spec` half of objectstack-ai#20583 (its location 2). objectstack-ai#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 objectstack-ai#20583's CLI fold) ```ts } 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: - it is the same defect class; - it is a mechanical fix whose shape this card pins (the same stamp, and the formula PR objectstack-ai#20579 pinned for the artifact); - the file is in this claim's surface, and no other claim holds it; - it adds no new gate family. 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](https://claude.ai/code/session_014EJ1ED8X4MMrT18BhVx4tx)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Fixes #20476
Clause-②: yes (widening)
defineStackapplies every ADR-0087 D2 conversion at load, in both modes, and until now it only said so on stderr.os validateandos buildaccept onlydefineStack/composeStacksoutput (ruling B on #20367), so the stack they received was already canonical and their own step-2 conversion pass found nothing. As a result--jsonansweredconversions: []for every accepted config, andos validate --strictexited 0 on a retiring spelling.The fix follows triage's direction. The producer records what it applied, and the doors fold that record in. There is no second conversion pass in the CLI.
What changed
spec (
stack-provenance.ts,stack.zod.ts)defineStack(strict andstrict: false) records everyConversionNoticeits load-time pass applied on the stack it returns. The record sits beside the provenance mark underSymbol.for('objectstack.stack.conversions')and is stamped in the samemarkStackProvenanceact. It is non-enumerable, non-writable, non-configurable and frozen, so the strict schema,Object.keysandJSON.stringifynever see it. The compiled artifact is byte-identical in size: 1372 bytes before and after on the measured case.ConversionNotice, the element the doors'conversionsfield already declares, not a second shape.defineStackkeeps its record.composeStacks: the output record is its inputs' records concatenated in input order, and the same built stack passed twice counts once (aSetover the frozen notices). A single input is returned as-is, record included. Composition converts nothing itself, so this concatenation is the whole of what was applied. Each notice'spathstays relative to thedefineStackcall that applied it, which is the source the author wrote.stackConversionsOf(value). It returns[]for any value that does not carry the provenance mark, so a forged record on an unmarked value is not read.api-surface/root.jsonandexport-origins/root.json, one line each (check:generated --fixproved exactly these 2 of 15 stale).cli (
utils/config.ts,commands/validate.ts,commands/compile.ts)loadConfigreadsstackConversionsOfoffmod.defaultbeside the mark, before the named-export spread drops both, and returns it asLoadedConfig.stackConversions.conversionssink at a new step 1b, right after the provenance refusal at 1a. The sink stays declared above thetry, and every exit still reads it. A throw at load and a refused unbuilt export still report[].export const pages = [...]beside adefineStackdefault) is never seen by the producer, and only that pass converts it. Probe atba5927f714, before any change:os validate --json --stricton that config exits 1 with oneconversionsentry. After the change it is still exactly one entry, with no double count.--jsonenvelope keeps its shape. No change to which exports the doors accept.Verification record
The measured case
A
defineStackconfig whosepage:headercomponent authorsdescription(live conversionpage-header-subtitle-alias,retiresIn: 18), run through the real CLI (bin/run-dev.js).ba5927f714)os validate --strict⚠block, "Strict mode: warnings treated as errors"os validate --json --strictconversions: []valid: true,warnings: [],conversions: [page-header-subtitle-alias @ pages[0].regions[0].components[0].properties.subtitle]os validate --jsonconversions: []os build --jsonconversions: []subtitle)conversions: []conversions: []Terminal output. stderr carries the producer's
defineStack: PATH: 'description' → 'subtitle' (converted at load; …)line exactly once per conversion, before and after. Nothing on the door's side writes a second stderr line, and the flipped pins assert the count is 1. The door's own rendering stays on stdout: the text face now adds one⚠line (validate: in the warning block--strictgates on; build: at step 2, as it always rendered its own pass's notices).Pins flipped (full-repo sweep)
The sweep covered every test asserting
conversions: [], or--strictexit 0, on a config with a live conversion. It found exactly the three loss-recording pins. The otherconversionspins in the CLI either assert no-conversion fixtures by construction (validate-json-warning-parity.e2e,jsx-gate-manifest-notice.e2e) or belong toos lint(see Acceptance notes).test/validate-json-failure-conversions.e2e.test.tsandtest/build-json-failure-conversions.e2e.test.ts:expectTheOneNoticenow asserts the payload carries exactly the one notice (conversionId,surface,from,to,path, pluscodeand a numericretiresIn) and that the producer line appears on stderr exactly once. The negative controls keep asserting[]: canonical kind, and throw at load.src/commands/validate-json-strict-exit.e2e.test.ts: the conversions-only cell is back to the measurement in its own header. Both--strictfaces exit 1 (floor, then parity),valid: true,warnings: [], the one entry. A new no---strictcontrol exits 0 and still lists it. The canonical control is unchanged at[]and exit 0.test/stack-conversion-record-door.test.ts(it runs in PR CI, unlike the nightly.e2efiles). Both doors cover four rows: the record across the named-export spread, acomposeStacksrecord, the doors' own pass over a named-export key (exactly one entry), and the canonical control.os validate --strictis checked on both faces.packages/spec/src/stack-conversions-record.test.ts, 16 tests.Reverse verification
Each fix was committed first. Every mutation went through
scripts/ablation-replace.mjs, which requires the anchor to hit exactly once and checks the blob hash before and after. Every restore was proven by blob equal to HEAD and an emptygit diff HEAD. Direction observed: red on the record rows, with the pass-only row and the controls staying green.validate.tsfold removed0d4bbdf6→6bb0e494--strictboth faces) / 7 green (4 build rows, the named-export pass row, canonical controls). Strict-exit e2e: the conversions cell and its no---strictcontrol red. Restored to0d4bbdf6.compile.tsfold removedb1ae356a→5bc31cd6defineStackstops recordingebbad2e7→ae987e44A and B run the CLI from
src/(no rebuild on that path). C was measured through the spec unit test, which readssrc/. Nodist/ablation was run.Suites and gates
@objectstack/specfull suite ata2dce8612c(the merge oforigin/mainb05743433b): 607 files, 17513 passed, 1 todo, exit 0. Typecheck at the same head, includingcheck:scripts-typecheckandcheck:test-typecheck: exit 0.@objectstack/cliata2dce8612c:published-subpath-*pins exited on the not-built prerequisite, then passed (29 tests) afterpnpm --filter @objectstack/cli build.OS_TEST_TIERS=nightly): 3 files, 36 passed.a2dce8612c, built CLI:examples/app-crmandexamples/app-todo:os validate --jsonexits 0 (valid: true) andos build --jsonexits 0 (success: true). Both answerconversions: [], with no producer conversion line on stderr.--strictexit 1 comes from 9 and 7 pre-existing advisories. This change adds nothing there, since there is nothing to fold.b80dce953b(merge oforigin/mainf572a7eb3c, which touches nopackages/clifile and none of the spec files here; an earlier revision of this line named1c761c0d71in error):node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commandsderived 89 commands, all exit 0.check:dual-build-cjs-loadsandcheck:i18n-coveragefirst exited 3 (PREREQUISITE NOT MET: other packages'dist/absent). Both were re-run after a fullturbo run build(72/72) and exited 0.--ranreconciles 89 derived, 89 run, 0 NOT-MEASURED, 0 UNRUN.9de090d5famergesorigin/mainf11b5f20a2intob80dce953b, to re-run CI afterLint & Repo Gatesfailed to read the issue board (PREREQUISITE NOT MET). Each of the 13 files keeps itsgit patch-id --stable(checked by the dev and by the seat), andcheck:generatedis 15/15 at the new head.--no-inline-config) on the 10 touched TS files atb80dce953b: 10 files, 0 errors, 0 warnings.eslint.config.mjsnever enables type-aware linting, so this diff cannot move the verdict of any untouched file. The repo-widepnpm lintis CI's.Acceptance notes
os lint --json. It answersconversions: []on adefineStackconfig with a live conversion. Measured atba5927f714on the same probe: exit 0,conversions: [], producer line on stderr.lint.tsruns its own pass overloadConfig's config and does not readLoadedConfig.stackConversions. The one-line fold is the same as here, butos lintis not one of the two doors the ruling and this claim name, so it is left for the seat.defineStackthat refuses after converting returns no stack, so there is nothing to carry the record. Measured: that config (requires: ['no-such-capability']beside thedescriptionheader) exits 1 withSTACK_CAPABILITY_UNKNOWNandconversions: [], and the notice is on stderr only. The run already fails loudly, and the notice surfaces once the refusal is fixed. Reaching the envelope from a refusal would need a second channel (the refusal carrying the notices), which is a design choice outside this change. ThestackConversionsOfTSDoc names this boundary.defineStack({ ...built, … })re-marks the copy with an empty record, because the source keys it re-normalizes are already canonical.content/docs/deployment/cli.mdx's "Warnings checked" list foros validatenames neither conversion notices nor the other advisory classes--strictalready gated on. It was incomplete before this change and is not made false by it.@objectstack/spec(mark present, no record) reads[]throughstackConversionsOf. The doors then behave exactly as before this change for that config.Generated by Claude Code