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 |
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