Skip to content

fix(rest,metadata-protocol): a public form's intake withdrawal at any metadata layer holds; layering can only narrow intake - #21864

Merged
objectstack-fleet[bot] merged 23 commits into
mainfrom
claude/issue-21835-form-withdrawal-kill-switch
Oct 6, 2026
Merged

objectstack-fleet[bot] merged 23 commits into
mainfrom
claude/issue-21835-form-withdrawal-kill-switch

Conversation

@objectstack-fleet

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

Copy link
Copy Markdown
Contributor

Fixes #21835

Clause-②: yes (widening)

Fixes a regression introduced after 17.6.0 (with #21420); it should land before 17.7.0 is cut.

What

Per the rulings recorded on #21835: a public form's withdrawal is a kill switch, layering can only narrow anonymous intake, a withdrawal closes the same form only, and only an explicit withdrawal counts.

  • Anonymous doors (GET /forms/:slug, POST /forms/:slug/submit). Both use one resolver and judge by the name of the view item they serve. When an organization is resolved, the env-wide view list beneath it is read as well. A form is served only when the env-wide item of the same name does not explicitly withdraw a form in the same slot (nested form, the same formViews key, or the flattened config) or with the same slug. Other views that share the public slug never close each other.
  • What counts as a withdrawal. A sharing that keeps its publicLink and sets enabled: false or allowAnonymous: false. Only an explicit false counts. Not a withdrawal: an absent switch, a sharing with no link (raw, or schema-parsed), a cleared link, a removed sharing block, or no body of the view at that layer. The public data collection docs page has a "Withdraw a public form" section with these rules.
  • Write door (save and publish). An org-scoped view save or draft promotion in the organization the doors read is refused with 403 NOT_OVERRIDABLE when it would leave open a form the env-wide definition explicitly withdraws. It judges by the stored row: the body is compared with the env-wide body of the row it is keyed by (the active env-wide row, else the package artifact), matched by slot or by slug. So renamed formViews keys, form.name, slot moves and listViews collision renames are the same form. It is also judged against the env-wide view list the way the doors read it, with container bodies expanded. Re-saving an overlay that was open before the withdrawal is refused. The message names both remedies.
  • Package-shipped forms. A package artifact is part of the env-wide definition, not a separate layer. A package artifact parsed by the stack schema (strict defineStack, the default) carries the schema's default enabled: false, so a shipped form that keeps its link without switching enabled on is an explicit withdrawal and fails closed. An artifact loaded without that parse (defineStack(..., { strict: false }) or a hand-built manifest) is judged as written: a switch it omits is absent, which is not a withdrawal. The env-wide definition is the administrator's switch, so an env-wide save may open a form the package ships closed.
  • Known limit: packages and names. A withdrawal of a view name closes that name in every package: when two packages ship a view of the same name, one package's withdrawal also closes the other package's form of that name. It may over-close, never under-close. Per-package precision is tracked in security(metadata): tighten the draft publish gate and package identity for org view overlays (follow-up to #21864) #21934. A publish judges the draft it promotes under the same package key (the stated one, else the resolved draft row's own), so with two packages holding a draft of the same view in one organization, each draft is judged on its own publish.
  • Intentional reversal. The earlier behaviour in which an organization overlay re-published a form the package had withdrawn is reversed. A form with no env-wide word on it stays organization-publishable (fix(rest): withdrawing a public form takes effect on every anonymous intake door #21420), and the fix(metadata-protocol): refuse an org-scoped public form withdrawal a walled posture cannot honour #21473 anchors and fix(rest): one anonymous-intake rule honours every declared public-form withdrawal #21566 field allowlist are unchanged.
  • Public surface: @objectstack/metadata-core adds one export, anonymousFormIntakeWithdrawnIn (minor). @objectstack/rest and @objectstack/metadata-protocol are patch.
  • Known limit (ruled to stay as is). The doors match by served item name, and the write door runs only on an org-scoped save or publish. An organization overlay stored before the env-wide withdrawal, or restored by rollback or commit revert, can still be served if it keeps the form open under a different key or slot than the env-wide definition. Withdrawing the form in that overlay closes it. Stated in the changeset and the docs.

Tests

The first bullet is round 6, the second round 5, the third round 4; the bullets after them were measured at e8778acb96 (round 2):

  • Round 6 at 7882eef683 (merged origin/main 9dce635337, merge commit 46d08189a7): metadata-core 18 files, 411 passed; metadata-protocol 216 files (3 skipped), 27940 passed, 19 skipped; rest 260 files, 4912 passed, 326 skipped; objectql 375 files, 7469 passed (suites at 4d5f6c4e61; the later commits touch docs, the changeset and three ported dogfood files only). Typecheck green for metadata-core, metadata-protocol, rest and objectql, test layers included. 97 of 97 derived gates green at 7882eef683, reconciled with dispatch-gates --ran; dispatch-gates --self-test 1976 cases pass. The cross-package skip of round 5 is removed per the ruling, so a withdrawal of a view name closes it in every package again. Pins: another package's withdrawal of the same name closes this package's form too (metadata-core and the doors); with two packages shipping the same view name, a row-anchored rename by a package-bound org save is refused (metadata-protocol), with a withdrawn-save control. Ablation: the two edited sources set back to their round-5 blobs and rebuilt, markers proved in dist/: 1 red in each of metadata-protocol, metadata-core and rest; restored to HEAD (git diff HEAD empty), rebuilt, markers proved absent.
  • Round 5 at d8657b5c19: metadata-core 18 files, 411 passed; metadata-protocol 216 files (3 skipped), 27938 passed, 19 skipped; rest 260 files, 4911 passed, 326 skipped; objectql 374 files, 7464 passed. Typecheck green for metadata-core, metadata-protocol, rest and objectql, test layers included. 97 of 97 derived gates green, reconciled with dispatch-gates --ran. New pins: two packages' drafts of one view in one organization are each judged on their own publish; one package's withdrawal of a name closes its own form (metadata-core and both doors; the cross-package half was inverted in round 6). Ablation: removing the package key from the publish gate's draft read turned the two-package pin red, and removing the package comparison turned the cross-package pin red; both restored to HEAD (git diff HEAD empty).
  • Round 4 at 79b847042d (targeted): metadata-core anonymous-form-intake.test.ts 39/39, metadata-protocol protocol.org-scoped-write-refused.test.ts 41/41, rest public-form-withdrawal + public-form-intake-availability 43/43. Typecheck green for metadata-core and metadata-protocol. Docs and changeset gates green. New pins: a package parsed false is a withdrawal; an env-wide save opens a package-closed form.
  • @objectstack/metadata-core: 18 files, 394 passed.
  • @objectstack/rest: 260 files, 4906 passed, 326 skipped.
  • @objectstack/metadata-protocol: 214 files (3 skipped), 27752 passed, 19 skipped.
  • Typecheck green for all three and dogfood.
  • Dogfood (real showcase boot): the layered-withdrawal suite 5/5 (including the re-save refusal) and the five sibling public-form suites 20/20.
  • Coverage: two views sharing a slug do not close each other (one read, with and without an organization, and across layers); a cleared link is not a withdrawal; a parsed link-less sharing is not a withdrawal; re-saving an already-open overlay is refused; a container-shaped save is judged after expansion.
  • Ablation (source set back to the base blobs, packages rebuilt): 3 / 4 / 4 tests red across metadata-core / rest / metadata-protocol; restored to HEAD.
  • Gates: 92 of 95 derived run green; check:skill-examples, check:dual-build-cjs-loads and check:type-check-debt are NOT MEASURED locally (workspace-wide prerequisites) and left to CI.

Acceptance notes


Generated by Claude Code

@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling labels Oct 5, 2026
@github-actions

github-actions Bot commented Oct 5, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

20 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 9e33ee7c5936e35a38158a7f9fdbcbd4445797a8.

⛔ 6 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/rest/src/rest-server.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 — 25 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 9e33ee7c5936e35a38158a7f9fdbcbd4445797a8 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 9e33ee7c5936e35a38158a7f9fdbcbd4445797a8

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

claude added 3 commits October 5, 2026 12:11
…d only when explicit

A withdrawal is judged by view identity (name + slot) across layers: other
views sharing a slug never close each other. Only a sharing that keeps the
link and clears a switch withdraws; a linkless sharing (raw or schema-parsed)
does not. The org-scoped write refusal judges the doors' verdict for every
form the save leaves open, over the container's list-read expansion.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6
…withdrawal is refused

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: e8778acb960387682072d73d0a235bda16bb2261
Local-runs: none

Inputs read: card #21835 (body and all six comments: triage, claim, os-dev-report round 1, claim correction, the two refining rulings, os-dev-report round 2), PR #21864 (body, 10-file list, net diff against main), and the check-runs on this head.

① Derived judgments

Accept-set changes:

  • Anonymous doors (GET /forms/:slug, POST /forms/:slug/submit), narrowed — right. resolveFormBySlug now also reads the env-wide view layer when an organization is resolved. A candidate is skipped when that layer's body of the same view name, same slot explicitly withdraws the slug. This matches the kill-switch ruling and both refining rulings: 「只关同一个表单」 is enforced by the name and slot match in anonymousFormIntakeWithdrawnIn, and 「不算,写进文档」 by sharingWithdrawsSlug, which needs the same slug kept in publicLink. The reverse direction (withdrawn in the organization, open env-wide) is closed by the organization read itself, because that read prefers the overlay. It is pinned by a test. With no organization, the single env-wide read is the only layer, so nothing changes there.
  • Withdrawn-form predicate — right, with one note. "Withdraws" means the same slug is kept in publicLink and the form is not open (enabled !== true or allowAnonymous !== true). A body that keeps the link but leaves a switch absent therefore counts as a withdrawal. That matches the spec's false defaults and the doors' own open rule (raw and parsed bodies agree), and it can only narrow intake, so it stays inside the ruling. A sharing with no link, a cleared or changed link, a removed sharing block, or an absent view body withdraws nothing. Each case is pinned in the metadata-core tests.
  • Removed behaviour, intentional reversal — right. The deleted rest test (an organization overlay re-publishing a package-withdrawn form is honoured) is replaced by its inverse, as the ruling requires. That behaviour arrived with fix(rest): withdrawing a public form takes effect on every anonymous intake door #21420 (49524f6906), which is not an ancestor of the 17.6.0 commit 617f25f8a. The reversal therefore narrows no released behaviour.
  • Write door, narrowed — right. An org-scoped view save in the organization the doors read used to return null there. It now goes through anonymousFormIntakeReopenRefusal → 403 NOT_OVERRIDABLE when a form the save would leave open is explicitly withdrawn env-wide, judged by the doors' own predicate after container expansion. Saves that keep the form withdrawn, or open nothing the env-wide layer withdrew, are unchanged and pinned as controls. The judgement for saves in organizations the doors do not read is untouched.
  • Read cost — right. The extra env-wide view read happens on each anonymous resolution, and only when an organization is resolved. The updated test in public-form-intake-availability.test.ts pins that call sequence (['view','view']), and no object read is added.

Public-surface changes (package exports maps):

  • @objectstack/metadata-core ".": +1 export, anonymousFormIntakeWithdrawnIn (re-exported through src/index.ts → export * from './anonymous-form-intake.js'). Additive. No signature of an existing export changes. AnonymousFormIntakeCandidate is unchanged. The new slot helpers are module-private. Right. The claim correction (comment 5993499550) speaks of "two public exports". That is stale: round 2 removed the unreleased anonymousFormWithdrawnSlugs, and the diff carries one export only.
  • @objectstack/metadata-protocol: no export change. The new method is private, and packageId? is added to a private method's argument bag. Right.
  • @objectstack/rest: no export change. The only change is the internal shape of findPublicFormView. Right.

② Semver level

  • The changeset is .changeset/public-form-withdrawal-kill-switch.md: @objectstack/metadata-core: minor, @objectstack/rest: patch, @objectstack/metadata-protocol: patch. These match the diff: one additive export (minor) and two packages whose fix narrows only unreleased behaviour (patch). No major is warranted.
  • Clause-②: yes (widening) appears in both the PR body and the changeset. It is well formed, and yes is matched by a minor on the widened package. This supersedes the claim's original Clause-②: no, as the PM's correction records. No (narrowing) arm is owed, because the reversed behaviour never shipped. ADR-0087 disposition: not owed (not breaking). Check Changeset is green on this head.
  • Non-blocking wording note: the changeset's "Before this release, an organization overlay … was honoured" could be read as released behaviour. It only ever existed between 17.6.0 and this fix. This does not change the level.

③ Boundary flags

  • open_questions[0] (rollback / commit-revert ungated at write time). Answered: option A for this PR. The rulings govern what the anonymous doors serve, and the doors keep such a form closed at read time, so there is no exposure. The remaining cost is an accepted write that is never honoured, which the changeset states. Option B changes the restore path's write ordering, which is beyond this card's surface. It remains a possible follow-up and is not a condition of this PR.
  • Round-1 flag (claim file surface omits packages/metadata-core/src). Answered by the PM claim correction, which adds it.
  • Round-2 flag (PR body describes the earlier round). Answered. The PR body at this head describes the round-2 design: same-form identity, explicit-only, the re-save refusal, one export.
  • Out-of-scope findings. (a) The check:type-check-debt re-measure rewrites dist outside the verify lock (a tooling race). (b) A cross-app slug collision. The maintainer ruled (b) a separate concern. Both are recorded with carrier none, not filed. Neither blocks this PR. Filing them is the PM seat's call.
  • Gates. On this head, 21 check-runs are success, 4 are skipped, and none has failed. 14 were still in_progress when this record was written (Test Core 1–6/6, Dogfood Regression Gate 1–3/3, Dogfood Verify CLI, Temporal Conformance, Lint & Repo Gates, Type Check · workspace, Type Check · consumer gates). They cover the families the dev declared NOT MEASURED locally (check:skill-examples, check:dual-build-cjs-loads, check:type-check-debt). This verdict does not certify those families: landing still requires every check green on this head.

Implemented-by: claude/issue-21835-form-withdrawal-kill-switch
Reviewed-by: session_018zT8d8NpiQ1ExhuNd5TxY6

VERDICT: PASS

claude added 2 commits October 5, 2026 13:29
…r slug, and the write door anchors identity on the stored row

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: d8657b5c19fd4fd2443953470adb1138f4b1f5df
Local-runs: none

Inputs: card #21835 (body and all 9 comments, rulings 5994082238 and 6005722623, os-dev-reports through round 5, 6007561084), PR #21864 (body, 11 files, net diff against main at this head), and the 43 check-runs on this head. No check-run has failed. 17 are still pending (listed at the end), so this record does not rest on them.

① Derived judgments

  • Anonymous doors (rest-server.ts findPublicFormView): RIGHT. The accept-set narrows. When an organization is resolved, the doors also read the env-wide view list, and a candidate is skipped when anonymousFormIntakeWithdrawnIn finds an explicit withdrawal of the same name, matched by slot or by slug. This matches the kill-switch ruling, the "same form only" ruling and the explicit-only ruling (5994082238). Name-anchoring plus the documented known limit is what ruling 6005722623 chose. With no organization, the single read is unchanged. The test change from ['view'] to ['view','view'] is the expected second read.

  • Shared judgement (metadata-core anonymousFormIntakeWithdrawnIn, anonymousFormExplicitWithdrawals): RIGHT. It counts only an explicit === false on a sharing that keeps its link. An absent switch, a link-less sharing and a cleared link withdraw nothing, as ruled. The package comparison skips a body only when both sides are bound to different packages. A package-less body is still compared, which fails closed and is consistent with ADR-0048's stand-in reading.

  • Publish gate draft key (promoteDraftForPublish): RIGHT. The draft is now judged and promoted under one package key: the stated one, or the resolved draft row's own. This removes the case where the judged draft and the promoted draft could differ. It costs one extra findOne on a publish that states no package (declared). The objectql test double change is test-only.

  • Write door, row anchor (metadata-protocol envWideRawViewRows): WRONG. This is what makes renamed keys, form.name, slot moves and collision renames "the same form" at save and publish. Ruling 6005722623 kept the doors name-anchored because, as it states, the write door "already refuses every new escape". Round 5 added a package comparison to the shared judgement, and the overlay body now carries _packageId. But this lookup is still not package-scoped:

    • lookupArtifactItem(type, name) is called without the saved row's package. The registry then returns the first package's artifact of that name in map order.
    • The stored-row branch returns every env-wide row of the name, whatever its package. It also drops the artifact fallback whenever any package has a row.
    • So when more than one package carries a view of that name, the anchor can pick only another package's body. The new package comparison then skips it, and the anchor judges nothing.

    Round 5 brought this in: before it, every body was compared and the anchor failed closed. Because of it, the PR body's and changeset's claim that the row anchor holds is false for a package-bound save in that layout.

    • Required: resolve the env-wide row for the saved row's own package, with the package-less row standing in. That means passing args.packageId to lookupArtifactItem, and falling back to the artifact per package rather than per name.
    • Required: add a pin with two packages that ship the same view name, where one package's row-anchored rename is refused.
  • Write door, list judgement and the message: RIGHT. The expanded body is judged against the env-wide list the doors read. 403 NOT_OVERRIDABLE with a userMessage follows the existing refusal's shape. Saves that keep the form withdrawn are still accepted.

  • Public surface (the exports maps): the @objectstack/metadata-core . entry re-exports anonymous-form-intake.js wholesale. It gains exactly one symbol, anonymousFormIntakeWithdrawnIn, and the helpers stay module-private: RIGHT. The metadata-protocol additions (anonymousFormIntakeReopenRefusal, envWideRawViewRows, the packageId? argument) are all private: no public-surface change, RIGHT. @objectstack/rest exports nothing new: RIGHT.

② Semver level

  • .changeset/public-form-withdrawal-kill-switch.md sets @objectstack/metadata-core: minor, @objectstack/metadata-protocol: patch, @objectstack/rest: patch. That matches what the diff publishes: one additive export in metadata-core, and in the other two a narrowing that fixes a security regression with no surface change. No package is missing. objectql is test-only and dogfood is private, so neither needs an entry. Not major: nothing is removed or retyped. Check Changeset succeeded on 2 of its runs; its third run is pending.
  • Clause-②: yes (widening) is consistent across the changeset, the PR body (line 3) and the claim correction 5993499550. It is right for the one new export.
  • The behaviour-change bullet (the org overlay re-opening, never shipped in a release, reversed on purpose) is stated. It matches the card's regression note (after 17.6.0, fix(rest): withdrawing a public form takes effect on every anonymous intake door #21420).

③ Boundary flags

  • Round-5 open question 1 (fix 3, artifacts loaded without the schema parse, option A): consistent with the rulings, no escalation. Ruling 6005722623 makes a schema-parsed false on a package artifact an explicit withdrawal (「包内的 false 算显式关闭」). Its premise was that parsing makes explicit and absent indistinguishable. A body that was never parsed has no false to count. For such a body the explicit-only ruling 5994082238 governs: an absent switch is not a withdrawal. That is the same rule DB rows get. The default path (strict defineStack) still fails closed. The docs tell authors to set enabled: false explicitly to ship closed. The seat accepting A matches what both rulings say. Option B stays an optional follow-up and is not owed by this card.
  • Round-5 open question 2 (PR-body edits): applied. The PR body carries edits 1 to 4 verbatim.
  • Round-3 open questions (door identity, package parsed false, package as a layer): answered by ruling 6005722623 and implemented. The door-identity answer's premise is the row-anchor defect in ①, which must be fixed, not re-ruled.
  • Round-2 open question (rollback and revert ungated): covered by the known limit in 6005722623. It is stated in the changeset and the docs.
  • Round-1 flag (metadata-core missing from the claim's file surface): answered by claim correction 5993499550.
  • Unanswered: the round-5 diff edits packages/objectql/src/protocol-publish-package-drafts.test.ts. That is outside the corrected file surface (rest, metadata-protocol, metadata-core, dogfood, .changeset/). The report discloses it, but no claim correction adds it. Required: the seat posts a claim correction that adds the path, test-only.
  • Out-of-scope findings (an unlocked dist rewrite by type-check-debt, slug collision across apps, the extra findOne): each was noted with no carrier. None blocks this PR.

Implemented-by: claude/issue-21835-form-withdrawal-kill-switch
Reviewed-by: session_018zT8d8NpiQ1ExhuNd5TxY6

VERDICT: FAIL

FAIL reasons:

  1. The write door's row anchor (envWideRawViewRows) is not package-scoped, so round 5's package comparison can turn it into a no-op. A pin for that layout is also needed.
  2. The objectql test path is outside the claim's file surface. A claim correction is owed.

Pending check-runs at this head (not waited on): Check Changeset (1 of 3 runs) · Test Core 1/6 to 6/6 · Dogfood Regression Gate 1/3 to 3/3 · Dogfood Verify CLI · Temporal Conformance (live PG + MySQL) · Build Core · Type Check · workspace · Type Check · consumer gates · Type Check · debt ledger · Lint & Repo Gates.

claude added 3 commits October 6, 2026 02:09
…ckage again

The cross-package skip in the withdrawal judge is removed, with the package
stamping in the org-scoped write door that only fed it. The write door's row
anchor reads the env-wide row of a name without a package key, so the skip
could leave it judging nothing when several packages ship the same view
name. Without it, a withdrawal of a name closes that name's form in every
package: it may over-close another package's form of the name, never
under-close. The pins assert that, and that a row-anchored rename by a
package-bound org save is refused when two packages ship the name.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6
…imit)

The package rule is replaced by the known limit: a withdrawal of a view
name closes that name's form in every package, which may over-close but
never under-closes. Per-package precision is tracked separately.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6
…self-test reads its mkdtemp base

Main is red on the dispatch-gates self-test: the dogfood per-file cwd
setup takes its mkdtempSync base from a value the scratch-dir scan cannot
read. These three files are ported unchanged from the open fix branch
(refs/pull/21935/head at 2edc5d5) so this branch's gates read green;
they merge away when that fix lands on main.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 7882eef683e0d415c065f50049f705280a5771d6
Local-runs: none

Inputs: card #21835 (body and all 12 comments: rulings 5994082238 and 6005722623, claim corrections 5993499550 and 6007635663, os-dev-reports through round 6, 6008895615), PR #21864 (body, 14 files, net diff against main at this head, and the earlier record 6007621979, FAIL at d8657b5c19), and the 39 check-runs on this head. Round 6 answers the maintainer ruling recorded after that FAIL, verbatim 「撤掉跨包那一改,合并」 (remove the cross-package change, then merge). Diffs read: d8657b5c19..7882eef683, 46d08189a7..7882eef683 (round 6 alone), origin/main...7882eef683, and 79b847042d (pre-round-5) for the byte comparisons.

① Derived judgments

  • Cross-package skip removed (metadata-core anonymousFormIntakeWithdrawnIn): RIGHT. 4d5f6c4e61 removes the anonymousFormPackageOf helper and the package comparison in the loop. _packageId no longer appears in the module. A withdrawal of a view name again closes every same-name body, whatever its package. That fails closed, which is what the ruling asks for. Only the JSDoc differs from 79b847042d: it now states the known limit and keeps round 5's wording on schema-parsed artifacts.
  • Write-door feeders removed (metadata-protocol anonymousFormIntakeReopenRefusal): RIGHT. The bound package stamp is gone from the judged item and from the row-anchor body. I extracted the function at 79b847042d and at this head, and the two are byte-equal (56 lines, cmp clean). envWideRawViewRows is also byte-equal to 79b847042d.
  • Round-5 publish-gate key fix kept (promoteDraftForPublish): RIGHT. The draft is still read and promoted under one key, draftKey. That key is the stated binding or, when none is stated, the resolved draft row's own package_id, so the body judged is the body promoted. The two-package draft pins and the objectql test double (test-only, added to the claim by 6007635663) are kept.
  • Anonymous doors (rest-server.ts): RIGHT, unchanged this round. When an organization is resolved, the env-wide list is read as well, and the shared judgement is applied name-anchored, as ruling 6005722623 chose. The net diff in rest-server.ts is only the doors' change. The comment hunk seen in 79b847042d..head comes from main.
  • Pins inverted: RIGHT.
    • metadata-core: another package's withdrawal of the name now closes this package's form (true), with a control where another package's open body closes nothing.
    • rest: the cross-package door case now expects closed, and a new control serves the form (200/201) when every package's body is open.
    • metadata-protocol: a new single: two packages ship the same view name pin. A package-bound org save that renames the key is refused (403 NOT_OVERRIDABLE) and nothing is saved. A control that keeps the form withdrawn saves.
    • The report's ablation turned each of the three red against the round-5 blobs.
  • Row anchor input across packages: RIGHT per the ruling, with a residue I escalate (see ③). envWideRawViewRows compares every env-wide stored row of the name. With no stored row, it falls back to lookupArtifactItem(type, name) with no package, and the registry answers that with its first composite match. So when several packages ship the same view name and no env-wide row is stored, the anchor judges one package's artifact, chosen by registry order. The new protocol pin's stub returns the withdrawing package's artifact for the package-less lookup, so it pins the favourable order only. This input selection is byte-equal to 79b847042d, and the earlier record had already described it to the ruling. It is not introduced by this round.
  • Public surface: RIGHT. @objectstack/metadata-core gains exactly one export against main, anonymousFormIntakeWithdrawnIn. The helpers stay module-private. The protocol additions are private, and @objectstack/rest exports nothing new.
  • Dogfood port: RIGHT. per-file-cwd.setup.ts, per-file-cwd.global-setup.ts and vitest.config.ts are blob-identical to PR test(dogfood): each file's temporary cwd is created from a base the scratch-dir scan can read #21935's head 2edc5d59d4 (dc1d3de3d4, 284a6f6c9e, 841365bfeb). The port is the same one as on fix(trigger-record-change)!: a record-change flow's trigger record carries the credential mask and omits internal fields #21928 (6008303479). test(dogfood): each file's temporary cwd is created from a base the scratch-dir scan can read #21935 has since merged, and current origin/main (412d7dd024) carries the same three blobs, so against current main the port adds nothing.

② Semver level

  • .changeset/public-form-withdrawal-kill-switch.md sets @objectstack/metadata-core: minor, @objectstack/metadata-protocol: patch and @objectstack/rest: patch. That matches what the diff publishes:
    • metadata-core gains one additive export.
    • the other two narrow behaviour to fix a security regression, with no surface change.
    • objectql (test-only) and the private dogfood package need no entry.
    • nothing is removed or retyped, so not major.
  • Both Check Changeset runs succeeded on this head.
  • Clause-②: yes (widening) appears in the changeset, in PR body line 3 and in claim correction 5993499550. It is right for the one new export.
  • The changeset and the docs page state the over-close known limit ("a withdrawal of a view name closes that name in every package … may over-close") and point to security(metadata): tighten the draft publish gate and package identity for org view overlays (follow-up to #21864) #21934. The changeset keeps the one-package-key publish sentence. No wording of the removed per-package rule is left in either file.

③ Boundary flags

  • Round-6 open question (PR-body edits 1 to 5): applied. The body carries the known-limit bullet, the round-6 Tests bullet and lead sentence, the edited round-5 bullet, and both acceptance notes, as written.
  • Previous FAIL reason 1 (row anchor not package-scoped): answered by the maintainer ruling, not by the required fix. The cross-package change is removed, so the shared judgement no longer skips another package's body.
  • Previous FAIL reason 2 (objectql path outside the claim): answered by claim correction 6007635663.
  • Escalated, not blocking under the ruling: the "never under-close" sentence. The changeset says "never under-close", and the docs say "it never leaves a withdrawn form open". That holds for the shared judgement given the bodies it is handed. It does not hold for the write door's row-anchor input in the layout above (several packages ship one view name, with no env-wide stored row). There the anchor sees one package's artifact by registry order, and a stored row of any package masks every artifact.
  • Earlier flags: round-5 fix 3 (unparsed artifacts judged as written), the round-3 questions, rollback and revert, and the round-1 file surface stay answered as the earlier record found. None reopens.
  • Out-of-scope findings:

Implemented-by: claude/issue-21835-form-withdrawal-kill-switch
Reviewed-by: session_018zT8d8NpiQ1ExhuNd5TxY6

VERDICT: PASS

Check-runs at this head: 22 success, 4 skipped, 0 failed. 13 are pending and were not waited on: Test Core 1/6 to 6/6, Dogfood Regression Gate 1/3 to 3/3, Lint & Repo Gates, Temporal Conformance (live PG + MySQL), Type Check · workspace and Type Check · consumer gates. Landing still needs every check green.

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 6, 2026 04:13
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 6, 2026 04:13
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 6, 2026
Merged via the queue into main with commit 3c7785d Oct 6, 2026
47 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-21835-form-withdrawal-kill-switch branch October 6, 2026 04:44
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…ctory (objectstack-ai#21919)

Fixes objectstack-ai#21914
Clause-②: no

## What changes

Every dogfood test file now runs in its own temporary working directory.
The suite fails when any file leaves `.objectstack/data` in
`packages/qa/dogfood`.

- **`test/per-file-cwd.setup.ts`** (new) is a `setupFiles` entry, wired
explicitly in BOTH projects of `vitest.config.ts`, because inline
projects inherit nothing from the root block. `shared-showcase` keeps
`isolate: false`; the module still runs once per file there.
- At module top level, before the test file's own imports, it creates a
directory under the run's temporary root and `chdir`s into it.
- In `afterAll` it restores the previous cwd. That `afterAll` is also
**the guard**: it THROWS when `packages/qa/dogfood/.objectstack/data`
exists. The message names the directory, its entries and the remedy. It
also says the named file may be a concurrent one on another worker
rather than the writer, and whether the directory was already present
when the file started.
- **`test/per-file-cwd.global-setup.ts`** (new) is a root-level
`globalSetup`. It runs once per run, covering both projects and each
`OS_TEST_SHARD` slice (measured).
- At the START it clears a stale `packages/qa/dogfood/.objectstack`, so
a developer's earlier run never reds the suite.
- It creates one temporary root for the run and hands it to the workers
with `provide` / `inject`.
- At the END it removes that root, which is **where the per-file
directories are removed**. The removal is run-level, not per file,
because the memoized `shared-showcase` boot keeps its SQLite handles
open in the directory of the file that booted it.
- The teardown judges nothing (see Evidence: a throwing teardown is a
false green).
- **`vitest.config.ts`** wires the two modules. A header section
explains why there are two halves and why the guard is not in the
teardown.
- **`test/enterprise-organizations.ts`**: the module-level
`probeOrganizations()` now passes this package's root as `hostRoot`,
resolved from the module's location (`new URL('..', import.meta.url)`),
not the cwd. This was measured to be needed; see Evidence.

No per-file edits. The five files the card names, and the other 87
measured writers, are covered by the module with no change of their own.
Test isolation only: `@objectstack/dogfood` is `private: true`, so no
published package moves and there is no changeset (`skip-changeset`).

## The invariant for every dogfood author

- **Each test file runs in its own temporary cwd.** Anything it writes
relative to the cwd is its own, no other file sees it, and it is removed
at the end of the run. A file needs no `mkdtemp` / `chdir` of its own.
- **A package-relative read must resolve from the module's location**
(`new URL('..', import.meta.url)`, `import.meta.dirname`), never from
`process.cwd()`. The cwd is a temporary directory.
- **A file that writes into `packages/qa/dogfood/.objectstack/data`
fails the run.** That happens through an absolute path built from the
package root, or through a `process.chdir()` back to the package
directory before a boot. The fix is to write relative to the file's own
cwd.
- Files that already `chdir` into a temp dir of their own still work,
because they restore to the per-file directory. Their own `chdir` is now
redundant and harmless.

## Why (measured)

A per-file probe over the whole suite measured 92 test files leaving
`.objectstack/data/showcase_external.db` in the package directory, not
the five the card names:

- 7 leave the populated federated fixture (24576 B, 2 tables): the
card's five, plus `showcase-demo-personas-loginable` and
`showcase-demo-personas-membership`, which pass `onEnable` in the
bundle.
- 85 leave an empty SQLite file (4096 B, 0 tables). The showcase's
declared external datasource has a cwd-relative filename, and its
auto-connect creates the file on every showcase boot, `onEnable` or not.

A later boot on the same runner found or missed the federated tables
depending on which files ran before it, and that ordering is how PR
objectstack-ai#21905 went red only on dogfood shard 3/3. The seat chose this route
(one module) and this guard (comment `6004950414` on objectstack-ai#21914), on the
dev's measurement (comment `6004909676`).

## Evidence

All runs are at head `967ce88a`, under the shared verify lock, from a
clean package directory.

- **Whole suite**: `pnpm --filter @objectstack/dogfood test` gave `Test
Files 205 passed | 1 skipped (206)` and `Tests 1591 passed | 9 skipped
(1600)`. Afterwards `packages/qa/dogfood/.objectstack` does not exist,
and no `os-dogfood-run-*` root is left in the temp dir.
- **CI's three-shard split**: CI's dogfood leg exports
`OS_TEST_SHARD=k/3` and `vitest.config.ts` turns it into vitest's
`shard`. Here each shard ran as `OS_TEST_SHARD=k/3 pnpm --filter
@objectstack/dogfood test`: the same vitest selection, without turbo, so
no cached replay. Each exited 0 and left no `.objectstack`:

  | shard | Test Files | Tests |
  |---|---|---|
  | 1/3 | 69 passed (69) | 507 passed (507) |
  | 2/3 | 69 passed (69) | 461 passed, 1 skipped (462) |
  | 3/3 | 67 passed, 1 skipped (68) | 623 passed, 8 skipped (631) |

  The three add up to the whole run: 206 files, 1600 tests.
- **Ablation (H4)** through `scripts/ablation-replace.mjs`, wrap mode.
The central `process.chdir(...)` was replaced by the bare
`mkdtempSync(...)`: anchor count 1 to 0, blob `0991eb9c` to `ee5a65be`.
- With the chdir dropped, `showcase-external-autoconnect` and
`showcase-search` ran: `Test Files 2 failed (2)`, `Tests 8 passed (8)`,
exit 1. Each failed in the guard:
`.../packages/qa/dogfood/.objectstack/data exists after this test file
ran. Entries: showcase_external.db` (plus `-shm` / `-wal` for the
shared-showcase file).
- Restore was proven by the tool: blob after restore equals HEAD
(`0991eb9c`), and `git diff HEAD` is empty.
  - The same two files then gave `2 passed`, exit 0, and left nothing.
- No build step is involved: vitest loads the mutated module from
source.
- **Stale directory**: `.objectstack/data/x.db` was planted, then 9
files were run. Result: `Test Files 9 passed (9)`, exit 0, nothing left
(the globalSetup cleared it).
- **Census**: those 9 files are the 7 populated-fixture writers plus
`showcase-search` and `showcase-permission-zoo`, both `shared-showcase`
files.
- **`hostRoot` line, measured both ways**, running `rls-multitenant`,
`org-create-default-team` and `enterprise-organizations.test`:
- Without the line (commit `4d07dc29`), the skip text read `not
resolvable from /tmp/os-dogfood-run-.../file-...` and told the reader to
declare the package in that temp directory's `package.json`.
  - With it (`967ce88a`), the text names `packages/qa/dogfood/`.
- The verdict is the same both ways (skipped), because no framework
package declares `@objectstack/organizations`.
- **Guard placement**: a throwing `globalSetup` teardown was measured on
vitest 4.1.11 to print `error during close` and still exit 0, a false
green. So the guard is the per-file `afterAll`. (A teardown that sets
`process.exitCode = 1` does exit 1, but the summary still reads
all-passed.)
- **Typecheck and lint**: `pnpm --filter @objectstack/dogfood typecheck`
is green, and `tsc --listFiles` includes both new modules and
`enterprise-organizations.ts`. `pnpm lint` exits 0.
- **Gates**: 130 commands at `967ce88a`, the dispatch list plus `pnpm
check:dispatcher-error-vocabulary` from `dispatch-gates --commands`.
`dispatch-gates --ran`: `48 derived famil(ies) accounted for — 48 run, 0
NOT-MEASURED`.
- `check:dual-build-cjs-loads` and `check:published-readme-exports`
first exited 3 (dist prerequisite: 7 packages unbuilt). After building
those 7, both exit 0.
- The three PR-context scripts (`check-closing-target-claim`,
`check-partof-closing-keyword`, `check-single-claim-paths`) are re-run
with this PR's context; the results are in the report on the card.

## Open PRs that add dogfood files

| PR | new file | boots the showcase | own `chdir` | under this PR |
|---|---|---|---|---|
| objectstack-ai#21864 | `showcase-public-form-withdrawal-layers.dogfood.test.ts` |
yes | no | Covered with no author action. Without this PR it would leave
`.objectstack/data` in the package directory. |
| objectstack-ai#21917 | `organization-delete-federated-fixture.dogfood.test.ts` |
yes, with `onEnable` | yes | Unaffected; its own `chdir` is redundant. |
| objectstack-ai#21906 | `external-import-code-datasource-namespace.dogfood.test.ts`
(also edits three `external-*` files) | yes, with `onEnable` | yes |
Unaffected. None of its files is edited here. |
| objectstack-ai#21877 | `datasource-contractless-credentials.dogfood.test.ts` | yes |
yes | Unaffected. |
| objectstack-ai#21897 | `flow-node-config-values-at-registration.dogfood.test.ts` |
no (fixture stack) | no | Runs in its own temp cwd; it reads nothing
relative to the cwd. |

None of these files reads a package-relative path through
`process.cwd()`. Only their own `prevCwd` captures do.

## Acceptance notes

- **Observation, not filed.** The showcase's external datasource is
declared read-only (`schemaMode: 'external'`, `allowWrites: false`). Its
auto-connect CREATES a missing `.objectstack/data/showcase_external.db`,
plus `-wal` / `-shm` (measured on 85 harness boots).
- The declaration's own comment in `showcase-external.datasource.ts`
says that if the fixture file cannot be opened, "the boot stops with
that as the reason rather than serving a showcase whose federation pages
are quietly dead".
- It was measured only through the verify harness's `bootStack`, never
at a public door (`os start` / `os dev`), so it stays here.
- **Latent, unreachable today.** `bootStack(..., { multiTenant: true })`
also defaults its `hostRoot` to the cwd:
`rls-multitenant.dogfood.test.ts:79`, and
`attachments-permission-matrix.dogfood.test.ts:766` through
`bootFixture`. Both are gated on `organizationsAvailable`, which is
false in this repository because no framework package may declare
`@objectstack/organizations` (ADR-0132). A run that declares it in this
package would need those boots to pass the package root too. Carrier:
whoever declares it.
- The own `chdir` in `external-validate-sees-runtime-save`,
`external-import-destructive-remedy`, PR objectstack-ai#21906's file and PR objectstack-ai#21917's
file is now redundant. It is left untouched and can be removed once
objectstack-ai#21906 lands. Carrier: the `domain:cli` seat.
- Attribution limit: under parallel workers, the guard can name a file
that ran at the same time as the writer. The message says so, and says
whether the directory was already present when the named file started.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01RWZbGvPFcRKvUqASZtunCU)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…jectstack-ai#21943)

Part of objectstack-ai#21932

Clause-②: no

## What changes

The platform checklist gains items for the rules the 17.7 pre-release
security follow-up landed, and two re-checks from the card are resolved.
All edits are in `docs/qa/platform-checklist/areas/*.json`.
`automation.json` is untouched (open PR objectstack-ai#21928 holds it).

| Card row | Disposition | Item |
|---|---|---|
| objectstack-ai#21792 (PR objectstack-ai#21809) settings audit and secret-valued settings | new
item | `platform-core.settings-audit-secret-fingerprint` |
| objectstack-ai#21846 (PR objectstack-ai#21872) implicit account linking | new item |
`identity-auth.implicit-account-linking-ownership` |
| objectstack-ai#21839 (PR objectstack-ai#21890) share-link password | three clauses added, rev 4 to
5 | `access-security.share-link-capability-tokens` |
| objectstack-ai#21836 (PR objectstack-ai#21879) global search skips unreadable objects, plus the
two cases objectstack-ai#21880 lists | new item |
`search.global-search-skips-unreadable` |
| re-check 1: A2 / A7 and the plugin-driver boundary | rev 2 to 3 |
`integration-system.datasource-credential-refusal-matrix` |
| re-check 2: the objectstack-ai#21845 CLI and quorum N1 notes | already applied by
objectstack-ai#21891, no edit | `cli.scaffold-first-run`,
`cli.scaffold-console-first-paint`, `approvals.quorum-m-of-n` |

Each item states rules, not reproductions. Withheld security detail
stays out.

### Grounding, per row

- **Settings audit fingerprint.** Both ledgers record the keyed digest
for a secret-valued setting, or no fingerprint when none is available,
and never the value or an unkeyed hash. Grounded in
`settings-service.ts#secretAuditDigest`,
`config-change-audit.ts#CONFIG_CHANGE_ACTION` and the contract text at
`crypto-provider.ts#keyedDigest`. The pin is
`settings-audit-secret-digest.test.ts` (7 cases). The offline check
carries a positive control: the non-secret key's unkeyed digest IS
found, so a no-hit on the secret rows means something. The
no-keyed-digest arm cannot be reached on a stock boot, so that clause is
scored from the pin.
- **Implicit account linking.** Four rules: no implicit link to an
unverified local user; an unlink is honoured; an explicit, signed-in
link still works and lifts the refusal; the platform IdP exception holds
only on its OAuth path. Grounded in `implicit-account-linking.ts`
(`decideImplicitLink`, `IMPLICIT_LINK_REFUSED`,
`PLATFORM_IDP_PROVIDER_ID`, `recordUnlinkTombstone`,
`refuseImplicitAccountLink`) and the published `sso.mdx` section. The
pin is `implicit-account-linking.test.ts`. The item reuses the local
OIDC provider recipe from `identity-auth.linked-accounts-social`. The
platform-IdP clause and the operator override are pin-scored, and
knownGaps says why.
- **Share-link password.** The stored hash leaves on no exit (mint,
list, redemption). The password is accepted from the `X-Share-Password`
header, the query form is still accepted, and the default CORS
allow-list carries the header. Both public routes answer `Cache-Control:
no-store` and `Vary: X-Share-Password` on every outcome, and the
authenticated routes do not. Grounded in
`share-link-service.ts#withoutPasswordHash`,
`share-link-routes.ts#SHARE_LINK_PUBLIC_RESPONSE_HEADERS`, the runtime
`share-links.ts#PUBLIC_RESPONSE_HEADERS` and
`adapter.ts#DEFAULT_CORS_ALLOW_HEADERS`. The pins are the `[objectstack-ai#21839]`
blocks in `share-link-password.test.ts`,
`share-links-public-cache-headers.test.ts` and the hono-plugin CORS
case. Existing clause indices are unchanged.
- **Global search.** An unreadable object is never queried, named or
counted. An explicit `objects=` naming one answers exactly as a name
that matches no object. The object stays refused at its own door. Row
scope still narrows a searched object, and a term found only in a field
hidden from the caller yields no hit. Grounded in
`protocol.ts#searchAll` (the `canReadObject` pre-filter and the
`getQueryableFields` narrowing). The pins are the dogfood
`search-skip-unreadable.dogfood.test.ts` and the 12 unit cases in
`protocol.search-skip-unreadable.test.ts`. The two objectstack-ai#21880 cases have no
end-to-end pin yet, and knownGaps says so. The open pinyin-companion
finding on objectstack-ai#21880 is recorded as a knownGap with a flag-off instruction,
at class level only. The persona reuses the area recipe
`qa-contributor-bound-member`.
- **Datasource credential matrix.** A2 / A7 (`acceptance[1]` and
`acceptance[6]`) are recorded as a known environment gap. They need a
reachable credential-protected database of a shipped driver, which no
run has had. No recipe is claimed, because none is proven. A successful
publish alone may not score them, and the stored-credential half of A7
can be read as a partial reading. Separately, the unknown-driver clause,
step 7, its negative and the title now state the ruled boundary from
objectstack-ai#21921 and the docs note objectstack-ai#21927. For a plugin driver, only the fixed
spellings are redacted (the canonical keys, the former aliases and URL
credentials). A non-canonical key served as written is the boundary, not
a FAIL. Grounded in `common.zod.ts#CANONICAL_CREDENTIAL_KEYS` and
`datasource-credential-redaction.ts#redactableConfigKeys`.

### Re-check 2 evidence (no edit)

At the claim ref `9dce635337`:

- `cli.scaffold-first-run` (rev 3) step 0 and
`cli.scaffold-console-first-paint` (rev 3) step 0 both drop the trailing
`npm install` and warn against adding it. Their rev 3 history entries
cite objectstack-ai#21845. No other `npm install` step remains in `cli.json`.
- `approvals.quorum-m-of-n` (rev 4) `negative[0]` requires a
NON-PRIVILEGED repeat actor and names the documented admin override
(objectstack-ai#3424) as never a distinctness FAIL.

## Remaining on objectstack-ai#21932 (held, not in this PR)

- The objectstack-ai#21864 row (public-form withdrawal layering). Its PR is still
open.
- The objectstack-ai#21928 row (run-state trigger record mask). That PR adds its own
item in `automation.json`.

objectstack-ai#21932 remains open for these two rows.

## Validation (at `a72b827e43`)

- `pnpm check:platform-checklist`: exit 0. It reports 15 areas and 273
items (269 active, 2 planned). The baseline was 270. Symbol anchors
resolve 674 of 684 (baseline 657 of 667): all 17 new anchors resolve,
and the objectstack-ai#16898 residual is unchanged at 10.
- `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` derived 13 commands, and all 13 exit 0.
`check:doc-formula-expressions` first exited 3 (PREREQUISITE NOT MET:
`@objectstack/formula` and `@objectstack/lint` were not built). After
building them it exited 0. `--ran` reconciliation: 13 derived, 13 run, 0
unrun.
- No package source changed, so there is no package build, test or
typecheck. No changeset: `docs/qa/**` publishes nothing.

## Acceptance notes

- Source citations name test cases and symbols, never line numbers,
because `check:platform-checklist` refuses a `file:line` pin.
- `content/docs/data-modeling/drivers.mdx` says a plugin driver's
`config` is "stored and served to administrators as written". The read
redactor still withholds the canonical spellings (`password`,
`authToken`), the former aliases and URL credentials for such a driver
(`redactableConfigKeys`). So the docs sentence is slightly broader than
the code, and the code is the more protective of the two. The checklist
follows the code. This is noted only, with no card. Carrier: none.
- A run of `search.global-search-skips-unreadable` picks the walled
object and the hidden-field value on the live boot, behind premise
guards. The item names likely candidates and does not assume them.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01VDtqoecgES7ScQYGbFVDRv)_

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…es carry the explicit system opt-in (objectstack-ai#21938)

Fixes objectstack-ai#21911
Clause-②: no
- Each producer takes the explicit system opt-in that exists today. No
gate before the hand-off fires on any of them, so nothing accepted or
refused changes today.

This is a slice of objectstack-ai#21908: the engine-lane producers of the
principal-less hand-off. objectstack-ai#21908 stays open, and it builds the deny
itself once every producer has a route.

## What changed

Every engine call in the card's functions now passes `context: {
isSystem: true }`. Inside a `SysMetadataRepository` transaction it
passes `{ ...ctx, isSystem: true }`, so the transaction handle still
rides along. This is the opt-in that already exists. There is no new
API, no export change, and no change to what any door authorizes.

| Row | Package | Function (engine calls moved) |
|:--|:--|:--|
| 1 | metadata-protocol | `findServedOverlayRow` (1 `findOne`) |
| 2 | metadata-protocol | `overlayLockLayerAt` (1 `find`, in its store
reader) |
| 3 | metadata-protocol | `readActiveOverlayRows` / `queryByOrg` (2
`find`), `readFlattenedMetaItems` (2 `find`, draft preview) |
| 4 | metadata-protocol | `foldStoredCollection` (1 `find`). These are
the only reads `assertRuntimeAuthoringRules` issues. |
| 5 | metadata-protocol | `SysMetadataRepository`: `get` 1, `put` 6,
`delete` 3, `promoteDraft` 1, `restoreVersion` 2, `listDrafts` 1,
`nextItemVersion` 1, `nextEventSeq` 1 |
| 6 | metadata-protocol | `recordMetadataAudit` (insert),
`persistPackageCommitRow` (insert), `publishPackageDrafts`,
`resolveOverlayPackageBinding`, `storedFlowBindingAgrees`,
`deletePackage`, `duplicatePackage` (1 read each),
`reassignOrphanedMetadata` (`find` + `update`) |
| 9 | objectql | `ObjectQLPlugin.readAuthoredActionRows` (3 `find`),
`readAuthoredHookRows` (2 `find`) |
| 10 | core | `readAuthoredTranslationLayer` (2 `find`) |

H1 holds: every row sits where the card says on `cab63967`. Every engine
call in each named function was enumerated with the TypeScript AST, not
only the first one: 32 in metadata-protocol, 5 in objectql and 2 in
core. Each now carries the opt-in.

## The six gates: none fires on these calls (Zone 1)

A system context skips the six gates the middleware still runs before
the hand-off's `next()`. Each one, on the `sys_metadata` family
(`sys_metadata`, `_history`, `_audit`, `_commit`):

- **package-managed**: acts only on `sys_permission_set`.
- **system-row**: acts only on `sys_position` and `sys_capability`.
- **curated-capability**: acts only on `sys_capability`.
- **audience-anchor**: acts only on `sys_position_permission_set`.
- **engine-owned**: the bucket matches (the family is `engine-owned` /
`append-only`), but `isUserContextWrite` needs a `userId`. A context
with no principal passes it by construction, exactly as a system one
does.
- **delegated-administration**: acts only on the RBAC link tables and
`sys_member`.

**Measured.** A local, uncommitted instrument sat at plugin-security's
engine middleware. It wrapped each of the six gates, so a throw was
recorded per gate and per operation. It also recorded the outcome after
the hand-off. After the change it dry-ran the six gates for every moved
`isSystem` call, with the flag cleared. The run covered the dogfood
suite and a booted showcase dev composition.

- Before: 0 gate throws on any of 35,248 (dogfood) + 388 (boot)
principal-less operations, from any producer. 0 downstream failures on
the card's functions.
- After: 0 gates would fire on any moved call.

The instrument was reverted, and `security-plugin.ts` equals its HEAD
blob (`5b4ab280`). plugin-security's `dist/` was rebuilt clean, and
`ablation-dist-preflight --absent` passed.

## Before and after, per function (H2)

Principal-less, non-system operations credited to each function. A
function is credited when it is the first frame past the engine, its
closures and the repository transaction wrapper.

| Function | dogfood before → after | boot before → after |
|:--|--:|--:|
| `findServedOverlayRow` | 13,614 → 0 | 80 → 0 |
| `overlayLockLayerAt` | 13,736 → 0 | 80 → 0 |
| `queryByOrg` (`readActiveOverlayRows`) | 2,037 → 0 | 16 → 0 |
| `readFlattenedMetaItems` draft preview | 0 → 0 (no run reached it;
unit-pinned) | 0 → 0 |
| `foldStoredCollection` | 761 → 0 | 0 → 0 |
| `SysMetadataRepository.get` / `put` / `delete` | 169 / 296 / 41 → 0 |
0 |
| `promoteDraft` / `restoreVersion` / `listDrafts` | 6 / 2 / 1 → 0 | 0 |
| `nextItemVersion` / `nextEventSeq` | 105 / 105 → 0 | 0 |
| `recordMetadataAudit` / `persistPackageCommitRow` | 110 / 1 → 0 | 0 |
| `publishPackageDrafts` / `resolveOverlayPackageBinding` /
`storedFlowBindingAgrees` | 1 / 1 / 6 → 0 | 0 |
| `deletePackage` / `duplicatePackage` / `reassignOrphanedMetadata` | 1
/ 1 / 2 → 0 | 0 |
| `readAuthoredActionRows` / `readAuthoredHookRows` | 810 / 496 → 0 | 3
/ 2 → 0 |
| `readAuthoredTranslationLayer` | 496 → 0 | 2 → 0 |
| **all principal-less operations, any producer** | **35,248 → 2,476** |
**388 → 204** |

After the change, the same calls arrive as `isSystem` operations in
matching numbers. For example: `findServedOverlayRow` 13,618,
`queryByOrg` 2,037, `put` 296, `recordMetadataAudit` 110. The dogfood
suite was green on both sides with identical counts: 205 files passed +
1 skipped, 1,590 tests passed + 9 skipped. The boot answered the same
statuses on both sides: admin data reads 200, anonymous reads 401. The
2,476 / 204 operations that remain come from the other slices' producers
(settings, messaging, storage, auth, webhooks, datasource).

## Tests

- **Unit pins, one per package (H4):**
- `metadata-protocol/src/protocol.platform-store-system-opt-in.test.ts`:
the engine double records the context of every call. It drives draft
save → publish → active save → rollback → delete, the overlay and list
reads (each private reader directly, too), and reassign / duplicate /
uninstall. Each step asserts that it reached the store and that every
call carried `isSystem: true`. Inside the transaction the context is
exactly `{ transaction, isSystem: true }`.
- objectql: `plugin-authored-actions.test.ts` and
`plugin-authored-hooks.test.ts` each gain one case.
  - core: `authored-translation-sync.test.ts` gains one case.
- **Ablations, each committed first and restored through
`scripts/ablation-replace.mjs` (blob equals HEAD, `git diff HEAD`
empty).** Each pin resolves its subject from `src`, so no `dist` leg was
needed. The expected direction was red, and red is what was observed:
1. `findServedOverlayRow` opt-in dropped: 2 of 3 metadata-protocol cases
red.
2. `put`'s history-insert opt-in reverted to `{ context: ctx }`: 2 of 3
red.
3. `readAuthoredHookRows`' first read set to `isSystem: false`: 1 of 11
red.
4. `readAuthoredTranslationLayer`'s first read set to `isSystem: false`:
1 of 6 red.
- The first attempt at 3 and 4 used a replacement that was a prefix of
its anchor. The tool refused it as a no-op and nothing ran. They were
re-run with the `false` spelling.
- **Package suites (H4 falsified test-side; see the acceptance notes)**,
at `89ced04af3`. The merge to `9fd7113eaa` brought only two docs pages
and one `rest` test:
- metadata-protocol: 217 files passed + 3 skipped, 27,921 tests passed.
  - objectql (`local` + `repo`): 376 files, 7,476 tests passed.
  - core: 80 files, 2,226 tests passed.
- `typecheck` for all three exit 0, including objectql's and core's
`check:test-typecheck`.
- **Instrumented runs:** the dogfood suite (7 chunks, 206 files) and a
booted showcase dev composition, before and after, all under
`os-verify-lock`. The numbers are in the table above.
- **Gates:** `dispatch-gates --commands` at `9fd7113eaa` derives 76
families. All 76 were run there and exited 0, and `--ran` reconciles 76
derived, 76 run, 0 NOT-MEASURED.
- `check:engine-split-ratio` first refused on the shallow clone. It was
deepened (`--shallow-since=2026-06-30`) and re-run.
- `check:dual-build-cjs-loads` first needed dists of unbuilt packages
and a `plugin-audit` declaration. These were built, and the gate re-ran
green.
- `check:objectql-double-limit` flagged the new double as limit-blind.
The double now applies the caller's bound.
- **Lint, a proven narrowing (CI runs the full `pnpm lint`):**
1. The population comes from `eslint.config.mjs`:
`**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}` plus the `packages/**` object.
2. `eslint --no-inline-config --format json` over the 13 changed `.ts`
files: 13 files, 0 errors, 0 warnings.
3. The config never enables type-aware linting (no
`parserOptions.project`, no typed rules), so this diff cannot move any
untouched file's verdict.

## Acceptance notes

- **Same family, not moved here (static, not in the card's list):**
`SysMetadataRepository.getByHash`, `list`, `history` and
`replayFromHistory` still reach the engine with no context. No measured
run reached them (0 records in either probe). They belong to objectstack-ai#21908's
closure census.
- **One engine check besides the six gates also stands down under
`isSystem`:** the referential-integrity check on a caller-supplied
lookup. On these writes the only lookup it judged was
`sys_metadata.organization_id`, which the repository fills from the
door-derived organization. The probe recorded 0 refusals from it (0
downstream failures on the card's functions). The other `isSystem` reads
on the census page touch none of these four objects, or only change a
log line (the tenant-audit warning, the reference-cleanup actor label).
- **H4 was falsified, and the fix is test-side only:** objectql's
protocol suites held seven exact-argument expectations, the reassign
rebind and the `listDrafts` WHERE. Each now includes the opt-in. Two
revert/rollback conflict pins (`protocol-commit-history`,
`protocol-writepath-object-ownership`) found `put`'s in-transaction read
by a bare `context` key, and every repository read now carries one.
Their engine now hands its transaction callback a handle, as
`ObjectQL.transaction` does, and the pin discriminates on that handle.
metadata-protocol's and core's own suites passed unchanged.
- **`scripts/engine-double-contract.pinned.json`** gains three rows
(`--write`, a grow-only coverage ledger) for the new pin's double. That
double is copied from the pinned one in
`protocol-publish-drafts-org-scope.test.ts`.
- **Probe artifact:** after the change, the `isSystem` count for
`overlayLockLayerAt` reads 10. This is not because its reads vanished.
The function is not `async`, so it does not appear in the async stack
the probe filtered system records by. Its principal-less count (the
claim) is 0 on both runs.
- **H5:** objectstack-ai#21864's head (`d8657b5c`, re-tested after every merge of
`main`) test-merges cleanly onto this branch at `9fd7113eaa`. Its hunks
are in `anonymousFormIntakeOrgScopeRefusal`, `saveMetaItem` and
`promoteDraftForPublish`, and none of them is a named function here. All
16 protocol calls keep the opt-in in the merged tree.
- **H6:** the cross-lane declaration is on objectstack-ai#6367 (`6003826474`).
- **H3:** the `isSystem` census page needs no edit. Object-literal
producers are not elevation reads, and the census gate is green with its
counts unchanged.

---

_Generated by [Claude
Code](https://claude.ai/code/session_011K3zqE8Pv1Evw5hc8tZCnN)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…curity follow-up) (objectstack-ai#21964)

Fixes objectstack-ai#21932

Clause-②: no

## What

This PR adds one checklist item,
`access-security.public-form-withdrawal-layers`, to
`docs/qa/platform-checklist/areas/access-security.json`. It sits right
after `access-security.public-form-intake`. Its fields are rev 1,
`since: v17.7`, P1, surface `api`. No other file changes.

This delivers the last two rows of objectstack-ai#21932. The first five rows and both
re-checks landed in PR objectstack-ai#21943.

### The objectstack-ai#21835 / PR objectstack-ai#21864 row: public-form withdrawal layering

The item is written against what PR objectstack-ai#21864 landed on `main`. Its merge,
`3c7785d4ab`, is an ancestor of this branch's base `01e0f71a`. Each rule
on the card maps to a clause:

| Card rule | Where in the item | Oracle | Code anchor |
|---|---|---|---|
| An env-wide withdrawal is not re-opened by an org overlay |
acceptance[0]: both doors answer 404 `FORM_NOT_FOUND` and no row lands.
acceptance[1]: an org-scoped save that would leave the form open answers
403 `NOT_OVERRIDABLE` | api | `rest-server.ts#registerFormEndpoints`,
`anonymous-form-intake.ts#anonymousFormIntakeWithdrawnIn`,
`protocol.ts#anonymousFormIntakeReopenRefusal` |
| Only an explicit false withdraws | acceptance[2]: with an absent
`allowAnonymous`, or no `publicLink`, env-wide, the org save that opens
the form is accepted and both doors serve it | api |
`anonymous-form-intake.ts#anonymousFormExplicitWithdrawals`. Premise:
`protocol.ts#projectStorableViewBody` |
| A package's shipped false withdraws | acceptance[4] | test |
`anonymousFormExplicitWithdrawals`. Pins:
`protocol.org-scoped-write-refused.test.ts` ('single: a package-shipped
form') and `anonymous-form-intake.test.ts` |
| The env-wide definition may open a package-closed form | acceptance[5]
| test | `protocol.ts#envWideRawViewRows`, with the same protocol pin |
| The ruled known limit is recorded as a known gap |
`fixtures.knownGaps[0]`, plus a negative saying it is not a FAIL | none
| The doors match by served item name. The save check runs only from
`saveMetaItem` and the draft promotion, never from `rollbackMetaItem` or
`revertCommit` |

acceptance[3] is the control pair, which the dogfood also pins:
- An organization can always withdraw the form for itself.
- A form open at both layers is served, and its row lands in the
organization.

`automated.ref` leads with
`packages/qa/dogfood/test/showcase-public-form-withdrawal-layers.dogfood.test.ts`.
That dogfood covers acceptance[0], [1] and [3] end to end. The ref also
names the rest, metadata-protocol and metadata-core unit pins.

The steps drive the stock showcase form, `showcase_inquiry.contact` at
`/forms/contact-us`. The admin saves it at two scopes: env-wide, and in
the Default Organization the doors read. Both doors are probed
anonymously.

### Where the code is narrower than the card's wording

Where they differ, the item follows the code:
- **A package's shipped false.** This holds only for an artifact the
stack schema parsed (strict `defineStack`, the default). There the
schema default `enabled: false` counts as an explicit false. An artifact
loaded unparsed (`strict: false`, or a hand-built manifest) is judged as
written, so a switch it omits is absent and withdraws nothing.
acceptance[4] says so.
- **Only an explicit false.** The false must sit on a sharing that keeps
a non-empty `publicLink`. A sharing with no link withdraws nothing, even
with both switches false. acceptance[2] says so.
- **A second documented limit.** `main` carries "Known limit: packages
and names" besides the ruled one. Cases where two packages ship the same
view name are outside this item's fixture. The item points at that docs
section as it reads at the run's commit, rather than restating it.

### The objectstack-ai#21867 / PR objectstack-ai#21928 row

Confirmed on `main` with no change. The item is
`automation.paused-run-trigger-record-masked` in
`docs/qa/platform-checklist/areas/automation.json`, at rev 1, `status:
active`. Its `automated.ref` is
`packages/qa/dogfood/test/flow-trigger-record-credential-mask.dogfood.test.ts`,
which is on disk. PR objectstack-ai#21928 merged as `1f0469655f`, an ancestor of this
branch's base.

### Overlap with objectstack-ai#21934

objectstack-ai#21934 is not addressed here. Its PR objectstack-ai#21962 was an open, unmerged draft
when this PR was opened, so the item is written against `main` as it
stands.
- **The ruled known limit does not depend on objectstack-ai#21934.** PR objectstack-ai#21962 leaves
the docs page's "Known limit." paragraph and the doors' name-based
identity unchanged.
- **The multi-package line holds either way.** PR objectstack-ai#21962 rewrites the
"Known limit: packages and names" section. This item points at that
section as it reads at the run's commit and names objectstack-ai#21934, so its line
stays true whether or not PR objectstack-ai#21962 lands.
- **The `envWideRawViewRows` note holds either way.** It is scoped to "a
form one package ships", which is true before and after PR objectstack-ai#21962. That
PR keeps the symbol and resolves it per package.
- **No shared files.** This PR touches only the checklist JSON. It
changes neither `content/docs/ui/public-data-collection.mdx` nor any
package source.

## Tests

All results are at head `2d51effa`.
- **Derived gates.** `node scripts/pm/dispatch-gates.mjs --commands
--repo objectstack-ai/objectstack` derived 13 commands, the same 13 the
dispatch named. All 13 exited 0, with each exit code captured before any
pipe. The `--ran` reconciliation reads 13 derived, 13 run, 0
NOT-MEASURED, 0 UNRUN.
- **Checklist gate.** `pnpm check:platform-checklist` answered `OK — 15
areas, 275 items (271 active, 2 planned)` with 690/700 symbol anchors
resolved. At the base `01e0f71a` it read 274 items and 676/686. All 14
new anchors resolve, and the 10 that do not are the named objectstack-ai#16898
residual.
- **Formula gate.** `pnpm --filter @objectstack/lint run
check:doc-formula-expressions` first exited 3 (PREREQUISITE NOT MET,
because formula and lint were unbuilt), so that run measured nothing. I
built both under `os-verify-lock` (VERDICT command-exit 0), and the gate
then exited 0.
- **Not owed.** No package source changed, so no build, test, typecheck
or lint is owed. The cited test-case names were read from the test files
on `main`. The pins themselves were not re-run here; they ran in CI on
PR objectstack-ai#21864 and PR objectstack-ai#21928.

## Acceptance notes

- **`coverage.json` is untouched.** The claim's file surface is
`areas/*.json`, and the `view` kind is already mapped. Mapping the new
item to `view` is optional, and is left to whoever next owns
`coverage.json`.
- **A stale clause in the sibling item.**
`access-security.public-form-intake` clause 7 says "republishing
restores service" but does not name the scope of the republish. With
layering, republishing in an organization over an env-wide withdrawal is
refused with a 403. The new item covers that case. The old item is
unchanged, with no revision bump, to keep this PR to the card's rows.
- **No changeset.** The diff touches only
`docs/qa/platform-checklist/areas/access-security.json`, which no
published package ships: the root package is private, and no package
`files` entry names `docs/qa`. `skip-changeset` applies.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01VF48aw8RPG6wzDnMgp6rtw)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…w-ups (package identity, judged draft, lock key, row anchor) (objectstack-ai#21962)

Fixes objectstack-ai#21934
Clause-②: yes (widening)

Four LOW/INFO follow-ups to the public-form withdrawal work of objectstack-ai#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 objectstack-ai#6023). The released changeset of objectstack-ai#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 objectstack-ai#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

- Residual of item 1, stated in the docs: the list still upserts a
stored view container's expansion by name, so where a package's
environment-wide copy of a view container is saved, the anonymous doors
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
environment-wide definition of the name (item 4). Keeping each package's
expansion apart changes the expansion rules the list and the by-name
read share; that design is tracked in objectstack-ai#21967.
- No door code changes, so no door-level dogfood case was added
(declared to `domain:cli` on objectstack-ai#6024).
- The new conflict subclass is internal to
`@objectstack/metadata-protocol` (not exported from its entry); callers
see a `ConflictError`.

---
_Generated by [Claude
Code](https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
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