Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
25a834d
fix(rest,metadata-protocol): a public form's intake withdrawal at any…
claude Oct 5, 2026
f226f1b
test: pin the public-form withdrawal kill switch across metadata layers
claude Oct 5, 2026
e7aa2eb
test(dogfood): a public form withdrawal at any layer holds on a real …
claude Oct 5, 2026
afb150b
chore: changeset for the public-form withdrawal kill switch
claude Oct 5, 2026
3730a51
Merge origin/main into claude/issue-21835-form-withdrawal-kill-switch
claude Oct 5, 2026
4a3d548
chore(changeset): metadata-core gains two public exports — minor, wid…
claude Oct 5, 2026
1d6bdd5
fix(metadata): a public form withdrawal closes the same form only, an…
claude Oct 5, 2026
cfc55af
docs: what withdraws a public form, and what does not
claude Oct 5, 2026
e8778ac
test(dogfood): re-saving an org overlay open from before an env-wide …
claude Oct 5, 2026
6663acc
fix(metadata): a withdrawal is explicit false only, matched by slot o…
claude Oct 5, 2026
95951e1
docs: the same-form identity rule for a public form withdrawal
claude Oct 5, 2026
035a6e0
Merge remote-tracking branch 'origin/main' into claude/issue-21835-fo…
claude Oct 5, 2026
41d5266
test: pin a package's schema-parsed false as a withdrawal, and the en…
claude Oct 6, 2026
79b8470
docs: state the withdrawal rules by door, the package rules and the k…
claude Oct 6, 2026
be9d98a
Merge remote-tracking branch 'origin/main' into claude/issue-21835-fo…
claude Oct 6, 2026
aa85cb7
fix(metadata-protocol): judge the draft a publish promotes, under its…
claude Oct 6, 2026
f37e09c
fix(metadata-core): a withdrawal closes only its own package's form o…
claude Oct 6, 2026
6d6f894
docs: state the package rule and that a parsed default applies to sch…
claude Oct 6, 2026
d8657b5
test(objectql): give the publish double's engine the draft-row read
claude Oct 6, 2026
46d0818
Merge remote-tracking branch 'origin/main' into claude/issue-21835-fo…
claude Oct 6, 2026
4d5f6c4
fix(metadata-core): a withdrawal of a view name closes it in every pa…
claude Oct 6, 2026
af60aff
docs: a withdrawal of a view name closes it in every package (known l…
claude Oct 6, 2026
7882eef
test(dogfood): port the per-file cwd setup fix so the dispatch-gates …
claude Oct 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .changeset/public-form-withdrawal-kill-switch.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
'@objectstack/rest': patch
'@objectstack/metadata-protocol': patch
'@objectstack/metadata-core': minor
---

A public form's explicit intake withdrawal at any metadata layer now holds: layering can only narrow anonymous intake, never re-open it

Clause-②: yes (widening)

- **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.
- **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.
- **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.
- **Package-shipped forms.** A package's form is part of the env-wide definition, not a separate layer beneath it. A package artifact that was 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 (fail closed). An artifact that reached the runtime without that parse (`defineStack(..., { strict: false })` or a hand-built manifest) is judged as written: there a switch it omits is absent, which is not a withdrawal. The env-wide definition is the administrator's switch: 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 #21934. A publish judges the draft it promotes under the same package key: with two packages holding a draft of the same view in one organization, each draft is judged on its own publish.
- **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.
- **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.
- **`@objectstack/metadata-core`** exports the shared judgement `anonymousFormIntakeWithdrawnIn` (a new, additive public export). Both the doors and the save path read it.
20 changes: 20 additions & 0 deletions content/docs/ui/public-data-collection.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,26 @@ System-managed anchors (`owner_id`, `organization_id`, audit columns, `id`) are

Set `sharingModel: 'private'` on the object so submissions are staff-scoped after creation. The public path only ever **inserts**; it never lists.

### 4. Withdraw a public form

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.

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.

**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.

**Which form a withdrawal closes.** Two checks apply the rule, and they match forms differently:

- **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.
- **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.
- **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.

**Forms a package ships.** A package's form is part of the environment-wide definition, not a separate layer beneath it. A definition parsed by the stack schema (strict `defineStack`, the default) gets the schema's default `enabled: false`, so a shipped form that keeps its link without setting `enabled: true` counts as withdrawn and an organization's copy cannot open it. A definition loaded without that parse (`defineStack(..., { strict: false })` or a hand-built manifest) is judged as written: a switch it leaves out is absent, which is not a withdrawal, so set `enabled: false` explicitly to ship a form closed. The environment-wide definition is the administrator's switch: an environment-wide save may open a form that the package ships closed.

**Known limit: packages and names.** A withdrawal of a view name closes that name in every package. When two packages each ship a view of the same name, one package's withdrawal also closes the other package's form of that name. This may close more than was meant, but it never leaves a withdrawn form open. Per-package precision is tracked in #21934.

**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.

## Why

Authorization is **derived from the declaration**, not configured separately — so the grant can't drift wider than the form. There is no standing "anonymous can write to this object" rule to misconfigure: the only thing the public can do is create one record through one whitelisted form. This is the difference from Airtable, where interfaces can't be shared publicly at all (only forms can) — here the same FormView metadata renders both internally (authed) and publicly (anonymous) through one renderer.
Expand Down
131 changes: 131 additions & 0 deletions packages/metadata-core/src/anonymous-form-intake.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
anonymousFormIntakeUnavailability,
anonymousFormIntakeUnavailableMessage,
anonymousFormIntakeUnavailableRemedy,
anonymousFormIntakeWithdrawnIn,
anonymousFormObjectName,
anonymousFormSharingPath,
publicFormSlug,
Expand Down Expand Up @@ -184,3 +185,133 @@ describe('where the reason is located, and the reason itself', () => {
expect(message.endsWith(` ${anonymousFormIntakeUnavailableRemedy(u)}`)).toBe(true);
});
});

describe('anonymousFormIntakeWithdrawnIn — an explicit withdrawal of the same row\'s form at any layer closes it', () => {
const view = (sharing: unknown, name = 'contact') => ({
name, object: 'inquiry', viewKind: 'form', config: { sharing },
});
const openView = view(OPEN);
const [candidate] = anonymousFormIntakeCandidates(openView);

it('the same row, the link kept with a switch explicitly false: withdrawn', () => {
expect(anonymousFormIntakeWithdrawnIn([view({ ...OPEN, allowAnonymous: false })], openView, candidate)).toBe(true);
expect(anonymousFormIntakeWithdrawnIn([view({ ...OPEN, enabled: false })], openView, candidate)).toBe(true);
});

it('only an explicit false withdraws: an absent switch is not a withdrawal', () => {
// publicLink + enabled:true, allowAnonymous absent: not a withdrawal.
expect(anonymousFormIntakeWithdrawnIn(
[view({ enabled: true, publicLink: '/forms/contact-us' })], openView, candidate,
)).toBe(false);
expect(anonymousFormIntakeWithdrawnIn(
[view({ allowAnonymous: true, publicLink: '/forms/contact-us' })], openView, candidate,
)).toBe(false);
});

it('two different rows sharing a slug do not close each other', () => {
const other = view({ ...OPEN, enabled: false }, 'legacy_contact');
expect(anonymousFormIntakeWithdrawnIn([openView, other], openView, candidate)).toBe(false);
const [otherOpen] = anonymousFormIntakeCandidates(view(OPEN, 'legacy_contact'));
expect(anonymousFormIntakeWithdrawnIn(
[view({ ...OPEN, enabled: false }), view(OPEN, 'legacy_contact')], view(OPEN, 'legacy_contact'), otherOpen,
)).toBe(false);
});

// Known limit (fails closed): the package a body is bound to is not
// compared, so a withdrawal of a name closes that name in every package.
describe('the package is not compared: a withdrawal of a name closes it in every package', () => {
const bound = (body: Record<string, unknown>, pkg: string) => ({ ...body, _packageId: pkg });
const withdrawn = view({ ...OPEN, enabled: false });

it('another package\'s withdrawal of the same name closes this package\'s form too', () => {
const openA = bound(openView, 'pkg_a');
const [c] = anonymousFormIntakeCandidates(openA);
expect(anonymousFormIntakeWithdrawnIn([bound(withdrawn, 'pkg_b')], openA, c)).toBe(true);
// Another package's OPEN body of the name withdraws nothing.
expect(anonymousFormIntakeWithdrawnIn([bound(openView, 'pkg_b')], openA, c)).toBe(false);
});

it('the same package\'s withdrawal of the same name closes it', () => {
const openA = bound(openView, 'pkg_a');
const [c] = anonymousFormIntakeCandidates(openA);
expect(anonymousFormIntakeWithdrawnIn([bound(withdrawn, 'pkg_a')], openA, c)).toBe(true);
// Beside another package's open body of that name: still closed.
expect(anonymousFormIntakeWithdrawnIn([bound(openView, 'pkg_b'), bound(withdrawn, 'pkg_a')], openA, c))
.toBe(true);
});

it('a body bound to no package stands in for every package\'s row of the name, on either side', () => {
const openA = bound(openView, 'pkg_a');
const [c] = anonymousFormIntakeCandidates(openA);
expect(anonymousFormIntakeWithdrawnIn([withdrawn], openA, c)).toBe(true);
expect(anonymousFormIntakeWithdrawnIn([bound(withdrawn, 'pkg_b')], openView, candidate)).toBe(true);
expect(anonymousFormIntakeWithdrawnIn([withdrawn], openView, candidate)).toBe(true);
});
});

it('not a withdrawal: no body of the row, no sharing, the link cleared', () => {
expect(anonymousFormIntakeWithdrawnIn([openView], openView, candidate)).toBe(false);
expect(anonymousFormIntakeWithdrawnIn([], openView, candidate)).toBe(false);
expect(anonymousFormIntakeWithdrawnIn([view(undefined)], openView, candidate)).toBe(false);
expect(anonymousFormIntakeWithdrawnIn([view({ enabled: false, allowAnonymous: false })], openView, candidate)).toBe(false);
expect(anonymousFormIntakeWithdrawnIn([view({ ...OPEN, enabled: false, publicLink: '' })], openView, candidate)).toBe(false);
});

it('a schema-parsed sharing with no public link is not a withdrawal, as its raw body is not', () => {
for (const raw of [{ password: 'secret' }, { allowedDomains: ['example.com'] }, { enabled: true }]) {
const parsed = SharingConfigSchema.parse(raw);
expect(parsed.enabled === true && parsed.allowAnonymous === true).toBe(false);
expect(anonymousFormIntakeWithdrawnIn([view(parsed)], openView, candidate)).toBe(false);
expect(anonymousFormIntakeWithdrawnIn([view(raw)], openView, candidate)).toBe(false);
}
});

it('a schema-parsed `false` that keeps the link IS a withdrawal (a package artifact fails closed)', () => {
// A package artifact is served as parsed, and the schema defaults
// `enabled` to false: a shipped sharing that keeps its link and never
// switches `enabled` on carries an explicit `false` once parsed. That is
// a withdrawal. Its raw body, with the switch absent, is not one.
const raw = { allowAnonymous: true, publicLink: '/forms/contact-us' };
const parsed = SharingConfigSchema.parse(raw);
expect(parsed.enabled).toBe(false);
expect(anonymousFormIntakeWithdrawnIn([view(parsed)], openView, candidate)).toBe(true);
expect(anonymousFormIntakeWithdrawnIn([view(raw)], openView, candidate)).toBe(false);
});

it('the same slot with a new slug (case-only included) is the same form: closed', () => {
const withdrawn = [view({ ...OPEN, enabled: false })];
for (const link of ['/forms/contact-us-2', '/forms/Contact-Us']) {
const moved = view({ ...OPEN, publicLink: link });
const [c] = anonymousFormIntakeCandidates(moved);
expect(anonymousFormIntakeWithdrawnIn(withdrawn, moved, c)).toBe(true);
}
});

describe('a container row: identity survives a key rename, form.name, a slot move and an expansion rename', () => {
const LINK_A = { ...OPEN, publicLink: '/forms/a' };
const LINK_B = { ...OPEN, publicLink: '/forms/b' };
const row = (body: Record<string, unknown>) => ({ name: 'inquiry', object: 'inquiry', ...body });
// Env-wide: formViews.a withdrawn, formViews.b open.
const envRow = row({ formViews: { a: { sharing: { ...LINK_A, enabled: false } }, b: { sharing: LINK_B } } });
const closedIn = (overlay: Record<string, unknown>) =>
anonymousFormIntakeCandidates(overlay)
.filter((c) => anonymousFormIntakeWithdrawnIn([envRow], overlay, c))
.map((c) => c.slug);

it('a key rename keeps the slug: closed', () => {
expect(closedIn(row({ formViews: { a2: { sharing: LINK_A }, b: { sharing: LINK_B } } }))).toEqual(['a']);
});
it('a slot move to the nested form, with a form.name: closed', () => {
expect(closedIn(row({ form: { name: 'renamed', sharing: LINK_A }, formViews: { b: { sharing: LINK_B } } })))
.toEqual(['a']);
});
it('a listViews entry that collides with the key (an expansion rename): closed', () => {
expect(closedIn(row({ listViews: { a: { type: 'grid' } }, formViews: { a: { sharing: LINK_A } } })))
.toEqual(['a']);
});
it('the sibling form (another slot and another slug) stays independent', () => {
expect(closedIn(row({ formViews: { a: { sharing: { ...LINK_A, enabled: false } }, b: { sharing: LINK_B } } })))
.toEqual([]);
});
});
});
Loading
Loading