Skip to content

fix(cli)!: the JSX page gate's console manifest fallback resolves through @objectstack/console/package.json, so a project without its own manifest gets full component checking - #20589

Merged
objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/issue-19922-console-manifest-fallback
Sep 29, 2026
Merged

objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/issue-19922-console-manifest-fallback

Conversation

@objectstack-fleet

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

Copy link
Copy Markdown
Contributor

Fixes #19922
Clause-②: no

What this changes

resolveSduiManifest() (packages/cli/src/utils/sdui-manifest.ts) is the one resolver os validate, os compile / os build and os lint use to arm the JSX page gate. os dev and os start run compile before they boot when dist/objectstack.json is missing or --compile is passed, and dev's default watch mode reruns it when a watched file changes. The resolver's second place to look, the copy @objectstack/console ships as dist/sdui.manifest.json, asked Node for that file by its own subpath. The console's exports map publishes ./package.json alone, so the resolve threw ERR_PACKAGE_PATH_NOT_EXPORTED, a catch swallowed it, and every project with no sdui.manifest.json of its own had its kind: 'html' pages checked at parse level only.

The fallback now resolves @objectstack/console/package.json from the CLI's own location (import.meta.url, the CLI's declared dependency in the same fixed release group) and joins dist/sdui.manifest.json to its directory, through a new consoleSduiManifestPath(origin). The console's exports stays closed. resolveSduiManifest(cwd, consoleOrigin) gains an optional origin, used only by the pins.

The old module header handed one decision to whoever made this leg reachable: what a broken shipped copy should do. It now gets the project leg's rule. A shipped copy that is present but cannot be read or parsed is unusable (new source: 'console'), and the command is refused with exit 1, naming the file, with the remedy "reinstall @objectstack/console". It is never read as "not found". With no page to check it is read by nothing and not refused, the same as the project leg.

This round (the seat's unlock record 5890591366 on #19922)

The ledger entry ui-html-page-div-refused landed on main (#20592, PR #20610), and Version Packages #17076 consumed .changeset/sdui-manifest-one-producer.md. This round:

  1. Merged origin/main at f1e921ab8e (a merge, not a rebase; merge commit f9cb969f44). One conflict: .changeset/sdui-manifest-one-producer.md, modify/delete, resolved in favour of main's deletion. build-json-failure-conversions.e2e.test.ts and validate-json-failure-conversions.e2e.test.ts auto-merged: main changed other regions of both, and the box fixture line and its docblock sentence survived. The branch's delta against main is exactly the 7 intended files. main has since gained one commit (cd901d7a5f), which touches none of them.

  2. The correction moved into this PR's changeset. The released note is @objectstack/console 17.5.0, patch entry 28ce612. A new paragraph goes through its closing paragraph one sentence at a time:

    • two sentences stop being true with this release: the file is no longer "only present in the tarball", and the CLI fallback no longer "keeps parse-level validation";
    • two still hold: exports is unchanged.

    No CHANGELOG.md, no content/docs/releases/ and nothing under packages/spec/ is edited.

  3. The ADR-0087 marker now reads not-required (already-registered ui-html-page-div-refused) with its reason. The gate's verdict: "check-adr-0087-registration: 1 declared-breaking changeset(s), each carrying an ADR-0087 disposition … not-required (already-registered)", exit 0.

  4. Two sentences re-measured and corrected:

    • "the html-tier renderer still renders div" was false. At the pinned objectui dd3f7e1be3, read with git show from the sibling checkout (nothing checked out, nothing edited):

      • packages/components/src/renderers/layout/page.tsx:487-488 builds the html compile's whitelist from getKnownTypes() minus deprecationFor(t, 'html');
      • packages/components/src/renderers/basic/div.tsx registers div with deprecated.surfaces: ['json', 'html'];
      • nameHtmlTierReplacement turns the resulting forbidden-tag into a refusal naming the replacement.

      That pin shipped in @objectstack/console 17.5.0: its CHANGELOG entry 3cf6449 says a kind:'html' page that authors a div "is refused at compile time, and the error names box". The changeset now says the console has refused div since 17.5.0, and that what is new is every other tag the manifest does not declare. The console's html compile accepts every non-deprecated registered component, while the manifest declares the public contract plus the html intrinsics. Measured below with avatar.

    • "objectstack compile (which dev and start run first)" was inexact. It now says exactly when they run it: dev.ts:319 compiles on flags.compile or a missing artifact, and dev.ts:383 re-runs it in watch mode; start.ts:228-232 has the same condition.

Premise and hypotheses, measured

Round-1 readings (on f11b5f20a2) are kept where they still hold. Round-2 readings are on 77338a7186: Node v26.7.0, macOS.

  • H0 (premise holds). On unmodified f11b5f20a2, a real os init project with a kind: 'html' page rooted in div passes os validate, os compile and os lint at exit 0. Each prints only the parse-level notice, and it does so even with a cmp-identical copy of the tracked manifest at packages/console/dist/sdui.manifest.json. From packages/cli/dist, the old subpath throws ERR_PACKAGE_PATH_NOT_EXPORTED.

  • H1 (the route finds the file in both layouts).

    • Workspace: consoleSduiManifestPath() answers packages/console/dist/sdui.manifest.json.
    • Installed package: npm pack of packages/console with a stand-in dist lists dist/sdui.manifest.json. Extracted under a scratch node_modules, the old subpath throws ERR_PACKAGE_PATH_NOT_EXPORTED from a sibling CLI origin, while resolveSduiManifest answers resolved.
  • H2 (Clause-② arm: narrowing). Round 2, merged tree, with the console copy present (cmp-identical stand-in):

    • a div page gives exit 1 (jsx-forbidden-tag, jsx-unknown-component);
    • a box page gives exit 0, with no findings;
    • an avatar page gives exit 1 (jsx-forbidden-tag, jsx-unknown-component).

    With no copy, all three exit 0 with the notice only. avatar is registered at the pin (renderers/data-display/avatar.tsx:17) and not deprecated, so the console's html compile renders it and nothing refused it before this change. That is the narrowing the (narrowing) arm and BREAKING rest on.

    • ⚠️ Round 1 misread this half. It took "the renderer still renders div" from ruling A's reading, which predates objectui#10757, instead of reading the pin. The pin had landed on main (chore(objectui): bump the console pin to dd3f7e1be356 (carries the injected-client boot fix) with the showcase div→box and trash-icon follow-through #20436) before round 1 ran. For div, this change moves a refusal the 17.5.0 console already gives at render time to author time. The arm still holds because of the undeclared tags.
    • CLI fixtures the live fallback newly refuses (round 1, with the console copy present): 23 tests went red across build-json-failure-conversions.e2e (5), validate-json-failure-conversions.e2e (4) and jsx-gate-manifest-notice.e2e (14). lint-conversion-notices.e2e stayed green, but its page is refused too. The three conversion fixtures moved from div to box. The notice file's 14 manifest-less cases are skipped by name where the CLI's own console copy exists. They run in the CI job, which builds no console, and their rules are pinned hermetically in src/utils/sdui-manifest.test.ts.
    • Examples: only examples/app-showcase carries html pages (three).
  • H3 (shipped pages stay clean), round 2, merged tree. main brought a regenerated sdui.manifest.json carrying tier: 'html' marks (feat(sdui-parser): the manifest marks the html tier's intrinsic tags tier: 'html', ported from objectui's lockstep copy #20582).

    • validate-jsx-pages.production-witness.test.ts: 5/5 pass.
    • examples/app-showcase with the console copy present: exit 0 on os validate / os compile / os lint, with zero jsx-* / sdui/* findings.
  • H4 (ablation, round 1), through node scripts/ablation-replace.mjs in WRAP mode:

    • the anchor resolve(CONSOLE_PACKAGE_JSON) went 1 → 0, and resolve(CONSOLE_SDUI_MANIFEST) (the old subpath) 0 → 1;
    • 4 console-leg pins went red ("expected undefined to be defined");
    • restore: the blob is back at the HEAD blob be1f8d4ad33e, and git diff HEAD is empty.

    The pins import the subject from src/, so no dist/ sits on that path. This round changed no source or test file.

Tests, at 77338a7186

  • The whole CLI unit project (--project unit --maxWorkers=2) with a real-path TMPDIR: 234/234 files, 3347/3347 tests. With the default macOS TMPDIR: 232/234. The 2 files are published-subpath-console.pin and published-subpath-hook-body.pin, 5 cases comparing /var against /private/var. They are host-only and untouched here.

  • pnpm --filter @objectstack/cli typecheck (tsc --noEmit plus check:test-typecheck): exit 0.

  • The four touched nightly *.e2e files (OS_TEST_TIERS=nightly --project integration):

    • with the console copy present: 53 passed, 14 skipped;
    • without it (the CI state): 67/67 passed.

    The rest of the integration layer is declared to CI.

Gates, at 77338a7186

  • node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands: 63 derived, the same 63 as round 1. All 63 exit 0, and --ran reconciled "63 run, 0 NOT-MEASURED (a DERIVED zero — all 63 recorded an exit code and none of them is 3)". check:dual-build-cjs-loads first answered PREREQUISITE NOT MET, then exit 0 after building its eight missing packages.
  • check-adr-0087-registration --base origin/main, check-empty-changeset --base origin/main (it now reads "No changeset from the merge base modified or deleted by this diff") and check-changeset-no-major --base origin/main: all exit 0.
  • Roster rows that could apply, all exit 0: check-changeset-fixed, check-sdui-manifest (plus --self-test), check:authz-resolver, check:error-code-casing, check:filter-alias-parity, check:cli-examples-parity, check:published-readme-exports, check:scaffold-emission-policy, check:console-injection.
  • pnpm lint (full repo, not narrowed): exit 0, no output.
  • node scripts/check-issue-citations.mjs --base origin/main: exit 0.
  • check:nul-bytes: exit 0, plus a control-byte scan of the 7 changed files: 0.

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.

every build, test, typecheck, ablation and `pnpm lint` command named above

Acceptance notes

  • Where the div → box prescription reaches an upgrader. The CLI's refusal text does not carry it: the gate answers "is not an allowed component" / "is not a known component", from @objectstack/sdui-parser (parse.ts). The upgrade guide does not carry it either: packages/spec/scripts/build-upgrade-guide.ts:78 loops majors up to PROTOCOL_MAJOR, PROTOCOL_VERSION is 17.0.0, and docs/protocol-upgrade-guide.md does not name ui-html-page-div-refused. What does carry it:
    • this changeset's FROM → TO table;
    • the console's own render-time refusal, which names box;
    • objectstack migrate meta --from 17. Measured on a stack with a div page, it lists the entry as one of 242 "manual change(s) require your judgment", headed "⚠ [protocol 18] kind:'html' page source …", with box as the replacement, and exits 0.
  • The ledger entry's own why text (packages/spec/src/migrations/entries/semantic/18.ui-html-page-div-refused.ts, the spec seat's file) still says "objectstack compile (which dev and start run first)", the phrasing corrected here. Noted, not edited.
  • Release order, flagged by the seat in 5890591366: Version Packages PR chore: version packages #20639 carries the ledger entry's changeset. If it merges before this PR, the ledger row ships one release ahead of the CLI refusal it describes.
  • packages/cli/src/utils/scaffold-validate.ts (the note at :128-:133) was re-read. It is true now, so it is not edited. A pre-existing imprecision stays as it was: os init reads the invoker's directory, which may carry its own sdui.manifest.json (this repository's root does).
  • Comment drift outside this claim, noted only:
    • .github/workflows/lint.yml (:899) and scripts/check-sdui-manifest.mjs (:28-30, :240) still say resolveSduiManifest() degrades to parse-only silently;
    • the header of packages/lint/src/validate-jsx-pages.ts still calls manifest validation "not wired";
    • docs/qa/platform-checklist/areas/studio-authoring.json describes the showcase tree as flex/div/a.
  • The five macOS-only published-subpath-* failures come from a tmpdir() path compared with the real path that module resolution returns. They are host-specific.
  • Measurement scaffolding was all in scratch, or in this worktree's gitignored packages/console/dist/, with each stand-in trap-removed. git status --porcelain printed 0 lines after every run.

Generated by Claude Code

hotlong and others added 5 commits September 29, 2026 12:55
…ack/console/package.json from the CLI's own location

The fallback asked for the shipped manifest by its own subpath, which the
console's exports map does not publish, so it threw, was swallowed, and a
project with no manifest of its own was always checked at parse level. It now
resolves the console's package.json from the CLI's location and joins
dist/sdui.manifest.json. A damaged shipped copy is refused with a reinstall
remedy instead of being read as not found.

Claude-Session: https://claude.ai/code/session_local_1d2a197c-c20e-4e90-9be8-413d4d432289
Co-authored-by: Claude <noreply@anthropic.com>
…age layout, hermetic under pnpm's NODE_PATH

Claude-Session: https://claude.ai/code/session_local_1d2a197c-c20e-4e90-9be8-413d4d432289
Co-authored-by: Claude <noreply@anthropic.com>
…om div to box, and the notice cases name their precondition

Claude-Session: https://claude.ai/code/session_local_1d2a197c-c20e-4e90-9be8-413d4d432289
Co-authored-by: Claude <noreply@anthropic.com>
…inor, migration to box), and the one-producer note reads the file the way the CLI now does

Claude-Session: https://claude.ai/code/session_local_1d2a197c-c20e-4e90-9be8-413d4d432289
Co-authored-by: Claude <noreply@anthropic.com>
…tic ledger entry, not no-migration-prescription

Claude-Session: https://claude.ai/code/session_local_1d2a197c-c20e-4e90-9be8-413d4d432289
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/m 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

9 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

What this run could not see
  • 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 — 25 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 89801cd9634c51930bb3d2f29f478c7c6ebae0ff → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 89801cd9634c51930bb3d2f29f478c7c6ebae0ff

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

This was referenced Sep 29, 2026
hotlong and others added 2 commits September 29, 2026 20:49
Conflict: .changeset/sdui-manifest-one-producer.md (modify/delete). Resolved
in favour of main's deletion: Version Packages consumed the changeset, so its
text has shipped; the correction moves into this branch's own changeset.

Claude-Session: https://claude.ai/code/session_local_1d2a197c-c20e-4e90-9be8-413d4d432289
Co-authored-by: Claude <noreply@anthropic.com>
… the released 17.5.0 manifest note is corrected here, and two sentences say exactly when dev/start compile and what the console already refused

Claude-Session: https://claude.ai/code/session_local_1d2a197c-c20e-4e90-9be8-413d4d432289
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 77338a718672e62e4fe91e1df3dba43486b094bb
Local-runs: none

Inputs read: card #19922 (body and all 13 comments, the rulings 5884397563 and 5890591366 included), ruling A on #20112 (5852014527) and that card's later comments, PR #20589 (body, 7-file list, the net diff against main at f1e921ab8e), the check-runs on 77338a7186, the pinned objectui dd3f7e1be3 through gh api …/objectui/contents/…?ref=dd3f7e1be3 (renderers/layout/page.tsx, renderers/basic/div.tsx, renderers/data-display/avatar.tsx, renderers/form/checkbox.tsx), and on main: sdui.manifest.json, packages/console/{package.json,CHANGELOG.md}, packages/cli/package.json, packages/spec/src/migrations/entries/semantic/18.ui-html-page-div-refused.ts, packages/spec/scripts/build-upgrade-guide.ts, packages/spec/src/kernel/protocol-version.ts, scripts/check-adr-0087-registration.mjs, .changeset/config.json. Gate scripts and workflow files were read in the worktree as references, nothing was run.

Check-runs on 77338a7186, read 2026-09-29T13:37:57Z, newest run per name: 34 names, 34 completed, 0 in progress, 0 failure — 29 success, 5 skipped (Auto Label and Check PR Size re-runs, Build Docs, Console Pin Gate, Packed-tarball smoke: all path- or opt-in-gated). Check Changeset (the pr-automation job that runs check-empty-changeset, check-adr-0087-registration and check-changeset-no-major) is success at 13:27:05Z; Lint & Repo Gates, all four Type Check lanes, Build Core, Test Core 1/6–6/6, Dogfood Verify CLI, the three Dogfood Regression shards and Temporal Conformance are success. PR head at the read: 77338a7186, mergeable_state: clean, draft.

① Derived judgments

  1. The accept set of os validate, os compile / os build and os lint narrows, and the diff declares it — right. With no project sdui.manifest.json, the JSX page gate now reads the copy @objectstack/console ships (packages/cli/src/utils/sdui-manifest.ts, resolveSduiManifest → consoleSduiManifestPath), so kind: 'html' pages that used to pass at parse level are refused for any tag or prop the manifest does not declare. The three commands are the only callers (validate.ts:433, compile.ts:472, lint.ts:979), and dev / start inherit it through the compile they spawn (dev.ts:319 needsCompile = !flags.artifact && (flags.compile || !existsSync(artifactPath)); dev.ts:838 in the watch loop; start.ts:228-232). Declared as BREAKING with a FROM → TO table (② below).
  2. A second, smaller narrowing is declared too — right. A console copy that is present but unreadable or unparseable used to fall through to absent (parse level, exit 0); it now returns unusable with source: 'console' and the command exits 1. The changeset's paragraph "A damaged install is refused, not skipped" carries it.
  3. The absent notice's second lookedAt entry changes from the package specifier to the absolute path of the console copy (or the package-relative name when @objectstack/console itself does not resolve) — right, not a contract move. The notice's anchor is the rule id sdui/jsx-parse-level-only, which is unchanged; the AuthoringFinding key set and info severity are unchanged (unit-pinned).
  4. No published surface of @objectstack/cli moves — right. The CLI's exports are ., ./console, ./hook-body, ./package.json, and src/index.ts re-exports command classes only; utils/sdui-manifest.ts is internal. The renamed constant (CONSOLE_SDUI_MANIFEST_SPECIFIER → CONSOLE_SDUI_MANIFEST), the new export consoleSduiManifestPath(origin?), the optional second parameter of resolveSduiManifest(cwd, consoleOrigin?) and the source member on the unusable variant are internal; the only importer of the old constant was the unit file, updated in this diff.
  5. @objectstack/console's exports stays closed — right. packages/console/** is untouched; exports on main is {"./package.json": "./package.json"}. The route resolves the one subpath the console exports and joins dist/sdui.manifest.json, the same strategy packages/cli/src/utils/console.ts:199-203 (resolveConsolePath, strategy 1) already uses for this static-asset package.
  6. os init's scaffold self-check (scaffold-validate.ts:94-98) now sees the console copy where present — right, no new refusal. It reads resolved only and hands undefined otherwise, which the module header documents as init's own decision. The built-in templates author no html page; the only html pages in tree are the showcase's three, already box (chore(objectui): bump the console pin to dd3f7e1be356 (carries the injected-client boot fix) with the showcase div→box and trash-icon follow-through #20436).
  7. Three conversion e2e fixtures move from div to box — right. Their subject is the JSON-failure conversion exit, not the tag; with a console copy present the div page would add a jsx-forbidden-tag error those exits are not about. The jsx-gate-manifest-notice.e2e fixtures keep div on purpose (its MANIFEST declares div, and its manifest-less cases are the ones skipped; ③.7).
  8. The changeset's two cleared sentences — both right against the pin. (a) At dd3f7e1be3, page.tsx:487-488 builds the html compile's whitelist as getKnownTypes() minus deprecationFor(t, 'html'); div.tsx registers div with deprecated.surfaces: ['json', 'html'] and a replacement that names box; nameHtmlTierReplacement (page.tsx) appends that replacement to the forbidden-tag refusal. The console 17.5.0 CHANGELOG entry 3cf6449 records the range that contains it (objectui 39b8d5102: a kind:'html' page that authors div "is refused at compile time, and the error names box"). So "the console has refused a div on a kind: 'html' page since 17.5.0 … naming box" is exact. (b) "dev and start run compile before they boot when dist/objectstack.json is missing or --compile is passed, and dev's default watch mode reruns it when a watched file changes" matches dev.ts:319, dev.ts:383 (watchActive = flags.watch !== false && …, the loop at :691-886 re-spawns compile on change/add) and start.ts:228-232. Residual, not a fault: neither command compiles when an artifact is named by --artifact, OS_ARTIFACT_PATH or OS_ARTIFACT_URL, and start also treats a home-directory dist/objectstack.json as present (start.ts:650-657); the sentence no longer overstates and no gate or consumer reads it.

② Semver level

  • Changeset .changeset/19922-console-manifest-fallback.md: '@objectstack/cli': minor; summary fix(cli)!: …; **BREAKING for kind: 'html' pages in projects without their own manifest.**; Clause-②: no (narrowing); the ADR-0087 marker adr-0087: not-required (already-registered ui-html-page-div-refused) … with a reason. Three breaking signals (the !, the banner, the arm) agree, so the gate reads it as declared-breaking and the disposition is owed and present.
  • Level minor — right. BREAKING ships as minor under the launch-window guard (check-changeset-no-major.mjs refuses major; the console's own 17.x entries carry "BREAKING (shipped as minor)" for the same reason). @objectstack/cli and @objectstack/console are in the one fixed group (.changeset/config.json), so the CLI that reads the file and the console that ships it release together.
  • Clause-②: no (narrowing) — the arm, the direction and the level are right, and on the evidence the dev now gives, not on round 1's. This diff adds no key to any published payload (value no), and it narrows a published accept set (arm narrowing): before it, a manifest-less project's html page using a registered, non-deprecated tag the manifest does not declare was refused by nothing — the CLI checked at parse level and the console's html compile whitelists every known, non-html-deprecated type. Verified at the pin: avatar (renderers/data-display/avatar.tsx:17, registered in ui, no deprecated) and checkbox (renderers/form/checkbox.tsx:70, registered in ui, no deprecated); both ABSENT from sdui.manifest.json on main (107 entries: 59 public plus 48 tier: 'html' intrinsics, deprecated on none); div ABSENT; box PRESENT with inputs className and children. So the live fallback refuses what the console renders, which is the narrowing. For div alone the change moves a refusal the 17.5.0 console already gives at render time to author time, and the changeset says exactly that. The FROM → TO table is right: row 1, div … to box "which takes the same className and children", matches box's declared inputs and the registration's own replacement text ("the one drop-in swap: same element, your className verbatim"); row 2 covers every other undeclared tag or prop generically, which is the honest shape (the set is the manifest, not a list).
  • The Clause-②: line on the PR body reads Clause-②: no (the claim's declaration, 5883713897, copied as the dispatch requires). The value is right for the level axis (check-changeset-no-major stands down on no, and the changeset is minor anyway); the ADR-0087 gate reads the arm from the changeset, where it is. The body carries no arm — the seat owns the body write and may carry no (narrowing) into it as it carried yes (widening) into feat(sdui-parser): the manifest marks the html tier's intrinsic tags tier: 'html', ported from objectui's lockstep copy #20582's; no gate turns on it.
  • The correction paragraph on the released @objectstack/console 17.5.0 patch entry 28ce612 — each verdict right, quoted verbatim from packages/console/CHANGELOG.md on main: (1) "For now the file is only present in the tarball." — no longer true from this release: the CLI reads it. (2) "This package's exports map exposes ./package.json and nothing else, so resolving @objectstack/console/dist/sdui.manifest.json through exports fails with ERR_PACKAGE_PATH_NOT_EXPORTED." — still true, exports unchanged. (3) "Anything that resolves through exports cannot read the file yet." — still true; the CLI resolves the exported ./package.json and joins the path, which is what the changeset says a reader must do. (4) "That includes the CLI's JSX-page manifest fallback, which catches the error and keeps parse-level validation, as before." — true of the 17.5.0 CLI, false from this release. Two no longer true, two still true, as the seat's unlock record asked. The PR edits no CHANGELOG.md, nothing under content/docs/releases/ and nothing under packages/spec/** (7 files: the changeset, sdui-manifest.ts, its unit file, four packages/cli/test/*.e2e.test.ts). The DELIBERATE CORRECTION of .changeset/sdui-manifest-one-producer.md is gone with that file (consumed by Version Packages chore: version packages #17076, merge resolved for main's deletion), and check-empty-changeset answers "No changeset from the merge base modified or deleted" — Check Changeset success confirms.
  • The ADR-0087 marker not-required (already-registered ui-html-page-div-refused) — right. The gate's already-registered arm requires every named id to resolve at HEAD and to exist at the merge base (check-adr-0087-registration.mjs:143-146; R5 refuses an id the diff itself adds). 18.ui-html-page-div-refused.ts is on main from 7510663c87 (spec(migrations): register the ADR-0087 semantic ledger entry for the html-tier div refusal that #19922's live console fallback brings to manifest-less projects (div → box) #20592 / PR feat(spec): register the ADR-0087 semantic entry ui-html-page-div-refused (#20592) #20610), before this branch's merge base f1e921ab8e, and this diff touches nothing under packages/spec/; the marker's reason names the migration channel (objectstack migrate meta --from 17, box for div). Check Changeset on this head is success, which is the gate's own answer.

③ Boundary flags

The brief's seven questions.

  1. Resolution in both layouts, exports closed, legitimacy — yes on all three. consoleSduiManifestPath(origin = import.meta.url) resolves @objectstack/console/package.json (the one exported subpath) with createRequire(origin) and joins dist/sdui.manifest.json. Workspace: from packages/cli/src/… (tsx via bin/run-dev.js) or packages/cli/dist/… (tsc, ESM-only: type: module, exports default only, build is tsc -p tsconfig.build.json), Node resolves the CLI's own node_modules/@objectstack/console symlink to its real path, so the answer is packages/console/dist/sdui.manifest.json — the unit pin "locates the CLI's own @objectstack/console dependency, whether or not it is built" asserts the owner package.json is @objectstack/console. Installed: the pin builds node_modules/@objectstack/console/{package.json (the REAL console package.json, so the real exports map), dist/sdui.manifest.json} and resolves from node_modules/@objectstack/cli/dist/index.js, answering resolved / absent (absolute path) / unusable. @objectstack/console is a runtime dependencies entry of @objectstack/cli (workspace:*, same fixed group), so the CLI resolving its own declared dependency from its own location is the legitimate route, and it is deliberately not cwd (under pnpm a project that does not depend on the console cannot resolve it; the gate's strength would then depend on hoisting).
  2. Damaged copy refused, never not-found; no page, left alone — yes. resolveSduiManifest returns readManifestFile(consoleManifest, 'console') unconditionally once the file exists (the old code returned it only when resolved and fell through to absent otherwise); read errors (EISDIR, EACCES), invalid JSON and a missing components map all yield unusable with the absolute path; resolveJsxGateManifest prints PATH is not a usable SDUI component manifest: REASON plus the hint "It is the copy @objectstack/console ships, so that install is damaged: reinstall @objectstack/console" and throws SduiManifestRefusalError (reportedToStderr, no minted code), which each command's catch-all turns into exit 1. Pinned: source: 'console', the path, the message and the remedy (unit); exit 1 with the file in the --json error and on stderr (the e2e's malformed cases, which use a project manifest and are not skipped). With no page the JSX gate checks, countJsxGatePages is 0 and the function returns { sduiManifest: undefined, notices: [] } before the unusable branch — pinned ("unusable with nothing to check: not read by anything, so not refused").
  3. The (narrowing) arm, BREAKING and minor — right; see ② for the pin reading. The dev's round-2 correction is sound: div was already refused by the 17.5.0 console at render time (page.tsx:487-488 plus div.tsx's deprecated.surfaces), and the narrowing rests on registered, non-deprecated, undeclared tags (avatar, checkbox verified at the pin and absent from main's manifest). The arm, the level and the FROM → TO table stand.
  4. The correction paragraph — each of the four verdicts right; no CHANGELOG.md, no content/docs/releases/, no packages/spec/** edited. Detailed in ②.
  5. The marker — right with the entry on main; the two re-measured sentences — exact, with the one residual named in ①.8(b) (artifact-by-flag/env exclusions and start's home-directory candidate), which is not a fault of the sentence as a changeset reader needs it.
  6. The Acceptance note on where the prescription reaches an upgrader — true. Not the CLI refusal text: it is @objectstack/sdui-parser's "is not an allowed component" / "is not a known component" (read-only here; the dev's measured output). Not the upgrade guide: build-upgrade-guide.ts:78 loops major from the floor to PROTOCOL_MAJOR, and PROTOCOL_VERSION is 17.0.0, so the step-18 entry is outside the loop (the spec seat read the same). What carries it: the changeset's FROM → TO table (this PR); the console's render-time refusal, which names box through nameHtmlTierReplacement and the registration's replacement; and objectstack migrate meta --from 17, whose entry ui-html-page-div-refused names box in its replacement (dev measured "242 manual change(s)", the spec seat measured --from 16 replaying to 18 — two independent runs, consistent with the entry text on main).
  7. The tests — the skips hide nothing from CI. The 14 skipped cases (8 in "no manifest, kind:html pages" plus 6 package-carried notice rows) skip only where consoleSduiManifestPath() exists on disk, i.e. a checkout with a built console. No CI tier has that: packages/console has no build script (only prepublishOnly), so turbo's build closure never produces packages/console/dist; Console Pin Gate is the only job that restores or saves that dist and it runs no tests (it is skipped on this PR anyway); test-nightly-tiers.yml mentions no console; Test Core runs under OS_TEST_TIERS=queue, which excludes *.e2e.test.ts by name, so these four files run nightly on main only, where all 67 cases run (the dev's "without it: 67/67"). The hermetic twins in src/utils/sdui-manifest.test.ts cover the resolver rules those 14 read (one info notice with the count and every place looked, the package-carried count, printJsxGateNotices's tag and hint, the refusal, silence with nothing to check). Precision residual, not a fault: three command-face facets — exit 0 under --strict, no new top-level --json key, os lint's passed/failing/suggestions/total — are pinned by the e2e alone (in nightly), so the header's "every rule they pin is also pinned hermetically" is exact for the resolver and slightly over-stated for the faces. The skip is skipIf/it.skipIf by name, so a skipped run reports the count, not a green.

Dev flags and open questions. Round 2 reports open_questions: []. Round 1's two questions were answered by the seat (5884397563: A and A) and executed as the unlock record (5890591366) redirected them: the ledger entry landed first (#20592 → marker already-registered), and the DELIBERATE CORRECTION moved into this changeset because #17076 consumed its target. Deviations, each judged: the round-1 misreading of "the renderer still renders div" is owned and corrected against the pin — right (above); the correction paragraph states two sentences still hold rather than inventing a fault — right; the PR body was not PATCHed (the dev's one body write was at creation) and the seat holds the replacement body — a seat mechanic, and the body as it stands has one closing keyword (Fixes #19922) and Clause-②: no on line 2; origin/main advanced by one commit (cd901d7a5f, none of the 7 files) and was not re-merged — mergeable_state reads clean at my read; the five macOS published-subpath-* cases are host-only (/var vs /private/var) and untouched — accepted, Linux CI green; the verify lock ran UNLOCKED on macOS (no flock) — declared, and every derived gate family is answered by the check-runs on this head, which is what this review reads.

Escalated, not blocking this head: (a) release order — Version Packages #20639 carries .changeset/20592-ui-html-page-div-refused.md; if it merges before this PR, the ledger row ships one release ahead of the CLI refusal it describes; the seat flagged it to the maintainer and does not touch release PRs; (b) the ledger entry's own why text (18.ui-html-page-div-refused.ts, spec seat's file) still says "objectstack compile (which dev and start run first)", the phrase this PR corrects in its own changeset — a packages/spec follow-up, out of this claim's surface; (c) comment drift the dev lists (lint.yml:899, check-sdui-manifest.mjs:28-30/:240, the validate-jsx-pages.ts header, the studio-authoring checklist wording) — Acceptance notes, no consumer reads them.

Implemented-by: claude/issue-19922-console-manifest-fallback
Reviewed-by: local_1d2a197c-c20e-4e90-9be8-413d4d432289

VERDICT: PASS

veigajoao pushed a commit to veigajoao/objectstack that referenced this pull request Sep 29, 2026
…used (objectstack-ai#20592) (objectstack-ai#20610)

Closes objectstack-ai#20592

Registers `ui-html-page-div-refused`, the protocol-18 ADR-0087 semantic
entry for the html-tier `div` refusal that PR objectstack-ai#20589 brings to projects
with no `sdui.manifest.json` of their own (`div` → `box`).

Clause-②: no

---
_Generated by [Claude
Code](https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
veigajoao pushed a commit to veigajoao/objectstack that referenced this pull request Sep 29, 2026
…o the commits that decided them, and the source-hashes header at its producer (objectstack-ai#20656)

Part of objectstack-ai#20594
Clause-②: no

## What changed

This is stage 3 of the `domain:cli` lane of the dead-citation sweep:
`packages/cli/src/**`, plus the generated-header producer the
`domain:services` pointer on the card hands this lane. Every comment or
docblock site in scope that cited a tracker number answering 404 now
cites, in ruling C+D's form C (comment 5749154545 on objectstack-ai#19123), the commit
in this repository's history that decided what the line describes, and
says in its own words what that commit decided. PR objectstack-ai#20533 is the method;
PR objectstack-ai#20624 (stage 1, `packages/runtime`) and PR objectstack-ai#20632 (stage 2,
`packages/rest`) are the precedents this follows. Later stages cover
`types` and the rest of the lane, so this PR says `Part of` and the card
stays open.

That is **313 comment sites on 304 lines in 64 files, covering 63
numbers**: the census's 170 rewritable sites (of its 174), 142 more in
test comments (which the census defers), and one site whose dead number
is the second half of a slash-joined pair the citation grammar does not
read (`serve.ts:1173`, `objectstack-ai#10943/objectstack-ai#11157`). Each rewritten line cites one
of **62 distinct commits**, except the six `objectstack-ai#15041` sites, which cite
ADR-0104's 2026-09-05 addendum: that ADR records the maintainer ruling
the lines describe, and the ruling allows the ADR to be cited instead of
a commit.

Only comments changed in `packages/cli/src`, apart from the two string
literals this stage declares (next section). Every touched file keeps
its line count (319 lines out, 319 in, over 65 files), so no line
citation into these files moves. Thirteen of the 319 lines held no dead
site; each is the other half of a sentence that had to change:
`create.ts:326`, `doctor-organizations-message-spelling.test.ts:11`,
`environments.test.ts:162`, `generate.ts:153`,
`serve-cluster-host-resolution.test.ts:816`,
`serve-host-fallback-base.test.ts:5`, `validate.ts:336`,
`validate.ts:813`, `validate.ts:815`, `hook-body-lowering.test.ts:10`,
`format.ts:1132`, `format.ts:1435` and `i18n-extract.ts:34` (mostly
「that card」 to 「that commit」 once the antecedent became a commit).

**No citation number is added.** Every tracker number on an added line
was already on the line it replaces. One PR number stands on an added
line, and it was there before:
`doctor-organizations-message-spelling.test.ts:9` read 「PR objectstack-ai#12463
(objectstack-ai#12151) single-sourced」 and now reads 「Commit 27b6902 (PR objectstack-ai#12463)
single-sourced」, keeping the live PR as a convenience link beside the
commit, as the ruling allows (objectstack-ai#12463 is that commit's own PR). No code
token moves (see the guard below).

Four dead comment sites are left on purpose, listed under "The sites
left". Two more files outside `packages/cli`: a `patch` changeset for
`@objectstack/cli`, and the shrink-only `check:doc-authoring` prose-id
ledger (see Deviations).

## The source-hashes producer and its 27 generated companions

The claim declares these as the only strings this stage moves, and the
regeneration of the files they write.

- **The producer.** `packages/cli/src/utils/i18n-extract.ts:2294` is the
string literal `renderSourceHashModule` writes as line 8 of every
`LOCALE.source-hashes.generated.ts`. It read 「bundles (objectstack-ai#11671,
maintainer ruling objectstack-ai#12069 Option A, extending objectstack-ai#8765 Option B).」 and now
reads 「bundles (commit 09b4f4e, maintainer ruling objectstack-ai#12069 Option A,
extending objectstack-ai#8765 Option B).」. `09b4f4e4e` is the commit that extended the
objectstack-ai#8765 Option B source-hash mechanism to the generated bundles per
maintainer ruling objectstack-ai#12069 Option A; its message says exactly that, and it
is the anchor stages 1 and 2 of objectstack-ai#20596 gave `objectstack-ai#11671`. objectstack-ai#12069 and objectstack-ai#8765
answer 200 (REST and web) and stay. The comment at `:249` beside
`previousSourceHashes` cites the same commit.
- **The flag's help text**
(`packages/cli/src/commands/i18n/extract.ts:227`, author-shown CLI
help), in form D: the lesson in words, no number. It read 「the
provenance companion that lets a stale fill be told from a translation
(objectstack-ai#11671).」 and now reads 「the provenance companion that records which
source revision each generated leaf is still a copy of, so a stale fill
can be told from a translation.」. The rest of the description is
unchanged.
- **The regeneration.** `node scripts/check-i18n-bundles.mjs --write`,
which runs each package's documented `os i18n extract` command from its
`i18n-extract.config.ts` (every one of the nine carries the
source-hashes flag), on a CLI built from `47241dd80e`. Before it, the
bundle check reported the nine packages DRIFTED, 3 bundles each, against
the new producer. After it:
- exactly 27 files changed, `+27 −27`: 3 locales in
`packages/platform-objects/src/apps/translations`,
`plugins/plugin-{approvals,audit,security,sharing,webhooks}` and
`services/service-{messaging,realtime,storage}`;
- every hunk is `@@ -8 +8 @@`, and the one removed and one added line
are the same in all 27 files;
- each file with line 8 deleted hashes identically at base and head (27
of 27), so every hash entry is byte-identical;
- `git status` shows nothing else in the nine packages, tracked or
untracked;
- `node scripts/check-i18n-bundles.mjs` then reads all nine packages in
sync (7 bundles each), and `check:i18n-stale-fill` serves 27 of 27
companions with 0 stale fills.
- **H3 holds.** `git grep -n "objectstack-ai#11671" -- '*.source-hashes.generated.ts'`
answers 0 hits at head; the same grep at `04b202e5cb` answers 27 (the
positive control).
- **Not published by the nine.** The header is a comment their bundlers
strip: after the build, none of the nine packages' `dist` holds
`09b4f4e4e` or the header's own phrase 「Each entry is the digest of the
SOURCE REVISION」, while the export name `…GeneratedSourceHashes` (the
positive control) is in each `dist`. So the regeneration changes no
published byte of those packages, and no changeset is owed for them. The
other lanes' stages can keep leaving their generated copies alone, as
the pointer asked.

## Held files

`packages/cli/src/utils/sdui-manifest.ts` and `sdui-manifest.test.ts`
stay at their base blobs (`41c4549ffe` and `3a242b54e0`, equal at base
and head), because PR objectstack-ai#20589 edits them. They carry four citations
(`objectstack-ai#4409` once, `objectstack-ai#20113` three times), and all four answer 200, so no
dead site is held and there is no anchor to list for a follow-up.

## Census: `packages/cli`, before and after

**Instrument.** The gate's own `node scripts/check-issue-citations.mjs
--census --json`, read-only and unchanged, run with the fleet token. Its
surface is comment prose in `packages/**/src/**/*.ts` with string
literals blanked, and it defers `*.test.ts`. The count is its
`allocated-but-absent` findings under `packages/cli/`. Every run
enumerated the whole board (185 pages), so none read a truncated board.

| reading | tree | board | whole-repo `allocated-but-absent` | cli sites
| lines | files | numbers |
|---|---|---|---|---|---|---|---|
| before | base `04b202e5cb`, run 2026-09-29T12:35:39Z to 12:43:03Z |
enumerated, 185 pages, frontier objectstack-ai#20642, 18,469 numbers | 1,707 | **174**
| 167 | 30 | 50 |
| after | head `6bb4d3b531`, run 13:46:09Z to 13:54:25Z | enumerated,
185 pages, frontier objectstack-ai#20652, 18,479 numbers | 1,510 | **4** | 4 | 3 | 2 |

The before count equals the card's 174 at `f11b5f20a2`. The 4 left are
the deliberate sites below. The whole-repo drop is 197: this diff's 170
cli sites plus the 27 generated headers (3 in each of the nine packages;
nothing else moved in any of them). The two merges of `origin/main`
moved no count. An earlier after-run at `d1e09a7eed` (13:19:18Z to
13:29:41Z, frontier objectstack-ai#20649) read the same 4 and 1,510; `packages/cli`
and the 27 companions are byte-identical between the two heads. One more
attempt at `6bb4d3b531` (13:35:15Z) exited 3, PREREQUISITE NOT MET, on a
malformed board page, and measured nothing; the run in the table is its
retry.

**Supplementary instrument, the whole scope.** The census does not read
test files or strings, and this stage's scope includes test comments. So
a second reading runs the gate's own exported `extractCitations`
(whole-file and comment-prose projections) and `classifyCitation` over
every `.ts`/`.tsx` file under `packages/cli/src` (298 files), against a
board enumerated through the gate's own `enumerateBoard`. The lit
controls objectstack-ai#20594, objectstack-ai#19123 and objectstack-ai#20632 answered 200 and are on both boards;
the dead controls objectstack-ai#11671, objectstack-ai#10514 and objectstack-ai#14828 answered 404 and are on
neither.

| reading | tree | board | citations | dead | src comment | test comment
| src string | test string |
|---|---|---|---|---|---|---|---|---|
| before, 12:43Z | `04b202e5cb` | 185 pages, frontier objectstack-ai#20642 | 3,029 |
**368** | 174 | 142 | 4 | 48 |
| after, 13:39Z | `6bb4d3b531` | 185 pages, frontier objectstack-ai#20650 | 2,715 |
**54** | 4 | 0 | 2 | 48 |

Its src-comment column equals the census's 174 and 4, which is the
control on the second instrument. The resolving citations (1,468 and 755
in comments, 50 and 53 as pull requests, 32 cross-repo) are the same in
both readings, so no live citation was lost; the drop of 314 is exactly
the dead sites removed (312 grammar-read comment sites and the two
`objectstack-ai#11671` strings). The one slash-joined dead number the grammar never
reads (`objectstack-ai#11157` in `objectstack-ai#10943/objectstack-ai#11157`) is gone too: a separate scan for
dead `#N` tokens the grammar skips answers 1 before and 0 after.

## Per-number table

Sites and files are the dead comment sites in scope at the base, test
sites counted in brackets. `left` is a site with no deciding commit (see
below). `strings kept` counts string-literal sites, which are tokens and
stay as they were. Every anchor was read in its message or its diff, not
only in its subject: it is the commit that made the change the line
describes, and its own message or diff names the number it replaces or
adds the citation the line carries.

| number | comment sites / files | rewritten | left | strings kept |
anchor |
|---|---|---|---|---|---|
| `objectstack-ai#6217` | 12/8 (1 test) | 12 | 0 | 0 | `2b641ddd4` |
| `objectstack-ai#6238` | 2/1 (2 test) | 2 | 0 | 2 | `c8d6f6e08` |
| `objectstack-ai#6265` | 1/1 (1 test) | 1 | 0 | 0 | `cfb549db8` |
| `objectstack-ai#6268` | 6/2 (2 test) | 6 | 0 | 1 | `68f5eccb1` |
| `objectstack-ai#6293` | 2/2 (1 test) | 2 | 0 | 0 | `c39a911ae` |
| `objectstack-ai#6344` | 2/2 (1 test) | 2 | 0 | 0 | `cfb549db8` |
| `objectstack-ai#6345` | 21/6 (11 test) | 21 | 0 | 4 | `e2798fab7` |
| `objectstack-ai#6535` | 1/1 | 1 | 0 | 0 | `a92b1793c` |
| `objectstack-ai#8692` | 5/2 (3 test) | 5 | 0 | 3 | `712e185db` |
| `objectstack-ai#10326` | 1/1 | 1 | 0 | 0 | `675ab574e` |
| `objectstack-ai#10359` | 2/2 | 2 | 0 | 0 | `15b63e85a` |
| `objectstack-ai#10398` | 1/1 (1 test) | 1 | 0 | 0 | `0681a76b8` |
| `objectstack-ai#10485` | 1/1 | 1 | 0 | 0 | `35ad101bc` |
| `objectstack-ai#10499` | 1/1 | 1 | 0 | 0 | `6d441e41f` |
| `objectstack-ai#10504` | 8/1 | 8 | 0 | 0 | `ff5733e03`, `0d4bd93e7` |
| `objectstack-ai#10514` | 16/2 (16 test) | 16 | 0 | 2 | `5359a9b4c` |
| `objectstack-ai#10763` | 1/1 (1 test) | 1 | 0 | 0 | `c2b97c2a1` |
| `objectstack-ai#10769` | 9/2 (6 test) | 9 | 0 | 0 | `3d7deb700` |
| `objectstack-ai#10908` | 10/3 (6 test) | 10 | 0 | 5 | `9cc6777d3` |
| `objectstack-ai#10909` | 2/1 | 2 | 0 | 0 | `5a90c56d1` |
| `objectstack-ai#10917` | 1/1 | 1 | 0 | 0 | `7940de5e0` |
| `objectstack-ai#10926` | 1/1 | 1 | 0 | 0 | `d173125fb` |
| `objectstack-ai#10943` | 5/3 (2 test) | 5 | 0 | 0 | `46d34ab7c` |
| `objectstack-ai#10944` | 9/3 (6 test) | 9 | 0 | 3 | `e598b1cbc` |
| `objectstack-ai#10952` | 5/1 | 5 | 0 | 0 | `0d4bd93e7`, `ff5733e03` |
| `objectstack-ai#10953` | 1/1 (1 test) | 1 | 0 | 0 | `be7262e72` |
| `objectstack-ai#10967` | 6/2 (6 test) | 6 | 0 | 1 | `e4a71d418` |
| `objectstack-ai#11022` | 1/1 (1 test) | 1 | 0 | 0 | `21756b325` |
| `objectstack-ai#11025` | 3/2 | 3 | 0 | 0 | `1c3a46f87` |
| `objectstack-ai#11048` | 1/1 | 0 | 1 | 0 | — |
| `objectstack-ai#11071` | 3/2 | 3 | 0 | 0 | `50fb191dc` |
| `objectstack-ai#11157` | 15/4 (10 test) (1 slash-joined) | 15 | 0 | 2 | `a4cb7817f`
|
| `objectstack-ai#11172` | 5/1 | 5 | 0 | 0 | `05181e8cc` |
| `objectstack-ai#11174` | 2/1 (2 test) | 2 | 0 | 1 | `ab23c67ab` |
| `objectstack-ai#11221` | 3/1 (3 test) | 3 | 0 | 1 | `e278a2970` |
| `objectstack-ai#11331` | 3/2 | 0 | 3 | 0 | — |
| `objectstack-ai#11671` | 1/1 | 1 | 0 | 0 (2 moved) | `09b4f4e4e` |
| `objectstack-ai#12125` | 11/3 | 11 | 0 | 0 | `79cf692b0` |
| `objectstack-ai#12151` | 3/2 (3 test) | 3 | 0 | 3 | `27b690272` |
| `objectstack-ai#12162` | 2/1 (2 test) | 2 | 0 | 0 | `c0f5e8f21` |
| `objectstack-ai#12181` | 4/2 (3 test) | 4 | 0 | 0 | `cf71d73f8` |
| `objectstack-ai#12297` | 3/1 | 3 | 0 | 0 | `9fd45a952` |
| `objectstack-ai#12943` | 2/1 (2 test) | 2 | 0 | 0 | `090f2302e` |
| `objectstack-ai#12961` | 1/1 | 1 | 0 | 0 | `901355c3b` |
| `objectstack-ai#13109` | 2/1 | 2 | 0 | 0 | `8b236c826` |
| `objectstack-ai#13193` | 6/2 (3 test) | 6 | 0 | 0 | `faff497fd` |
| `objectstack-ai#13218` | 1/1 | 1 | 0 | 0 | `c45d8e6b4` |
| `objectstack-ai#13347` | 3/3 (2 test) | 3 | 0 | 3 | `098a08ffa` |
| `objectstack-ai#13651` | 12/7 (4 test) | 12 | 0 | 0 | `ada3834ad` |
| `objectstack-ai#14192` | 2/2 | 2 | 0 | 0 | `4d0d9445a` |
| `objectstack-ai#14336` | 4/1 | 4 | 0 | 0 | `79c71d29d` |
| `objectstack-ai#14397` | 1/1 | 1 | 0 | 1 | `957f7bb45` |
| `objectstack-ai#14657` | 20/2 (7 test) | 20 | 0 | 1 | `431979e67` |
| `objectstack-ai#14667` | 1/1 | 1 | 0 | 0 | `dc7c226b9` |
| `objectstack-ai#14824` | 3/1 | 3 | 0 | 0 | `cf6b67164` |
| `objectstack-ai#14828` | 22/3 (7 test) | 22 | 0 | 3 | `08706f0e0` |
| `objectstack-ai#14829` | 9/3 (6 test) | 9 | 0 | 3 | `ee370d318` |
| `objectstack-ai#14902` | 1/1 | 1 | 0 | 0 | `61821e54c` |
| `objectstack-ai#15040` | 6/3 (3 test) | 6 | 0 | 2 | `8644d1d33` |
| `objectstack-ai#15041` | 6/5 (4 test) | 6 | 0 | 2 | ADR-0104 (2026-09-05 addendum,
landed as 932acc3) |
| `objectstack-ai#15045` | 2/2 (1 test) | 2 | 0 | 0 | `288fe9c34` |
| `objectstack-ai#16887` | 2/1 (2 test) | 2 | 0 | 0 | `9cdffbe36` |
| `objectstack-ai#17080` | 1/1 (1 test) | 1 | 0 | 0 | `8b4890343` |
| `objectstack-ai#17081` | 8/3 (4 test) | 8 | 0 | 3 | `f721ef0ff`, `24d622b94` |
| `objectstack-ai#17883` | 10/3 (5 test) | 10 | 0 | 3 | `b06b2db5c` |
| **total** | **317** | **313** | **4** | **49** | **62 distinct commits
+ ADR-0104** |

Every cited sha matches exactly one object (`git rev-parse
--disambiguate`, count 1 for each of the 62), is a commit, has one
parent, and is an ancestor of the base (`merge-base --is-ancestor`, exit
0 for all 62). The checkout is not shallow (`--is-shallow-repository`
false); the control leg `13a6cb4ad` exits 0 and the negative control
(this branch's own `47241dd80e`, not on `main`) exits 1. ADR-0104's
2026-09-05 addendum landed as `932acc3df`, which passes the same four
checks. Where an earlier stage gave a number an anchor and the cli site
describes the same decision, the same anchor is reused (17 numbers,
objectstack-ai#17081 for its application half only; for example `e2798fab7` for objectstack-ai#6345,
`68f5eccb1` for objectstack-ai#6268, `35ad101bc` for objectstack-ai#10485, `09b4f4e4e` for objectstack-ai#11671
and `61821e54c` for objectstack-ai#14902), so each number carries one anchor across
the tree. Several numbers are the PR number of their own anchor commit
(objectstack-ai#6344, objectstack-ai#10398, objectstack-ai#14667, objectstack-ai#16887), so the sha is the same object the
number named.

**Numbers with more than one anchor, by site:**
- `objectstack-ai#10504` / `objectstack-ai#10952` (`format.ts`): `ff5733e03` added the opt-in zero
row for `UI:` alone and `0d4bd93e7` made it required for every section,
so lines naming both now name both commits. `format.ts:1573` read
「(objectstack-ai#10504, objectstack-ai#10952, objectstack-ai#11172)」 and now reads 「(commits ff5733e, 0d4bd93,
05181e8)」.
- `objectstack-ai#10943` / `objectstack-ai#11157` (`serve.ts`): `46d34ab7c` made the host importer's
fallback base a caller-supplied parameter, and `a4cb7817f` made `serve`
pass its own base and collapsed `importConfigPlugin` from three branches
to two. The slash-joined `serve.ts:1173` 「(objectstack-ai#10943/objectstack-ai#11157)」 now reads
「(commits 46d34ab and a4cb781)」; `serve.ts:1459` 「(objectstack-ai#10908 → objectstack-ai#11157)」
reads 「(commits 9cc6777 → a4cb781)」.
- `objectstack-ai#17081` (8 sites): one card with two halves. `f721ef0ff` took the
platform's half (the `Dev admin` banner line says what the account sees)
for 7 sites; `format.ts:1132` describes the application half and now
reads 「[objectstack-ai#17556 — commit 24d622b]」, the spelling the spec stage used at
`dev-login.zod.ts:10`, with `format.ts:1133` naming objectstack-ai#17081 in words
(「its parent card」).
- `objectstack-ai#15041` (6 sites): the decision is a maintainer ruling recorded
verbatim in ADR-0104's 2026-09-05 addendum, whose Sequencing section
names the three steps the lines cite. So the lines cite the addendum
(「The ruling in ADR-0104's 2026-09-05 addendum decided it」, 「sequencing
step 2 of ADR-0104's 2026-09-05 addendum」), not a commit.

**Wordings to check, each true of its commit:**
- A commit does not rule. Where a line said a number ruled, it now says
what the commit did with the ruling: 「semantics by the ruling commit
68f5ecc landed」, 「Ruled at triage, landed as commit e598b1c」, 「the
triage commit 9cc6777 landed requires this text be CHOSEN」, 「Rulings
objectstack-ai#5728 and objectstack-ai#14412, and the ruling commit d173125 landed,」, and
`resync.ts:96` keeps the ruling's date from `712e185db`'s message:
「commit 712e185 (the 2026-08-15 ruling)」.
- A line that named a DEFECT by its number now says so: 「the defect
commit 79cf692 fixed」, 「the defect commit 9cc6777 fixed」, 「the exact
defect commit 08706f0 closed」, 「the hard-failure class of the `22P02`
commit 8644d1d fixed」, 「Before commit 5359a9b (raw)」 / 「Since commit
5359a9b (masked)」.
- **A stale claim, corrected by its anchor.** `validate.ts:813-815` said
「`ManifestSchema` is not `.strict()` and drops unknown keys with nothing
said (objectstack-ai#14192)」, but `ManifestSchema` has been `strictObject` since
`4d0d9445a`, which landed before the commit that wrote the sentence.
Citing that commit in a present-tense sentence would contradict itself,
so the three lines move to the past tense: 「was not `.strict()` and
dropped unknown keys with nothing said until commit 4d0d944, so acting
on that inference produced a manifest that looked fine」.
- **Anchors found by diff, not by subject.** `objectstack-ai#10326` has no commit
message naming it; `675ab574e` took the 1.7.1 measurement the line
cites, and its own changeset and test name objectstack-ai#10326. `objectstack-ai#12162` is named by
no message either; `c0f5e8f21` is the only commit that ever added the
number, and its message states the point the lines make. `objectstack-ai#14397`'s
citation was added by `957f7bb45`'s own diff, which is the change the
heading describes. `objectstack-ai#10763` is added three times by `c2b97c2a1`'s diff.
- `objectstack-ai#10499` (`init.ts:978`): the line uses the earlier drift between the
two scaffold paths as precedent; that drift was about pnpm build
approvals, and `6d441e41f` gated the two paths against each other, so
the line reads 「already drifted once (closed by commit 6d441e4)」.
- `objectstack-ai#6293`: `c39a911ae` is the commit that found the 「headless husk」
`JSON.stringify(stack)` leaves where a declaration was; `bf4ebe2f3`,
which wrote the cli lines, only cites it.
- Quoted text: `generate-field-type-vocabulary.pin.test.ts:92` sits
inside a verbatim quotation of the file's former clause, so the commit
stands in an editorial bracket (「([commit ee370d3]'s pin argues this
in full)」), as PR objectstack-ai#20624 did.
- Headings with a dash rule (`serve.ts:1125`, `:1459`, `:1521`, `:3276`,
`serve-cluster-host-resolution.test.ts:831`,
`generate-field-type-vocabulary.pin.test.ts:260`,
`files-to-references.ts:274`) trim their trailing dashes to hold the
width; `serve-cluster-host-resolution.test.ts:893` is a trailing comment
whose code part is byte-identical.

## The sites left

**No deciding commit (4 sites, all visible to the census):**
- `init.ts:267` (objectstack-ai#11048): 「whether to admit that band at all is objectstack-ai#11048」
names an open support decision (pnpm 10.0 to 10.4). The only commit
naming it, `568de194e`, files it unassigned; no later commit decides it,
and the floor is still `>=10.15` at the base.
- `plugin/publish.ts:118`, `osplugin.ts:21`, `osplugin.ts:49` (objectstack-ai#11331):
the parenthetical points at the unpack-time integrity re-verification
leg, which was never built (`b60f48b52`: 「The enforce leg points at
objectstack-ai#11331」). No commit decides it; the ownership clause on the same lines
comes from `f89812e4d`, but the number is not about that clause.

**String sites kept as tokens (49).** 48 are test titles and test-code
strings in 22 files. One is a non-test string: the `os meta resync` skip
explanation at `commands/meta/resync.ts:71`, 「on installs from before
objectstack-ai#8692, the platform's own seeded defaults carry that same stamp」, which
an operator reads (see Acceptance notes).

## Mechanical guard: no code token moves, and exactly two string
literals do

The check compares the TypeScript parser's leaf tokens (TypeScript
6.0.3, JSDoc nodes excluded, comments being trivia) of each touched file
at base `04b202e5cb` against the working tree, over all 65 touched files
in `packages/cli/src`, and lists EVERY differing token, not only the
first. Controls mutate the head text in memory only, so nothing on disk
moved for them.

- Real run: 156,633 base tokens, token counts equal in every file,
**exactly 2 differing tokens**, both `StringLiteral`:
`commands/i18n/extract.ts:227` (the help text) and
`utils/i18n-extract.ts:2294` (the header line). No other token in any
file differs.
- Comment-insertion control (`serve.ts`): still exactly those 2 (exit 1,
no new difference).
- Code-insertion positive control (a declaration in `serve.ts`): the
count differs (17,815 to 17,820) and a third difference appears at token
0.
- String positive control (one character added inside the first string
literal past offset 2000 of `serve.ts`): a third difference appears, a
`StringLiteral` at token 145.
- The 27 generated companions: 2,940 base tokens, 0 differing tokens
(their only change is a JSDoc line).

Line balance: every touched file is +N/−N, and every line count is equal
at base and head (94 files). A raw scan of the 92 changed source and
generated files for control bytes finds none (its positive control, a
scratch file holding a U+0001 byte, matches).

## Changeset

This change ships bytes, so a `patch` changeset for `@objectstack/cli`
is included, in PR objectstack-ai#20632's form and level. Unlike stage 2's, it names
the two strings, because 「Comments only」 would not be true here.

Measured on the built package: `files[]` is `dist`, `README.md` and
`CHANGELOG.md`. After the build, the rewritten docblocks reach `dist`:
142 `commit SHA` citations in 39 of its `.js` / `.d.ts` files. For
example `storage-driver.ts:91`'s rewritten line 「(commit 68f5ecc).
These are the」 is in both `dist/utils/storage-driver.d.ts` and `.js`,
beside the unchanged next line of the same docblock 「runtime's
declarations, not copies of them — in particular」 (the positive
control); a negative control phrase appears nowhere. The new help text
is in `dist/commands/i18n/extract.js`, the header literal with
`09b4f4e4e` is in `dist/utils/i18n-extract.js`, and `objectstack-ai#11671` appears
nowhere in the package's `dist`.

## Gates (head `6bb4d3b531`)

This host has no `flock`, so `os-verify-lock.sh` ran in its declared
unlocked mode. Its disclosure, verbatim, from each locked run at this
head:

```text
os-verify-lock: VERDICT command-exit 0 · UNLOCKED (declared) · no usable `flock` on this host, so the shared verify lock was NEVER taken and NOTHING was serialized · ran 110s (1m50s) · declare it in the PR body · pnpm exec turbo run build --filter='./packages/*' --filter='./packages/*/*' --concurrency=4
os-verify-lock: VERDICT command-exit 0 · UNLOCKED (declared) · no usable `flock` on this host, so the shared verify lock was NEVER taken and NOTHING was serialized · ran 12s · declare it in the PR body · pnpm --filter @objectstack/cli typecheck
os-verify-lock: VERDICT command-exit 1 · UNLOCKED (declared) · no usable `flock` on this host, so the shared verify lock was NEVER taken and NOTHING was serialized · ran 150s (2m30s) · declare it in the PR body · pnpm --filter @objectstack/cli exec vitest run --project unit --maxWorkers=2
os-verify-lock: VERDICT command-exit 0 · UNLOCKED (declared) · no usable `flock` on this host, so the shared verify lock was NEVER taken and NOTHING was serialized · ran 5s · declare it in the PR body · pnpm --filter @objectstack/cli exec vitest run --project unit --maxWorkers=2 test/published-subpath-console.pin.test.ts test/published-subpath-hook-body.pin.test.ts
os-verify-lock: VERDICT command-exit 0 · UNLOCKED (declared) · no usable `flock` on this host, so the shared verify lock was NEVER taken and NOTHING was serialized · ran 9s · declare it in the PR body · pnpm --filter @objectstack/cli exec vitest run --project integration --maxWorkers=2 src/commands/generate-declared-column-default.pin.test.ts src/commands/generate-string-family-width.pin.test.ts src/commands/meta/delete-reset-carriers.test.ts 
os-verify-lock: VERDICT command-exit 0 · UNLOCKED (declared) · no usable `flock` on this host, so the shared verify lock was NEVER taken and NOTHING was serialized · ran 30s · declare it in the PR body · pnpm --filter @objectstack/cli exec vitest run --maxWorkers=2 src/commands/validate-json-strict-exit.e2e.test.ts
```

The regeneration ran earlier against the same unlocked lock, on a
closure build at `47241dd80e`:

```text
os-verify-lock: VERDICT command-exit 0 · UNLOCKED (declared) · no usable `flock` on this host, so the shared verify lock was NEVER taken and NOTHING was serialized · ran 102s (1m42s) · declare it in the PR body · pnpm --workspace-concurrency=2 --filter '@objectstack/cli...' --filter '@objectstack/platform-objects...' --filter '@objectstack/plugin-approvals...' --filter '@objectstack/plugin-audit...' --filter '@objectstack/plugin-security...' --filter '@objectstack/plugin-sharing...' --filter '@objectstack/plugin-webhooks...' --filter '@objectstack/service-messaging...' --filter '@objectstack/service-realtime...' --filter '@objectstack/service-storage...' build
```

The branch merged `origin/main` twice, as the dispatch orders
(`d1e09a7eed` merging `cd901d7a5f`, and `6bb4d3b531` merging
`0cb72cfc72`); neither touched `packages/cli`, a translations directory
or the prose-id ledger. After each merge: `pnpm install
--frozen-lockfile`, then the whole workspace (`turbo run build
--filter='./packages/*' --filter='./packages/*/*'`, 71 tasks, 71
successful).

- **Tests** (unit tier, then every touched file outside it):
- `vitest run --project unit`: 234 files, 3,342 tests; **232 files and
3,337 tests pass, 5 tests in 2 untouched files fail on this host**:
`test/published-subpath-console.pin.test.ts` and
`test/published-subpath-hook-body.pin.test.ts` compare a path under
`os.tmpdir()` with the realpath the resolver answers, and on macOS
`/var` is a symlink to `/private/var`. With `TMPDIR` set to its realpath
the same two files pass, 29 of 29 (the fourth line above). Neither file
is in this diff; the cli change is comments and two strings.
- The unit tier holds 32 of the 36 touched test files. The other four
ran by name: the three integration-tier files (`--project integration`:
3 files, 60 tests pass) and the nightly-tier
`validate-json-strict-exit.e2e.test.ts` (`OS_TEST_TIERS=nightly`: 1
file, 7 tests pass). So every touched test file ran.
- The producer's own tests and the companions' readers:
`test/i18n-extract-source-hashes.test.ts`,
`test/i18n-extract-companion-orphan.test.ts` and
`test/i18n-extract-generated-apps-leaf-provenance.test.ts` (3 files, 25
tests); `@objectstack/platform-objects`'s `src/apps/translations` (24
files, 430 tests); `@objectstack/plugin-sharing`'s `src/translations` (3
files, 13 tests). All pass, at `d71ab0e27e`, whose `packages/cli` and
companions are byte-identical to this head.
- **Typecheck:** `pnpm --filter @objectstack/cli typecheck` exits 0.
`tsc --listFiles` counts 298 `src` files under `tsconfig.json`, all 158
`src` test files among them, so every touched test file is type-checked;
`check:test-typecheck` holds its ledger (3 files, 28 errors, 6 pinned
signatures).
- **Lint:** the repo-wide `pnpm lint` (`eslint . --no-inline-config`)
exits 0 at `6bb4d3b531` (2026-09-29T13:44:40Z to 13:45:11Z). Not
narrowed.
- **Citation judging:** `node scripts/check-issue-citations.mjs --base
origin/main` exits 0 after the second merge: 67 citations judged across
56 files (66 resolve, 1 cross-repo). These are the live numbers that
stay on rewritten lines, 54 of them the objectstack-ai#12069 and objectstack-ai#8765 pair in the 27
headers. It defers `*.test.ts`, so the added-minus-removed count over
the whole diff covers the rest: 0 numbers added.
- **Derived gates:** `node scripts/pm/dispatch-gates.mjs --repo
objectstack-ai/objectstack --commands` at `6bb4d3b531` derived 76
families (68 before the prose-id ledger commit put a `scripts/` path in
the change set). All 76 ran, and `--ran` over a record carrying each
exit code reads 「76 derived, 76 run, 0 NOT-MEASURED, 0 UNRUN」 (a derived
zero).
- 75 exit 0. One exits 1 for this host, not for this diff: `pnpm
check:bash32-floor` runs its self-test first, and 7 of its 179 cases
assert that each bash-4 probe is shell THIS host can parse; the only
bash here is `/bin/bash` 3.2.57, which cannot. The gate's real-tree
half, run alone (`node scripts/check-bash32-floor.mjs`), exits 0: 32
tracked shell files, 0 findings. The self-test half is NOT MEASURED
here, reason: no bash 4+ on this host; CI's bash measures it. This diff
touches no shell file.
- Among them: `check:doc-authoring` (809 pinned sibling prose-id sites,
no growth, no unrecorded burn-down), `check:i18n` (9 packages in sync),
`check:i18n-coverage` (13 configs, none new), `check:i18n-stale-fill`
(27 of 27 companions served, 0 stale), `check:i18n-walk-parity`,
`check:nul-bytes` (9,283 files, no raw control bytes),
`check:published-files`, `check:cli-command-ids` and
`check:issue-citations` (self-test).
- **Artifact rosters:** 35 of the 38 non-self-test roster rows exit 0 at
`6bb4d3b531`, `check-changeset-fixed` (its roster sits under
`.changeset/`), `check:error-code-casing`, `check:filter-alias-parity`
and `check:authz-resolver` (the four whose rosters share a directory
with this diff) among them. The other three,
`check-closing-target-claim`, `check-partof-closing-keyword` and
`check-single-claim-paths`, need a pull request's context; they are run
against this PR once it exists and reported on the card. The 17
checker-health-only rows were not run.

## Hypotheses (measured first)

- **H0 holds.** The filtered census answers 174 dead sites at
`04b202e5cb` (167 lines, 30 files, 50 numbers), equal to the card's
count at `f11b5f20a2`: no drift.
- **H1 holds, with the listed exceptions.** After the rewrite the
filtered census answers 4, all for an unfound anchor: `objectstack-ai#11048` (an open
support decision) and `objectstack-ai#11331` three times (an enforce leg never built).
No site is held for an open PR: the two held files carry no dead
citation. The claim's read and this stage's two reads of the open PRs'
file lists (12:38:15Z, 7 open PRs; 13:58:56Z, 9 open PRs) found only PR
objectstack-ai#20589 in `packages/cli/src` and none touching a companion or the
prose-id ledger. PR objectstack-ai#20652, opened after the claim, edits
`packages/platform-objects`'s three `LOCALE.objects.generated.ts`
bundles beside the companions: no file overlap.
- **H2 holds, by the token guard.** A comment-stripped comparison of
every touched file (the parser's leaf tokens, JSDoc excluded, every
difference listed) finds exactly the two declared `StringLiteral` tokens
and nothing else, and its code and string controls each add a
difference. The emitted `dist` is not byte-identical, because docblocks
and the two strings ship, which is why the changeset is `patch`.
- **H3 holds.** `git grep -n "objectstack-ai#11671" --
'*.source-hashes.generated.ts'`: 0 hits at head, 27 at `04b202e5cb`.

## Acceptance notes

- **Form D, not touched here.** 49 dead numbers stand inside string
literals: 48 in test titles and test-code strings (22 files, 21
numbers), and one an operator reads: the `os meta resync` skip
explanation at `commands/meta/resync.ts:71`, 「on installs from before
objectstack-ai#8692, the platform's own seeded defaults carry that same stamp」. The
comments beside it (`resync.ts:55` and `:96`) now cite `712e185db`.
Ruling D (no number, the lesson in words) is a string change outside
this stage's two declared strings; the card already carries a form-D
stage for the lane (ACCEPT 5888034755), and this string is its
author-shown first.
- **`objectstack-ai#11671` outside this stage's surface.** The number still stands in
other lanes' files: the nine `scripts/i18n-extract.config.ts` docstrings
(outside the census surface), six `src/translations/index.ts` files
(`plugin-approvals`, `plugin-audit`, `plugin-security`,
`plugin-webhooks`, `service-realtime`, `service-storage`), six sites in
`packages/platform-objects/src` (`source-hash.ts` three times,
`setup.translation.ts`, `metadata-translations/index.ts`,
`source-hash.test.ts`),
`packages/cli/test/i18n-extract-source-hashes.test.ts:3`, and 13 in
`scripts/**` and `.github/workflows/lint.yml`. The anchor for all of
them is `09b4f4e4e`. Noted for those lanes' stages, not touched.
- **Outside the scope and the census surface.** `packages/cli` outside
`src/**` holds 242 dead citations: `test/` 185, `scripts/` 27, `bin/`
10, `vitest.config.ts` 15, `vitest-tiers.ts` 2,
`vitest-tiers.fixtures.ts`, `tsconfig.test.json` and
`test-typecheck-debt.json` 1 each (whole-file projection, the before
board). They stay for a later stage of this card.
- **A host-dependent pin.** `test/published-subpath-console.pin.test.ts`
and `test/published-subpath-hook-body.pin.test.ts` fail 5 tests on
macOS, where `os.tmpdir()` is a symlink, and pass with a realpath
`TMPDIR`. CI's Linux runners do not see it. Noted, not filed.
- **A hex colour in the whole-file reading.** `serve.ts:5621` holds the
CSS colour `#141417` in a string. The census blanks strings, so it never
sees it; the supplementary whole-file projection reads it as a citation
beyond the frontier (`never-issued`). It is not a citation; it is the
second of the two `src string` sites left in the supplementary table,
beside `objectstack-ai#8692`.
- **The slash-joined grammar gap, again.** `CITATION_RE` refuses a `#`
preceded by `/`, so the second number of `#A/#B` is never judged. In
`packages/cli/src` one such dead number stood (`serve.ts:1173`,
rewritten here). The same shape PR objectstack-ai#20624 and PR objectstack-ai#20632 reported, for
the grammar family PR objectstack-ai#20533 names.

## Deviations

- **One file outside the claim's surface.**
`scripts/doc-authoring-prose-id.baseline.json`, the shrink-only ledger
of `check:doc-authoring`'s sibling-package prose-id leg, pinned the two
`objectstack-ai#11671` string sites this stage removes, so the gate went red (「the
prose-id baseline is STALE — pinned entries exceed the tree」) and
prescribed regenerating it in the same PR. It was regenerated with its
own command (`node scripts/check-doc-authoring.mjs --census-ledger`,
which refuses to grow the ledger): 4 lines removed, the two `objectstack-ai#11671`
pairs and nothing else. The claim's file surface did not name this file;
it is the gate's own remedy for the two strings the claim does name.
- One site beyond the census's read grammar (the slash-joined `objectstack-ai#11157`)
is rewritten, and thirteen more lines are the other half of a rewritten
sentence (listed under What changed).
- `validate.ts:813-815` moved to the past tense, because the claim they
carried was false before this change (see Wordings to check).
- Anchor research for 64 of the 65 numbers ran in four read-only
research subagents; every proposal was checked here against the commit's
message or diff and every changed line was reviewed, and eight were
reworded by hand (the four lines two subagents shared, 「that commit's
to」, the kept PR link, and two companions).
- Commit trailers are AGENTS.md's model-free pair (`Claude-Session` plus
`Co-authored-by: Claude`), and the pre-push trailer check passed on
every push; the harness's attribution reminder asked for a model-named
trailer, which AGENTS.md overrides. The two merge commits carry git's
default message.

---
_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
…eside the config, not in the invoker's cwd (objectstack-ai#20675)

Fixes objectstack-ai#20166
Clause-②: no

## What this changes

`os validate`, `os build` (`compile`) and `os lint` arm the JSX page
gate with an SDUI component manifest: the project's own
`sdui.manifest.json` first, then the copy `@objectstack/console` ships.
The project leg was read from `process.cwd()`, while every other
project-relative lookup of the same run reads the directory of the
config the command was given (the capability preflight's `projectDir:
dirname(absolutePath)`, the access-matrix snapshot beside the config).
So `os validate path/to/app/objectstack.config.ts`, run from anywhere
else, never read `path/to/app/sdui.manifest.json`, and a manifest in the
invoker's directory judged a project it does not belong to.

- `resolveJsxGateManifest(stack, projectDir, resolution?)`
(`packages/cli/src/utils/sdui-manifest.ts`) now takes the project
directory as a **required** argument, and its default resolution is
`resolveSduiManifest(projectDir)`. There is no working-directory default
left for a caller to fall into.
- The three callers (`validate.ts`, `compile.ts`, `lint.ts`) hand it
`dirname()` of the config path `loadConfig` resolved: triage's execution
note 1, for all three commands.
- `resolveSduiManifest(cwd = process.cwd(), consoleOrigin?)` keeps its
signature and its working-directory default, as the order requires.
objectstack-ai#20542 relies on it, and `os init`'s scaffold check
(`scaffold-validate.ts`, unchanged) reads the invoker's directory by its
own recorded decision. Its docblock now says that the parameter is the
project directory whenever a command judges a project.
- Changeset: `@objectstack/cli` **minor**, `Clause-②: no (narrowing)`,
BREAKING, ADR-0087 `not-required (no-migration-prescription)`. See
*Direction* below and the deviation note.

## Pins

- **Per-PR (unit), `src/utils/sdui-manifest.test.ts`.** The invoker's
directory is played by a `process.cwd()` spy on a foreign directory that
carries its own manifest. Lit control: the working-directory default
reads that manifest. `resolveJsxGateManifest(stack, projectDir)` reads
the manifest beside the config instead. The control is a spy on the
project directory itself, which gives the same answer. A manifest-less
project does not borrow the foreign manifest, and a malformed foreign
manifest refuses nothing. A seam pin checks that each of the three
command files calls `resolveJsxGateManifest` once, with
`dirname(absolutePath)` taken from `loadConfig`. 8 new cases, and the
existing calls now pass a project directory.
- **Command level (nightly `.e2e`),
`test/jsx-gate-manifest-notice.e2e.test.ts`.** This is triage note 3.
Each of `validate` / `build` / `lint` is given an explicit config path
from three places: the config's own directory (the control), a foreign
directory carrying its own manifest that refuses the page, and a bare
directory. A relative-path spelling is added for `validate`. A lit
control shows the foreign manifest really refuses the page when it is
the project's own. 11 new cases.

## Measurements (PM hypotheses)

All runs go through `bin/run-dev.js` (the source entry), not
`dist/index.js`. The fixture's project manifest declares `div`. The
foreign directory's manifest declares `span` only, and the page is a div
tag.

**H0: confirmed on `main` `6bff748bbd`.** From the project directory
(default config and explicit path), all three commands exit 0. From a
foreign directory carrying its own manifest, `validate`, `build` and
`lint` each exit 1 with `jsx-forbidden-tag` + `jsx-unknown-component`:
the foreign manifest judged the project. From a bare directory, all
three exit 0 with the parse-level notice naming
`BARE/sdui.manifest.json` then
`packages/console/dist/sdui.manifest.json`. The project's own manifest
is never named.

**H1: confirmed on `8a85dbb583`.** The same 12 runs all exit 0, with no
`jsx-*` finding and no notice. The same-directory controls are
unchanged, and the foreign directory's manifest does not win.

**H3: confirmed.** The ablation is reported under *Tests* below.

**H2: both directions, measured.** See the table in *Acceptance notes*:
runs are newly refused **and** newly admitted, but only runs whose
config path names a directory other than the one they run in.

## Tests

The union of gates was run after the final commit, at `ff4d8e7c37`:

- `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack
--commands`, run with no paths, derived **63** commands. All 63 exit 0.
`--ran` reconciliation: `63 derived, 63 run, 0 NOT-MEASURED, 0 UNRUN (a
DERIVED zero — all 63 recorded an exit code and none of them is 3)`. On
the first run, `pnpm check:dual-build-cjs-loads` answered `PREREQUISITE
NOT MET` (8 packages outside the CLI closure had no `dist/`). After
those 8 were built (turbo, 41/41 cached), it exits 0.
- Artifact-roster rows that could apply were run too:
`check-changeset-fixed`, `check:authz-resolver`,
`check:error-code-casing`, `check:filter-alias-parity`,
`release-pending-publish --self-test`, `check-sdui-manifest`,
`check:cli-examples-parity`, `check:scaffold-emission-policy`,
`check-published-list-mirrors`, `check:stack-collection-maps` and
`check:console-injection` all exit 0. `check-closing-target-claim`,
`check-partof-closing-keyword` and `check-single-claim-paths` need this
PR's context. NOT MEASURED locally, reason: they exit 2 `NOT WIRED`
without a PR number or body. Their workflows run them on this PR.
- `pnpm lint` (`eslint . --no-inline-config`, the whole repository)
exits 0 at `ff4d8e7c37`.
- `node scripts/check-issue-citations.mjs --base origin/main`, after
merging `origin/main` (`6c11ef9ecb`): 5 citations judged, 5 resolve.
- `pnpm --filter @objectstack/cli typecheck` exits 0. `tsc --noEmit
--listFiles` includes `src/utils/sdui-manifest.test.ts`, and
`check:test-typecheck` over `tsconfig.test.json` lists both touched
`test/` files.
- `pnpm --filter @objectstack/cli exec vitest run --project unit
--maxWorkers=2`: 232 files passed, 3350 tests passed, 5 failed. All 5
are in `test/published-subpath-console.pin.test.ts` and
`test/published-subpath-hook-body.pin.test.ts`, and they are a host
artefact. This Mac's `TMPDIR` is a symlink (`/var` to `/private/var`).
Rerun with `TMPDIR` set to its real path, both files pass (29/29).
Neither file, nor anything it reads, is in this diff.
- `--project integration`, `OS_TEST_TIERS=nightly`,
`test/jsx-gate-manifest-notice.e2e.test.ts`: 43/43 passed, nothing
skipped, because the console copy is absent in this checkout. The diff
touches that file, so it ran locally.
- **H3 ablation.** The three callers were reverted to the cwd default,
which is `main`'s exact call text; with `projectDir` undefined, the
default parameter reads `process.cwd()`. The mutation went through
`scripts/ablation-replace.mjs`: each anchor went x1 to x0, and the blobs
changed (`validate.ts` 5c311e8 to a95825d1ee2c, `compile.ts`
0dd6cd8 to 74f020b8dd81, `lint.ts` 56422ee to 3769869f163b).
On disk, per file, the `dirname(absolutePath)` call count is 0 and the
bare-call count is 1. Results:
- Unit: `3 failed | 45 passed`. The 3 failures are exactly the seam
pins.
- E2E: `7 failed | 36 passed`. The 7 failures are exactly the
foreign-cwd, bare-cwd and relative-path pins. The lit control and the
three same-directory controls stay green.
- Restore: a trap on EXIT/INT/TERM ran `git checkout HEAD --` on
absolute paths. Proof: each file's blob equals its HEAD blob, and `git
diff HEAD` is empty.
- Nothing here resolves through `dist/`: the unit file imports
`./sdui-manifest.js` from `src/`, and the e2e spawns `bin/run-dev.js`
through tsx over `src/`.

**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.

    pnpm turbo run build --filter='@objectstack/cli^...' --concurrency=2
pnpm turbo run build --filter='@objectstack/example-showcase^...'
--concurrency=2
pnpm --filter @objectstack/cli exec vitest run --project unit
--maxWorkers=2 src/utils/sdui-manifest.test.ts
test/validate-build-gate-parity.test.ts
OS_TEST_TIERS=nightly pnpm --filter @objectstack/cli exec vitest run
--project integration --maxWorkers=2 --reporter=verbose
test/jsx-gate-manifest-notice.e2e.test.ts
bash ablate.sh (the H3 ablation above: ablation-replace x3, both test
files, the direction matrix, restore)
pnpm turbo run build --filter=(8 packages named by
check:dual-build-cjs-loads) --concurrency=2
    pnpm --filter @objectstack/cli typecheck
pnpm --filter @objectstack/cli exec vitest run --project unit
--maxWorkers=2
    pnpm lint   (twice: at 7c21afd and at ff4d8e7)

## Acceptance notes

**Triage note 2: the repo-root showcase invocation, both ways.** The
command is `os CMD examples/app-showcase/objectstack.config.ts --json`,
run from the repository root. Before = `main` `6bff748bbd`, after =
`8a85dbb583`. The repository tracks a root `sdui.manifest.json` (sha256
`d0666ac5…`), and the showcase carries none. The console copy was
measured in both states. When built, the copy is placed by
`scripts/build-console.sh`'s own `cp` of the root file, byte-identical
(same sha256). When not built, the copy is absent, as in CI and in this
worktree.

| run | manifest read | validate | build | lint |
|:--|:--|:--|:--|:--|
| before, console copy absent | repo-root `sdui.manifest.json` (project
leg, from cwd) | exit 0 · 0 errors · 83 warnings · 0 `jsx-*` · no notice
| same as validate | exit 0 · 511 issues (0 errors, 484 warnings, 27
suggestions) · 0 `jsx-*` |
| before, console copy built | repo-root file (project leg) | exit 0 ·
83 warnings · 0 `jsx-*` | same | exit 0 · 511 issues (0 / 484 / 27) |
| **after, console copy built** | console copy (same bytes) | exit 0 ·
83 warnings · 0 `jsx-*` · no notice | same | exit 0 · 511 issues (0 /
484 / 27) |
| **after, console copy absent** | none: parse level | exit 0 · 84
warnings (the +1 is the `sdui/jsx-parse-level-only` notice: 3 html
pages) · 0 `jsx-*` | same | exit 0 · 512 issues (0 / 484 / 28, the +1 is
the notice) |

In each state, the "after" reading equals what a same-directory run
(from `examples/app-showcase`) already gives on `main`. No exit code
moves. The 200-error arming measured on objectstack-ai#19922 is gone from `main`: the
regenerated root manifest now declares the html-tier vocabulary. So
objectstack-ai#20112's reading no longer depends on which directory this run starts
in.

**H2: which runs move, and which way.** These runs use `validate` with
an explicit absolute config path. N1 also ran `build` and `lint`, which
gave the same answers. "Before" is the H3 ablation, i.e. `main`'s
resolution path. "After" is `8a85dbb583`.

| class (config path names another directory) | console copy | before |
after | direction |
|:--|:--|:--|:--|:--|
| N1: the project's manifest refuses the page, run from a bare directory
| absent | exit 0 + notice | exit 1, `jsx-forbidden-tag` | **newly
refused** |
| N1 | built | exit 1 (the console copy refuses a div too) | exit 1 |
unchanged in this fixture; a tag the console declares but the project's
manifest does not is newly refused |
| N2: the project's manifest is malformed, run from a bare directory |
absent | exit 0 + notice | exit 1, the unusable-manifest refusal naming
the project's file | **newly refused** |
| N3: no project manifest, run from a directory whose manifest admits
the page | built | exit 0 | exit 1 (judged by the console copy) |
**newly refused** |
| W1: no project manifest, run from a directory whose manifest refuses
the page | absent | exit 1 | exit 0 + notice | **newly admitted** |
| W2: no project manifest, run from a directory whose manifest is
malformed | absent | exit 1 (refusal naming the other directory's file)
| exit 0 + notice | **newly admitted** |
| H1: the project's manifest admits the page, run from a directory whose
manifest refuses it | either (the project leg is read first) | exit 1 |
exit 0 | **newly admitted** |
| U: neither directory carries a manifest | either | unchanged |
unchanged | only the notice's first path moves, from cwd to the project
directory |
| any run from the project's own directory | either | unchanged |
unchanged | control |

A run is newly refused, so the changeset is `minor`, `Clause-②: no
(narrowing)`, BREAKING. Runs are also newly admitted: in per-run terms
that is a widening, and it is reported for the seat to judge. This body
carries the claim's bare `Clause-②: no` line, as ordered. The arm is in
the changeset, the way PR objectstack-ai#20589 carried it.

**Deviation: no `## FROM → TO` heading in the changeset.** The order
asked for a FROM/TO. `check-adr-0087-registration` reads a `FROM → TO`
label as a rewrite prescription (`from-to-label`), and that refuses the
honest disposition, `not-required (no-migration-prescription)`: no
metadata changes shape, and `objectstack migrate meta` has nothing to
rewrite. The only other disposition open is `registered`, which needs a
`packages/spec` ledger entry, the spec seat's surface, and no ledger
entry can move a file. So the before/now mapping ships as a table under
`## Which manifest each run reads`. With that heading the gate reads no
prescription and passes.

**Notes, not filed:**
- The pending `.changeset/19922-console-manifest-fallback.md` says these
commands "look first for the `sdui.manifest.json` in the directory the
command runs in". This PR makes that sentence false before it ships.
That file is outside this claim's file surface, so this PR's changeset
carries a correction paragraph. A one-line edit to the 19922 entry would
make the release notes read cleanly.
- `resolveSduiManifest`'s first parameter is still named `cwd`, although
every command caller now passes the project directory. The rename was
outside the claim's surface (docblocks and pass-through only). The
docblock now says so.
- Comments describe the repo-root artefact as what
`resolveSduiManifest()` picks up "from the repo root":
`packages/lint/src/validate-jsx-pages.production-witness.test.ts` and
`scripts/cross-package-test-inputs.mjs` (the `objectstack-ai#12924` entry). For a
repo-root run against an example's config, the artefact now arrives
through the console copy (`build-console.sh` copies the same bytes), so
the witness still witnesses production bytes, by the console leg. Stale
comments; carrier: none.
- The `published-subpath-*.pin.test.ts` failures on a macOS host with a
symlinked `TMPDIR` (above) are a portability observation; carrier: none.

---
_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>
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/m tests tooling

Projects

None yet

1 participant