Skip to content

feat(metadata-protocol): a stored page naming an absent plugin is reported at load, and draft promotion re-stamps requires - #21121

Merged
objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-20870-page-requires-load-and-promote
Oct 1, 2026
Merged

objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-20870-page-requires-load-and-promote

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #20870
Clause-②: no

Summary

Stage ③, engine half, of #20312 (ruling 5881821895, letter A, staged ①②③; ruling 5902378057, A + E). Stages ① and ② landed as #20852 (b531c7bf0). The spec half (liveness row, describe, docs) is #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):

[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 (#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

claude added 5 commits October 1, 2026 06:22
… absent plugin at load, and re-stamp requires on draft promotion

Claude-Session: https://claude.ai/code/session_01Ujdtvqs7ree7WyQmEDwEnG
Co-authored-by: Claude <noreply@anthropic.com>
… load and re-stamps requires on promotion

Claude-Session: https://claude.ai/code/session_01Ujdtvqs7ree7WyQmEDwEnG
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added the size/m label Oct 1, 2026
@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Oct 1, 2026
@github-actions

github-actions Bot commented Oct 1, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/metadata-protocol, touching 8 documentable anchor(s).

1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/concepts/metadata-lifecycle.mdx (via ObjectStackProtocolImplementation (symbol, a top-level class))

⛔ 2 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v16.mdx (via ObjectStackProtocolImplementation (symbol, a top-level class))
  • content/docs/releases/v17/17-0.mdx (via ObjectStackProtocolImplementation (symbol, a top-level class))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

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

Coarse fallback — 11 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 53ed3d1093393d24b85e1dbf2259ab8eed933062 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 53ed3d1093393d24b85e1dbf2259ab8eed933062

⚠️ 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 53ed3d1093393d24b85e1dbf2259ab8eed933062 → 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: c7b8b639975060fa2b0397e1b2e5e30c63bd82e2
Local-runs: none

Review of PR #21121 (card #20870, #20312 stage ③ engine half) at the head above, against the card's body and all four of its comments (pointer 5912748033, claim 5925813548, report 5927166105, seat answer 5927264094), the PR body and file list, the net diff against origin/main at their merge base (5 files, +332/-9), rulings 5881821895 and 5902378057 read at their source on #20312 and #20542, and the check-runs on this head. Nothing was built, run or re-run locally; every code statement below is read off the files at this head.

Check-runs on this head (the gate verdicts). 34 check-runs, all completed: 31 success, 3 skipped (Build Docs, Console Pin Gate, Packed-tarball smoke: path-filtered or opt-in, none reached by this diff, which removes or renames no export), 0 failure. All seven required contexts are green: Lint & Repo Gates, TypeScript Type Check, Test Core (6 of 6 shards), Dogfood Regression Gate (3 of 3), Build Core, Temporal Conformance (live PG + MySQL), Governed Surface Queue Guard. Check Changeset is green. At my first read of this head several of these were still in_progress; the re-read taken for this record found every one completed, so nothing below rests on an in-progress check.

① Derived judgments

Each accept-set or public-surface change the diff implies, named right or wrong.

  1. Load report (protocol.ts: loadMetaFromDb calls the new private reportPageRequiresAbsentAtLoad) — right. One console.warn line per stored page whose requires names a namespace no manifest component carries, printed only when hydrateOverlayIntoRegistry returned true for that page and after it did; loaded++ is unchanged, so the page is registered and served exactly as on main. Accept set: none moved, nothing is refused at load. Public surface: none, a private method and one operator-facing warn line under the marker [page_requires_plugin_absent]. The level is warn, which the degradation rule in AGENTS.md gives a functional degradation visible to whoever opens the page. The runtime string carries no tracker number and no angle bracket. Ruling A stage ③ says report at load and refuse at save: satisfied.

  2. One manifest channel, one namespace derivation — right. The load path reads the manifest through this.resolveSduiManifest(), the same SDUI_MANIFEST_SERVICE read the save door makes at its stamp line and inside the gate, once per hydration and only when a page row is in hand. The namespace set is the new non-exported manifestNamespaces() in runtime-authoring-gate.ts; findHtmlPageSourceGaps now calls it in place of its inline copy (the same map and filter chain, equivalent semantics) and findPageRequiresAbsentFromManifest calls it for the load report. No second reader channel and no second copy of the set exist in the diff. One observation, not a defect: on the promotion path resolveSduiManifest() is now called twice within one publish, once by the gate and once at the new line in promoteDraftForPublish, through the same resolver and the same key. A value re-registered between those two reads would be seen by the stamp and not by the gate; the worst outcome is a draft stamp carried as written, which is what main does on every publish.

  3. findPageRequiresAbsentFromManifest (new module export) — right, internal. Exported from runtime-authoring-gate.ts but not re-exported by src/index.ts, which takes only SDUI_MANIFEST_SERVICE from that module; the package's exports map has the single root entry. Not package-reachable, so no public-surface change. It answers null when nothing is judged (not a page, no requires list, no usable manifest) and an empty list when every namespace is present: the save door's own posture on a host with no manifest.

  4. SysMetadataRepository.promoteDraft gains the optional deriveActiveBody (sys-metadata-repository.ts) — right; it is public-reachable surface, and additive. SysMetadataRepository is exported from src/index.ts and getOverlayRepo is typed to return it, so the new optional field on promoteDraft's options is reachable from a package export; the MetadataRepository interface in metadata-core declares no promoteDraft, so no interface moved. Omitted, activeBody is draft.body, the prior behaviour byte for byte; the draft row is still drained by its own hash; the derivation sits between the currentActive read and the put, so derivation and write read one row. An additive optional option on an exported class is minor-level surface growth, and the changeset names it. It is not a widening tell in the mechanical sense (no Zod key, no closed-set member, no api-surface row, no registration), so it does not contradict Clause-②: no; see ②.

  5. Promotion re-stamps requires (promoteDraftForPublish passes deriveActiveBody as stampHtmlPageRequires of the draft body against the manifest read at publish) — right: nothing newly accepted or refused on either channel, and the stored answer does change. stampHtmlPageRequires never throws and never refuses. It returns the body unchanged when the type is not an html page kind, when no usable manifest is registered, when the source does not compile, or when a declared requires disagrees set-wise (sameNamespaces) with the compiled one; otherwise it returns the body with requires set to the compiled list. On the environment channel the gate still runs first on the draft body (unchanged code), and findHtmlPageSourceGaps refuses a disagreeing list and passes a missing one (an undefined requires returns the compile findings only), so every promotion main refuses is still refused and every one it accepts is still accepted. On the package-author channel (gate off) a disagreeing list is left as written by the stamp, exactly what the save door stores on that channel at its own stamp line, which runs on every channel. What changes is the stored and served requires of the active row, in the two cases the dev measured red on unmodified main: a draft with no stamp (saved before a manifest existed) now reaches active with the save door's list instead of none, and an agreeing list is stored in the save door's spelling (its order, no repeats) instead of the draft's. That is a behaviour change on a field ADR-0080 §5 and ruling A declare derived from the source, it is stated in the changeset body in those words, and it moves no accept set. On a host with no manifest the draft is promoted as written, the save door's posture on that host: a declared limit, not a stale stamp carried in the card's sense, since there is nothing to compute one from. Card scope 2 ("never carries a stale stamp forward") holds wherever the gate runs; on the gate-off channel the diff reproduces the save door's own behaviour, which ruling stage ② prescribes (a disagreeing list is refused, never silently overwritten).

  6. Both publish doors covered — right. publishMetaItem and publishPackageDrafts both reach promoteDraftForPublish, the only repo.promoteDraft caller in src, so the batch promotion re-stamps too; the batch pin covers it.

  7. Scope held — right. Five files, all under packages/metadata-protocol plus the changeset: no packages/spec, packages/runtime or packages/cli edit, so the load path is in the metadata layer and no cross-lane surface arose; the spec half (page requires, #20312 stage ③ (spec half): the liveness row flips to live, the describe and the docs state save + load, the lint reason and the ADR-0087 guide entry name the save door #20871: liveness row, describe, docs) is untouched and stays Blocked-by: this card. The seven pins in protocol.runtime-authoring-gate.test.ts cover the card's three (absent plugin reported with page and plugin named, and served; promoted requires equals stampHtmlPageRequires of the same body, single and batch; all-present control) plus the two no-manifest postures and the agreeing-spelling case. No model identifier anywhere in the diff.

② Semver level

  • Changeset .changeset/20870-page-requires-load-and-promote.md: @objectstack/metadata-protocol minor, carrying Clause-②: no. Right. The diff publishes a new load-time report, a behaviour change in what a promotion stores, and an additive optional option on an exported class's method: feature-level, nothing removed or renamed, so minor is both floor and ceiling. patch would understate the new option and the changed stored value, major has no removal to carry, and skip-changeset is out because @objectstack/metadata-protocol is a released package. The changeset body states the before and after for the promoted requires and names the option, so an upgrading consumer greps it.
  • Clause-②: no — right, and consistent across the claim (5925813548), the PR body and the changeset. The accept set of neither door moves (①.5); the new load report refuses nothing (①.1); the public-surface growth is one additive optional option, not a new package export, authorable key, closed-set member or registration, so none of the four widening tells. The dev's measurement (four pins red on unmodified main, all four carried-forward missing or as-spelled lists, none a refusal) agrees with the code reading, but the reading above stands on the code at this head. Had the diff made promotion refuse a batch main accepts, the line would be yes (narrowing); it does not.
  • Check Changeset: success on this head.

③ Boundary flags

Every dev flag and every open_questions entry, answered or escalated.

  • Declared deviation: sys-metadata-repository.ts is outside the claim's file surface — answered, accepted. The claim named protocol.ts's publish path, runtime-authoring-gate.ts, the pins and the changeset. The draft-to-active write lives in SysMetadataRepository.promoteDraft, which reads the draft row itself; applying the stamp inside that call keeps derivation and write on one row (①.4). Same package, same lane (domain:engine), 17 changed lines, additive, default unchanged, named in the changeset, declared in both the PR body and the report. No seat decision needed.
  • "Measure first" (card scope 3, claim bullet 3): the browser dogfood pass was NOT MEASURED — answered, accepted with its limit named. The claim's own spelling was a read of objectui at the console pin, and the report gives that reading with file and line evidence: the Studio computes no requires on the client (its one client compile drops it) and sends back the stored stamp it last read. That answers the question the measurement exists for, and the server-side probe on this branch shows the consequence. The limit: no console carrying the manifest was booted in a browser, so the round-trip was not seen end to end in the Studio. That residual is exactly the open question below and is the seat's; it changes nothing in this diff.
  • Load hook residual: a host that registers the manifest after start() gets no load report for that boot — answered. The hook is the hydration itself (loadMetaFromDb, run from ObjectQLPlugin.start() after every init()); os serve registers before the first plugin, so every in-repo host is covered; the conclusion is drawn from a registered manifest's contents and an unregistered key judges nothing, so no verdict is recorded from "not yet registered" (check:startup-registry-verdict runs inside the green Lint & Repo Gates). The per-env kernel case is already cloud#2482 under ruling 5902378057, not a new gap.
  • open_questions[0]: the Studio round-trip of a stale requires stamp answering 422 page-requires-disagrees-with-source (options A, B, C; the dev recommends A) — escalated to the seat, not answered here. It does not change this PR's diff: the 422 is stage ②'s accept set, landed in feat(cli,metadata-protocol): the save door compiles an html page's source against the deployment's SDUI manifest #20852, and this PR neither widens nor narrows it (①.5). The seat's in-seat answer now sits on the card as comment 5927264094 (A, the Studio's page editor changes; filed [finding] page editor: an edited html page sends back the stored requires stamp, so a published page that gains a plugin component cannot be published (422 page-requires-disagrees-with-source) objectui#11357; open to the maintainer's veto). This record neither answers nor re-judges that question; it notes that the answer, as the seat wrote it, changes nothing in this diff and that this PR lands on its own record. The dev's first out_of_scope_findings row (the objectui page-editor echo at the pin, with file and line) is the same finding, and 5927264094 says it is filed.
  • Other out_of_scope_findings — noted, not altering the diff, not filed by this review. (a) The CLI's no-manifest boot line ("not validated at save") stays true and now also covers the skipped load report; a wording "at save or load" is a cross-lane string edit pinned in the CLI's tests, left to the seat. (b) POST /packages/:id/publish is a loader-plane snapshot publish, not a sys_metadata promotion, and never ran the save door: observation only.
  • Report shape and write discipline — in order. mcp_calls: 0; three REST writes through the fleet relay, each read back; the card's assignee untouched; the PR is a draft with no auto-merge armed. The pins landed in protocol.runtime-authoring-gate.test.ts itself rather than beside it, which satisfies the claim.
  • No governed surface in this diff (no docs/adr/**, .claude/**, skills/**, AGENTS.md, CLAUDE.md), so the landing path is the ordinary queue once the seat accepts. This record vouches for the contract; the check-runs above vouch for the gates.

Implemented-by: claude/issue-20870-page-requires-load-and-promote
Reviewed-by: session_01Ujdtvqs7ree7WyQmEDwEnG

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 1, 2026 08:30
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 1, 2026 08:31
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 1, 2026
Merged via the queue into main with commit 250dec8 Oct 1, 2026
36 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-20870-page-requires-load-and-promote branch October 1, 2026 08:59
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

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

2 participants