Skip to content

Commit 79b8470

Browse files
committed
docs: state the withdrawal rules by door, the package rules and the known 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>
1 parent 41d5266 commit 79b8470

3 files changed

Lines changed: 30 additions & 12 deletions

File tree

‎.changeset/public-form-withdrawal-kill-switch.md‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,9 @@ A public form's explicit intake withdrawal at any metadata layer now holds: laye
99
Clause-②: yes (widening)
1010

1111
- **What counts as a withdrawal.** A withdrawal keeps the form's `publicLink` and sets `sharing.enabled: false` or `sharing.allowAnonymous: false`. Only an explicit `false` counts: a switch that is absent is not a withdrawal. Removing the `sharing` block, clearing the `publicLink`, or deleting the view at one layer is not a withdrawal either. A sharing that names no public link withdraws nothing.
12-
- **What counts as the same form.** A withdrawal closes the same form across layers, and identity is the stored view definition (the row an overlay is keyed by). Inside that definition, a withdrawn form matches by its place (`form`, the same `formViews` entry, or `config`) or by its public slug. Either match is enough, so moving the form to another key or place, renaming it, or pointing its link at a new slug (including a change of letter case) does not escape. A form that differs from every withdrawn form in both place and slug, such as a sibling in the same container, stays independent. A different view that uses the same slug is a different form, and the two never close each other.
13-
- **Anonymous form doors.** `GET /forms/:slug` and `POST /forms/:slug/submit` serve a form only when the env-wide layer beneath the organization's read does not withdraw the same view, matched by name and then by place or slug. A withdrawn form answers `404 FORM_NOT_FOUND` on both doors and creates no record. A form that is open at every layer is served as before. A form that only an organization carries is still served there.
12+
- **Organization-scoped saves and publishes.** A `view` save or draft promotion in the organization the anonymous form doors read is refused with `403 NOT_OVERRIDABLE` if it would leave open a form that the environment-wide definition withdraws. This check judges by the stored row: the organization's body is compared with the env-wide body of the row it is keyed by (the active env-wide row, else the package's artifact), and also with the env-wide view list the way the doors read it (a container-shaped body is expanded the way the list read expands it). Inside the row, a withdrawn form matches by its place (`form`, the same `formViews` entry, or `config`) or by its public slug, and either match is enough. So a renamed `formViews` key, a `form.name`, a move to another place, a listViews collision rename in the expansion, and a new or re-cased slug are all judged as the same form. A form that differs from every withdrawn form in both place and slug, such as a sibling in the same container, stays independent. The check also covers an organization copy that was already open before the withdrawal, the next time it is saved. The message names the remedies: save the overlay withdrawn, or publish the form from its environment-wide definition. An organization-scoped save that keeps the form withdrawn is still accepted.
13+
- **Anonymous form doors.** `GET /forms/:slug` and `POST /forms/:slug/submit` judge by the name of the view item they serve. Beneath the organization's read they read the env-wide view list, and they serve a form only when the env-wide item of the same name does not explicitly withdraw a form in the same place or with the same slug. A withdrawn form answers `404 FORM_NOT_FOUND` on both doors and creates no record. A form that is open at every layer is served as before. A form that only an organization carries is still served there. A different view that uses the same slug is a different form, and the two never close each other.
14+
- **Package-shipped forms.** A package's form is part of the env-wide definition, not a separate layer beneath it. Its artifact is read as parsed, and the schema defaults `enabled` to `false`, so a parsed `false` on a form that keeps its link is an explicit withdrawal (fail closed). The env-wide definition is the administrator's switch: an env-wide save may open a form the package ships closed.
15+
- **Known limit.** The doors match by served item name, and the save check runs only on an organization-scoped save or publish. An organization overlay that was stored before the env-wide withdrawal, or that a rollback or commit-revert restores, can still be served if it keeps the form open under a different key or place than the env-wide definition. Withdraw the form in that overlay to close it. Rollback and commit-revert restores are not gated by the save check.
1416
- **Behaviour change.** Between 17.6.0 and this fix, an organization overlay that published a form the environment-wide (package) definition withdrew was honoured: the doors served the organization's copy. That behaviour never shipped in a release, and it is reversed on purpose. The environment-wide withdrawal now wins.
15-
- **Organization-scoped saves.** A `view` save or draft promotion in the organization the doors read is refused with `403 NOT_OVERRIDABLE` if it would leave open a form that the environment-wide definition withdraws. The save is judged twice: once against the env-wide view list, the way the doors read it (a container-shaped body is expanded the way the list read expands it), and once against the env-wide body of the same stored row. This holds even when the organization's copy was already open before the withdrawal. The message names the remedies: save the overlay withdrawn, or publish the form from its environment-wide definition. An organization-scoped save that keeps the form withdrawn is still accepted. Rollback and commit-revert restores are not gated by this judgement yet.
1617
- **`@objectstack/metadata-core`** exports the shared judgement `anonymousFormIntakeWithdrawnIn` (a new, additive public export). Both the doors and the save path read it.

‎content/docs/ui/public-data-collection.mdx‎

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -56,13 +56,19 @@ Set `sharingModel: 'private'` on the object so submissions are staff-scoped afte
5656

5757
To stop taking submissions, keep the form's `publicLink` and set a switch to `false`: `enabled: false` or `allowAnonymous: false`. Both anonymous endpoints then answer `404 FORM_NOT_FOUND`, and nothing is created.
5858

59-
A withdrawal is a kill switch across metadata layers. If the environment-wide definition withdraws the form, an organization's copy of the same view cannot open it again. The endpoints keep answering not found, and an organization-scoped save that would leave the form open is refused with `403 NOT_OVERRIDABLE`. To publish the form again, save it environment-wide with both switches on. An organization's copy can always withdraw the form for itself.
59+
A withdrawal is a kill switch across metadata layers. If the environment-wide definition withdraws the form, an organization's copy of the same view cannot open it again: the endpoints keep answering not found, and an organization-scoped save or publish that would leave the form open is refused with `403 NOT_OVERRIDABLE`. To publish the form again, save it environment-wide with both switches on. An organization's copy can always withdraw the form for itself.
6060

61-
Only an explicit withdrawal of the same form closes it:
61+
**What counts as a withdrawal.** Only an explicit `false` withdraws, on a sharing that keeps its `publicLink`. A switch that is simply absent is not a withdrawal. Removing the `sharing` block, clearing the `publicLink`, or deleting the view at one layer does not withdraw the form at the other layers. A form that only an organization publishes stays open there.
6262

63-
- **The same form is the same stored view definition.** An organization's copy of a view is keyed by the name of the definition it overrides. Inside that definition, a withdrawn form matches the organization's form by its place (`form`, the same `formViews` entry, or the view's own `config`) or by its public link. A match on either is enough. So renaming the `formViews` key, moving the form to another place, or pointing the link at a new slug (including a change of letter case) does not re-open it. A form that differs from every withdrawn form in both place and link is a different form, such as a sibling in the same view.
63+
**Which form a withdrawal closes.** Two checks apply the rule, and they match forms differently:
64+
65+
- **Saving and publishing in an organization** judges the organization's copy against the stored environment-wide definition it overrides (the row its copy is keyed by). Inside that definition, a withdrawn form is the same form as the organization's when they share a place (`form`, the same `formViews` entry, or the view's own `config`) or a public link. A match on either is enough, so a renamed `formViews` key, a `form.name`, a move to another place, a renamed expanded item, and a new or re-cased slug all still count as the same form. A form that differs from every withdrawn form in both place and link is a different form, such as a sibling in the same view.
66+
- **The anonymous endpoints** judge each form by the name of the view item they serve. Beneath the organization's read they read the environment-wide view list, and a form is closed when the environment-wide item of the same name explicitly withdraws a form in the same place or under the same link.
6467
- **A different view is a different form.** A different view that uses the same public link (for example, another app's "contact us" form) neither closes this one nor is closed by it.
65-
- **Only `false` withdraws.** A switch that is simply absent is not a withdrawal. Removing the `sharing` block, clearing the `publicLink`, or deleting the view at one layer does not withdraw the form at the other layers. A form that only an organization publishes stays open there. To close a form for good, keep the link and set `enabled: false` or `allowAnonymous: false`.
68+
69+
**Forms a package ships.** A package's form is part of the environment-wide definition, not a separate layer beneath it. The package's definition is read as parsed, and the schema defaults `enabled` to `false`, so a shipped form that keeps its link without setting `enabled: true` counts as withdrawn and an organization's copy cannot open it. The environment-wide definition is the administrator's switch: an environment-wide save may open a form that the package ships closed.
70+
71+
**Known limit.** The save check runs only when an organization's copy is saved or published. A copy that was already stored before the environment-wide withdrawal, or that a rollback or revert restores, is judged only by the endpoints, which match by served item name. If that copy keeps the form open under a different key or place than the environment-wide definition, the endpoints can still serve it. To close it, withdraw the form in that organization's copy too; the next organization-scoped save of a copy that keeps it open is refused.
6672

6773
## Why
6874

‎packages/metadata-core/src/anonymous-form-intake.ts‎

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
* Clearing either switch withdraws the form from every anonymous door.
2424
*
2525
* A withdrawal is a kill switch: any metadata layer whose body of the same
26-
* stored row explicitly withdraws the form (the link kept, a switch set to
26+
* view name explicitly withdraws the form (the link kept, a switch set to
2727
* `false`), matched by slot or by slug, closes it, and layering may only narrow
2828
* intake, never re-open it ({@link anonymousFormIntakeWithdrawnIn}).
2929
*
@@ -264,7 +264,11 @@ function anonymousFormSlot(view: Record<string, any>, candidate: AnonymousFormIn
264264
* false` or `allowAnonymous === false`. Judged on the body as stored: a switch
265265
* that is absent is not a withdrawal (only an explicit `false` is), and a
266266
* sharing with no public link withdraws nothing — removing the sharing block or
267-
* clearing the link is not a withdrawal.
267+
* clearing the link is not a withdrawal. A body served as parsed (a package
268+
* artifact) carries the schema's default `enabled: false`, which is an
269+
* explicit `false`: a shipped form that keeps its link without switching
270+
* `enabled` on is withdrawn (fail closed). The env-wide definition is the
271+
* switch above it, so an env-wide save may still open it.
268272
*/
269273
function anonymousFormExplicitWithdrawals(view: unknown): Array<{ slot: string; slug: string }> {
270274
if (!view || typeof view !== 'object') return [];
@@ -294,9 +298,16 @@ function anonymousFormExplicitWithdrawals(view: unknown): Array<{ slot: string;
294298
* withdraws it, and the organization-scoped write door refuses a save that
295299
* would leave one open.
296300
*
297-
* Identity is the stored row: `layer` holds bodies of rows, and the candidate's
298-
* `view` is a body of a row of the same `name` — the row its overlay is keyed
299-
* by. The layer withdraws the candidate when its body of that row EXPLICITLY
301+
* Identity is the `name`: the layer withdraws the candidate only through a
302+
* body of the same `name` as the candidate's `view`. What that name is depends
303+
* on the caller. The organization-scoped write door passes the env-wide body
304+
* of the stored row the overlay is keyed by, so a key rename, a `form.name`,
305+
* a slot move or an expansion rename is still judged against the form it was.
306+
* The anonymous doors pass the env-wide view list and the item they serve, so
307+
* they judge by the served item name (a known limit: an overlay stored before
308+
* the withdrawal, or restored by rollback or revert, that moves its form to
309+
* another key or slot is not matched there). The layer withdraws the
310+
* candidate when its body of that name EXPLICITLY
300311
* withdraws a form ({@link anonymousFormExplicitWithdrawals}) that matches the
301312
* candidate by slot (the same `form`, `formViews` key or `config`) OR by slug
302313
* (the same public link, compared exactly as the doors resolve it). Either

0 commit comments

Comments
 (0)