Skip to content

feat(cli,metadata-protocol): the save door compiles an html page's source against the deployment's SDUI manifest - #20852

Merged
objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/issue-20312-save-door-sdui-manifest
Sep 30, 2026
Merged

objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/issue-20312-save-door-sdui-manifest

Conversation

@objectstack-fleet

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

Copy link
Copy Markdown
Contributor

Part of #20312
Clause-②: yes (narrowing — on a host that registers a manifest, the runtime metadata save door newly refuses an html page whose source uses a component the manifest does not declare, or whose requires disagrees with its source; the new exported SDUI_MANIFEST_SERVICE widens @objectstack/metadata-protocol)

Summary

Stage ① (the channel) and stage ② (save-time compile and refusal) of ruling 5881821895 (letter A), merged into one domain:cli PR by dispatch pointer 5902497015 (ruling 5902378057 on #20542, A + E). Stage ③ (the load-time report, the ledger row, the describe and the docs) is this card's own follow-on and is not in this PR.

  • The channel. @objectstack/metadata-protocol exports one constant service key, SDUI_MANIFEST_SERVICE = 'sdui-manifest'. It is a plain key, not a CoreServiceName slot, and it needs no spec edit. os serve resolves the deployment's manifest once at boot through the CLI's existing resolveSduiManifest(path.dirname(configPath)) and registers the result under that key with kernel.registerService, before any plugin inits (packages/cli/src/commands/serve.ts, helper registerDeploymentSduiManifest in packages/cli/src/utils/sdui-manifest.ts). The protocol reads the key on every publish (resolveSduiManifest on the protocol, the resolveFlowCanonicalizer pattern) and passes sduiManifest into evaluateRuntimeAuthoringGate.
  • Stage ②. With a usable manifest, the gate compiles a kind: 'html' page's source (and the deprecated 'jsx' spelling) with @objectstack/sdui-parser's compile(), the compiler behind the CLI-only validateJsxPages rule, imported and not re-implemented. It lives beside the gate's existing sduiManifest option, as a gate-local judgement in the same shape as the platform-schedule refusal: findHtmlPageSourceGaps in runtime-authoring-gate.ts. Compiler errors refuse the publish with the existing 422 INVALID_METADATA envelope, under the CLI's own rule ids (jsx-forbidden-tag, jsx-unknown-component, and so on). Each issue's where and message name the component. Compiler warnings ride advisories. A hand-written requires that disagrees with the compiled one is refused under page-requires-disagrees-with-source, and the message names each namespace: one no manifest component carries, one the source does not use, or one the source uses but the list leaves out. saveMetaItem stamps requires from the compile (stampHtmlPageRequires), on a draft save too. A draft that does not compile, or whose requires disagrees, is stored as written, because drafts are not gated ([runtime/metadata] 作者时规则只存在于 CLI:Studio/REST/MCP 的运行时授权面是第四扇门,26 条规则一条不跑——#4409 修完后最大的敞口 #4463 D1), and its publish refuses it. No new error code.
  • The boot line. A host that resolves no manifest (absent), or resolves an unusable one, registers nothing. It prints one line, Page source and \requires` not validated at save: …, naming the file and reason or every place looked, and the boot continues. The save door then stores html pages exactly as before. A registered value that has no components` map gets one warning from the protocol and is never compiled against.

Declared cross-lane touch

packages/metadata-protocol (domain:engine): runtime-authoring-gate.ts (the key, the gate-local compile, the stamp helper), protocol.ts (the per-publish read, the argument into evaluateRuntimeAuthoringGate, the stamp in saveMetaItem), and one export line in index.ts. There is no new file under packages/metadata-protocol/src: the pins sit in the existing protocol.runtime-authoring-gate.test.ts. None of open PR #20830's one-line text edits is touched. A local merge of its head onto this branch is clean (git merge-tree exit 0).

Deviation from the claimed file surface (declared): packages/metadata-protocol/package.json gains "@objectstack/sdui-parser": "workspace:*", and pnpm-lock.yaml gains its importer line. The claim says "the compile the save door runs is the existing one, imported". Under pnpm's strict layout that import does not resolve unless the package declares the dependency. The alternative imports are closed: the gate may reach @objectstack/lint only through /runtime, which must not export validateJsxPages, and the wiring guard forbids a registry rule named at the gate. @objectstack/sdui-parser has zero dependencies and never executes source, so the kernel boot-path contract in runtime-lazy-deps.test.ts (never typescript or sucrase) is untouched.

Measurements (measurement came first)

False-refusal rate over stored html pages: 0 of 3, measured at objectstack 15b586dc before the refusal was written. The measurement compiled every kind: 'html' / 'jsx' page in the repository with @objectstack/sdui-parser dist against the pinned console's sdui.manifest.json (107 components). That manifest is the one os serve hands a served example, through the console copy. The population is the 3 showcase pages, the only authored html pages among the 25 *.page.* files. Hand-written requires: 0.

page ok compiled requires errors warnings
showcase_capability_map true ['ui'] 0 0
showcase_command_center_jsx true ['ui'] 0 0
showcase_start_here true ['ui'] 0 0

Control: compile('PLUGIN-NONEXISTENT tag') answers ok: false with forbidden-tag and unknown-component. Test fixtures, which are not stored pages: 3 html sources in metadata-protocol / metadata-core tests use a bare div, which this manifest does not declare (the ui-html-page-div-refused ledger entry). They would be refused only on a host with a registered manifest, and none of those tests registers one.

The console fallback's exports failure (ruling ①). The subpath specifier @objectstack/console/dist/sdui.manifest.json still throws ERR_PACKAGE_PATH_NOT_EXPORTED from the CLI. However, the CLI's console leg stopped using that specifier in #19922: it resolves the console's package.json and joins the path to it. With an installed console that carries the manifest, and the exports map left as it is (./package.json only), resolveSduiManifest answers resolved (107 components). In this workspace it answers absent, only because packages/console/dist is not built here. ⇒ packages/console/package.json is not edited.

Cloud's per-project kernel: NOT MEASURED. The cloud repository is outside this session's scope, so whether its per-environment kernel serves the same console and can read the same manifest is not read here. In-repo, the only host that registers the key is os serve. Dispatch pointer 5902497015 names cloud#2482 as the card that registers the console manifest under this key in each per-env kernel.

Pins

  • packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts, block html page source compiled at the save door against the SDUI manifest (#20312), 10 cases:
    • an unknown component answers 422 INVALID_METADATA with where/message naming the component, and nothing persists;
    • a known component saves, with requires stamped from the compile;
    • an agreeing hand-written requires is kept, in compiled order;
    • a disagreeing one is refused in all three shapes, with each namespace named;
    • a host with no manifest saves exactly as before, with nothing compiled and nothing stamped;
    • the key is read per publish: registering, widening and removing it each take effect on the next save;
    • a draft is ungated, but its publish refuses it;
    • a clean draft is stamped and publishes;
    • an unusable registered value gets one warning and is never compiled against;
    • the exported key's value.
  • packages/cli/src/utils/sdui-manifest.test.ts, block registerDeploymentSduiManifest, 5 cases:
    • resolved registers the manifest and prints nothing;
    • absent registers nothing and gives one line naming every place looked;
    • unusable registers nothing and names the file and the reason;
    • the default resolves beside the project directory;
    • os serve makes one registration, under metadata-protocol's key, from path.dirname(absolutePath).
  • Ablations (one-shot, through scripts/ablation-replace.mjs; restore verified as blob == HEAD with git diff HEAD empty):
    • findHtmlPageSourceGaps forced to return null turns 4 of 10 red: the unknown component, the three requires shapes, the per-publish read, and the draft publish.
    • Removing the saveMetaItem stamp turns 3 of 10 red: stamp, agreeing order, and the draft stamp.

Acceptance notes

  • The draft→active promotion (publishMetaItem, publishPackageDrafts) judges the draft with the manifest but does not re-stamp. The draft's own save stamped it when the host had a manifest. A draft saved on a manifest-less host and then published on one with a manifest is judged but stays unstamped.
  • kind: 'react' pages are not compiled (ADR-0081: real JS, not constrained JSX), so a hand-written requires on one is not judged.
  • A 422's message headline carries the finding locators (pages.NAME.source [jsx-forbidden-tag]), as every gate refusal does (A publish failure's error string inlines the same validation prose that issues[] already carries, so every console renders each finding twice #10524). The component is named in issues[].where and issues[].message.
  • Stage ③ is not here: the load-time report, packages/spec/liveness/page.json:9 → live, the page.zod.ts describe, and the docs.

Verification

All readings are at head 898a5bde unless noted. It merges origin/main at 30839063, #20830 included.

  • pnpm --filter @objectstack/metadata-protocol test: 191 files passed, 3 skipped; 2811 tests passed, 19 skipped. Measured at b3d92e56, after fix(metadata-protocol): refusals, hints and log lines state each decision in words instead of a tracker number (stage 2) #20830 merged; the later merge brought in CI-only files.
  • pnpm --filter @objectstack/metadata-protocol typecheck and pnpm --filter @objectstack/cli typecheck: exit 0 at b3d92e56.
  • pnpm --filter @objectstack/cli exec vitest run --project unit: 237 files, 3375 tests passed at b3d92e56. The integration tier is declared to CI. In its place, serve.ts's boot path was exercised by two real boots, below.
  • Boot smoke (examples/app-crm, os dev --fresh on a random port):
    • No manifest: the boot prints the one line naming both places looked, then Server is ready. A PUT /api/v1/meta/page/smoke_page whose source is an unknown component answers 200, stored unchanged with no requires, as before.
    • Manifest beside the served config: no line is printed. The unknown component answers 422 INVALID_METADATA with jsx-forbidden-tag and jsx-unknown-component, where = page "smoke_page" plus the tag. requires: ["ui","plugin-absent"] answers 422 under page-requires-disagrees-with-source, naming 'plugin-absent'. A known-component page answers 200, and a GET reads back requires: ["ui"]. Both servers were torn down.
  • Gates: node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands (no paths) derived 77 commands. All 77 ran at 898a5bde and every one exited 0. --ran over the exit-coded record reports 77 derived, 77 run, 0 NOT-MEASURED, 0 UNRUN: a derived zero, not a claimed one.
    • An earlier sweep at ab8ea2b5 found one real finding. check:test-source-alias wanted the new @objectstack/sdui-parser import aliased to source in packages/metadata-protocol/vitest.config.ts (done), and the CLI pin's key import moved to module top.
    • The other non-zero exits in that sweep were prerequisite exits (3): missing dist, and the shallow clone for check-plugin-teardown-shape --self-test. Each went green once built or deepened.
  • pnpm lint (the whole repo, eslint . --no-inline-config): exit 0 at 898a5bde.

Generated by Claude Code

@github-actions github-actions Bot added dependencies Pull requests that update a dependency file documentation Improvements or additions to documentation tests tooling labels Sep 30, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/cli, @objectstack/metadata-protocol, touching 18 documentable anchor(s). ⚠️ 3 changed file(s) yielded no anchor (packages/metadata-protocol/package.json, packages/metadata-protocol/src/index.ts, packages/metadata-protocol/vitest.config.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

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

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

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

Coarse fallback — 33 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 c90f9fb6e21809430db13371d8929a21e6787a8d → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json c90f9fb6e21809430db13371d8929a21e6787a8d

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

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

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 898a5bde811db636458dd2c1f89abcc09839fef2
Local-runs: none

Inputs read: card #20312 (body and all 8 comments, including ruling 5881821895, pointer 5902497015, claim 5909146624, os-dev-report 5910933403); PR #20852 body, its 11-file list, and the net diff against merge-base 30839063b5 (621 insertions, 8 deletions); the 35 check-runs on the head collapsed to latest-per-name: 33 success, 2 skipped (Console Pin Gate, Packed-tarball smoke), 0 failure — Check Changeset, Lint & Repo Gates, Build Core, Validate Package Dependencies, all six Test Core shards, all four Type Check jobs and TypeScript Type Check green. Merge-base readings: ADR-0080 §5 (docs/adr/0080 lines 101-110, 142), page.zod.ts:903-904, liveness/page.json row requires (planned), AGENTS.md checklist step 3 (the Clause-② grammar), scripts/check-adr-0087-registration.mjs, scripts/check-changeset-no-major.mjs, scripts/pm/clause2-line.mjs, plus the surfaces each judgment below names.

① Derived judgments

(a) The channel — RIGHT, as the pointer rules it. runtime-authoring-gate.ts exports SDUI_MANIFEST_SERVICE = 'sdui-manifest'; index.ts gains exactly that one export line (the barrel exported nothing from the gate module at base, so the other new module-level exports — the two rule ids, isUsableSduiManifest, findHtmlPageSourceGaps, stampHtmlPageRequires — stay off the public surface, consistent with the precedent gate-local rule PLATFORM_SCHEDULE_CREATE_RECORD_ORG_MISSING, which is also not on the barrel). It is a plain string key: Kernel.registerService(name: string, service) (packages/core/src/kernel.ts:275) takes any name, so no CoreServiceName slot and no packages/spec edit is needed. os serve registers it from resolveSduiManifest(path.dirname(absolutePath)), where absolutePath is the served configPath from anchorServedApp (serve.ts:2359), i.e. exactly path.dirname(configPath); the block sits between runtime.getKernel() and "Load plugins from configuration", so before plugins init. The protocol reads it per publish via this.getServicesRegistry?.().get(SDUI_MANIFEST_SERVICE) — byte-for-byte the resolveFlowCanonicalizer shape (protocol.ts:4828), lazy and never cached — and passes sduiManifest into the single evaluateRuntimeAuthoringGate call site (protocol.ts:5169 at base; no other caller exists in packages/**). os dev spawns os serve as a child (dev.ts:600-605), so the boot smoke exercised this path. Pinned: per-publish registering / widening / removing the key takes effect on the next save.

(b) The save-door refusal — RIGHT. INVALID_METADATA is an existing ledgered code (packages/spec/src/api/error-code-ledger.zod.ts:610), and the gate-local findings ride the same 422 envelope, issues[], migration hatch and rulesRun disclosure as the #6285 refusal. The compile is compile() imported from @objectstack/sdui-parser, the identical function validateJsxPages calls (packages/lint/src/validate-jsx-pages.ts:23,62), not a copy; only the eight-line diagnostic-to-finding mapper is repeated, and it reproduces the CLI's own vocabulary (jsx- plus the compiler code, where naming the page and the tag, path pages.NAME.source), so a page refused here is refused under the same rule id on os validate. The validateJsxPages wrapper itself is correctly not imported: the wiring guard (authoring-rule-wiring.test.ts:395-423) forbids the gate naming a registry rule and restricts it to @objectstack/lint/runtime, and runtime-lazy-deps.test.ts:403 forbids validateJsxPages on that entry. Each issue names the component in where and message; the disagreeing-requires finding names each namespace with its reason (unprovided / unused / missing). Drafts stay ungated (D1 early return at gate line 694), and the draft→active promotion runs the same gate on the draft body (promoteDraftForPublish, protocol.ts:18094), so the draft door is not a bypass — pinned.

(c) requires stamped from the compile — RIGHT; the residual is right to leave. stampHtmlPageRequires runs in saveMetaItem after the gate and before the #3050 pre-persistence veto, on drafts too, and only when the source compiles and any hand-written value agrees (option A of the dev's second self-decided question). Leaving a non-compiling or disagreeing draft as written is the correct reading of D1 (drafts are not gated) and of the AI-safety axis (never silently overwrite an author's value); the publish then refuses it — pinned. The named residual (promotion copies the draft row in SysMetadataRepository.promoteDraft without re-stamping, so a draft saved on a manifest-less host and published on a manifest host is judged but stays unstamped) lives in packages/metadata, outside this lane, and costs nothing at stage ② because such a page is still compiled and refused at publish; it only matters to the stage ③ load-time reader, which is where it should be picked up. Escalated in ③.

(d) A host with no manifest — RIGHT, pinned in both packages. registerDeploymentSduiManifest registers nothing on absent and unusable, returns one line opening Page source and requires not validated at save: that names either every place looked or the file and its reason, plus the remedy (the project manifest path or installing the console) — AGENTS.md "Route & surface ownership" rule 3 ("say so once at boot, naming the remedy"). os serve prints it as one console.warn line and continues. The protocol side is pinned: no manifest ⇒ nothing compiled, nothing stamped, an unknown-component page with requires: ['plugin-absent'] stores exactly as written; a registered non-manifest value warns once per process and is never compiled against.

(e) The false-refusal measurement — population complete; the fixture note overstates, harmlessly. My own census at the merge-base agrees with the dev's: 25 *.page.* files; the only kind: 'html'/'jsx' pages outside tests are the three showcase pages (capability-map, command-center-jsx, start-here); zero JSON/YAML html pages; zero hand-written requires on any page (every requires: hit in CLI/runtime/metadata sources is stack-level). The pinned manifest (sdui.manifest.json, 107 components, 17 namespaces) does not declare div, so the control is right. No artifact-side requires producer exists (the CLI test comments about "requires []" concern the conversions field, not page.requires), so no round-trip false-refusal path is hidden from the measurement. The fixture note is slightly wrong in its mechanism: the protocol.batch-verb-driver-text.test.ts fixtures are seeded rows driven through duplicatePackage and artifact-forward-conversion.test.ts calls a pure conversion — neither crosses saveMetaItem, so they would not be refused on any host, manifest or not. The note errs toward disclosure; no action.

(f) The added dependency — RIGHT, and mechanical. check-undeclared-dep-imports.mjs (in Lint & Repo Gates, green) refuses a non-test src/** import of an undeclared workspace package, so the package.json line and its lockfile importer line are forced by the claim's own "imported, not copied" instruction. No cycle: @objectstack/sdui-parser declares zero dependencies and its src imports no @objectstack/* package. No lean entry is touched: the only guarded lean entry is @objectstack/objectql/core, where @objectstack/metadata-protocol is already in the DENIED set, so a new dependency of metadata-protocol cannot enter that closure; check:lean-entry-closure and check:dts-closure run in Build Core (green), check:dts-closure again in both Type Check · debt ledger and Type Check · consumer gates (green), Validate Package Dependencies (lockfile currency) green. The parser never loads typescript/sucrase, so the kernel boot-path pin is untouched. The vitest alias is anchored (/^@objectstack\/sdui-parser$/) and is the printed remedy of check:test-source-alias, which runs in Lint & Repo Gates (green).

(g) No packages/spec, no packages/console edit — RIGHT. Spec: see (a). Console: at base the CLI's console leg resolves @objectstack/console/package.json (CONSOLE_PACKAGE_JSON, sdui-manifest.ts:106) and joins dist/sdui.manifest.json; CONSOLE_SDUI_MANIFEST survives only as the name printed in lookedAt. The exports map (./package.json only) therefore suffices, exactly as the #19922 mechanism and the dev's measurement say; the pointer made the console edit conditional on the measurement, and the measurement answered no.

(h) Changeset prose — accurate; banner right; disposition accepted by the ADR's text, with one precedent divergence escalated. Every claim in the changeset matches the diff (key, resolver order, per-publish read, rule ids, advisories, draft behaviour, boot line, measured pages). The **BREAKING** banner names exactly the narrowing and nothing wider: on a host with a manifest, PUT /api/v1/meta/page/NAME and the draft publish of a kind: 'html' page now 422 on an undeclared component or a disagreeing requires, with the one-line fix. The save-door callers at base are REST PUT/publish, the runtime meta domain (MCP) and publishPackageDrafts — all "the runtime metadata save door"; artifact boot registration does not cross saveMetaItem, so the banner does not under-name the reach. Level minor under the launch-window convention with breaking-ness carried by the banner and the disposition, as the changeset itself states. ADR-0087: not-required (no-migration-prescription), exactly one marker, is right by the ADR's text — the category's one check is that the body carries no migration prescription, and it carries none (no FROM/TO label, no operand-arrow rewrite, no table, no heading; the only arrow-shaped bytes are the comment closer, which no operand precedes); nothing authorable is renamed or removed, so AGENTS.md's FROM→TO requirement does not apply; rows at rest are not judged. Divergence: three days earlier the same manifest-driven refusal at the CLI door was recorded as a D3 semantic entry, ui-html-page-div-refused (#20592), whose text says "this entry is the migration channel" for a refusal inside authored JSX and whose reason names only validate/compile/lint. Claiming it as already-registered would have been less honest (the ADR itself warns the gate never verifies coverage), so the catch-all is the correct choice for this PR; but the generated upgrade guide will name the CLI-side refusal and not the save-door one. Escalated in ③ for stage ③, which owns the docs flip. Tracker numbers: none in the body; the filename 20312-… is the repo convention.

② Semver level

@objectstack/metadata-protocol: minor, @objectstack/cli: minor — RIGHT. judgeLevel in check-changeset-no-major.mjs requires that a yes declaration grade at least one package whose packages/**/src/** moved at minor or above; both are minor, and major is refused during the launch window.

Declaration: Clause-②: yes (narrowing) is the right reading — the save door's accept set narrows (BREAKING) and the public surface widens by one export; the reader's own four-combination table names yes (narrowing) as "a diff that widens one surface and narrows another. Both facts are true and both are read."

Line form: PR body line 2 reads Clause-②: yes (narrowing — on a host that registers a manifest, … widens @objectstack/metadata-protocol), the same line in the changeset. It is the sole key-initial Clause-② line in the body. Read against scripts/pm/clause2-line.mjs, the fleet's one reader: CLAUSE2_KEY_LINE matches (key at line start, plain colon); matchValueToken takes yes as the first token after the colon; readArmToken requires a parenthetical opened as the next non-blank thing after the value with the arm word as its FIRST token and then keeps whatever follows as the seat's argument — its docblock gives no (narrowing — the IANA zone domain) as the worked example that "reads the arm and keeps the reason". The key appears once and is not backticked, so neither describing tell fires. Reading: declared, value yes, arm narrowing. check-changeset-no-major.mjs and check-adr-0087-registration.mjs both import this reader and nothing else, and both run in the Check Changeset job (green). So CI passed because the form IS accepted — a calibrated reading (the #13914 control shape), not a tolerance the line slipped through. AGENTS.md's printed minimal spelling Clause-②: yes (narrowing) with the explanation on its own line is equally read and is what the claim itself wrote; no rewrite is required, and the record notes both are canonical to the one reader.

③ Boundary flags

Dev deviations (5) — all answered, none a breach:

  1. packages/metadata-protocol/package.json + pnpm-lock.yaml dependency line: the mechanical consequence of "imported, not copied"; forced by the undeclared-dep gate; declared in the PR body — accepted (①f).
  2. vitest.config.ts anchored alias: the gate's printed remedy, gate green — accepted.
  3. No packages/console/package.json edit: measurement-backed and verified against the resolver's console leg at base — accepted (①g).
  4. No new file under packages/metadata-protocol/src: pins in the existing test file, honouring security(flows): move a flow's inbound-hook secret out of flow metadata into the write-only secret seam #7799 established — no read, the generic data door included, returns it #20790's claim — accepted.
  5. First sweep's prerequisite exits at ab8ea2b5: resolved before the final 77-of-77 sweep — accepted.

Dev open_questions (3, self-decided) — each answered:

  • Q1 (import compile() from the parser, option A): RIGHT — the pointer says the compile is the existing one imported and forbids lint/sdui-parser edits; the lint route is closed by two guards; the alternative surface flip would edit another lane and return no requires to stamp.
  • Q2 (stamp drafts conservatively, option A): RIGHT — see ①c; option C would silently replace an author's value, option B would leave every Studio draft-then-promote page unstamped.
  • Q3 (promotion does not re-stamp, accept for this PR): RIGHT to leave — ESCALATED: the card's stage ③ dispatch should carry this residual by name (promoteDraft in packages/metadata, another lane), since the load-time reader is the only consumer it can affect.

Dev out_of_scope_findings (2) — both answered:

  • RUNTIME_HEAVY_SOURCE_PARSE reason on validateJsxPages is stale prose (the rule only calls the pure parser): confirmed at base (authoring-rules.ts:449-451); not a defect; ESCALATED as a carrier note for stage ③ or the lint lane.
  • sdui-manifest.ts header "One resolver, three callers" is now stale: polish, no carrier — agreed.

Reviewer's own flags (none blocking):

  • ADR-0087 upgrade-guide coverage: the save-door refusal is not named by any ledger entry while its CLI twin is (ui-html-page-div-refused). ESCALATED for stage ③: amend that entry's reason to name the save door, or add a D3 semantic entry, in the PR that flips the docs.
  • Fixture note mechanism (①e): overstated, harmless; no action.
  • Not measurable here: whether the objectui Studio sends a client-computed requires on save (the objectui pin is outside this repo). If it does and a project's own sdui.manifest.json differs from the console copy, a disagreeing requires would 422 with the namespace named — inside the declared narrowing, but worth one dogfood pass at stage ③.
  • Cloud's per-env kernel: NOT MEASURED, as the dev says; cloud#2482 registers the key there. The stage-① confidence gap stays open by design.

Implemented-by: claude/issue-20312-save-door-sdui-manifest
Reviewed-by: session_01VvcEokUG1tvVxkceYfR5XB

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review September 30, 2026 13:02
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Sep 30, 2026
Merged via the queue into main with commit b531c7b Sep 30, 2026
44 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-20312-save-door-sdui-manifest branch September 30, 2026 13:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dependencies Pull requests that update a dependency file documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants