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
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…orted at load, and draft promotion re-stamps requires (objectstack-ai#21121)

Fixes objectstack-ai#20870
Clause-②: no

## Summary

Stage ③, engine half, of objectstack-ai#20312 (ruling `5881821895`, letter A, staged
①②③; ruling `5902378057`, A + E). Stages ① and ② landed as objectstack-ai#20852
(`b531c7bf0`). The spec half (liveness row, describe, docs) is objectstack-ai#20871
and is not in this PR.

- **At load, report.** `loadMetaFromDb`, the boot hydration of stored
`sys_metadata` rows, now prints one `warn` line for each stored page
whose `requires` names a namespace that no component in the deployment's
SDUI manifest carries. The line names the page and every such namespace,
under the marker `[page_requires_plugin_absent]`. The report runs only
after the page has loaded, and it changes nothing about the page: the
page is registered and served. The manifest is read through
`resolveSduiManifest()`, the same `SDUI_MANIFEST_SERVICE` read the save
door makes. The namespace set comes from one helper,
`manifestNamespaces()` in `runtime-authoring-gate.ts`, which the save
door's `requires` judgement now uses too. There is no second manifest
reader.
- **Draft to active promotion re-stamps `requires`.**
`promoteDraftForPublish` serves both `publishMetaItem` (`POST
/meta/:type/:name/publish`) and `publishPackageDrafts`. It now passes
`SysMetadataRepository.promoteDraft` a new optional `deriveActiveBody`.
That callback applies the save door's own `stampHtmlPageRequires` to the
draft row being promoted, against the manifest read at publish time. The
active row therefore stores what an active save of the same body would
store.
- **Measure first (the Studio):** see the readings below. The Studio
does not compute `requires` on the client. It does send back the stored
`requires` it last read, and that sent-back stamp is what goes stale.

Example line (from the built `dist`, on this branch):

```text
[Protocol] [page_requires_plugin_absent] stored page/landing requires 'plugin-kanban', a namespace no component in this deployment's SDUI component manifest carries: the plugin that provides it is not loaded in the console this deployment serves. The page is loaded and served anyway, and what its source draws from that plugin will not render. Install it in that console, or take its components out of the page's source and save the page without `requires` — it is derived from the source at save.
```

## Measurements (taken before the code)

**H1 — the load path and its ordering.**
- A stored page reaches the runtime in one place: `loadMetaFromDb`
(env-wide `active` rows), called from `ObjectQLPlugin.start()` →
`restoreMetadataFromDb`.
- `page` is `allowOrgOverride: false`
(`packages/spec/src/kernel/metadata-plugin.zod.ts:856`), so no
org-scoped page row is minted. Legacy org-scoped residue is already
reported by `reportUnhydratableOrgScopedRows`.
- Package install (`installPackage`) records the package's own manifest
in the registry and writes no page row. Code-package pages are not
stored rows, and `os build` stamps no `requires` on them.
- `os serve` registers the manifest before the first `kernel.use(...)`
(`packages/cli/src/commands/serve.ts`, the block above "Load plugins
from configuration"). `os dev` compiles and spawns `serve`. Hydration
runs in a `start()`, after every `init()`. So the manifest is readable
when pages load.
- **Hook chosen: the hydration itself (`loadMetaFromDb`), not
`kernel:ready`.** It is literally the moment stored pages load. It
inherits hydration's own topology gating, so a project kernel that skips
hydration also skips the report, and it needs no second `sys_metadata`
read. The conclusion it records is drawn from a registered manifest's
contents, never from "nothing is registered". So the
`check:startup-registry-verdict` shape does not apply. It passed in the
gate run below.
- **Residual:** a host that registers the manifest after `start()` gets
no load report for that boot. No in-repo host does this. cloud#2482
should register it the way `os serve` does.

**H2 — no manifest.** The save door judges nothing without a manifest,
and the host says so at boot. The load report takes the same posture:
`findPageRequiresAbsentFromManifest` answers `null` and nothing is
printed. The `[page_requires_plugin_absent]` line can appear only when a
usable manifest is registered. An unusable registered value gets the
protocol's existing single warning and is not read.

**H3 — what promotion did before this PR.** The new pins were run
against unmodified `origin/main` `fed0db8f6`. Result: 4 red, 3 green
(the 3 green are controls).
- A draft saved before a manifest existed was promoted with **no**
`requires` (`expected undefined to deeply equal [ 'ui', 'plugin-kanban'
]`), on both `publishMetaItem` and `publishPackageDrafts`.
- An agreeing draft list was **carried forward as spelled** (`[
'plugin-kanban', 'ui', 'ui' ]`).
- A **disagreeing** list was already refused: the gate runs on the draft
body before `promoteDraft` (existing pin "a draft is not gated but its
publish is").

So "re-stamps" here means the promoted body carries the recomputed list
where it used to carry the draft's. Nothing new is refused, which is why
the line stays `Clause-②: no`.

**H4 — does the Studio send a client-computed `requires`?** Read from
objectui at the console pin `e420df310f5b` (`.objectui-sha`):
- **No client computation.** The only client `compile` call on a page is
the render path,
`packages/components/src/renderers/layout/page.tsx:606`. It destructures
`tree` and `diagnostics` and drops `requires`. No non-test source in
`packages/app-shell`, `packages/components`, `packages/react` or
`apps/console` assigns a page's `requires`; a word-bounded grep finds
only an action-preview i18n key.
- **But the stored stamp is sent back.** `SourcePageEditor.tsx:149`
patches only `{ source }` into the draft.
`ResourceEditPage.tsx:1033-1038` seeds that draft from the served
document. `ResourceEditPage.tsx:1443` and `:1477-1481` send the whole
draft back with `mode: 'draft'`. No per-type `fromDraft` exists for
`page`.
- **What that costs, measured on this branch** (a one-off probe, file
restored to its HEAD blob afterwards): save a page whose components are
all in the `ui` namespace (stamped `['ui']`) and publish it. Then add a
kanban component to its source and send the whole document back. The
draft keeps `['ui']`, and the publish answers `422 INVALID_METADATA`,
`page-requires-disagrees-with-source: 'plugin-kanban' is used by the
source but not listed`. The author never wrote `requires`. This PR does
not change that refusal (stage ②'s accept set); it is reported to the PM
as a finding and an open question.

## Pins


`packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts`,
block `stored html page requires at load and at draft promotion
(objectstack-ai#20312)`, 7 cases:

- at load, a stored page naming a plugin the manifest does not carry is
reported (page and plugin named, the present `ui` not named) and still
loads (`loaded: 1`, registered);
- at load, all plugins present: no report (the control);
- at load, no manifest: no report (H2);
- a draft saved before the manifest arrived is promoted with exactly
`stampHtmlPageRequires(...)`'s `requires`;
- an agreeing draft list is promoted as the save door spells it;
- the `publishPackageDrafts` batch re-stamps too;
- a promotion with no manifest stores the draft as written.

**Ablations** (one-shot, through `scripts/ablation-replace.mjs`; each
restore verified as blob equal to HEAD with `git diff HEAD` empty):
- The load-report call disabled (`... && false`): 1 of 7 red, the
reporting case.
- `deriveActiveBody` ignored (`const activeBody = draft.body;`): 3 of 7
red, the single, agreeing and batch promotion cases.

## Verification

At `c7b8b6399` (the head this PR opens on, after merging `origin/main`):

- `@objectstack/metadata-protocol` full suite: 196 files passed / 3
skipped, 2938 tests passed / 19 skipped; `typecheck` exit 0
(`--listFiles` includes the gate test file).
- Downstream consumers of `@objectstack/metadata-protocol` (the
`...@objectstack/metadata-protocol` direction), the files that drive
`promoteDraft` and boot hydration, at the first merge `7402cf72a`:
objectql (`--project local`) 6 files / 174 tests, rest 2 / 27, runtime 1
/ 5, all green.
- `node scripts/pm/dispatch-gates.mjs --commands` (no paths, from the
merge base): 62 families. All 62 exit 0 at `c7b8b6399`. The three that
read every package's `dist/` (`check:dual-build-cjs-loads`,
`check:lean-entry-closure`, `check:type-check-debt`) ran after a full
`turbo run build` of `./packages/*` on that head; before that build they
answered `PREREQUISITE NOT MET` (exit 3), which is recorded here as a
non-measurement, not a pass. `--ran` reconciliation: 62 run, 0 NOT
MEASURED, every line recorded with its exit code.
- Driver conformance ledger: 50 / 0 / 0 before and after.
- Lint, narrowed and declared. The population is eslint's own:
`eslint.config.mjs` lints `packages/**` TS outside `packages/spec`.
`--format json` counted 4 files (the four touched sources): 0 errors, 0
warnings. The config never enables type-aware linting
(`eslint.config.mjs` notes no `parserOptions.project` and no typed
rules), so this diff cannot move any untouched file's verdict. The
repo-wide `pnpm lint` is CI's.

## Declared deviation from the claimed file surface

The claim names the pins beside
`protocol.runtime-authoring-gate.test.ts`, `protocol.ts`'s publish path,
`runtime-authoring-gate.ts` and the changeset. This PR also edits
`packages/metadata-protocol/src/sys-metadata-repository.ts`. The
draft-to-active write happens inside
`SysMetadataRepository.promoteDraft`, which reads the draft row itself.
The new optional `deriveActiveBody` applies the stamp to that same row,
so the derivation and the write read one row, not two. The option is
additive: omitted, the draft body is promoted byte for byte. It is named
in the changeset, which bumps `@objectstack/metadata-protocol` `minor`.
No other lane is touched: the load path is in the metadata layer, not
`packages/runtime` or `packages/cli`.

## Acceptance notes

- **The Studio's sent-back `requires` (H4).** This is the reason a stale
stamp exists at all in Studio editing. Proposed fix: objectui's page
editor omits `requires` from the body it saves, as the save door's own
hint says ("omit it"). Handed to the PM as a finding with the evidence
above. Not fixed here.
- The host's no-manifest boot line
(`packages/cli/src/utils/sdui-manifest.ts:270`) still says "not
validated at save". It is still true. With this PR the load report is
skipped on that host as well, so the line could say "at save or load".
Not edited here: cross-lane, pinned in the CLI's tests, and not false.
- On the `package-author` authoring channel (the control-plane assembly;
gate off), a disagreeing draft `requires` is promoted as written. That
matches what the save door stores on that channel.
- `POST /packages/:id/publish` (`MetadataManager.publishPackage`) is the
loader-plane snapshot publish over the in-memory registry. It is not a
`sys_metadata` draft promotion and never ran the save door, so it is
untouched.
- The load report is printed once per boot per affected page. It is not
deduplicated across boots, so each boot restates it until the page or
the console changes.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…bjectstack-ai#21290)

## What this is

A pre-cut draft of the curated release page for **17.6.0**,
`content/docs/releases/v17/17-6.mdx`, plus its entry in
`content/docs/releases/v17/meta.json` and in
`scripts/docs-audit/handwritten-docs.json`. It also appends one dated
correction to `content/docs/releases/v17/17-5.mdx` (details below).

Docs-only. Everything is under `content/docs/releases/` (release-owned)
or the docs-audit list, so this is the dedicated docs-only PR
`AGENTS.md` sanctions for that tree. It publishes nothing from any
package, hence `skip-changeset`.

Clause-②: no

It follows the 17.5.0 page's structure:

1. Highlights
2. What's new, including the "read this before treating the version
number as a safety guarantee" list of silent runtime changes
3. Breaking changes & migration (triaged, not exhaustive)
4. New capabilities
5. Notable fixes
6. New in Console (Studio)
7. Shipped in 17.5.0, listed again in 17.6.0's CHANGELOG
8. Upgrade checklist (every line marked *not exercised*)

## How it was compiled

- **Source:** all 338 changesets pending on `main` at `748b2407`. Every
changeset was read and triaged into breaking, feature, fix or internal.
PR numbers and short SHAs are taken from the commit that added each
changeset.
- **Already shipped in 17.5.0:** 16 of the 338 changesets (from 15
commits) describe code that 17.5.0 already published. I checked each
commit with `git merge-base --is-ancestor` against the version commit
`8c87d26a` and the publish commit `0f6dcac5`.
- Eight follow the version commit in first-parent order. The 17.5.0 page
already covers them.
- Seven are ancestors of the version commit but were not in the version
PR when it merged. The 17.5.0 page does not mention them. Two of them
are breaking (`e73ee2d` objectstack-ai#20567, `c876a74` objectstack-ai#20504), and `7a1faf1`
(objectstack-ai#20579) makes `os validate --strict` fail on a live conversion.
- The new section matches the list in
`.changeset/20622-release-aftercare-upgrade-and-unannounced-notes.md`.
- **Cross-check:** each of the nine ADR-0087 conversions added since
17.5.0 is covered by a migration entry on the page:
  - `action-block-endpoint-to-target`
  - `connector-sync-keys-removed`
  - `connector-triggers-removed`
  - `cube-refresh-key-removed`
  - `dataset-count-measure-empty-field-removed`
  - `form-field-public-picker-removed`
  - `form-view-subform-columns-canonicalized`
  - `page-header-breadcrumb-removed`
  - `time-default-utc-suffix-dropped`
- **Console section:** built from the three pin-bump changesets
(`dd3f7e1be356 → db11afd4967c → e420df310f5b → 31971ff1e28f`), which
carry 232 releasing objectui changesets, 23 of them declared breaking.
The range was also read with `scripts/objectui-range.mjs`.
- **Fact-check pass:** a second pass checked every cited SHA's changeset
against the page, in three slices. It found 31 claims that were wrong or
overstated, and the three follow-up commits correct them. Some examples:
- An RLS `contains` verdict that `d2bc644` (objectstack-ai#21253) later reversed in
this same release.
- The `manage_platform_settings` scope: reads of both types, but writes
of `datasource` only.
  - The dataset door's status after `434c6c7` (objectstack-ai#21240).
  - Filter positions that differ per change.
  - `7a1faf1`, which is not a breaking change.
- **Other corrections made while compiling:**
- The connector migration table does not claim that a `job` schedules a
`connectorSource` pull. `0efbdc3` says nothing schedules a pull until
the `job` stage lands, and none landed in this release.
- The public-form picker search-key change (`bafb8c9`, objectstack-ai#21136) is noted
as moot, because the route it changed is retired later in the same
release (`3dc33b2`, objectstack-ai#21222).

## Open items for the cut

These are recorded in the page's RELEASE-TIME TODO comment.

- **Anonymous endpoints.** Under the deny baseline (objectstack-ai#21217), an
app-declared anonymous endpoint (`authRequired: false`) can no longer
read or write objects. objectstack-ai#21158 is still open, and until it lands no
supported channel grants anonymous callers a permission set. The page
says so instead of prescribing a grant.
- **Console pin.** Three 17.6.0 server changes refuse a body that the
pinned Studio (`31971ff1e28f`) still sends. objectui fixed each one
after the pin:

| Studio action | Server change that refuses it | objectui fix (not in
the pin) |
|:--|:--|:--|
| A dataset count measure saved with the Field box blank (`field: ''`) |
objectstack-ai#21240 | `0858267e` |
| An External / Validate-only datasource saved without a credential |
objectstack-ai#21133 | `8001068b` |
| Republishing an html page that gained a plugin component | objectstack-ai#20852 |
`3ae91930` |

The page lists these under "Known console issues". If the pin moves
before the cut, the lines it fixes come out. Otherwise the
accepted-for-GA waiver is recorded.

## The correction to 17.5.0

One dated correction line is appended in place to the "Also shipped in
17.5.0" paragraph of `17-5.mdx`, in the form objectstack-ai#20985 used. It names the
seven commits that shipped in 17.5.0 with no CHANGELOG entry and links
to the 17.6.0 section. The original text is unchanged.

## Deliberately not in this PR

- `content/docs/releases/v17/index.mdx` is **untouched**. Its status
blockquote and per-release list may only claim 17.6.0 after the release
ships.
- An MDX comment at the top of `17-6.mdx` lists the release-time edits:
  1. the publish date;
  2. changesets landed after `748b2407`;
  3. the per-package CHANGELOG entry count;
  4. the console-pin outcome;
  5. objectstack-ai#21158 if it lands first;
  6. the `v17/index.mdx` update.

## Verification

Run locally on the head commit, with the workspace installed. All of
these pass:

- `check-issue-citations --base origin/main`: every added citation
resolves.
- `check:doc-anchors`, `check:role-word`, `check:docs-audit-scope`,
`check:nul-bytes`, `check:doc-authoring`, `check:docs-single-h1`,
`check:docs-redirects`, `check:docs-locale-catch-all`
- `check:corpus-claim-drift`, `check:docs-spec-enumerations`,
`check:published-readme-links`
- `check:release-notes`, `check:release-page-status`,
`check:release-index-currency-sync`, `check:release-body`
- `check-release-section-coverage` (plain and `--strict`),
`check-doc-frontmatter`, `check-docs-nav-label`,
`check-docs-section-name`, `check-section-landing-index`,
`check-doc-route-spelling --advisory`

Both pages compile as MDX with `@mdx-js/mdx` 3 + `remark-gfm`, and the
three tables parse with no ragged rows.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_014VGCS11YUtYAiinRcdqQwL

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…t load (objectstack-ai#21451)

Fixes objectstack-ai#20871
Clause-②: no

## Summary

This is the spec half of objectstack-ai#20312 stage ③. The engine half landed first:
- the save door (stages ① and ②) landed as objectstack-ai#20852;
- the load report and the draft-promotion re-stamp (stage ③ engine half,
objectstack-ai#20870) landed as objectstack-ai#21121 (`250dec897`).

This PR makes the spec say what those landings made true. No runtime
code changes.

- **`packages/spec/liveness/page.json`**: the `requires` row moves from
`planned` to `live`. It carries `verifiedAt: 2026-10-02` and
`evidenceScope: in-repo`. Its `evidence` names the save door, the
promotion re-stamp and the load report, each as `file#symbol`. Its
`producer` names the host that supplies the second input, the
deployment's SDUI component manifest. The liveness README's producer
table asks for one, because the reader compares the authored value
against something a caller supplies.
- **`packages/spec/src/ui/page.zod.ts`**: the `requires` describe used
to say "(validated at save and load)", while the ledger said "declared,
not enforced yet". The describe and its TSDoc now state what happens:
- At save, on a server that has the deployment's SDUI component
manifest, a `kind: 'html'` page's source is compiled (alias `'jsx'`
too). A written list that disagrees with the source is refused (`422
INVALID_METADATA`, `page-requires-disagrees-with-source`). A draft keeps
the list until its publish, which refuses it. The derived list is
stored.
- At load, a stored page whose list names a plugin no manifest component
carries is reported, and it is still served.
  - A server with no manifest checks neither, and says so once at boot.
- **`packages/lint/src/authoring-rules.ts`**: `validateJsxPages` no
longer shares the `RUNTIME_HEAVY_SOURCE_PARSE` reason ("parses authored
source through typescript/sucrase"). It gets its own reason,
`RUNTIME_HTML_SOURCE_COMPILED_AT_SAVE`. That constant's TSDoc no longer
lists jsx page bodies. `validateReactPages` keeps the old reason, which
is true for it (Sucrase).
- **ADR-0087 guide entry**: the `reason` of
`18.ui-html-page-div-refused.ts` now names the runtime save door.
`migrations/registry.ts` was regenerated with `gen:migration-registry`,
never by hand. The existing entry is amended rather than a new D3 entry
added. Step 18 is unreleased (`@objectstack/spec` is at 17.6.0), the
entries README makes an entry file the unit of edit, and `ace770d5fc`
amended this same entry's `reason` the same way.
- **Docs**: the only "validated at save and load" sentence under
`content/docs/**` was the `requires` row of
`content/docs/references/ui/page.mdx`. That tree is AUTO-GEN, rendered
from the describe, so it was regenerated rather than hand-edited. It now
matches the describe, and `check:docs` holds the two equal, so this PR
adds no separate grep pin. The hand-written `content/docs/ui/pages.mdx`
has no `requires` row and no such sentence.
- **Counts**: `liveness/state-counts/page.md` was regenerated. `page`
goes from 22 live and 1 planned to 23 live and 0 planned (24
classified).
- **Changeset**: `patch` for `@objectstack/spec` and
`@objectstack/lint`, with `Clause-②: no`. No accept set moves.

## Declared deviation from the claimed file surface

`packages/spec/liveness/README.md` also changed: the `page` row of the
hand-written state table. Its Notes cell said "live + one planned",
which this PR makes false. It now records the flip. `check:liveness`
holds the row set and the counts, but never a Notes cell's text.

## Premise checks

- **A1, positions at `ceb4a939b4`**, all confirmed:
- `liveness/page.json:9` was `planned`, with the note "save/load
enforcement of plugin presence is deferred (M3b)".
  - `page.zod.ts:903` was the `requires` line.
- `authoring-rules.ts:450`-`:451` held the "typescript/sucrase" reason.
`validateJsxPages` used it at `:1108` and `validateReactPages` at
`:1122`.
- The guide entry was
`migrations/entries/semantic/18.ui-html-page-div-refused.ts`.
- **A2, is the authored value read, or only overwritten?** It is read,
and refused when it disagrees. The two objectstack-ai#20312 blocks of
`packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts`
(`-t 20312`) give 17 passed and 39 skipped. They include the case
"refuses a hand-written `requires` that disagrees with the source,
naming each namespace". That case pins `{ code: 'INVALID_METADATA',
status: 422 }` for three shapes:
  - an unused namespace;
  - a namespace no manifest component carries;
  - a used namespace left unlisted.

So authoring the key changes runtime behaviour, which is the README's
definition of `live`.
- **A3, what `validateJsxPages` parses with.**
`packages/lint/src/validate-jsx-pages.ts` imports `parseJsx` and
`compile` from `@objectstack/sdui-parser`, whose `package.json` declares
no dependencies. `@objectstack/metadata-protocol`'s
`runtime-authoring-gate.ts` imports the same `compile` statically, so
the kernel already loads it. The rule stays off the runtime surface for
a different reason: the save door runs the same compile itself
(`findHtmlPageSourceGaps`), under the same `jsx-CODE` rule ids. The new
reason says that.
- **A4, the guide entry's new prose**, checked against `main`:
- `os serve` (which `dev` and `start` spawn) resolves the manifest from
beside the served config, then from the console's copy
(`registerDeploymentSduiManifest`);
  - the save door compiles html source against it on every publish;
  - a draft is judged at its publish;
- a host with no manifest prints one boot line and stores pages
unjudged;
  - rows at rest are not recompiled at load.
- **A5, the docs.** See Summary. Studio's round trip of a stale stamp
answering `422` is exactly what the new sentence describes (a written
list that disagrees is refused), so the docs do not name it.
objectui#11357 is closed.

## The readers and the producer (A2)

| moment | role | file#symbol |
|:--|:--|:--|
| save | judges the authored list |
`packages/metadata-protocol/src/runtime-authoring-gate.ts#findHtmlPageSourceGaps`
|
| save | stores the derived list |
`packages/metadata-protocol/src/runtime-authoring-gate.ts#stampHtmlPageRequires`
|
| draft promotion | re-stamps the promoted body |
`packages/metadata-protocol/src/protocol.ts#promoteDraftForPublish`
(`deriveActiveBody`) |
| load | reports an absent plugin |
`packages/metadata-protocol/src/protocol.ts#reportPageRequiresAbsentAtLoad`,
called from `loadMetaFromDb`, judged by
`runtime-authoring-gate.ts#findPageRequiresAbsentFromManifest` |
| producer | supplies the manifest |
`packages/cli/src/utils/sdui-manifest.ts#registerDeploymentSduiManifest`,
called from `packages/cli/src/commands/serve.ts` and read per publish
and at load through `protocol.ts#resolveSduiManifest` |

**The ledger gate reads the row.** As a one-shot ablation through
`scripts/ablation-replace.mjs`, the evidence path
`runtime-authoring-gate.ts#findHtmlPageSourceGaps` was rewritten to a
file that does not exist.
- `check:liveness` went red: "1 'live' / 'planned' / 'experimental' /
'live-elsewhere' entr(ies) cite a file that is missing from THIS repo:
page/requires".
- The same run reports "854 pointer(s) written `path#symbol`, 854 naming
a symbol the cited file contains", so the cited symbols are held as well
as the paths.
- The restore was verified: blob `a866b58134` equals HEAD, and `git diff
HEAD` is empty.

## Verification at `3e1f0dabff`

This run resumed one that was lost to a container restart. Nothing from
before the restart is cited. `origin/main` was merged through
`scripts/pm/os-regen-merge.sh` (merge `3e1f0dabff`). `registry.ts` is
not driver-routed, and both sides survived the text merge: this branch's
step 18 text, and main's new
`dashboard-widget-single-series-multi-measure-refused` entry. Every
reading below is at `3e1f0dabff`.

- **Build.** `turbo run build --filter='./packages/**'`: 71 of 71 tasks
successful. The tree was clean afterwards.
- **`@objectstack/spec`**:
  - `build`: exit 0.
- `check:generated`: exit 0, "All 15 generated artifacts are up to
date".
- `check:liveness`: exit 0, "packages/spec/liveness/state-counts/ is
current".
- `test` (`vitest run --project local`, two shards): 300 files, 9053
passed and 1 todo; then 300 files, 8631 passed. Both exit 0.
  - `typecheck`: exit 0.
- **`@objectstack/lint`**: `test` gives 119 files and 5585 passed, exit
0. `typecheck` exits 0.
- **Derived gates.** `node scripts/pm/dispatch-gates.mjs --repo
objectstack-ai/objectstack --commands` (no paths) derived 110 commands.
All 110 ran, each exit code written to disk before any reading, and all
exited 0. `--ran` reconciles them: "110 derived, 110 run, 0
NOT-MEASURED, 0 UNRUN".
- On the first pass, two were infrastructure non-measurements, not reds,
and both were re-run green.
- `check-adr-0087-registration --self-test` could not write its fixture
commits: the container's commit-signing server answered `503`. On
re-run: "441 assertions".
- `check:query-options-erasure` hit the per-command 300s cap on a
contended box. On re-run it exited 0 in 491s: "ratchet holds: 67 unswept
non-test site(s) in 17 file(s), none new".
- **Named gates**, with their own verdict lines:
- `pnpm check:adr-0087-registration`: "this PR adds no declared-breaking
changeset (1 non-breaking changeset(s) seen)".
- `pnpm check:empty-changeset`: "No empty-frontmatter changeset
introduced by this diff (1 declaring changeset(s) added)".
- `check-changeset-no-major --base origin/main`: "This diff introduces
no `major` bump". Driven offline against this body (`--event`): "LEVEL
AXIS: this PR declares clause-② `no`, so no package here is declared to
have grown a published surface".
- `check-changeset-fixed`: the `.changeset/config.json` "fixed" group
"is in sync with 69 public workspace packages".
- `pnpm check:doc-authoring`: "17283 customer-facing string(s) across
1234 spec sources clean".
- `pnpm check:nul-bytes`: "OK (scanned 9771 text file(s) ... no raw
ASCII control bytes)".
- Roster gates with a roster under these paths are all exit 0:
`check:meta-url-spelling`, `check:authz-resolver`,
`check:error-code-casing`, `check:filter-alias-parity`.
- **Lint, narrowed and declared.** `pnpm lint` is run by CI. Here:
- Population: `eslint.config.mjs` lints
`**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}`. Of the 9 changed files, exactly
the 4 `.ts` files are in it.
- Count: `eslint --no-inline-config --format json` over those 4 files
gives 4 results, 0 errors and 0 warnings.
- Invariance: the config never enables type-aware linting (no
`parserOptions.project`, no `projectService`), so this diff cannot move
the verdict on any untouched file.
- **Mergeability.** `main` moved after the merge. A local `git
merge-tree --write-tree HEAD origin/main` at `53fd35e3e3` is clean. None
of this diff's driver-routed paths changed on `main`, so GitHub sees the
same answer. CI judges the merge ref.

## Acceptance notes

- `packages/lint/src/runtime-lazy-deps.test.ts`'s header says "The two
rules that need them stay CLI-only (`RUNTIME_HEAVY_SOURCE_PARSE`)".
After this PR, one registry rule (`validateReactPages`) carries that
constant. This is test prose, not a published surface, and it is not
edited here. Carrier: none.
- The no-manifest boot line in `packages/cli/src/utils/sdui-manifest.ts`
says "Page source and `requires` not validated at save". That host skips
the load report too, so the line could say "at save or load". It is not
false, it is in a `domain:cli` file pinned by the CLI's tests, and it
stays out of scope here. Carrier: none.
- A host with no manifest has its save door judge nothing, while
`validateJsxPages` still checks syntax and structure without a manifest.
The new reason's TSDoc records this. The host announces it at boot, so
it is not a finding.
- `skills/**`: zero hits for a page `requires` sentence or "validated at
save and load". Nothing to list.
- Review fix round: the reconciliation-ledger root `omit` row for `page`
/ `requires`
(`packages/spec/src/system/metadata-form-zod-reconciliation.test.ts`)
said "declared, not enforced yet", which this PR makes false; it is
re-ledgered under "platform-written, never authored" on the schema's own
words with the measured truth per page kind, and no form offer, per seat
answer 5959584348 (commit `54c73b11ff`).

---

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

---------

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

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