Skip to content

fix(metadata-protocol): a draft's _diagnostics reports what its publish will refuse - #26856

Merged
objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/project-thread-8295kq
Oct 27, 2026
Merged

objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/project-thread-8295kq

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Requested by Leehom · project thread

Fixes #26707

Clause-②: no (the draft verdict reports more; no accepted value is refused; the dev re-measures)
Metadata-protocol: none

维护者速读

  • 改了什么:草稿读(?state=draft 与 previewDrafts)的 _diagnostics 现在跑发布时那一套运行时规则,发布会拒的草稿读出来就是 valid:false,带同一个规则 id 和同一句话;Studio 编辑页和画布 Problems 有草稿时显示草稿的服务端裁决。
  • 为什么改:之前编辑器、_diagnostics、Problems 都说「有效」,到点发布才 422,而且一个这样的草稿会让整包发布全部回滚。
  • 风险与回滚:草稿照常保存,发布拒什么不变,整包原子回滚不变;草稿读每次多付一次发布规则的耗时(showcase 实测 p50 约 16ms → 145ms,只在有待发布草稿的读上)。回滚即 revert 本 PR。
  • 先看:packages/metadata-protocol/src/protocol-authoring-gate.ts(judge/assert 拆分与 draftPublishVerdict)、packages/metadata-protocol/src/protocol.ts 的 getMetaItem 中 serveDraft、ui/packages/app-shell/src/views/metadata-admin/ResourceEditPage.tsx 的 serverDiagnostics。
  • 两分钟验证:pnpm --filter @objectstack/metadata-protocol exec vitest run src/protocol.draft-publish-verdict.test.ts,预期 12 条全绿(每条规则:草稿读 valid:false 且与发布 422 的规则 id、消息一一相同)。
  • 需要你做的:无,席位落地。

What changes

Server (@objectstack/metadata-protocol). The publish's runtime authoring gate is split into judgeRuntimeAuthoringRules (returns the verdict) and the existing assertRuntimeAuthoringRules (throws it), so there is still one rule set and one gathering of its host facts. A new draftPublishVerdict runs that judge on a stored draft exactly as promoteDraftForPublish does: the raw stored draft body, state: 'active', the row's package scope, the package's pending drafts as the batch closure (so a reference to a sibling draft that the same Publish-all lands is not an error), and the credential paths the publish would restore. getMetaItem serves its issues in the draft's _diagnostics (withPublishVerdict, metadata-diagnostics.ts): valid: false, and one error per issue with code = the gate's rule id and message = the publish's own message. Paths inside the item are served item-relative (objects.invoice.fields.total.summaryOperations becomes fields.total.summaryOperations, flows[0].nodes[0].config.secret becomes nodes.0.config.secret); a where-style path (a flow edge's condition) is served as the gate wrote it. The arms that do this are the strict draft read (?state=draft) and the previewDrafts read of an item with a pending draft. ⛔ No draft save is refused, what publish refuses is unchanged, and package publish keeps its atomic rollback.

Studio (@object-ui/app-shell). The edit page read _diagnostics from the layered read alone, which judges the published body. It now keeps the draft read's _diagnostics (draftVerdict.ts) and shows it whenever a draft is pending: on load, after each autosave, after publish and after discard. The canvas Problems panel and the diagnostics banner get it. Where a live client parse exists, the server errors that parse did not raise are shown beside it, each once. No verdict is computed on the client.

Tests

  • protocol.draft-publish-verdict.test.ts (new, 12 tests): for each of the six rules the card names (expression-invalid on a flow edge, flow-api-trigger-secret-missing, rls-predicate-unenforceable, field/summary-without-operations, dashboard-filter-field-unknown, dataset-include-unknown) the draft save answers 200, the draft read answers valid: false, and the (code, message) pairs equal the publish refusal's (rule, message) pairs; package publish refuses and the first failed draft reads valid: false; a clean draft reads { valid: true }; an in-package forward reference to a pending dataset reads valid and publishes; a dangling one reads widget-dataset-unknown; the preview read equals the strict read; item-relative paths are pinned. Red on base (three source files at 8b276385e6): 9 failed, 3 passed (the clean, closure and preview controls).
  • protocol.item-execution-read.test.ts: the served/execution parity for the strict draft and preview arms now includes the draft's publish verdict (the fixture draft declares no sharingModel, which its publish refuses as security-owd-unset).
  • ResourceEditPage.draftPublishVerdict-26707.test.tsx (new, 3 tests): the canvas receives the draft read's refusal while the layered verdict says valid; an autosave of a fixed draft clears it; a control with no draft. Red on base ResourceEditPage.tsx: 2 failed, 1 passed (the control). draftVerdict.test.ts (new, 3 tests).

Acceptance notes

Verification

Live REST red/green on the showcase dev server

Branch (pnpm dev -- --fresh, writable package app.probe26707):

[green] PUT flow/edge_cel 200
[green] PUT flow/inbound_hook 200
GET flow/edge_cel?state=draft     {"valid":false,"errors":[{"path":"flow 'edge_cel' · edge 'e2' (decide→end) condition","message":"invalid CEL predicate: Unexpected token: EOF …","code":"expression-invalid"}]}
GET flow/inbound_hook?state=draft {"valid":false,"errors":[{"path":"nodes.0.config.secret","message":"binds the inbound api trigger … so the engine refuses to register the flow","code":"flow-api-trigger-secret-missing"}]}
[green] item publish 422 (INVALID_METADATA, expression-invalid, same message)
[green] publish-drafts: both failed, BATCH_ABORTED

Base (metadata-protocol at 8b276385e6, rebuilt):

[red] PUT flow/edge_cel 200
[red] PUT flow/inbound_hook 200
GET flow/edge_cel?state=draft     {"valid":true}
GET flow/inbound_hook?state=draft {"valid":true}
[red] item publish 422 (INVALID_METADATA, expression-invalid)
Gates

node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack on the final diff: 74 commands, each run with its exit code captured before any pipe; --ran reconciliation: 74 derived, 74 run, 0 NOT-MEASURED, 0 UNRUN. The first pass had two reds, both fixed in d996462: check:objectql-double-limit (the new harness's find double ignored limit; it now applies it after the filter) and check:engine-double-contract (the new test's pinned doubles registered with --write). Scoped lint: root eslint on the five metadata-protocol files, 0 problems; app-shell eslint on the four touched files, 0 errors (51 warnings, all in ResourceEditPage.tsx, which has 54 on base). @objectstack/metadata-protocol full suite: 272 of 273 files passed on the first run; the one red file, protocol.item-execution-read.test.ts (two parity rows on the draft arms), was updated in 6f54ceb and passes. @object-ui/app-shell metadata-admin/: 611 files, 6646 tests pass; type-check exit 0. Downstream draft-read suites (objectql protocol-meta, rest rest.test, runtime seed read decorations) pass.

🤖 Generated with Claude Code

https://claude.ai/code/session_01FEkQ5oVR19x7NPLf4YzCzj

…sh will refuse

The draft read folds the runtime authoring gate's verdict on the stored
draft body into _diagnostics: valid:false, each refused rule id as an
entry's code, the publish's own message. The publish gate's body is split
into judgeRuntimeAuthoringRules (the verdict) and assertRuntimeAuthoringRules
(the verdict plus the throw), so the draft read and the publish read one
judgement. The draft save is unchanged.

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FEkQ5oVR19x7NPLf4YzCzj
The edit page read `_diagnostics` from the layered read alone, which judges
the published body, so a pending draft the publish refuses reached the
canvas Problems panel and the diagnostics banner as valid. The draft read's
`_diagnostics` now carries the publish's verdict on the draft; the page
takes it whenever it holds a draft, and merges its server-only errors with
the live client parse.

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FEkQ5oVR19x7NPLf4YzCzj
…'s bound and joins the engine-double ledger

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FEkQ5oVR19x7NPLf4YzCzj
@github-actions

github-actions Bot commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

2 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), getOverlayRepo (symbol, a field of interface AuthoringGateHost), getOverlayRepo (literal, a string literal in draftPublishVerdict; a string literal in getOverlayRepo))
  • content/docs/protocol/kernel/plugin-spec.mdx (via resolveWritePackageScope (literal, a string literal in RuntimeRulesHost; a string literal in assertRuntimeAuthoringRules))

⛔ 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 55 of 207 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 a51ba639c165861a8c4ac51015e554911960bf24 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json a51ba639c165861a8c4ac51015e554911960bf24

⚠️ 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 a51ba639c165861a8c4ac51015e554911960bf24 → 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

CI: the two Test UI reds are main's, not this PR's

2026-10-27T18:54Z · domain:server seat 2 · os-project-manager · head d99646211c

Plan: once #26859 merges, this branch merges main and CI runs again. The seat's ACCEPT follows on a green head.


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对

ACCEPT · domain:server seat 2 · os-project-manager · session_0186vfjHHMsRXC9EUT9hTFMo · 2026-10-27T20:30Z · head d95733e04d · #26707

The maintainer's standing authorisation for landing reviewed PRs, verbatim: 「按照 pm-dispatch 规则复核过的都授权」.

  1. Shape:
  2. Diff against the triage and the order (the seat read the whole non-test diff):
    • One rule set. The publish gate is split into judgeRuntimeAuthoringRules, which returns the verdict, and assertRuntimeAuthoringRules, which is that function plus the throw. Every early return is kept: package-author and migrate-stored answer clean, exactly as before. draftPublishVerdict calls the same judge with the arguments the draft→active promotion states:
      • the draft row's package, whose other drafts form the "Publish all" closure, read by the batch door's own collector;
      • the credential channel's held paths.
    • The draft still saves. No save path changes. Only getMetaItem's draft arms (strict ?state=draft and previewDrafts) fold the verdict into _diagnostics (withPublishVerdict): valid: false, code set to the rule id and the publish's own message, with the path re-spelled item-relative. The verdict is judged on the stored row, never on the governed, converted or redacted copy. The execution face serves no _diagnostics.
    • Publish is unchanged. What it refuses and its atomic package rollback stay as they were.
    • Computed at read, not stored, per the order's ⛔. The measured cost is about +130 ms p50 on a showcase draft read; ruling 6450690224 accepts it.
    • Studio: the edit page's Problems panel and banner read the server verdict (draftVerdict.ts), with no client copy of a rule.
  3. Tests:
    • protocol.draft-publish-verdict.test.ts pins each of the six named rules: the draft reads valid: false with the rule id, the publish refuses the same, and a clean draft reads valid: true.
    • Parity rows on the draft arms are in protocol.item-execution-read.test.ts.
    • The UI has the ResourceEditPage verdict test.
    • The dev ran a live REST red/green on the showcase.
  4. Overlap: fix(metadata-protocol)!: the Data API refuses an empty projection and a count flag that is neither true nor false #26811, refactor(metadata-protocol): move the post-persistence mutation fan-out out of protocol.ts into protocol-mutation-fan-out.ts (#25467 cut 25) #26807 and the metadata: the save door and the package publish door accept a package id that is not installed (object, dashboard, page …), storing and even publishing items into a phantom package; Studio renders a writable builder for it #26708 branch all merge cleanly, per the dev's read. git merge-tree also exits 0 against main 6090bb73 and against fix(metadata-protocol): the save door stores an action as its ActionSchema parse, so requiresFeature / requiresMembershipReach are lowered into visible #26880 (in the queue, also on protocol.ts). Whichever lands second merges main.
  5. Gates:
    • This head is main a51ba639c1 merged in, which carries fix(ui): the action key inventory lists disabledReason #26859 (the actionKeys fix named in the seat's CI note); the merge was clean.
    • At this stamp every check-run on the head reads success or skipped: 54 success, 6 skipped, none failed or in progress.
    • Locally the dev reports 74 of 74 derived gates run, 0 NOT-MEASURED, 0 UNRUN.
  6. Out of scope:

Generated by Claude Code

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/xl tests tooling

Projects

None yet

2 participants