Skip to content

fix(metadata-protocol): org overlay withdrawal and publish gate follow-ups (package identity, judged draft, lock key, row anchor) - #21962

Merged
objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/issue-21934-org-overlay-publish-gate
Oct 6, 2026
Merged

objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/issue-21934-org-overlay-publish-gate

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 6, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #21934
Clause-②: yes (widening)

Four LOW/INFO follow-ups to the public-form withdrawal work of #21864, one commit and one pin each, so any item can be dropped at review without the others. Each change is described in the card's public terms. All four land in @objectstack/metadata-protocol; the only other source edit is a docblock in @objectstack/metadata-core, plus the narrowed sentence on the public data collection docs page.

Item 1: package identity of a served org overlay (21f75eb892)

Measured. The judgement the anonymous form doors and the organization-scoped save check share (anonymousFormIntakeWithdrawnIn, packages/metadata-core/src/anonymous-form-intake.ts:329) compares no package, and the doors' lookup (findPublicFormView, packages/rest/src/rest-server.ts:10735) reads no _packageId. So the package stamp the list merge puts on a package-less org overlay (packages/metadata-protocol/src/protocol.ts:2166 and :9077) cannot by itself make a withdrawal miss it. The item's outcome was still reachable on main (a3bd157730), through the same list merge rather than through a package comparison: the env-wide view list the doors judge against could hold only one package's item of a view name that two packages ship. Measured through the protocol's real list reads and the doors' own verdict: an overlay stored package-less before the withdrawal stayed open after one package's withdrawal and closed after the other's.

Changed. protocol.ts, the list merge's view branch: only a name a stored view container's expansion writes is upserted by name. Every other name keeps one item per package that ships it (ADR-0048), as the list already served it while no view row was stored. Neither of the card's two directions applies (nothing compares packages, so marking stamped copies or reading the org row's own package_id changes no verdict); the fix is at the producer of the layer the doors read. No door code changes.

Pin (protocol.org-scoped-write-refused.test.ts, "a package-less organization overlay, two packages shipping its view name"): the organization read serves the overlay once per package, each copy stamped with that package; for the package first and the package second in registry order, after it withdraws the name env-wide the env-wide list holds the withdrawal beside the other package's body, the doors serve no copy of the overlay, and a re-save of the overlay is refused.

Item 2: the publish gate and the promotion are separate reads (d1365db627)

Measured (H2 confirmed). promoteDraftForPublish reads the draft through repo.get to judge it (protocol.ts:21623 at base), and SysMetadataRepository.promoteDraft reads the draft row again with its own findOne (sys-metadata-repository.ts:969 at base). Nothing tied the two reads together, so a draft saved between them, or a draft that appeared where the gate found none, was promoted without being judged.

Changed. A publish promotes only the draft its gate judged. SysMetadataRepository.promoteDraft takes an optional expectedDraftHash (string | null): when stated, the draft row it reads must carry that hash (with null, no draft row may exist), otherwise it throws a ConflictError subclass before anything is written. promoteDraftForPublish passes the judged draft's hash (or null), and answers the conflict as 409 METADATA_CONFLICT with its own wording (publish again to judge and promote the current draft). This covers publishMetaItem and each promotion of publishPackageDrafts.

A route without a new public option exists and was not taken: the existing deriveActiveBody callback receives the body the promotion read and could compare it with the judged body and throw. It turns a derivation hook into a guard and compares bodies instead of the stored hash the card's direction names, so the explicit option was preferred. That option is the Clause-② widening below.

Pin ("a publish promotes only the draft its gate judged"): a draft saved after the gate read, and a draft saved where the gate judged none, are not promoted and the conflict answers; control: with no save in between, the judged draft is promoted and its draft row drained.

Item 3: the lock lookup uses the request's package (114ed6393c, follow-up 7c30229b43)

Measured (H3 confirmed). The publish path passed request.packageId to lockWriteRefusal (protocol.ts:21583 at base), while the gate resolves its draft key a few lines later: the stated binding, else the resolved draft row's own package_id (draftKey). Since the lock resolution reads every row and every shipping package in scope and takes the strictest lock, the package in the address decides whose lock prose the refusal carries, not whether it refuses: the INFO grade.

Changed. The draft key is resolved before the lock check and threaded into the lock lookup. The authoring-rule narrowing to the stated package is left exactly as it is. Follow-up 7c30229b43: with the draft-key read moved above the lock check, a store that cannot be read is answered at that read as the lock read answered it before (an unprovisioned sys_metadata holds no draft; any other failure is 503 SERVICE_UNAVAILABLE, never the driver's own error).

Pin ("a publish consults the lock of the package key it resolved"): with two packages' env-wide rows of one view both locked, a publish that states no package is refused with the lock of the draft row's own package; control: stating a package consults that package's lock. Follow-up pin ("a publish that states no package, over a store that cannot be read"): it answers 503 and promotes nothing.

Item 4: the save check's row anchor across packages (1e271aaae1)

Measured (H4 confirmed). envWideRawViewRows (protocol.ts:16092 at base) returned every stored env-wide row of the name when any existed (so one package's row hid every package's artifact), and otherwise fell back to lookupArtifactItem(type, name) with no package key (the first package in registry order).

Changed. The save check anchors each package's row on that package's env-wide definition: the package's own env-wide row, else the package-less env-wide row (which stands in for every package, as in the list merge), else that package's artifact, read through shippedArtifactsOf.

Wording. The "never under-closes" sentence is narrowed in the anonymousFormIntakeWithdrawnIn docblock and in the "Known limit: packages and names" paragraph of content/docs/ui/public-data-collection.mdx (declared to domain:devx on #6023). The released changeset of #21864 is not edited; this card's changeset states the narrowing. As corrected in 3eea8f0995 after the contract review, the narrowed text keeps "a withdrawal of a view name still closes that name in every package, so it may over-close" and the statement that the organization-scoped save check judges every package's environment-wide definition of the name, and states the endpoints' one exception: where a package's environment-wide copy of a view container is saved, the endpoints read that copy's expansion alone for each form it expands, and can miss another package's withdrawal of that form, whether saved or shipped. Reading each package's expansion separately is tracked in #21967. The narrowed text assumes items 1 and 4 both land; if item 1 is dropped, the endpoint exception in that paragraph widens to every view name two packages ship.

Pin ("the save check anchors each package's row on that package's env-wide definition"): with the withdrawing package not first in registry order, a package-less and a package-bound org save that renames the form are refused; another package's env-wide row anchors that package only; controls: the save that keeps the form withdrawn saves, and a package-less env-wide row stands in for every package.

Clause-②

Measured against the built entry declarations, base a3bd157730 against head 5297072f13, comments stripped before the diff:

  • @objectstack/metadata-protocol dist/index.d.ts: SysMetadataRepository.promoteDraft(ref: MetaRef, opts: {...}) gains expectedDraftHash?: string | null; (head line 9883). An optional input field: a widening. The only other declaration difference is comment placement.
  • @objectstack/metadata-core dist/index.d.ts: the declaration of anonymousFormIntakeWithdrawnIn (parameters layer, view, candidate, returning boolean) is byte-identical (base line 21311, head line 21316); only its docblock changed.

Unchanged at the final head 3eea8f0995: the later commits change a method body, a docblock, the docs page and a changeset, and the rebuilt declarations are identical with comments stripped. So Clause-②: yes (widening), and item 2's changeset is minor. The other three changesets are patch. @objectstack/metadata-core carries no changeset: its edit is a comment.

Tests

Final head 3eea8f0995 (origin/main 76fec88b16 merged at 5297072f13). The last commit, 3eea8f0995, corrects wording only (the docs page, one changeset, a docblock); packages/metadata-protocol/src is byte-identical at 7c30229b43, where its suites ran:

  • @objectstack/metadata-protocol at 7c30229b43: typecheck green (tsc --noEmit; the edited test file is in the program, counted with --listFiles), full suite 218 files passed, 3 skipped; 27984 tests passed, 19 skipped.
  • @objectstack/metadata-core at 3eea8f0995: typecheck green (both programs); 18 files, 411 tests passed.
  • @objectstack/objectql (a consumer of the protocol, against its dist built at 5297072f13; the later code commit only changes an outage path): 378 files, 7507 tests passed.
  • Reverse verification through scripts/ablation-replace.mjs, each from a committed head, each item's code set back to its base shape (anchor hit once, blob changed on disk; the test imports the source, so no dist leg), the item's pin run, then restored and proved (blob equal to HEAD, git diff HEAD empty):
    • from 1e271aaae1 (its protocol.ts blob is the one at 5297072f13): item 1: 5 red, 2 green (the two write-door re-save cases, which item 1 does not touch); item 2: 2 red, 1 green (the control); item 3: 1 red, 1 green (the control); item 4: 3 red, 2 green (the two controls);
    • from 7c30229b43: item 3's follow-up, its store-failure classification removed: its pin 1 red.
  • Gates at 3eea8f0995, derived by node scripts/pm/dispatch-gates.mjs --commands (no paths; the same 93 commands as at 5297072f13 and 7c30229b43): 92 run green, among them check:doc-authoring, check:docs-audit-scope, the docs-audit check-affected-docs and check-drift-comment, check:docs, check:nul-bytes, and the changeset gates (check-changeset-no-major with this PR's payload, check-empty-changeset, check-adr-0087-registration, check:changeset-gate-self-tests). 1 NOT MEASURED: pnpm check:dual-build-cjs-loads (PREREQUISITE NOT MET: it loads every workspace package's dist, 32 of which were not built in this worktree; it was green at 7c30229b43, whose code this head keeps). Reconciled: dispatch-gates --ran answers "93 derived famil(ies) accounted for — 92 run, 1 NOT-MEASURED". The artifact-roster block (53) is green, the PR-context gates run against this PR. The four symbol-anchor sweeps (check:adr-symbol-anchors, check:scripts-symbol-anchors, check:spec-docblock-symbol-anchors, check:adr-anchors) are green.

Acceptance notes


Generated by Claude Code

claude added 5 commits October 6, 2026 06:01
…every name, so the form doors judge every package's withdrawal

The view list read serves one item per package that ships a view name
(ADR-0048) whether or not a view row is stored; only a name a stored
container's expansion writes is upserted by name. The env-wide list is
the layer the anonymous form doors judge a withdrawal against, so a
package-less organization overlay stored before one package's
withdrawal is compared against every package's body of the name.

Pin: a package-less organization overlay under two packages shipping
its view name is served once per package, each copy stamped with that
package; after either package withdraws the name env-wide (first or
second in registry order), the env-wide list holds the withdrawal, the
doors serve no copy, and a re-save of the overlay is refused.

Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi
Co-authored-by: Claude <noreply@anthropic.com>
…dged

The publish gate reads the draft to judge it, and the promotion read
the draft row again to write it. The promotion is now handed the judged
draft's hash (null when the gate found no draft):
SysMetadataRepository.promoteDraft takes an optional expectedDraftHash
and refuses a draft row with another hash, or any draft row where the
caller judged none, with a ConflictError before anything is written.
The protocol answers it as 409 METADATA_CONFLICT with its own wording.

Pin: a draft saved after the gate read, or where the gate judged none,
is not promoted and the conflict answers; with no save in between the
judged draft is promoted and its draft row drained.

Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi
Co-authored-by: Claude <noreply@anthropic.com>
…y it resolved

The publish path resolves one package key for the draft it promotes
(the caller's stated binding, else the draft row's own) and reads and
promotes the draft under it. Its ADR-0010 lock lookup took only the
package the request stated. The key is now resolved first and threaded
into the lock lookup too. The authoring gate's narrowing to the stated
package is left as it is.

Pin: with two packages' env-wide rows of one view both locked, a
publish that states no package is refused with the lock of the draft
row's own package; stating a package consults that package's lock.

Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi
Co-authored-by: Claude <noreply@anthropic.com>
…that package's env-wide definition

The organization-scoped save check judges a view overlay against the
env-wide body of the row it is keyed by. That anchor is now resolved
per package, the way the list read resolves each package's item: the
package's own env-wide row, else the package-less env-wide row (which
stands in for every package), else that package's artifact. It no
longer falls back to one artifact per name, the first in registry
order, and one package's stored row no longer stands for every
package's artifact of the name.

The "never under-closes" wording on the withdrawal judgement's docblock
and on the public data collection page is narrowed to match what holds:
a withdrawal of a name may over-close across packages; both checks read
every package's env-wide definition, except that the anonymous form
endpoints read one package's expansion of each form name when several
packages' stored view containers expand it.

Pin: with the withdrawing package not first in registry order, a
package-less or package-bound org save that renames the form is
refused; another package's env-wide row anchors that package only;
controls: the save that keeps the form withdrawn saves, and a
package-less env-wide row stands in for every package.

Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Oct 6, 2026
@github-actions

github-actions Bot commented Oct 6, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

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

  • content/docs/api/error-catalog.mdx (via sys_metadata (literal, a string literal in promoteDraftForPublish))
  • content/docs/api/index.mdx (via sys_metadata (literal, a string literal in promoteDraftForPublish))
  • content/docs/automation/flows.mdx (via sys_metadata (literal, a string literal in promoteDraftForPublish))
  • content/docs/concepts/metadata-lifecycle.mdx (via ObjectStackProtocolImplementation (symbol, a top-level class), sys_metadata (literal, a string literal in promoteDraftForPublish))
  • content/docs/data-modeling/drivers.mdx (via sys_metadata (literal, a string literal in promoteDraftForPublish))
  • content/docs/data-modeling/objects.mdx (via sys_metadata (literal, a string literal in promoteDraftForPublish))
  • content/docs/deployment/cli.mdx (via sys_metadata (literal, a string literal in promoteDraftForPublish))
  • content/docs/deployment/environment-variables.mdx (via sys_metadata (literal, a string literal in promoteDraftForPublish))
  • content/docs/deployment/validating-metadata.mdx (via sys_metadata (literal, a string literal in promoteDraftForPublish))
  • content/docs/kernel/cluster.mdx (via sys_metadata (literal, a string literal in promoteDraftForPublish))
  • content/docs/kernel/contracts/metadata-service.mdx (via sys_metadata (literal, a string literal in promoteDraftForPublish))
  • content/docs/kernel/services-checklist.mdx (via sys_metadata (literal, a string literal in promoteDraftForPublish))
  • content/docs/permissions/authorization.mdx (via sys_metadata (literal, a string literal in promoteDraftForPublish))
  • content/docs/permissions/permission-sets.mdx (via sys_metadata (literal, a string literal in promoteDraftForPublish))
  • content/docs/plugins/packages.mdx (via sys_metadata (literal, a string literal in promoteDraftForPublish))

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

  • content/docs/releases/implementation-status.mdx (via sys_metadata (literal, a string literal in promoteDraftForPublish))
  • content/docs/releases/v16.mdx (via ObjectStackProtocolImplementation (symbol, a top-level class), sys_metadata (literal, a string literal in promoteDraftForPublish))
  • content/docs/releases/v17/17-0.mdx (via ObjectStackProtocolImplementation (symbol, a top-level class), sys_metadata (literal, a string literal in promoteDraftForPublish))
  • content/docs/releases/v17/17-3.mdx (via sys_metadata (literal, a string literal in promoteDraftForPublish))
  • content/docs/releases/v17/17-5.mdx (via sys_metadata (literal, a string literal in promoteDraftForPublish))
  • content/docs/releases/v17/17-6.mdx (via sys_metadata (literal, a string literal in promoteDraftForPublish))

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/metadata-core/src/anonymous-form-intake.ts) — pages documenting those are invisible to this run
  • the SDK route bridge reached 54 of 206 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 — 13 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 2a22177ae786b1adf6cc22ce596c47bddd254b4a → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 2a22177ae786b1adf6cc22ce596c47bddd254b4a

⚠️ 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 2a22177ae786b1adf6cc22ce596c47bddd254b4a → pass the list as
args.docs, on the commit named under Which tree this was computed on.

…dable store as the lock read did

Item 3 moved the publish path's draft-key read ahead of the lock check.
An unreadable store is now answered at that read the way the lock read
answers it: an unprovisioned sys_metadata holds no draft, and any other
failure is the 503 SERVICE_UNAVAILABLE the lock read raised before,
never the driver's own error.

Pin: a publish that states no package, over a store that cannot be
read, answers 503 and promotes nothing.

Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 7c30229b43a7712de0eb227c9748677a49a527f4
Local-runs: none

Inputs: card #21934 body; triage grade 6007709051; addendum 6008978556 (item 4 in scope); unlock 6009643967; claim 6010039236 (revised in place to Clause-②: yes (widening)); os-dev-report 6011280365; PR #21962 body and file list; the net diff against merge base 76fec88b16; the 35 check-runs on this head (33 success, 2 skipped: Console Pin Gate, Packed-tarball smoke opt-in; none failed). File lines below are at this head unless marked base. The card is security; nothing withheld was sought and this record judges in the card's public terms only.

① Derived judgments

Kill-switch invariant across the four items: right in code. Each change refuses a superset of what base refused at the place the card names, and opens nothing base closed. The only wrong derived judgment is in the wording deliverable the addendum asked for (last bullet of this section).

Item 1, the view list keeps one item per package (packages/metadata-protocol/src/protocol.ts:9070-9105).

  • Base collapsed every view name through one by-name map whenever any view row was stored (base protocol.ts:9063-9083, shown in the diff), so the env-wide view list, which is the layer the doors judge against, held one package's body of a name two packages ship. Head restricts the by-name upsert to the names a stored container's expansion writes (written, :9075); byName is filled only for those (:9077-9081); every other name passes through merged unchanged (:9094-9104). For a written name head and base run the same set sequence; for every other name head keeps every item the package-aware merge seated. So head's layer is a superset of base's layer for every name. Right.
  • The shared judgement compares name and bodies only, never a package (packages/metadata-core/src/anonymous-form-intake.ts:334-352: true when any same-name body withdraws by slot or by slug). More bodies in the layer can only close more. The doors change no code (packages/rest/src/rest-server.ts:10735-10751 findPublicFormView; :10803 the organization list; :10817 the env-wide layer). Pin protocol.org-scoped-write-refused.test.ts:1015-1114 covers the withdrawing package first and second in registry order, the env-wide list holding the withdrawal beside the other body, the doors serving no copy, and the re-save refused. Right.
  • Every reader of the view list this reaches, each now served one item per package for a multi-package name once any view row is stored, the shape the list already had with no row stored (ADR-0048 merge, protocol.ts:8964-8967 and the per-package stamp at :2166): the anonymous form doors (rest-server.ts:10803, :10817); the organization-scoped save check (protocol.ts:16064); REST GET /meta/:type (rest-server.ts:6078) and the meta book tree (rest-server.ts:6375); the runtime meta domain (packages/runtime/src/domains/meta.ts:449, :1933); the MCP runtime list (packages/mcp/src/mcp-server-runtime.ts:575); the diagnostics sweep's per-type counts (protocol.ts:7930). The references scan (protocol.ts:27091) has no matcher whose fromType is view, so it is unaffected. No reader's accept set narrows. The output widening is the card's first direction ("mark stamped copies in the merge", card body item 1) applied at the producer, so it is in scope. One consequence named: the organization read now serves a package-less overlay of a view item once per package, each copy stamped (:2166; pin :1065-1073), where base served the last survivor; the doors pick by slug and body, so the door verdict is the same for either copy. Right.
  • The card's two directions were measured not to apply: nothing compares packages, so marking stamped copies or reading the org row's own package_id changes no verdict (6011280365 items[0]). Confirmed at anonymous-form-intake.ts:334-352. Right.

Item 2, a publish promotes only the draft its gate judged.

  • SysMetadataRepository.promoteDraft gains optional expectedDraftHash?: string | null (packages/metadata-protocol/src/sys-metadata-repository.ts:966). The check runs after the NO_DRAFT answer (:996-1003) and before any write (this.get at :1020, put at :1022), guarded on !== undefined (:1014), so an omitted option keeps base behaviour exactly, and a missing draft row still answers NO_DRAFT. Right.
  • The publish path cannot omit it: promoteDraftForPublish passes expectedDraftHash: draftForGate ? draftForGate.hash : null unconditionally (protocol.ts:21807), and repo.promoteDraft is called from nowhere else in the repository (protocol.ts:21736 is the only non-test call site). Both doors reach it: publishMetaItem (:21423) and each promotion of publishPackageDrafts (:22790). Right.
  • Both reads hash through one function, rowToItem (:2010-2016, row.checksum else hashSpec), so the gate's hash and the promotion's hash are comparable. null from the gate with a draft row present refuses; a draft with another hash refuses. DraftConflictError extends ConflictError (:244) with the ConflictError(ref, expectedParent, actualHead) shape (packages/metadata-core/src/errors.ts:22-33, code METADATA_CONFLICT), caught at protocol.ts:21811 and answered 409 with its own wording (:21816-21818). Pin protocol.org-scoped-write-refused.test.ts:1120-1170 covers a draft saved after the gate read, a draft saved where the gate judged none, and the control. Right.
  • DraftConflictError is not exported from the entry (packages/metadata-protocol/src/index.ts:153-160 export the class, the reset helper and five types only), so callers see a ConflictError and no new public symbol lands. Right.

Item 3, the lock lookup takes the key the gate resolved.

  • The draft-key read moves above the lock check (protocol.ts:21634-21648), and the lock lookup takes draftKey (:21662) instead of request.packageId (base :21583). With a stated package draftKey equals the stated package, so base behaviour holds; with none stated the resolved row's own package picks the lock prose. lockWriteRefusal's packageId never narrows the rows in scope, it only picks whose prose a refusal carries (:17525-17530), so the refusal verdict is unchanged and the INFO grade stands. The authoring-rule narrowing still reads request.packageId (the assertRuntimeAuthoringRules call in :21671-21730). Pin :1177-1210. Right.
  • Follow-up 7c30229b43: the moved read is wrapped so a store that cannot be read answers as the lock read answered it (:21640-21646 through rethrowUnlessMetadataStoreUnprovisioned :8152-8168, which returns only for a missing table and otherwise throws metadataReadFailureError :3425, the 503 SERVICE_UNAVAILABLE of :3376-3382). An unprovisioned store means no draft, as before. Pin :1280-1296 asserts 503 and no active row. Nothing is promoted on that path. Right.
  • ensureOverlayIndex() now runs before the lock check (:21626; base ran it after). It is idempotent index provisioning and changes no accept set. Right.

Item 4, the save check anchors each package's row on that package's env-wide definition (protocol.ts:16120-16141).

  • Base returned every stored env-wide row of the name when any existed, else the single package-less artifact lookup (base :16092-16103, in the diff). Head: stored env-wide bodies (:16128-16131); with a package-less row among them, those bodies alone (:16134, equal to base); otherwise those bodies plus every shipped artifact of a package without its own row (:16135-16139), through shippedArtifactsOf (:17060-17072), whose first member is the same package-less lookupArtifactItem call base made (:17065). So the anchor set at head is a superset of base's in all three branches. Right.
  • Consequence for the brief's question: no organization save that main refused is accepted at this head. The per-package precision the addendum (6008978556) directs is realised as more anchors, one per package (own row, else package-less stand-in, else that package's artifact), never fewer; the pin's two controls (:1251-1266) pass for saves base also accepted. Right, not an under-close.
  • Named over-close: a package A-bound organization save is still judged against package B's withdrawal (pin :1240-1249). The addendum's "using the saved row's own package" could be read as judging the bound package only; the dev kept the shared judgement's rule that packages are not compared (anonymous-form-intake.ts:334-352), which is the known limit the maintainer accepted on security(forms): a public-form setting at one metadata layer can re-open intake that another layer withdrew — 17.7 regression, detail withheld pending maintainer #21835 (6005722623) and the direction that fails closed. Right.
  • The save check's first half, the env-wide list read at :16064, inherits item 1's superset, so the whole check refuses a superset of base. Right.
  • Docs line 68 ("the row its copy is keyed by, in each package that ships it", content/docs/ui/public-data-collection.mdx:68) matches :16128-16139. Right.

Wording deliverable (addendum 6008978556: "narrow that sentence to match"): WRONG, blocking. Three places state the endpoint exception as "when two packages each have an environment-wide copy of the same view container saved" (content/docs/ui/public-data-collection.mdx:75; .changeset/21934-save-check-anchor-per-package.md:44; the anonymousFormIntakeWithdrawnIn docblock, packages/metadata-core/src/anonymous-form-intake.ts:326-333, "stored view containers of several packages expand"). The code's exception is wider. One stored env-wide copy of a view container is enough: its expansion's names are written (protocol.ts:9073-9075) and the by-name upsert then replaces every other item of each such name (:9077-9092, :9099-9101), including another package's shipped item of that name, because both loaders register a shipped container's expansions as independent per-package view items named object.key (packages/objectql/src/engine.ts:7159-7165 registerItem('view', vi, ...); packages/metadata/src/plugin.ts:1198-1210; names at packages/spec/src/ui/view.zod.ts:6918), and the list filters only the containers out afterwards (protocol.ts:9352-9354). So with package A's env-wide copy of container task saved with the form open, and package B shipping task with the same form withdrawn (the strict-schema default for a shipped form with a link, docs line 72), the env-wide layer holds package A's expansion of task.intake_form alone, and the endpoints serve an organization overlay of that form stored before, or restored, although package B's definition withdraws it and the docs' own rule says a withdrawal of a name closes it in every package. The sentence "Both checks read every package's environment-wide definition of the name, with one exception at the endpoints" therefore over-claims closure for this case. This behaviour is pre-existing (base collapsed every name, so head is not a regression) and the save check does read package B's artifact here (:16135-16139), but the wording the card asked for must match what lands. Fix: state the exception as "where a package's environment-wide copy of a view container is saved, the endpoints read that copy's expansion alone for each form it expands, and can miss another package's withdrawal of that form, whether saved or shipped", in all three places, and align the PR body's Acceptance note, or take the dev's option A.

Check-runs on this head: right. Test Core (all 6 shards), TypeScript Type Check, the four Type Check gates, Lint and Repo Gates, Check Changeset, Build Core, Build Docs, Check Documentation Links, Dogfood Regression Gate (3 shards), Dogfood Verify CLI, Temporal Conformance, Spec property liveness, Governed Surface Queue Guard and the PR-context guards all conclude success; Console Pin Gate and the opt-in packed-tarball smoke are skipped. Their conclusions are the gate verdicts; nothing was re-run.

② Semver level

  • @objectstack/metadata-protocol: four changesets, one minor (.changeset/21934-publish-promotes-judged-draft.md:22, carrying Clause-②: yes (widening) at :27) and three patch (21934-view-list-one-item-per-package.md:52, 21934-publish-lock-resolved-package.md:8, 21934-save-check-anchor-per-package.md:38). The one declaration change the diff publishes is the optional input field expectedDraftHash?: string | null on SysMetadataRepository.promoteDraft (sys-metadata-repository.ts:966), exported through index.ts:153. An optional input is a widening, so minor is the right level and the three behaviour fixes are rightly patch. DraftConflictError is internal (index.ts:153-160), so no further surface moves. Matches the diff.
  • @objectstack/metadata-core: no changeset, rightly. The only edit is the docblock at anonymous-form-intake.ts:322-335; the declaration at :334-338 is unchanged. Check Changeset is green on this head.
  • Clause-②: yes (widening) is stated identically in the PR body, the revised claim 6010039236, the item-2 changeset (:27) and the dev report's clause_2 (6011280365). Right. No skip-changeset.

③ Boundary flags

Dev report 6011280365, each flag answered:

  • open_questions[0] (item 1 residual, options A or B): B is acceptable as the route for this card, with a correction. The residual must be filed as its own card carrying option A's design (per-package expansion upsert, taught to resolveRowlessExpandedView too), and the stated reach is wrong: one stored env-wide container copy suffices and shipped expansions are displaced as well (evidence in ①, wording bullet). Escalated to the seat as the blocking defect above; the three sentences must be corrected before this head is adopted.
  • out_of_scope_findings[0] (class a, the same residual, dedupe words given): same answer. The dedupe words should add the shipped-artifact variant.
  • out_of_scope_findings[1] (the draft-key read at protocol.ts:21641-21643 carries no explicit system opt-in context, unlike the repository's reads at sys-metadata-repository.ts:487-497): pre-existing at base :21615, moved unchanged, census gate green. Not a defect of this card. The seat may file it with carrier none or fold it into the residual card.
  • deviations[0] (item 1 changed code, not a pin only): right. The card's first direction is "in the merge" and the stamp compares nothing (anonymous-form-intake.ts:334-352), so the producer was the only place a fix could land.
  • deviations[1] (no metadata-core changeset): right. Docblock only; declaration byte-identical; Check Changeset green.
  • deviations[2] (four changesets): right. Each item stays droppable, and the minor one carries the Clause-② line.
  • deviations[3] (amend and --force-with-lease before the PR existed) and deviations[5] (AGENTS.md trailers): process notes outside the contract; recorded, not judged.
  • deviations[4] (reverse verification at 1e271aaae1, protocol.ts blob identical at 5297072f13): accepted; the check-runs on this head are the gate verdicts in any case.
  • deviations[6] (item 3 is two commits; dropping it drops both): right, and the follow-up keeps 503 (① item 3).
  • deviations[7] (PR body quotes the suites and union at 5297072f13, omits the follow-up commit, lists check:dual-build-cjs-loads as not measured): non-blocking; the check-runs on this head answer it. The seat should refresh the PR body's Tests section, the item 3 paragraph and the Acceptance note's residual wording when the fix lands.
  • Lane declarations in the claim 6010039236 (rest-server.ts not touched; docs page declared to domain:devx on [PM seat] domain:devx @ objectstack — 🟢 baozhoutao · session_01VDtqoecgES7ScQYGbFVDRv · R9 · landed 3 · #20004 awaits skip-changeset · in flight 0 #6023; no door dogfood case, declared to domain:cli on [PM seat] domain:cli — 🟢 os-warren · session_01RWZbGvPFcRKvUqASZtunCU #6024): the file list confirms rest-server.ts is untouched and the docs page is the only out-of-lane file. Right.

Implemented-by: claude/issue-21934-org-overlay-publish-gate
Reviewed-by: session_017ErfyP2Rx7XWHJA27QjyUi

VERDICT: FAIL

Blocking defect, one: the narrowed "Known limit" wording (content/docs/ui/public-data-collection.mdx:75, .changeset/21934-save-check-anchor-per-package.md:44, packages/metadata-core/src/anonymous-form-intake.ts:326-333) states the endpoint exception as requiring two saved env-wide container copies, while the code's exception (protocol.ts:9073-9101 with objectql/src/engine.ts:7159-7165) also displaces another package's shipped withdrawal once one package's container copy is saved, so the sentence over-claims closure of a withdrawn form. The code of all four items is right and strictly tightening; correcting the three sentences (or landing option A) is all this head needs.

Adopted by domain:engine#1 (session_017ErfyP2Rx7XWHJA27QjyUi) at 2026-10-06T07:42Z as the record of head 7c30229b43, the current head. The seat checked the record's key evidence at this head: packages/objectql/src/engine.ts:7159–:7165 registers a shipped container's expansions as independent view items, and protocol.ts:9073–:9075 derives written from the stored containers' expansions.

REWORK, patch round 1 (narrow). The code of all four items stands. Only the residual's wording changes, in the three places the record names, plus the PR body:

  • content/docs/ui/public-data-collection.mdx, the "Known limit: packages and names" paragraph;
  • .changeset/21934-save-check-anchor-per-package.md;
  • the anonymousFormIntakeWithdrawnIn docblock (packages/metadata-core/src/anonymous-form-intake.ts);
  • the PR body's Acceptance note. The body's Tests section and item 3 paragraph are refreshed to the final head in the same edit.

The exception, stated as the record directs: where a package's environment-wide copy of a view container is saved, the endpoints read that copy's expansion alone for each form it expands, and can miss another package's withdrawal of that form, whether saved or shipped. Option A stays with #21967. Its reach is corrected there in this round.


Generated by Claude Code

…nv-wide container copy, and shipped withdrawals too

The narrowed "Known limit: packages and names" wording stated the
anonymous endpoints' exception as two packages each saving an
env-wide copy of one view container. One saved copy is enough: where a
package's env-wide copy of a view container is saved, the endpoints
read that copy's expansion alone for each form it expands, and can miss
another package's withdrawal of that form, whether saved or shipped.
The organization-scoped save check still judges every package's
env-wide definition of the name, and a withdrawal of a view name still
closes that name in every package, so it may over-close.

Corrected in the public data collection docs page, this card's
save-check changeset and the anonymousFormIntakeWithdrawnIn docblock.
Reading each package's expansion separately is tracked in #21967. No
code change.

Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 3eea8f0995e4348d93f8deac466698fe852b17aa
Local-runs: none

Round 2, after the FAIL record on 7c30229b43 (adopted as 6011725183) and the dev's patch round 1 (6012226132). Inputs: card #21934 body; triage 6007709051; addendum 6008978556; unlock 6009643967; claim 6010039236 (Clause-②: yes (widening)); dev reports 6011280365 and 6012226132; PR #21962's patched body and file list; the net diff against merge base 76fec88b16 and the one-commit diff 7c30229b43..3eea8f0995; the 42 check-run entries on this head (re-runs of the PR-context workflows included: every entry completed, 36 success, 6 skipped, none failed). The residual card #21967 and its reach correction 6011733182 were read as data to confirm the round-1 escalation landed. The seat's pre-fetched files are from the old head and were not used. File lines are at this head unless marked.

① Derived judgments

What moved: wording only. 3eea8f0995 has parent 7c30229b43 (fast-forward, no amend) and touches three files, 10 insertions and 8 deletions: content/docs/ui/public-data-collection.mdx:71, .changeset/21934-save-check-anchor-per-package.md:8, and the anonymousFormIntakeWithdrawnIn docblock at packages/metadata-core/src/anonymous-form-intake.ts:327-334. packages/metadata-protocol is byte-identical between the two heads (git diff --quiet empty), the declaration at anonymous-form-intake.ts:336-340 is untouched, and the other three changesets are unchanged. Every ① judgment of the round-1 record on the four items' code stands unchanged; this record re-affirms them in one line each and judges the cure.

Items 1 to 4, code (unchanged since 7c30229b43): right. Item 1 keeps one item per package for every view name not written by a stored container's expansion (protocol.ts:9070-9105), so the env-wide layer the doors judge against is a superset of base's for every name. Item 2 passes expectedDraftHash unconditionally at the only repo.promoteDraft call (protocol.ts:21807, :21736), reached by both publishMetaItem (:21423) and publishPackageDrafts (:22790); the repository refuses before any write and keeps base behaviour when the option is omitted (sys-metadata-repository.ts:1014-1015, after NO_DRAFT at :996-1003, before get/put at :1020-1022). Item 3 threads the resolved draftKey into the lock lookup (:21662) with the authoring narrowing untouched, and the moved read classifies an unreadable store to 503 (:21640-21646, :8152-8168, :3376-3382). Item 4's anchor set is a superset of base's in all three branches (protocol.ts:16128-16139, shippedArtifactsOf :17060-17072 starting with base's own package-less lookup at :17065), so no organization save main refused is accepted. Pins unchanged at protocol.org-scoped-write-refused.test.ts:1015-1296.

The blocking defect of round 1: cured. The three sentences now state the endpoint exception at the reach the code has:

The round's new remedy sentence ("To close such a form at the endpoints, withdraw it in every saved environment-wide copy of that container as well", mdx:71; "until the form is withdrawn in every saved environment-wide copy", changeset :8): true of the code. In the doors' env-wide read no package is named, so standInRows is empty (protocol.ts:8964) and the stand-in branch at :9084-9091 never keeps a held item; for a written name the item served is always a saved copy's expansion, the last one in row order (:9082-9092; expandStoredViewContainers' header at :9612-9614 states the later expansion replaces the earlier). If every saved env-wide copy of the container withdraws the form, whichever expansion survives withdraws it, and the doors close the overlay's form by slot or slug (anonymous-form-intake.ts:336-354). With one saved copy, withdrawing in it is enough; with none saved, the name is not written and every package's shipped item is in the layer, so no remedy is needed. Right. One nit, not blocking: read per copy, "that copy's expansion alone" is loose when two packages' copies are saved (the endpoints read the last copy's expansion, not each copy's), and the remedy sentence beside it carries the exact rule.

Readers and accept sets: unchanged from round 1. No code moved, so the reader enumeration and the "no accept set narrows" judgment of the round-1 record hold.

Check-runs on this head: right. Test Core (6 shards), TypeScript Type Check, the four Type Check gates, Lint and Repo Gates, Check Changeset, Build Core, Build Docs, Check Documentation Links, Flag docs affected by code changes, Dogfood Regression Gate (3 shards), Dogfood Verify CLI, Temporal Conformance, Spec property liveness, Governed Surface Queue Guard and the PR-context guards conclude success. Console Pin Gate and the opt-in packed-tarball smoke are skipped; the duplicated Auto Label and Check PR Size entries are re-runs on the body patch, one skipped and one success each. None failed. Their conclusions are the gate verdicts; nothing was re-run.

② Semver level

  • Unchanged from round 1 and still matching the diff. @objectstack/metadata-protocol: one minor (.changeset/21934-publish-promotes-judged-draft.md:22, carrying Clause-②: yes (widening) at :27, byte-identical between the heads) and three patch. The one published declaration change remains the optional input expectedDraftHash?: string | null on SysMetadataRepository.promoteDraft (sys-metadata-repository.ts:966), exported through index.ts:153; DraftConflictError stays internal (index.ts:153-160). A widening, so minor is right.
  • @objectstack/metadata-core: no changeset, rightly. This round again edits only the docblock (:327-334); the declaration at :336-340 is unchanged, so the built entry declaration is identical with comments stripped (6012226132 clause_2). Check Changeset is green on this head.
  • Clause-②: yes (widening) is stated identically in the patched PR body (line 2), the claim 6010039236, the item-2 changeset and both dev reports. Nothing else moved on the level axis. Right.

③ Boundary flags

Dev round report 6012226132: open_questions and out_of_scope_findings are empty; three deviations, each answered:

  • deviations[0] (a remedy sentence added beside the corrected exception): right, judged true of the code in ① above.
  • deviations[1] (not merged with origin/main this round; the branch is behind): non-blocking. CI judges the merge ref and every check-run on this head is green, so the branch has no conflict with main at the time of the runs. The seat may merge origin/main before enqueueing if the queue asks for it.
  • deviations[2] (check:dual-build-cjs-loads not measured locally in the recreated worktree): non-blocking. The round changes no code; the gate was green at 7c30229b43, whose code this head keeps, and Build Core and Lint and Repo Gates are green on this head.

Round-1 flags, closed out:

Implemented-by: claude/issue-21934-org-overlay-publish-gate
Reviewed-by: session_017ErfyP2Rx7XWHJA27QjyUi

VERDICT: PASS

Adopted by domain:engine#1 (session_017ErfyP2Rx7XWHJA27QjyUi) at 2026-10-06T08:22Z as the record of head 3eea8f0995, the current head. It supersedes the FAIL record 6011725183 of 7c30229b43, whose one blocking defect this head cures.

ACCEPT (seat review). The seat read the round's diff against 7c30229b43: three files, wording only, matching the record.

  • Readings against main at 787104baa9: NOT governed (0 of 9 paths), every skip is in the roster (4), and git merge-tree is clean.
  • Docs drift (advisory): the 15 flagged pages are hit by the sys_metadata literal. The seat read content/docs/concepts/metadata-lifecycle.mdx § Conflict handling. It already says every write entry point, publish included, answers a ConflictError with 409 metadata_conflict, and the judged-draft refusal is one such conflict. No doc edit is owed.
  • Not filed: the record's note on the publish's draft-key read and its system opt-in context. It predates this PR, the census gate is green on it, and it has no carrier.

Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 6, 2026 08:23
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 6, 2026 08:23
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 6, 2026
Merged via the queue into main with commit c9761cd Oct 6, 2026
44 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-21934-org-overlay-publish-gate branch October 6, 2026 09:01
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/l tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

security(metadata): tighten the draft publish gate and package identity for org view overlays (follow-up to #21864)

2 participants