Repository navigation
feat(cli,metadata-protocol): the save door compiles an html page's source against the deployment's SDUI manifest - #20852
Conversation
…inst the registered SDUI manifest Claude-Session: https://claude.ai/code/session_01DLAS1QUnHCmaso1hjjiBi5 Co-authored-by: Claude <noreply@anthropic.com>
… manifest; save door compiles html page source against it Claude-Session: https://claude.ai/code/session_01DLAS1QUnHCmaso1hjjiBi5 Co-authored-by: Claude <noreply@anthropic.com>
…istered SDUI manifest Claude-Session: https://claude.ai/code/session_01DLAS1QUnHCmaso1hjjiBi5 Co-authored-by: Claude <noreply@anthropic.com>
…ve-door-sdui-manifest
…ported key at module top Claude-Session: https://claude.ai/code/session_01DLAS1QUnHCmaso1hjjiBi5 Co-authored-by: Claude <noreply@anthropic.com>
…ve-door-sdui-manifest
…ve-door-sdui-manifest
📓 Docs Drift CheckThis PR changes 2 package(s): 22 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 9 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 33 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # 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
|
Contract reviewServed-tier: Inputs read: card #20312 (body and all 8 comments, including ruling ① Derived judgments(a) The channel — RIGHT, as the pointer rules it. (b) The save-door refusal — RIGHT. (c) (d) A host with no manifest — RIGHT, pinned in both packages. (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 (f) The added dependency — RIGHT, and mechanical. (g) 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 ② Semver level
Declaration: Line form: PR body line 2 reads ③ Boundary flagsDev
Dev
Dev
Reviewer's own flags (none blocking):
Implemented-by: VERDICT: PASS Generated by Claude Code |
…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>
…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>
…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>
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
requiresdisagrees with its source; the new exportedSDUI_MANIFEST_SERVICEwidens@objectstack/metadata-protocol)Summary
Stage ① (the channel) and stage ② (save-time compile and refusal) of ruling 5881821895 (letter A), merged into one
domain:cliPR 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.@objectstack/metadata-protocolexports one constant service key,SDUI_MANIFEST_SERVICE = 'sdui-manifest'. It is a plain key, not aCoreServiceNameslot, and it needs no spec edit.os serveresolves the deployment's manifest once at boot through the CLI's existingresolveSduiManifest(path.dirname(configPath))and registers the result under that key withkernel.registerService, before any plugin inits (packages/cli/src/commands/serve.ts, helperregisterDeploymentSduiManifestinpackages/cli/src/utils/sdui-manifest.ts). The protocol reads the key on every publish (resolveSduiManifeston the protocol, theresolveFlowCanonicalizerpattern) and passessduiManifestintoevaluateRuntimeAuthoringGate.kind: 'html'page'ssource(and the deprecated'jsx'spelling) with@objectstack/sdui-parser'scompile(), the compiler behind the CLI-onlyvalidateJsxPagesrule, imported and not re-implemented. It lives beside the gate's existingsduiManifestoption, as a gate-local judgement in the same shape as the platform-schedule refusal:findHtmlPageSourceGapsinruntime-authoring-gate.ts. Compiler errors refuse the publish with the existing422 INVALID_METADATAenvelope, under the CLI's own rule ids (jsx-forbidden-tag,jsx-unknown-component, and so on). Each issue'swhereandmessagename the component. Compiler warnings rideadvisories. A hand-writtenrequiresthat disagrees with the compiled one is refused underpage-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.saveMetaItemstampsrequiresfrom the compile (stampHtmlPageRequires), on a draft save too. A draft that does not compile, or whoserequiresdisagrees, 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.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 nocomponents` 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 intoevaluateRuntimeAuthoringGate, the stamp insaveMetaItem), and one export line inindex.ts. There is no new file underpackages/metadata-protocol/src: the pins sit in the existingprotocol.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-treeexit 0).Deviation from the claimed file surface (declared):
packages/metadata-protocol/package.jsongains"@objectstack/sdui-parser": "workspace:*", andpnpm-lock.yamlgains 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/lintonly through/runtime, which must not exportvalidateJsxPages, and the wiring guard forbids a registry rule named at the gate.@objectstack/sdui-parserhas zero dependencies and never executes source, so the kernel boot-path contract inruntime-lazy-deps.test.ts(nevertypescriptorsucrase) is untouched.Measurements (measurement came first)
False-refusal rate over stored html pages: 0 of 3, measured at objectstack
15b586dcbefore the refusal was written. The measurement compiled everykind: 'html'/'jsx'page in the repository with@objectstack/sdui-parserdistagainst the pinned console'ssdui.manifest.json(107 components). That manifest is the oneos servehands 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-writtenrequires: 0.requiresshowcase_capability_map['ui']showcase_command_center_jsx['ui']showcase_start_here['ui']Control:
compile('PLUGIN-NONEXISTENT tag')answersok: falsewithforbidden-tagandunknown-component. Test fixtures, which are not stored pages: 3 html sources inmetadata-protocol/metadata-coretests use a barediv, which this manifest does not declare (theui-html-page-div-refusedledger entry). They would be refused only on a host with a registered manifest, and none of those tests registers one.The console fallback's
exportsfailure (ruling ①). The subpath specifier@objectstack/console/dist/sdui.manifest.jsonstill throwsERR_PACKAGE_PATH_NOT_EXPORTEDfrom the CLI. However, the CLI's console leg stopped using that specifier in #19922: it resolves the console'spackage.jsonand joins the path to it. With an installed console that carries the manifest, and theexportsmap left as it is (./package.jsononly),resolveSduiManifestanswersresolved(107 components). In this workspace it answersabsent, only becausepackages/console/distis not built here. ⇒packages/console/package.jsonis 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, blockhtml page source compiled at the save door against the SDUI manifest (#20312), 10 cases:INVALID_METADATAwithwhere/messagenaming the component, and nothing persists;requiresstamped from the compile;requiresis kept, in compiled order;packages/cli/src/utils/sdui-manifest.test.ts, blockregisterDeploymentSduiManifest, 5 cases:os servemakes one registration, under metadata-protocol's key, frompath.dirname(absolutePath).scripts/ablation-replace.mjs; restore verified as blob == HEAD withgit diff HEADempty):findHtmlPageSourceGapsforced to returnnullturns 4 of 10 red: the unknown component, the threerequiresshapes, the per-publish read, and the draft publish.saveMetaItemstamp turns 3 of 10 red: stamp, agreeing order, and the draft stamp.Acceptance notes
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-writtenrequireson one is not judged.messageheadline carries the finding locators (pages.NAME.source [jsx-forbidden-tag]), as every gate refusal does (A publish failure'serrorstring inlines the same validation prose thatissues[]already carries, so every console renders each finding twice #10524). The component is named inissues[].whereandissues[].message.packages/spec/liveness/page.json:9→live, thepage.zod.tsdescribe, and the docs.Verification
All readings are at head
898a5bdeunless noted. It mergesorigin/mainat30839063, #20830 included.pnpm --filter @objectstack/metadata-protocol test: 191 files passed, 3 skipped; 2811 tests passed, 19 skipped. Measured atb3d92e56, 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 typecheckandpnpm --filter @objectstack/cli typecheck: exit 0 atb3d92e56.pnpm --filter @objectstack/cli exec vitest run --project unit: 237 files, 3375 tests passed atb3d92e56. The integration tier is declared to CI. In its place,serve.ts's boot path was exercised by two real boots, below.examples/app-crm,os dev --freshon a random port):Server is ready. APUT /api/v1/meta/page/smoke_pagewhose source is an unknown component answers200, stored unchanged with norequires, as before.422 INVALID_METADATAwithjsx-forbidden-tagandjsx-unknown-component, where =page "smoke_page"plus the tag.requires: ["ui","plugin-absent"]answers422underpage-requires-disagrees-with-source, naming'plugin-absent'. A known-component page answers200, and a GET reads backrequires: ["ui"]. Both servers were torn down.node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands(no paths) derived 77 commands. All 77 ran at898a5bdeand every one exited 0.--ranover the exit-coded record reports 77 derived, 77 run, 0 NOT-MEASURED, 0 UNRUN: a derived zero, not a claimed one.ab8ea2b5found one real finding.check:test-source-aliaswanted the new@objectstack/sdui-parserimport aliased to source inpackages/metadata-protocol/vitest.config.ts(done), and the CLI pin's key import moved to module top.dist, and the shallow clone forcheck-plugin-teardown-shape --self-test. Each went green once built or deepened.pnpm lint(the whole repo,eslint . --no-inline-config): exit 0 at898a5bde.Generated by Claude Code