Skip to content

fix(metadata-protocol): both publish doors judge a stored flow draft in its D2-converted spelling - #29694

Merged
objectstack-fleet[bot] merged 2 commits into
mainfrom
claude/project-thread-bg2li5
Nov 3, 2026
Merged

objectstack-fleet[bot] merged 2 commits into
mainfrom
claude/project-thread-bg2li5

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Requested by Warren · project thread
Fixes #29557

Clause-②: yes (widening)
Metadata-protocol: none

维护者速读

  • 改了什么:流程草稿在两道发布门(单项发布、包发布)和草稿读取的发布预判里,改为按 D2 转换后的拼写判定,与保存门一致;落库与上线的仍是草稿原始字节。
  • 为什么改:早先存下的旧拼写草稿(如 get_record 的 filters)保存能过、引擎注册也能过,却在发布时被 422 拒掉,同一流程两道门答案不一。
  • 风险与回滚:只放宽(转换后仍不合格的草稿照旧被拒,问题列表与新拼写一致);回滚即 revert 本 PR。
  • 先看:protocol-flow-save-replay.ts(新共用缝 storedFlowVerdictBody)、protocol.ts 的 promoteDraftForPublish 判定入参、protocol-authoring-gate.ts 的 draftPublishVerdict。
  • 两分钟验证:pnpm --filter @objectstack/metadata-protocol exec vitest run --maxWorkers=2 src/protocol.draft-publish-verdict.test.ts -t 29557,预期 4 条全绿。
  • 需要你做的:无,席位落地。

What changes

  • One seam. protocol-flow-save-replay.ts gains flowVerdictBody(body, { includeRetired }), the conversion the save door's fallback verdict applies (applyConversionsToFlow, the full chain minus FLOW_FALLBACK_VERDICT_NOT_REPLAYED), and storedFlowVerdictBody(type, body), that conversion asked of a stored flow draft. The save door's fallback now calls flowVerdictBody instead of its inline call; no second converter exists.
  • Both publish doors. promoteDraftForPublish, which the per-item publish (publishMetaItem) and the package publish (publishPackageDrafts) both promote through, hands the runtime authoring gate storedFlowVerdictBody(singularType, draftForGate.body). repo.promoteDraft still promotes the stored draft, so the active row is the draft's bytes. The credential read and every other promotion refusal still read the stored draft.
  • The draft read's publish verdict (draftPublishVerdict, the _diagnostics fold of Studio/metadata: a draft the editors, the draft _diagnostics and the Problems tab all call valid is refused only at publish — and one such draft rolls back the whole package release #26707) judges the same converted body, so a draft read does not report a refusal its publish no longer gives.
  • Which chain. A stored draft is a row at rest, so the publish replays the full chain, retired entries included: the chain the automation engine replays when it registers the published row (canonicalizeFlow defaults includeRetired to true for every caller but the save door) and the one applyConversionsToStoredItem replays on read. The save door's retired-entry predicate (flowSaveReplaysRetired, ruling B') is not asked again at publish: it judges authored input, and the draft's own save asked it.
  • Lint prose (comment 6587200138). One sentence in the validate-flow-region-shape.ts docblock and one in its rule-explanations.ts entry name the runtime gate as a reader of the rule and the body it judges.
  • No packages/spec change, no rule skipped, no carve-out in packages/lint.

Why

The engine registers a stored flow in a legacy ADR-0087 D2 spelling (its canonicalizer replays the conversion), and the save door judges the converted body and stores the raw one (#21982). The two publish doors judged the raw stored draft, so such a draft saved and then failed at publish. Measured red on origin/main 744211f before the fix, with the card's issues, at both doors:

Red, before the fix
× publishes through the per-item door, and the active row is the stored bytes
× publishes through the package door, and the active row is the stored bytes
× its draft read says it publishes
Error: flow/legacy_fetch failed author-time validation: 2 issues — flows[0].nodes[1].config.filters [expression-invalid]; flows[0] [flow-region-shape-invalid]
Tests  3 failed | 40 skipped (43)

Tests

New describe('[#29557] …') in packages/metadata-protocol/src/protocol.draft-publish-verdict.test.ts (the established harness for these doors; no automation service reachable, so every save takes the fallback). The draft is the realistic one: a stored active row carries get_record config.filters, and the author's draft re-save carries the same hit, so the save replays the full chain and stores it raw.

  • The per-item door publishes it; the active row carries filters as stored; the draft is gone.
  • The package door publishes it (publishedCount: 1); the active row carries filters as stored.
  • draftPublishVerdict answers [] for the stored draft, and the served _diagnostics carry neither expression-invalid nor flow-region-shape-invalid.
  • A legacy draft that also carries a conditional edge whose CEL does not parse is refused 422 INVALID_METADATA at the per-item door with exactly the (rule, path, message) triples its filter twin gets, and the package door refuses it with the same triples and publishes nothing.
Ablation (fix committed first, mutated through scripts/ablation-replace.mjs, restored to HEAD blob)
leg 1: protocol.ts  body: storedFlowVerdictBody(singularType, draftForGate.body)  ->  body: draftForGate.body
  × publishes through the per-item door, and the active row is the stored bytes
  × publishes through the package door, and the active row is the stored bytes
  × a draft still refused once converted is refused at both doors with the issues its canonical spelling gets
  Tests  3 failed | 1 passed | 40 skipped (44)
  ok restored: blob == HEAD (6b19396fb8a2) and git diff HEAD is empty
leg 2: protocol-authoring-gate.ts  body: storedFlowVerdictBody(singular, draft.body)  ->  body: draft.body
  × its draft read reports no publish refusal for it
  Tests  1 failed | 3 passed | 40 skipped (44)
  ok restored: blob == HEAD (fc9aabba63f1) and git diff HEAD is empty

Acceptance notes

  • The served _diagnostics of a flow read (draft and active alike) still carry the read's own schema half, which parses the served body as stored: on this harness both the draft read and, after the publish, the active read answer valid: false with the get_record contract's filters refusal. That seam is the read's diagnostics, not the publish verdict, and it predates this change for any legacy active row; reported to the seat, not touched here.
  • The restore path (refuseRestoredBodyTheSaveDoorRefuses) still hands the runtime gate the history body as stored. Not measured; reported to the seat.
  • protocol.ts no longer imports applyConversionsToFlow or FLOW_FALLBACK_VERDICT_NOT_REPLAYED (both now read in the seam module; the latter is still re-exported).
  • PR template headings are folded into the os-dev section order.

Verification

All readings on head 3b5f63de7 (the fix 8d76dec4 plus a merge of origin/main 0688156; no file overlap).

  • pnpm --filter @objectstack/metadata-protocol test: Test Files 308 passed, 4 skipped; Tests 22266 passed, 26 skipped (lock VERDICT command-exit 0; run on 8d76dec4, before the merge).
  • pnpm --filter @objectstack/lint test: Test Files 192 passed; Tests 7892 passed (VERDICT command-exit 0; on 8d76dec4).
  • pnpm --filter @objectstack/metadata-protocol typecheck and pnpm --filter @objectstack/lint typecheck: exit 0.
  • After the merge, rebuilt, on 3b5f63de7: vitest run src/protocol.draft-publish-verdict.test.ts src/protocol.save-flow-canonicalization.test.ts src/runtime-authoring-gate.flow-region.test.ts: 3 files, 102 tests passed.
  • dispatch-gates --commands on 3b5f63de7: 61 derived; --ran reconciliation: 60 run, 1 NOT MEASURED (check:dual-build-cjs-loads, exit 3 PREREQUISITE NOT MET: it reads every package's dist and this worktree built only the touched closure; CI runs it), 0 unrun. check-plugin-teardown-shape --self-test and check:lean-entry-closure first answered exit 3 (shallow fixture commit, unbuilt objectql) and exit 0 after git fetch origin 621a4876 and an objectql build. pnpm lint exit 0.
Gate record (command :: exit code)
node scripts/check-adr-0087-registration.mjs --base origin/main :: exit 0
node scripts/check-adr-0087-registration.mjs --self-test :: exit 0
node scripts/check-changeset-no-major.mjs --base origin/main :: exit 0
node scripts/check-closing-keyword-parity.mjs :: exit 0
node scripts/check-closing-keyword-parity.mjs --self-test :: exit 0
node scripts/check-comment-mask-adoption.mjs :: exit 0
node scripts/check-comment-mask-adoption.mjs --self-test :: exit 0
node scripts/check-comment-mask-corpus.mjs :: exit 0
node scripts/check-dts-emitted.mjs --self-test :: exit 0
node scripts/check-empty-changeset.mjs --base origin/main :: exit 0
node scripts/check-empty-changeset.mjs --self-test :: exit 0
node scripts/check-issue-citations.mjs :: exit 0
node scripts/check-plugin-teardown-shape.mjs :: exit 0
node scripts/ci/assert-turbo-remote-cache.mjs --self-test :: exit 0
node scripts/ci/plan-test-core-shards.mjs --self-test :: exit 0
node scripts/ci/prune-turbo-cache.mjs --self-test :: exit 0
node scripts/docs-audit/check-affected-docs.mjs :: exit 0
node scripts/docs-audit/check-drift-comment.mjs :: exit 0
node scripts/pm/release-rehearsal-clone.mjs --self-test :: exit 0
node scripts/release-pending-publish.mjs --self-test :: exit 0
pnpm --filter @objectstack/spec run check:duration-unit-keys :: exit 0
pnpm check:changeset-gate-self-tests :: exit 0
pnpm check:ci-filter-parity :: exit 0
pnpm check:closed-ceremony-text :: exit 0
pnpm check:cross-package-test-inputs :: exit 0
pnpm check:dispatcher-error-vocabulary :: exit 0
pnpm check:doc-authoring :: exit 0
pnpm check:docs-transcript-drift :: exit 0
pnpm check:driver-memory-census :: exit 0
pnpm check:dts-closure :: exit 0
pnpm check:dual-build-cjs-loads :: exit 3
pnpm check:durability-log-level :: exit 0
pnpm check:engine-double-contract :: exit 0
pnpm check:error-status-conformance :: exit 0
pnpm check:filter-alias-parity :: exit 0
pnpm check:gitlink-declared :: exit 0
pnpm check:issue-citations :: exit 0
pnpm check:keyed-text-bounds :: exit 0
pnpm check:logger-receiver-detach :: exit 0
pnpm check:nul-bytes :: exit 0
pnpm check:objectql-double-limit :: exit 0
pnpm check:org-identifier :: exit 0
pnpm check:page-declaration-shape :: exit 0
pnpm check:platform-object-tenancy-census :: exit 0
pnpm check:published-files :: exit 0
pnpm check:query-options-erasure :: exit 0
pnpm check:refd-timer-probe :: exit 0
pnpm check:registry-log-declared :: exit 0
pnpm check:rest-log-spy-declared :: exit 0
pnpm check:slot-lookup :: exit 0
pnpm check:sourcemap-no-sources-content :: exit 0
pnpm check:system-context-census :: exit 0
pnpm check:test-source-alias :: exit 0
pnpm check:tier-file-adoption :: exit 0
pnpm check:type-check-coverage :: exit 0
pnpm check:type-check-debt :: exit 0
pnpm check:undeclared-dep-imports :: exit 0
pnpm check:watch-hint-literal :: exit 0
pnpm check:where-matcher :: exit 0
pnpm lint :: exit 0
node scripts/check-plugin-teardown-shape.mjs --self-test :: exit 0
pnpm check:lean-entry-closure :: exit 0

Human Minimum

  • 1. Schemas start as Zod; packages/spec holds no runtime logic.
  • 2. Every name I added uses the casing for its kind.
  • 3. No formatter ran; pnpm lint passes.
  • 4. One issue, one branch off an up-to-date main, one pull request.
  • 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset.
  • 6. packages/spec touched: its generated artifacts are regenerated and committed. (Not touched.)
  • 7. No hand edits to content/docs/references/, packages/*/CHANGELOG.md or content/docs/releases/.
  • 8. Title, body and comments are in English.

Generated by Claude Code

@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Nov 3, 2026
@github-actions

github-actions Bot commented Nov 3, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/lint, @objectstack/metadata-protocol, touching 7 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/lint/src/validate-flow-region-shape.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

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

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

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

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

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

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/lint/src/validate-flow-region-shape.ts) — pages documenting those are invisible to this run
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 60 of 216 client-bound route-ledger rows — the other 156 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 156: 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; 101 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 — 14 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 1c1a5d4021e803c98850a79cf17bf57ab496d08d → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 1c1a5d4021e803c98850a79cf17bf57ab496d08d

⚠️ 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 1c1a5d4021e803c98850a79cf17bf57ab496d08d → 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

domain:server#4 · os-warren · session_01JQwxbacSXJQqAztgt4UMrS

Contract-review-claim: session_01JQwxbacSXJQqAztgt4UMrS · head 3b5f63d · 2026-11-03T01:47Z

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#4 · os-warren · session_01JQwxbacSXJQqAztgt4UMrS

Contract review

Served-tier: TIER_DEFAULT
Head-sha: 3b5f63de7d702989ab1a6f7d0939607b83bd5494
Local-runs: none

① Derived judgments

  • Accept set widened, as declared: a stored flow draft in a legacy ADR-0087 D2 spelling that the engine registers (get_record config.filters) was refused at POST /meta/flow/:name/publish and publish-drafts with 422 (expression-invalid, flow-region-shape-invalid). It now publishes. RIGHT: it is the same verdict the save door has given since build: a script node's undeclared config key passes objectstack validate, compile and registerFlow, then fails every run — the key half of #21898's class (subflow by reading) #21982, through one seam (flowVerdictBody / storedFlowVerdictBody in protocol-flow-save-replay.ts), and the stored and promoted bytes stay the raw draft.
  • The draft read's publish verdict (draftPublishVerdict, _diagnostics) moves with the doors. RIGHT: otherwise the draft read would report a refusal the publish no longer gives.
  • No accept set narrows: a draft still refused after conversion is refused with the issues its converted spelling gets (pinned).
  • Public surface: flowVerdictBody and storedFlowVerdictBody are module exports inside @objectstack/metadata-protocol. They are not re-exported from the package index, so no new public export.

② Semver level

patch for @objectstack/metadata-protocol and @objectstack/lint, with Clause-②: yes (widening) and Metadata-protocol: none. This matches: a widening of a door's accept set, no key added or removed, no stored shape change. The lint half is two prose lines (the flow-region-shape-invalid docblock and its os explain entry now name the server gate and the body it judges), carried from 6587200138.

③ Boundary flags

  • Dev finding (a): the flow read's _diagnostics schema half still judges the served raw body, so a legacy row reads valid: false while the doors and the engine accept it. Harness-measured and older than this PR. The seat files it as a follow-up card.
  • Dev finding (b): the restore path judges a history body raw. Not measured; noted only.
  • Docblock precision (should-fix, accepted): storedFlowVerdictBody says the stored chain is "the full one" the engine replays. Through flowVerdictBody it still excludes FLOW_FALLBACK_VERDICT_NOT_REPLAYED (flow-decision-mode-inclusive-explicit). So a draft that needs only that conversion would still be refused at publish. That is conservative, a refusal and never a silent accept. It goes into the same follow-up card.

Implemented-by: claude/project-thread-bg2li5
Reviewed-by: session_01JQwxbacSXJQqAztgt4UMrS

VERDICT: PASS

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#4 · os-warren · session_01JQwxbacSXJQqAztgt4UMrS

席位核对

ACCEPT · domain:server#4 · head 3b5f63de7d702989ab1a6f7d0939607b83bd5494 · 2026-11-03T02:05Z · seat review of record at TIER_DEFAULT for the whole PR. The Clause-②: yes (widening) declaration carries the seat's contract record PASS at TIER_DEFAULT on this head (6593301192; declaration face, no schema, no governed path, no narrowing). Not governed: check-governed-merges --pr 29694 0 of 8 paths, +230/−14.

  1. Shape: draft, base main, Fixes #29557. 8 files: a verdict seam in protocol-flow-save-replay.ts (flowVerdictBody, storedFlowVerdictBody), its callers in protocol.ts, protocol-authoring-gate.ts (promoteDraftForPublish) and protocol-meta-item-reads.ts (draftPublishVerdict), two lint prose lines, one test file, and one patch changeset (@objectstack/metadata-protocol, @objectstack/lint). Inside the claim surface (6592652584). No packages/spec.
  2. Premise measured: on 744211fc, a stored flow draft in a legacy D2 spelling (get_record config.filters) is refused at both publish doors with expression-invalid and flow-region-shape-invalid. The new pins go green with the fix. Ablation: both legs red, then restored (dev report 6593260917).
  3. Gates on this head: check-runs pinned to 3b5f63de: 63 runs, none failed. Dev local: metadata-protocol 22266 and lint 7892 tests passed, typecheck 0; dispatch-gates 61 derived / 60 run / 1 NOT MEASURED (dual-build-cjs-loads, prerequisite; covered by Build Core on this head); pnpm lint 0.
  4. Accepted deviations: a third call site beyond the two named doors: the draft read's publish verdict judges through the same seam, so the draft read does not report a refusal the publish no longer gives. Accepted. The contract record's should-fix (the storedFlowVerdictBody docblock says "the full chain", but the seam still excludes flow-decision-mode-inclusive-explicit, which is conservative) is carried to metadata-protocol: a flow read's _diagnostics schema half judges the raw stored body, so a legacy D2 row the publish doors and the engine accept reads valid: false #29696.
  5. Out of scope: dev finding (a), the flow read's _diagnostics schema half judging the raw body, and finding (b), the restore path, are filed as metadata-protocol: a flow read's _diagnostics schema half judges the raw stored body, so a legacy D2 row the publish doors and the engine accept reads valid: false #29696 (this seat, serial after this PR).
  6. Landing: not governed, under 3000 lines, contract PASS on this head ⇒ the seat lands it (pr_ready + automerge_enable). At MERGED: landing record on server(metadata): the publish doors gate the raw stored flow draft, while the live save gates its D2-converted body (#21982), so a legacy spelling the engine registers is refused at publish #29557 (closed by Fixes), pm:dispatched removed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

1 participant