Repository navigation
feat(metadata-protocol): a stored page naming an absent plugin is reported at load, and draft promotion re-stamps requires - #21121
Conversation
…draft promotion Claude-Session: https://claude.ai/code/session_01Ujdtvqs7ree7WyQmEDwEnG Co-authored-by: Claude <noreply@anthropic.com>
… 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>
…ge-requires-load-and-promote
…ge-requires-load-and-promote
📓 Docs Drift CheckThis PR changes 1 package(s): 1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 2 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 11 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 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
|
Contract reviewServed-tier: 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 Check-runs on this head (the gate verdicts). 34 check-runs, all completed: 31 ① Derived judgmentsEach accept-set or public-surface change the diff implies, named right or wrong.
② Semver level
③ Boundary flagsEvery dev flag and every
Implemented-by: VERDICT: PASS Generated by Claude Code |
…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>
Fixes #20870
Clause-②: no
Summary
Stage ③, engine half, of #20312 (ruling
5881821895, letter A, staged ①②③; ruling5902378057, A + E). Stages ① and ② landed as #20852 (b531c7bf0). The spec half (liveness row, describe, docs) is #20871 and is not in this PR.loadMetaFromDb, the boot hydration of storedsys_metadatarows, now prints onewarnline for each stored page whoserequiresnames 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 throughresolveSduiManifest(), the sameSDUI_MANIFEST_SERVICEread the save door makes. The namespace set comes from one helper,manifestNamespaces()inruntime-authoring-gate.ts, which the save door'srequiresjudgement now uses too. There is no second manifest reader.requires.promoteDraftForPublishserves bothpublishMetaItem(POST /meta/:type/:name/publish) andpublishPackageDrafts. It now passesSysMetadataRepository.promoteDrafta new optionalderiveActiveBody. That callback applies the save door's ownstampHtmlPageRequiresto 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.requireson the client. It does send back the storedrequiresit last read, and that sent-back stamp is what goes stale.Example line (from the built
dist, on this branch):Measurements (taken before the code)
H1 — the load path and its ordering.
loadMetaFromDb(env-wideactiverows), called fromObjectQLPlugin.start()→restoreMetadataFromDb.pageisallowOrgOverride: 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 byreportUnhydratableOrgScopedRows.installPackage) records the package's own manifest in the registry and writes no page row. Code-package pages are not stored rows, andos buildstamps norequireson them.os serveregisters the manifest before the firstkernel.use(...)(packages/cli/src/commands/serve.ts, the block above "Load plugins from configuration").os devcompiles and spawnsserve. Hydration runs in astart(), after everyinit(). So the manifest is readable when pages load.loadMetaFromDb), notkernel: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 secondsys_metadataread. The conclusion it records is drawn from a registered manifest's contents, never from "nothing is registered". So thecheck:startup-registry-verdictshape does not apply. It passed in the gate run below.start()gets no load report for that boot. No in-repo host does this. cloud#2482 should register it the wayos servedoes.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:
findPageRequiresAbsentFromManifestanswersnulland 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/mainfed0db8f6. Result: 4 red, 3 green (the 3 green are controls).requires(expected undefined to deeply equal [ 'ui', 'plugin-kanban' ]), on bothpublishMetaItemandpublishPackageDrafts.[ 'plugin-kanban', 'ui', 'ui' ]).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 pine420df310f5b(.objectui-sha):compilecall on a page is the render path,packages/components/src/renderers/layout/page.tsx:606. It destructurestreeanddiagnosticsand dropsrequires. No non-test source inpackages/app-shell,packages/components,packages/reactorapps/consoleassigns a page'srequires; a word-bounded grep finds only an action-preview i18n key.SourcePageEditor.tsx:149patches only{ source }into the draft.ResourceEditPage.tsx:1033-1038seeds that draft from the served document.ResourceEditPage.tsx:1443and:1477-1481send the whole draft back withmode: 'draft'. No per-typefromDraftexists forpage.uinamespace (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 answers422 INVALID_METADATA,page-requires-disagrees-with-source: 'plugin-kanban' is used by the source but not listed. The author never wroterequires. 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, blockstored html page requires at load and at draft promotion (#20312), 7 cases:uinot named) and still loads (loaded: 1, registered);stampHtmlPageRequires(...)'srequires;publishPackageDraftsbatch re-stamps too;Ablations (one-shot, through
scripts/ablation-replace.mjs; each restore verified as blob equal to HEAD withgit diff HEADempty):... && false): 1 of 7 red, the reporting case.deriveActiveBodyignored (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 mergingorigin/main):@objectstack/metadata-protocolfull suite: 196 files passed / 3 skipped, 2938 tests passed / 19 skipped;typecheckexit 0 (--listFilesincludes the gate test file).@objectstack/metadata-protocol(the...@objectstack/metadata-protocoldirection), the files that drivepromoteDraftand boot hydration, at the first merge7402cf72a: 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 atc7b8b6399. The three that read every package'sdist/(check:dual-build-cjs-loads,check:lean-entry-closure,check:type-check-debt) ran after a fullturbo run buildof./packages/*on that head; before that build they answeredPREREQUISITE NOT MET(exit 3), which is recorded here as a non-measurement, not a pass.--ranreconciliation: 62 run, 0 NOT MEASURED, every line recorded with its exit code.eslint.config.mjslintspackages/**TS outsidepackages/spec.--format jsoncounted 4 files (the four touched sources): 0 errors, 0 warnings. The config never enables type-aware linting (eslint.config.mjsnotes noparserOptions.projectand no typed rules), so this diff cannot move any untouched file's verdict. The repo-widepnpm lintis 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.tsand the changeset. This PR also editspackages/metadata-protocol/src/sys-metadata-repository.ts. The draft-to-active write happens insideSysMetadataRepository.promoteDraft, which reads the draft row itself. The new optionalderiveActiveBodyapplies 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-protocolminor. No other lane is touched: the load path is in the metadata layer, notpackages/runtimeorpackages/cli.Acceptance notes
requires(H4). This is the reason a stale stamp exists at all in Studio editing. Proposed fix: objectui's page editor omitsrequiresfrom 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.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.package-authorauthoring channel (the control-plane assembly; gate off), a disagreeing draftrequiresis 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 asys_metadatadraft promotion and never ran the save door, so it is untouched.Generated by Claude Code