Skip to content

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

Draft
objectstack-fleet[bot] wants to merge 20 commits into
mainfrom
claude/issue-21835-form-withdrawal-kill-switch
Draft

objectstack-fleet[bot] wants to merge 20 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.
  • Packages and names. A package is part of a row's identity (ADR-0048). One package's withdrawal of a view name closes only that package's form of the name. A definition bound to no package stands in for every package's row of its name, so its withdrawal applies to all of them. 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 5, the second round 4; the bullets after them were measured at e8778acb96 (round 2):

  • 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 leaves another package's form of it open and closes its own (metadata-core and both doors). 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 13 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 9dce635337c2cc42a4149aa49289ad77d172363d.

⛔ 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 9dce635337c2cc42a4149aa49289ad77d172363d → packageMentionDocs.

Which tree this was computed on

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

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

⚠️ 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 9dce635337c2cc42a4149aa49289ad77d172363d → 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
claude added 3 commits October 5, 2026 23:50
…v-wide definition as the switch

A package artifact that keeps its public link and never switches `enabled`
on carries an explicit `false` once parsed, and that is a withdrawal: an
organization-scoped save that opens it is refused. An env-wide save may
open a form the package ships closed, and the env-wide list then serves
that open body.

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

The write door judges an organization-scoped save or publish by the stored
row, so renamed keys, `form.name`, slot moves and expansion renames are the
same form. The anonymous doors judge by the served item name, by slot or
slug, and withdraw only on an explicit false. A package's parsed false is a
withdrawal, and the env-wide definition may open a form the package ships
closed. An overlay stored before the withdrawal, or restored by rollback or
revert, that keeps the form open under another key or slot is a known limit.

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

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 79b847042df7ef332003ae9620cfcdd3cd75c181
Local-runs: none

Inputs: card #21835 (body, the triage, the claim and its correction, the three os-dev-reports, both ruling comments 5994082238 and 6005722623), the PR #21864 body and its 10-file list, the net diff origin/main...79b847042d, and the check-runs on this head. Published surface read from the exports maps.

① Derived judgments

  • @objectstack/metadata-core public surface: one additive export. anonymousFormIntakeWithdrawnIn(layer, view, candidate) reaches "." through export * from './anonymous-form-intake.js' in src/index.ts. Its only type input, AnonymousFormIntakeCandidate, was already exported. The two helpers it uses (anonymousFormSlot, anonymousFormExplicitWithdrawals) are module-private. The diff removes and renames nothing. The anonymousFormWithdrawnSlugs export from round 2 is absent at this head, and it never reached main. The claim correction's "two exports" is superseded by the round-3 and round-4 reports, which name one. Right.

  • What counts as a withdrawal, and how a match is made: right for the rulings. A withdrawal needs a non-empty publicLink and enabled === false or allowAnonymous === false. That is the explicit-only rule from ruling 5994082238. An absent switch, a link-less sharing, a cleared link and a missing body are not withdrawals. Identity is the same name, matched by slot or by slug. Another row that shares a slug closes nothing, which follows the ruling that a withdrawal closes the same form only (「只关同一个表单」). Right.

  • Anonymous doors (GET /forms/:slug, POST /forms/:slug/submit): accept set narrowed. When an organization is resolved, resolveFormBySlug reads the env-wide view list as well. A candidate that the env-wide item of the same name explicitly withdraws is no longer served, and it answers the existing 404 FORM_NOT_FOUND. A form that is open at every layer, or that only an organization publishes (fix(rest): withdrawing a public form takes effect on every anonymous intake door #21420), is still served. The request without an organization is unchanged. This matches the kill-switch ruling and the ruling to keep the doors name-anchored (「保持现状,写进文档」). The one test edit, public-form-intake-availability expecting ['view','view'], records that extra read and nothing else. Right.

  • Write door (metadata-protocol, org-scoped view save and draft promotion): accept set narrowed. Before this change, a save in the organization the doors read was never gated (return null). Now it returns 403 NOT_OVERRIDABLE when it would leave open a form the env-wide definition explicitly withdraws. The save is judged twice:

    • against the env-wide list, with a container-shaped body expanded the way the list read serves it;
    • against the raw env-wide body of the same stored row: the active sys_metadata row, else lookupArtifactItem.

    Saves that keep the form withdrawn, or that open nothing the env-wide definition withdraws, are still accepted. packageId is passed through only for container placement, and it is optional, so the internal signature only grows. Right.

  • Package artifacts: parsed false fails closed; an env-wide save may open them. envWideRawViewRows reads the artifact only when no env-wide DB row exists. A DB row therefore supersedes the artifact, so the package is not a separate layer. The parsed enabled: false default counts as a withdrawal. This matches the two package rulings in 6005722623 (「包内的 false 算显式关闭」, 「不算,环境级是开关」), and the new protocol pins cover both directions. Right.

  • Reversed behaviour is honestly scoped. At 17.6.0 (617f25f8a), resolveFormBySlug read env-wide only and had no org write gate. So an org overlay that re-opened an env-withdrawn form was never honoured in a released version. The changeset's claim that it "never shipped in a release" holds. Right.

  • Known limit is ruled and documented. An overlay stored before the withdrawal, or restored by rollback or revert, that moves its form to a different key or slot can still be served. Ruling 6005722623 accepts this, and the changeset, the docs section "Withdraw a public form" and the JSDoc all state it. Right, as ruled.

② Semver level

Changeset: @objectstack/metadata-core minor, @objectstack/rest patch, @objectstack/metadata-protocol patch.

  • metadata-core minor: an additive public export. Clause-②: yes requires at least minor. Matches.
  • rest and metadata-protocol patch: neither package's exports surface changes. Both changes narrow behaviour, but only for writes and reads that 17.6.0 never honoured, as the security fix on a p1 regression card requires. No authorable key, export or config field is removed or renamed, so this is not a (narrowing) break in the AGENTS.md sense. Matches.

Clause-②: yes (widening). The same line appears in the PR body, in the changeset body, and in the claim correction 5993499550. The original claim's Clause-②: no is superseded. The changeset is not breaking, so no ADR-0087 marker is owed.

③ Boundary flags

  • Round-2 open question (gate rollback and revert?): answered by ruling 6005722623. Rollback and revert stay ungated, as the known limit, and the changeset and docs say so.

  • Round-3 open question, door identity: answered with option C, keep the doors name-anchored and document the limit. The diff does exactly that.

  • Round-3 open question, parsed artifact false: answered with option A, explicit and fail closed. Implemented and pinned.

  • Round-3 open question, package as its own layer: answered with option A, no. The artifact is read only when no env-wide row exists. Pinned: an env-wide save opens the form, and an org save is then accepted.

  • Round-4 open questions: none.

  • Dev flag, claim file surface missing packages/metadata-core/src: corrected by the PM seat in 5993499550.

  • Dev flag, PR body stale after round 3: rewritten. The body at this head carries the round-4 text. The Measured at e8778acb96 header predates round 4, which has its own bullet. Cosmetic only.

  • Deviation, merge 035a6e0dae pushed before tests ran: a clean merge, followed by green targeted suites. Full-package suites, dogfood and the remaining derived gates are deferred to CI, and that is declared.

  • Out-of-scope findings (noted, not filed):

    • The public-form slug collision between two apps is ruled a separate concern on this card.
    • The check:type-check-debt re-measure race that rewrites dist is tooling, outside this diff's surface.

    Neither one gates this PR.

  • Check-runs on this head: none failed. Every completed run is success or skipped. Still in progress when read: Test Core 1–6/6, Dogfood Regression Gate 1–3/3, Dogfood Verify CLI, Build Core, Build Docs, the four Type Check lanes, Lint & Repo Gates, Temporal Conformance, and one of the two Check Changeset runs (the other is success). This verdict judges the contract. Landing still waits on those runs going green.

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

VERDICT: PASS

claude added 3 commits October 6, 2026 00:20
… package

A draft is keyed by its package too (ADR-0048), so two packages can each
hold a draft of the same view in one organization. The publish gate now
reads the draft under the same package key the promotion uses: the stated
binding, or, when none is stated, the binding of the draft row resolved
once and then stated to both the read and the promotion. The judged body
is the body that becomes active.

Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6
Co-authored-by: Claude <noreply@anthropic.com>
…f a name

The env-wide view list serves one item per package for a name (ADR-0048),
so the withdrawal judge compares a layer body only when it and the served
view are bound to the same package, or either is bound to none (the
package-less definition stands in for every package's row). The write
door carries the package a row is saved under the same way.

Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/xl and removed size/l labels Oct 6, 2026
claude added 2 commits October 6, 2026 00:33
…ema-parsed artifacts

A package is part of a view row's identity, so one package's withdrawal
closes only its own form of a name, and a package-less definition applies
to every package's row of it. The schema's default `enabled: false` reaches
an artifact only through the stack schema's parse; an artifact loaded
without it is judged as written.

Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6
Co-authored-by: Claude <noreply@anthropic.com>
A publish that states no package now reads the pending draft row's package
binding once, so the seed self-apply double's engine answers that read
(no row) and the repository double answers the rest, as before.

Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6
Co-authored-by: Claude <noreply@anthropic.com>
@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.

This branch has not been deployed

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

Labels

Projects

None yet

2 participants