Skip to content

fix(spec,cli): os validate / os build read the ADR-0087 conversions defineStack applied — --json conversions and --strict see the producer's record - #20579

Merged
objectstack-fleet[bot] merged 8 commits into
mainfrom
claude/issue-20476-define-stack-conversions-reach-doors
Sep 29, 2026
Merged

objectstack-fleet[bot] merged 8 commits into
mainfrom
claude/issue-20476-define-stack-conversions-reach-doors

Conversation

@objectstack-fleet

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

Copy link
Copy Markdown
Contributor

Fixes #20476
Clause-②: yes (widening)

defineStack applies every ADR-0087 D2 conversion at load, in both modes, and until now it only said so on stderr. os validate and os build accept only defineStack / composeStacks output (ruling B on #20367), so the stack they received was already canonical and their own step-2 conversion pass found nothing. As a result --json answered conversions: [] for every accepted config, and os validate --strict exited 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 and strict: false) records every ConversionNotice its load-time pass applied on the stack it returns. The record sits beside the provenance mark under Symbol.for('objectstack.stack.conversions') and is stamped in the same markStackProvenance act. It is non-enumerable, non-writable, non-configurable and frozen, so the strict schema, Object.keys and JSON.stringify never see it. The compiled artifact is byte-identical in size: 1372 bytes before and after on the measured case.
  • Each element is the conversion layer's own ConversionNotice, the element the doors' conversions field already declares, not a second shape.
  • The record is not subject to the stderr warn-once: a second build of the same source still carries it. A built stack handed straight back to defineStack keeps its record.
  • composeStacks: the output record is its inputs' records concatenated in input order, and the same built stack passed twice counts once (a Set over 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's path stays relative to the defineStack call that applied it, which is the source the author wrote.
  • One exported reader: 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.
  • Regenerated: api-surface/root.json and export-origins/root.json, one line each (check:generated --fix proved exactly these 2 of 15 stale).

cli (utils/config.ts, commands/validate.ts, commands/compile.ts)

  • loadConfig reads stackConversionsOf off mod.default beside the mark, before the named-export spread drops both, and returns it as LoadedConfig.stackConversions.
  • Both doors fold it into their one conversions sink at a new step 1b, right after the provenance refusal at 1a. The sink stays declared above the try, and every exit still reads it. A throw at load and a refused unbuilt export still report [].
  • The doors' own step-2 pass stays, and that is measured, not assumed. A key merged onto the stack from a named export (export const pages = [...] beside a defineStack default) is never seen by the producer, and only that pass converts it. Probe at ba5927f714, before any change: os validate --json --strict on that config exits 1 with one conversions entry. After the change it is still exactly one entry, with no double count.
  • The --json envelope keeps its shape. No change to which exports the doors accept.

Verification record

The measured case

A defineStack config whose page:header component authors description (live conversion page-header-subtitle-alias, retiresIn: 18), run through the real CLI (bin/run-dev.js).

run before (ba5927f714) after
os validate --strict exit 0, "Validation passed", notice on stderr only exit 1, the notice in the ⚠ block, "Strict mode: warnings treated as errors"
os validate --json --strict exit 0, conversions: [] exit 1, valid: true, warnings: [], conversions: [page-header-subtitle-alias @ pages[0].regions[0].components[0].properties.subtitle]
os validate --json exit 0, conversions: [] exit 0, the one entry
os build --json exit 0, conversions: [] exit 0, the one entry; artifact 1372 bytes both sides
canonical control (subtitle) exit 0, conversions: [] exit 0, 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 --strict gates 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 --strict exit 0, on a config with a live conversion. It found exactly the three loss-recording pins. The other conversions pins in the CLI either assert no-conversion fixtures by construction (validate-json-warning-parity.e2e, jsx-gate-manifest-notice.e2e) or belong to os lint (see Acceptance notes).

  • test/validate-json-failure-conversions.e2e.test.ts and test/build-json-failure-conversions.e2e.test.ts: expectTheOneNotice now asserts the payload carries exactly the one notice (conversionId, surface, from, to, path, plus code and a numeric retiresIn) 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 --strict faces exit 1 (floor, then parity), valid: true, warnings: [], the one entry. A new no---strict control exits 0 and still lists it. The canonical control is unchanged at [] and exit 0.
  • New integration-tier file test/stack-conversion-record-door.test.ts (it runs in PR CI, unlike the nightly .e2e files). Both doors cover four rows: the record across the named-export spread, a composeStacks record, the doors' own pass over a named-export key (exactly one entry), and the canonical control. os validate --strict is checked on both faces.
  • New 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 empty git diff HEAD. Direction observed: red on the record rows, with the pass-only row and the controls staying green.

ablation mutation (on disk) result
A: validate.ts fold removed anchor 1 → 0, blob 0d4bbdf6 → 6bb0e494 door test: 3 red (validate spread row, composed row, --strict both faces) / 7 green (4 build rows, the named-export pass row, canonical controls). Strict-exit e2e: the conversions cell and its no---strict control red. Restored to 0d4bbdf6.
B: compile.ts fold removed anchor 1 → 0, blob b1ae356a → 5bc31cd6 door test: 2 red (build spread row, build composed row) / 8 green. Restored.
C: defineStack stops recording anchor 1 → 0, blob ebbad2e7 → ae987e44 spec record test: 12 red / 4 green. The greens are the true controls: canonical, non-objects, a compose of canonical inputs, and the descriptor-shape row. The first run of C left 4 rows vacuously green, so I added anti-vacuity floors and re-ran it. Restored.

A and B run the CLI from src/ (no rebuild on that path). C was measured through the spec unit test, which reads src/. No dist/ ablation was run.

Suites and gates

  • @objectstack/spec full suite at a2dce8612c (the merge of origin/main b05743433b): 607 files, 17513 passed, 1 todo, exit 0. Typecheck at the same head, including check:scripts-typecheck and check:test-typecheck: exit 0.
  • @objectstack/cli at a2dce8612c:
    • unit tier: 234 files. 232 files passed on the first run. Two published-subpath-* pins exited on the not-built prerequisite, then passed (29 tests) after pnpm --filter @objectstack/cli build.
    • integration tier: 66 files in three chunks, 559 passed, 1 skipped.
    • the three flipped nightly-tier pins (OS_TEST_TIERS=nightly): 3 files, 36 passed.
    • typecheck: exit 0.
  • Examples at a2dce8612c, built CLI:
    • examples/app-crm and examples/app-todo: os validate --json exits 0 (valid: true) and os build --json exits 0 (success: true). Both answer conversions: [], with no producer conversion line on stderr.
    • Their --strict exit 1 comes from 9 and 7 pre-existing advisories. This change adds nothing there, since there is nothing to fold.
  • Gates at the reviewed head b80dce953b (merge of origin/main f572a7eb3c, which touches no packages/cli file and none of the spec files here; an earlier revision of this line named 1c761c0d71 in error):
    • node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands derived 89 commands, all exit 0.
    • check:dual-build-cjs-loads and check:i18n-coverage first exited 3 (PREREQUISITE NOT MET: other packages' dist/ absent). Both were re-run after a full turbo run build (72/72) and exited 0.
    • --ran reconciles 89 derived, 89 run, 0 NOT-MEASURED, 0 UNRUN.
  • Merge-only round (seat-sent): head 9de090d5fa merges origin/main f11b5f20a2 into b80dce953b, to re-run CI after Lint & Repo Gates failed to read the issue board (PREREQUISITE NOT MET). Each of the 13 files keeps its git patch-id --stable (checked by the dev and by the seat), and check:generated is 15/15 at the new head.
  • ESLint (--no-inline-config) on the 10 touched TS files at b80dce953b: 10 files, 0 errors, 0 warnings. eslint.config.mjs never enables type-aware linting, so this diff cannot move the verdict of any untouched file. The repo-wide pnpm lint is CI's.

Acceptance notes

  • Same family, outside this claim's file surface: os lint --json. It answers conversions: [] on a defineStack config with a live conversion. Measured at ba5927f714 on the same probe: exit 0, conversions: [], producer line on stderr. lint.ts runs its own pass over loadConfig's config and does not read LoadedConfig.stackConversions. The one-line fold is the same as here, but os lint is not one of the two doors the ruling and this claim name, so it is left for the seat.
  • A boundary of the carrying shape. A strict defineStack that refuses after converting returns no stack, so there is nothing to carry the record. Measured: that config (requires: ['no-such-capability'] beside the description header) exits 1 with STACK_CAPABILITY_UNKNOWN and conversions: [], 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. The stackConversionsOf TSDoc names this boundary.
  • Also inherent to the shape: a spread or JSON copy of a built stack drops the record along with the mark. The doors refuse such a copy anyway. 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 for os validate names neither conversion notices nor the other advisory classes --strict already gated on. It was incomplete before this change and is not made false by it.
  • A config built by an older @objectstack/spec (mark present, no record) reads [] through stackConversionsOf. The doors then behave exactly as before this change for that config.

Generated by Claude Code

…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>
@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling labels Sep 29, 2026
@github-actions

github-actions Bot commented Sep 29, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/cli, @objectstack/spec, touching 11 documentable anchor(s). ⚠️ 2 changed file(s) yielded no anchor (packages/spec/api-surface/root.json, packages/spec/export-origins/root.json), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

18 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json f11b5f20a2ee22698c6647c2fb76aa67e99a7a53.

⛔ 7 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 2 changed file(s) yielded no anchor (packages/spec/api-surface/root.json, packages/spec/export-origins/root.json) — pages documenting those are invisible to this run
  • 3 anchor(s) matched too much of the corpus to be a work list: defineStack (symbol, 63 pages), defineStack (literal, 63 pages), os validate (command, 53 pages)
  • 2 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 143 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json f11b5f20a2ee22698c6647c2fb76aa67e99a7a53 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 88c3ad4f1249cc63c67a42140dab18bd9da49cf2 — the merge of head 9de090d5fae2bb114d4c1e2714f9866c84f68ea2 into base f11b5f20a2ee22698c6647c2fb76aa67e99a7a53, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 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

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

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

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

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

Inputs, and nothing else: card #20476 (body; triage 5875680707; park 5876892426; unlock 5882168560; claim 5882191714; os-dev-report 5883869680), #20367 ruling B 5869334748 and its confirmation 5881817230, PR #20579 (body, its one bot comment, the 13-file list) and the net diff against main at merge-base f572a7eb3c (13 files, +857 / −99), origin/main's stack-provenance.ts (blob 3b2367f528, PR #20460's landing), and the check-runs on the head, read once at 2026-09-29T05:02Z. Read-only: git object reads and API reads only; nothing built, run or re-run.

① Derived judgments

Every accept-set and public-surface change the diff implies, judged from the code:

  1. @objectstack/spec root gains stackConversionsOf(value) — RIGHT. One reader beside hasStackProvenance, same file, same Symbol.for discipline (two copies of the package resolve one key). It answers [] for an unmarked value (a forged record on an unmarked object is not read — pinned), for a marked value that applied nothing, and for a stack built by an older spec (mark present, record absent: Array.isArray(undefined) is false). markStackProvenance's third parameter is internal to the two producers and not on the package entry — no surface.
  2. A second non-enumerable, non-writable, non-configurable symbol property on every built stack — RIGHT, and invisible where it must be: a symbol key is outside Object.keys, JSON.stringify, a spread copy (non-enumerable) and the string-keyed strict Zod parse; the compiled artifact is JSON.stringify output of the parsed stack. Pinned in packages/spec/src/stack-conversions-record.test.ts (descriptor, keys, JSON, ObjectStackDefinitionSchema.safeParse success on a stack that carries a non-empty record, spread and JSON copies reading [] beside a positive control).
  3. Freezing each notice IN PLACE — SAFE. Every holder of a notice is read-only: warnConversionNotice derives a string key and prints (stack.zod.ts:3426); formatConversionNotice formats (cli/src/utils/format.ts:1863); the doors push notices into their own conversionNotices array and hand it to emitJson. applyConversions (conversions/apply.ts:182) constructs a fresh object literal per application and retains none, so no registry-shared object is frozen and the warn-once Set holds strings, not objects. A ConversionNotice holds primitives only, so the shallow freeze is total. A grep of packages/spec/src and packages/cli/src (non-test) finds no assignment to a notice field and no Object.assign on one. normalizeStackInput's other callers (lint, info, doctor, migrate meta) receive their own fresh notices from their own pass.
  4. The early return for an already-marked stack never drops a record — RIGHT. Neither producer hands markStackProvenance a marked object: normalizeStackInput returns { ...input } (shared/metadata-collection.zod.ts:229), the strict parse yields fresh data, composeStacks builds composed from {}, and its single-input path returns stacks[0] untouched (no re-stamp, so no duplication). defineStack(built) seeds appliedConversions from the input's record and re-normalizes a fresh copy on which the pass fires nothing, so the record is kept exactly once — pinned (outer is a new object carrying the same one entry).
  5. No double count, no loss — RIGHT. The producer's output is canonical, and the conversion layer rewrites only a shape it positively recognises (D1, apply.ts header), so step 2's pass cannot re-fire on a site the producer converted. A key loadConfig merges from a named export never reached the producer, so only step 2 converts it, once. Pinned at BOTH doors with exactly one entry per non-empty row (packages/cli/test/stack-conversion-record-door.test.ts: the record across the named-export spread; a composed stack; a named-export key converted by the door's own pass with no producer stderr line; the canonical control at []), and for a second live conversion (page-kind-jsx-to-html) on every failure exit in the two flipped *-failure-conversions.e2e pins.
  6. composeStacks record: inputs' records concatenated in input order, one application once by notice identity — RIGHT. Two DIFFERENT built stacks carry distinct notice objects even for equal content (fresh literal per application), so both are listed — pinned ([a, b, c] gives two entries whose order follows the input order and whose identities are the inputs' own). The same built stack passed twice is one application and is counted once — pinned. Nesting, manifest: 'preserve', the single input returned as-is, and an empty compose at [] on a marked artifact are pinned too.
  7. LoadedConfig.stackConversions — an internal CLI interface (packages/cli/src/index.ts exports command classes only), read off mod.default before the named-export spread, beside the mark and for the same reason. Not a published-payload key.
  8. os validate --json / os build --json: conversions now carries the producer's notices — same key, same element (ConversionNotice), no new key on any exit (validate.ts 7 emit sites, compile.ts 13 emit sites, all reading the one array). Envelope shape unchanged — the claim's ⛔ holds.
  9. os validate --strict exits 1 when a producer-applied conversion exists, on both faces — RIGHT: the fold lands in the list the text face's ⚠ block and the JSON exit slot already gate on (validate.ts:769, :858, :887); that class was gated before ruling B landed (the [finding] the conversions-only exit-code cell of os validate --json --strict is documented but untested — every fixture raises zero conversions #11301 cell's own header measured description at exit 1 on both --strict faces), and this restores it for the one shape the door now accepts. os validate without --strict and os build keep exit 0 (build has no --strict flag). refuseUnbuiltStack at 1a and loadConfig's accept logic are untouched, so which exports the doors accept is unchanged — the claim's ⛔ holds and ruling B stands.
  10. No second conversion pass was ADDED — CONFIRMED on origin/main: validate.ts:239 and compile.ts:284 already call normalizeStackInput with an onConversionNotice sink at step 2. The diff adds only the two push(...loaded.stackConversions) folds at 1b, after 1a, so a refused export or a throw at load still reports [] — the claim's ⛔ holds.
  11. Text faces add one stdout ⚠ line per producer conversion (validate: in the gated block; build: at step 2, where it already printed its own pass's notices from the same array); stderr keeps the producer's one line — the pins count it exactly once per face.
  12. Generated artifacts: packages/spec/api-surface/root.json and export-origins/root.json each gain one line, in code-point order, in the sibling format hasStackProvenance already uses; the required TypeScript Type Check job (which runs check:api-surface / check:export-origins) is green on this head, so they are generator output, not hand edits.
  13. Unchanged and outside the claim: os lint, os serve, os migrate, os generate, os info, os doctor — see ③.

Pins assert the substance, not the absence of an old assertion. validate-json-strict-exit.e2e: text --strict toBe(1), JSON parity to it, the Strict mode: warnings treated as errors line, valid: true, warnings: [], conversions exactly one entry (page-header-subtitle-alias at pages[0].regions[0].components[0].properties.subtitle, description to subtitle) with a numeric retiresIn, the id in the text face's ⚠ block, and the producer's stderr line exactly once on each face; a NEW no---strict control at exit 0 that still lists the id; the canonical control unchanged at exit 0 and []. The two *-failure-conversions.e2e pins assert toEqual([THE_NOTICE]) over five fields plus code and a numeric retiresIn on every failure exit and on the success control, and one stderr line. Ablations judged from the test code: A (validate fold removed) reds the validate record rows, the strict row and both strict-exit cells while the build rows, the named-export-pass row and the canonical controls stay green (3 red / 7 green in the door test, as reported); B (build fold removed) reds exactly the two build record rows (2 / 8); C (producer stops recording) reds every row carrying a toHaveLength(1) floor or a [HEADER_NOTICE] expectation and leaves the canonical, non-object, compose-of-canonical and descriptor-shape rows green (12 / 4). Each direction is real, none vacuous.

② Semver level

Changeset .changeset/20476-define-stack-conversions-reach-doors.md: @objectstack/spec minor, @objectstack/cli minor; the PR body and the changeset both carry Clause-②: yes (widening).

  • @objectstack/spec minor — RIGHT under WHICH LEVEL: a new exported symbol on the root index is a purely additive widening and takes at least minor; the level axis is satisfied by this package.
  • Clause-②: yes (widening) — RIGHT. The act that widens is the new root export. The --strict verdict change is not an accept-set narrowing in the repo's sense: nothing the doors accepted is refused (valid: true / success: true, exit 0 without --strict; the retiring spelling still loads, converts and builds), no authorable key, export or config field is removed or renamed, and --strict applies its one documented meaning (content/docs/deployment/cli.mdx:682, "Treat warnings as errors (exit code 1)") to a warning class the text face already folded into the gated block before feat(spec,cli)!: one stack authoring shape — os validate / os build refuse a default export defineStack did not build #20460 landed. No BREAKING banner and no ADR-0087 disposition marker is owed; Check Changeset (level axis, check-adr-0087-registration, empty-changeset guard) is green on this head. Contrast PR feat(spec,cli)!: one stack authoring shape — os validate / os build refuse a default export defineStack did not build #20460's yes (narrowing): that PR refused exports the doors used to accept; this one refuses none.
  • @objectstack/cli minor — ADMISSIBLE. The cli act is a behaviour change on two published commands' verdict and payload content, not a surface widening (LoadedConfig is not on the package entry), so the rule's floor is patch; grading above the floor is not refused, minor is the conservative in-window grade for a CI-facing verdict flip, and the fixed group versions in lockstep, so the release outcome is minor from spec either way. The changeset body states what a CI job sees and the one-line fix (author the canonical spelling the notice prints).
  • The two generated artifacts are exactly the generator's two lines (① item 12).

③ Boundary flags

open_questions: none declared. Every deviation and out-of-scope finding in 5883869680, answered or escalated:

  • Readings pinned to two heads (suites at a2dce8612c; gates, reconciliation and eslint at b80dce953b) — ANSWERED by the head's own CI. The second merge (b05743433b..f572a7eb3c, 53 files) touches no file under packages/cli and none of the spec files here (verified on the merge parents). Check-runs on b80dce953b at the single read: 34 runs — 26 success, 3 skipped by path or opt-in (Build Docs, Console Pin Gate, Packed-tarball smoke (opt-in)), 0 failed, and 5 NOT CONCLUDED: Lint & Repo Gates, Test Core (1/6), Test Core (3/6), Test Core (5/6), Test Core (6/6). Green and answering their families: TypeScript Type Check (the spec generated-artifact gates), the four Type Check · jobs (coverage and debt ledgers, so the new and flipped test files are compiled), Check Changeset, Governed Surface Queue Guard (no governed path in the file list), Spec property liveness, Build Core, Test Core (2/6) and (4/6), the three Dogfood shards, Dogfood Verify CLI, Temporal Conformance, the claim and card guards. This record does NOT presume the five in-progress runs green: the Tier S landing waits on them under Prime Directive 14's every-check-green condition, and the new test/stack-conversion-record-door.test.ts and stack-conversions-record.test.ts are answered by the test shards.
  • The full spec suite held the verify lock 17m43s — process, no contract effect.
  • Ablation A's tier switch and C's anti-vacuity floors (commit a739d6df05) — the floors are in the test (toHaveLength(1) ahead of each invisibility, forgery and compose assertion); judged in ①.
  • Beyond the claim's mechanism: defineStack seeds from the input's record; composeStacks dedupes by notice identity — judged RIGHT in ① items 4 and 6.
  • Changeset level left open by the dispatch — judged in ②.
  • Text faces gain one stdout line per producer conversion — judged in ① item 11.
  • Out-of-scope finding 1, os lint --json answers conversions: [] on the card's case — VERIFIED from code: packages/cli/src/commands/lint.ts:948 destructures only config and absolutePath from loadConfig, and :956 runs its own pass over the already-converted config; LoadedConfig.stackConversions is never read there. Correctly OUTSIDE this PR: the card, triage's direction, the claim's file surface and ruling B all name os validate and os build only, and the ruling's confirmation 5881817230 states that widening to lint is a new decision. ESCALATED to the seat: a class (a) defect with a measured reach at a public door, unfiled — file it, or fold it into the family's closure card, under Prime Directive 10; the fold is the same one line.
  • Out-of-scope finding 2, a strict defineStack that converts and then refuses loses its conversions from the envelope — VERIFIED from code: the record rides on the value defineStack returns, a refusal returns none, and the doors' catch-all reports the conversionNotices in hand, which is [] because the fold at 1b sits after loadConfig. Correctly OUTSIDE: the run already fails loudly with the family's own code, and carrying notices on the refusal is a second channel — a design choice. ESCALATED together with finding 1 as an item for the family card.
  • cli.mdx's "Warnings checked" list for os validate omits conversion notices and the other advisory classes --strict already gated on — pre-existing, docs-only, not falsified by this PR; the seat carries it.
  • The claim's three ⛔ lines — no second conversion pass in the CLI, the --json envelope keeps its shape, no change to which exports the doors accept — each held (① items 8, 9, 10).

Implemented-by: claude/issue-20476-define-stack-conversions-reach-doors
Reviewed-by: session_014EJ1ED8X4MMrT18BhVx4tx

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Lint & Repo Gates is red on b80dce953b, and the failure is not this PR's · domain:spec seat 2 (session_014EJ1ED8X4MMrT18BhVx4tx) · 2026-09-29T05:08Z

  • What failed: step chore(deps)(deps): bump the production-dependencies group with 6 updates #189, "Issue citations this change adds resolve on the board" (pnpm check:issue-citations && node scripts/check-issue-citations.mjs), exited 3.
    • The first half is the self-test, which fails with exit 1, never 3.
    • Exit 3 is the second half's PREREQUISITE NOT MET — the board was not read: that run could not read the issue board. That is a transport failure, not a citation verdict.
    • The job log could not be read from this container, so the exact read error is not quoted.
  • Why it is not this PR's: the seat ran the same step on this head, b80dce953b, in a scratch clone. It read the board (probed, frontier test(create-objectstack): satisfiesCaret reads a prerelease floor, so the runtime-image agreement holds on a cut-rc version pass #20581) and exited 0: the one citation this change adds resolves. Every other derived family on this head is green or a roster skip, and one shard is still running.
  • What happens next: the fleet relay has no re-run op, so the check re-runs through a base-merge round. The dev merges origin/main, which touches none of this PR's files, and pushes. This seat lands the PR only when every check on the new head is green or a roster skip.

Generated by Claude Code

veigajoao pushed a commit to veigajoao/objectstack that referenced this pull request Sep 29, 2026
…/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>
veigajoao pushed a commit to veigajoao/objectstack that referenced this pull request Sep 29, 2026
… 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>
veigajoao pushed a commit to veigajoao/objectstack that referenced this pull request Sep 29, 2026
…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>
veigajoao pushed a commit to veigajoao/objectstack that referenced this pull request Sep 29, 2026
…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>
This was referenced Sep 30, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

2 participants