From 5fcf6a9efb8c019336832a6871acaefa1a95f87b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 00:29:35 +0000 Subject: [PATCH 1/4] docs(rest,app-crm): the public form submit is authorized by its declaration, not by a guest_portal set The submit route's docstring said a deployment without a `guest_portal` profile "falls open", and the CRM example said that profile authorizes the anonymous write. Neither is what runs: the SecurityPlugin admits on the declaration-derived `publicFormGrant` before any permission set resolves. The comments now state that mechanism, what the `guest_portal` name on the submit context does reach (the grant's result masker), and why the CRM keeps `GuestPortalProfile` (ADR-0138's reference declaration). The stale `lead.hook.ts` / `case.hook.ts` pointer now names the patterns that exist. Comments only; no behaviour changes. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01QyoWKK9RwUNxBxmevy9Neb --- .../app-crm/src/security/sales-positions.ts | 21 ++++++++--- examples/app-crm/src/views/lead.view.ts | 11 ++++-- packages/rest/src/form-routes.ts | 36 ++++++++++++++----- packages/rest/src/rest-server.ts | 26 +++++++++----- 4 files changed, 68 insertions(+), 26 deletions(-) diff --git a/examples/app-crm/src/security/sales-positions.ts b/examples/app-crm/src/security/sales-positions.ts index 9cad970c3c3..8f228d43739 100644 --- a/examples/app-crm/src/security/sales-positions.ts +++ b/examples/app-crm/src/security/sales-positions.ts @@ -62,12 +62,23 @@ export const SalesUserPermissionSet = definePermissionSet({ }); /** - * Guest profile for the public Web-to-Lead form (lead.view.ts `web_to_lead`). + * The `guest_portal` permission set — the name the public form doors' submit + * context requests. ADR-0138 records this declaration as the reference for that + * name (fate: keep), which is why the example keeps it. * - * Applied to anonymous (unauthenticated) visitors who POST the public form. The - * anonymous permission path checks the FULL object name, so this MUST key - * `crm_lead` (not a short `lead`). INSERT-only — guests can never read, edit, or - * delete any record. + * It does NOT authorize the public Web-to-Lead form (lead.view.ts + * `web_to_lead`). That form's own declaration does: the submit route derives a + * `publicFormGrant` on `crm_lead` (create + read-back on that object only, + * ADR-0056 Option A), and the SecurityPlugin admits on that grant before any + * permission set resolves. The form works the same without this set. + * + * What the name reaches: the submit's context carries + * `permissions: ['guest_portal']`, so a set registered under this name is the + * one the grant's result masker reads over what the grant hands back, through + * its `fields` and the capabilities it holds against a field's + * `requiredPermissions`. Its `objects` bits are not read on that path. This + * declaration has object bits only, so on the form path it changes nothing, and + * the submit answers the created id only. */ export const GuestPortalProfile = definePermissionSet({ name: 'guest_portal', diff --git a/examples/app-crm/src/views/lead.view.ts b/examples/app-crm/src/views/lead.view.ts index 1c135b6779d..2451147613f 100644 --- a/examples/app-crm/src/views/lead.view.ts +++ b/examples/app-crm/src/views/lead.view.ts @@ -99,9 +99,14 @@ export const LeadViews = defineView({ * Hosted at GET/POST `/api/v1/forms/contact-us` (+ `/submit`). An * unauthenticated visitor — or an `EmbeddableForm` iframe on a marketing * site — submits it to create a `crm_lead`. `sharing.allowAnonymous` opens - * the public endpoint; the `guest_portal` profile (INSERT-only on crm_lead) - * authorizes the write. The lead object's own defaults/hooks stamp internal - * fields (status, owner, score). + * the public endpoint, and this declaration is also what authorizes the + * write: the submit route derives a `publicFormGrant` on `crm_lead` from it + * (create + read-back on that object only, ADR-0056 Option A). No + * permission set is needed, `GuestPortalProfile` included (see its + * comment). Only the fields in `sections` are written; the lead object's + * field defaults fill what the form does not collect (`status`, + * `lead_score`), and the server-managed anchors (`owner_id`, + * `organization_id`, audit columns) are never taken from a submission. */ web_to_lead: { type: 'simple', diff --git a/packages/rest/src/form-routes.ts b/packages/rest/src/form-routes.ts index 43166e1840c..0a3815277e8 100644 --- a/packages/rest/src/form-routes.ts +++ b/packages/rest/src/form-routes.ts @@ -407,8 +407,8 @@ export function registerFormEndpoints(this: FormRouteHost, basePath: string): vo }, }); - // POST /forms/:slug/submit — INSERT a record on the target object - // with the `guest_portal` permission set attached. + // POST /forms/:slug/submit — INSERT a record on the target object, + // authorized by the form's own declaration (`publicFormGrant`, below). this.routeManager.register({ method: 'POST', path: `${basePath}/forms/:slug/submit`, @@ -432,9 +432,11 @@ export function registerFormEndpoints(this: FormRouteHost, basePath: string): vo // Only allow the fields declared on the matched FormView. // This prevents a public visitor from stuffing privileged // columns (owner_id, status, internal_notes, …) into the - // row. Object hooks (`beforeInsert`) are still responsible - // for stamping server-side defaults — see the CRM - // `lead.hook.ts` / `case.hook.ts` for the canonical pattern. + // row. What the form does not collect, the object fills: a + // field's `defaultValue` (`crm_lead.status` in + // `examples/app-crm`), or a `beforeInsert` hook where the value + // needs logic (`StampInquiryDefaultsHook` in + // `examples/app-showcase/src/data/hooks/index.ts`). const allowedFields = new Set(); // [#24131] …and, off the SAME entries, the fields the form // requires the visitor to fill: an entry declaring @@ -522,10 +524,26 @@ export function registerFormEndpoints(this: FormRouteHost, basePath: string): vo // ADR-0056 (Option A): authorization DERIVED from the declared // form — a narrow create grant scoped to exactly this form's target // object. The SecurityPlugin honors `publicFormGrant` (create + the - // immediate read-back, that object ONLY), so public forms work under - // secure-by-default (anonymous-deny) WITHOUT a deployment-configured - // `guest_portal`. `guest_portal` + `anonymous` are kept for back-compat - // with object hooks (guest detection via falsy `ctx.user?.id`). + // immediate read-back, that object ONLY) before it resolves any + // permission set, so public forms work under secure-by-default + // (anonymous-deny) whether or not the deployment registers a + // `guest_portal` set, and no set's object bits widen or narrow + // this insert. + // + // `permissions: ['guest_portal']` admits nothing. It names the set + // the SecurityPlugin resolves for this caller: where a deployment + // registers one under that name, the grant's result masker reads + // that set's field permissions (and the capabilities it holds + // against a field's `requiredPermissions`) over what the grant + // hands back; where none is registered, it resolves to no set and + // the masker reads the zero-set posture. ADR-0138 records the name + // (fate: keep). The answer below is the created id only (#22437), + // so the masked record never reaches the visitor through this door. + // + // Neither `permissions` nor `anonymous` reaches an object hook: + // the engine builds `ctx.session` and `ctx.user` without either + // key. With no `userId` here, `ctx.user` is unset, so a hook sees + // this submission as a write with no user, like any other. const context: any = { publicFormGrant: { object: match.object }, permissions: ['guest_portal'], diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index 43a35f01634..a4deb362212 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -8255,15 +8255,23 @@ export class RestServer { * answering `{ id }` only (#22437) * * Both routes bypass `enforceAuth` even though anonymous-deny is on for the - * deployment (e.g. ObjectOS multi-tenant). Security is delegated to the - * `guest_portal` permission set carried on the execution context — the - * SecurityPlugin enforces INSERT-only access to the target object. If - * the deployment hasn't registered a `guest_portal` profile, the - * security middleware falls open with `permissions: []` (no userId), - * matching the existing anonymous-access semantics; deployers must - * keep secure-by-default deployments paired with a `guest_portal` - * profile (the CRM example does this) to enforce the INSERT-only - * contract. + * deployment (e.g. ObjectOS multi-tenant). The submit is authorized by the + * form's own declaration (ADR-0056 Option A): it hands the engine a + * `publicFormGrant` scoped to the form's target object, and the + * SecurityPlugin admits on that grant alone, before any permission set + * resolves. It admits the create and the read-back (`find` / `findOne` / + * `count`) on that object only, refuses every other operation and object, + * and strips the server-managed anchors (`PUBLIC_FORM_SERVER_MANAGED_FIELDS`) + * from the payload; the route's allow-list of the form's declared fields is + * the first net. No deployment configures anything for this, and the grant + * never falls open: it admits the same operations whether or not a + * `guest_portal` set is registered. + * + * The submit's context also names `guest_portal` in `permissions`. That + * name admits nothing. Where a deployment registers a set under it, the + * SecurityPlugin resolves that set as the caller's for the grant's result + * masker over what the grant hands back (ADR-0138 records the name); the + * submit answers the created id only (#22437). * * The matched FormView's parent ViewSchema is found by scanning * `protocol.getMetaItems({ type: 'view' })`. For each entry we inspect From 3a043feb08010e246271b9995ac463ad50bf6532 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 00:36:00 +0000 Subject: [PATCH 2/4] docs(rest,app-crm,docs): name every default the CRM form leans on, say what guest_portal reaches, add the rest patch changeset The registerFormEndpoints docstring ships in @objectstack/rest's published declarations and JS (measured in dist), so the correction is a patch changeset. The CRM lead form's defaults include `source`, filled from its `default: true` select option. public-data-collection.mdx now states that the guest_portal name on the submit context authorizes nothing and is read only as the result masker's field permissions. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01QyoWKK9RwUNxBxmevy9Neb --- .changeset/24130-form-submit-docstring.md | 11 +++++++++++ content/docs/ui/public-data-collection.mdx | 2 ++ examples/app-crm/src/views/lead.view.ts | 2 +- packages/rest/src/form-routes.ts | 5 +++-- 4 files changed, 17 insertions(+), 3 deletions(-) create mode 100644 .changeset/24130-form-submit-docstring.md diff --git a/.changeset/24130-form-submit-docstring.md b/.changeset/24130-form-submit-docstring.md new file mode 100644 index 00000000000..0a2a125a593 --- /dev/null +++ b/.changeset/24130-form-submit-docstring.md @@ -0,0 +1,11 @@ +--- +'@objectstack/rest': patch +--- + +docs(rest): the public form routes' docstring says what authorizes an anonymous submit — the form's own declaration (`publicFormGrant`), not a `guest_portal` permission set — and that nothing falls open without one (#24130) + +Clause-②: no + +The docstring of `RestServer#registerFormEndpoints`, which ships in `@objectstack/rest`'s published declarations, said the submit route delegates security to a `guest_portal` permission set on its execution context, that a deployment without that set "falls open", and that secure-by-default deployments must pair the form routes with one. None of that is what runs. The submit hands the engine a `publicFormGrant` scoped to the form's target object, and the security plugin admits on that grant alone, before any permission set resolves: the create and the read-back on that object only, every other operation and object refused, the server-managed anchors stripped from the payload. The grant never falls open and admits the same operations whether or not a `guest_portal` set is registered. The docstring now says this, and says what the `guest_portal` name on the context does reach: where a deployment registers a set under it, that set is what the grant's result masker reads over the record the grant hands back, and the submit answers the created id only. + +Text only; nothing the form routes do changes. diff --git a/content/docs/ui/public-data-collection.mdx b/content/docs/ui/public-data-collection.mdx index ecea0119dce..6b04b0fb15d 100644 --- a/content/docs/ui/public-data-collection.mdx +++ b/content/docs/ui/public-data-collection.mdx @@ -46,6 +46,8 @@ POST /api/v1/forms/contact-us/submit → INSERT one showcase_inquiry Only the fields listed in `sections[].fields` are accepted on submit; everything else is stripped server-side. The submit route also enforces each entry's `required: true` before anything is written — a submission that leaves such a field absent, `null`, blank, or an empty list on a multi-value field is refused with `400 VALIDATION_FAILED` naming the field — except on an entry the form hides (`hidden: true`) or shows only conditionally (`visibleWhen` on the entry or its section), which nothing server-side evaluates. Server-controlled fields (`status`, `source`, owner stamps) are set by a defaults hook, never by the submitter. The submit route derives a narrow `publicFormGrant: { object: 'showcase_inquiry' }` — create + read-back of the row it just made, and nothing else. +The submit's execution context also names a `guest_portal` permission set. That name authorizes nothing: the grant is decided before any permission set resolves, so the form works the same whether or not a set of that name exists, and such a set's object permissions (`allowCreate`, `allowRead`, …) are not read on this path. Where a deployment registers one, only its field permissions, and the capabilities it holds against a field's `requiredPermissions`, are read, to mask the created record the server hands back to the route. The visitor never sees that record: the submit answers with the new record's `id` only. + System-managed anchors (`owner_id`, `organization_id`, audit columns, `id`) are **never** accepted from a public submission — not even when a section declares them, and not through the zero-sections all-fields fallback. The route filter and the data layer enforce the same set (`PUBLIC_FORM_SERVER_MANAGED_FIELDS` from `@objectstack/spec/security`), so an anonymous visitor cannot forge record ownership or tenant placement regardless of how the form is declared (#3022). ### 3. Keep the object private once inside diff --git a/examples/app-crm/src/views/lead.view.ts b/examples/app-crm/src/views/lead.view.ts index 2451147613f..2a9f9d93f24 100644 --- a/examples/app-crm/src/views/lead.view.ts +++ b/examples/app-crm/src/views/lead.view.ts @@ -104,7 +104,7 @@ export const LeadViews = defineView({ * (create + read-back on that object only, ADR-0056 Option A). No * permission set is needed, `GuestPortalProfile` included (see its * comment). Only the fields in `sections` are written; the lead object's - * field defaults fill what the form does not collect (`status`, + * field defaults fill what the form does not collect (`status`, `source`, * `lead_score`), and the server-managed anchors (`owner_id`, * `organization_id`, audit columns) are never taken from a submission. */ diff --git a/packages/rest/src/form-routes.ts b/packages/rest/src/form-routes.ts index 0a3815277e8..d1bb32ecbf5 100644 --- a/packages/rest/src/form-routes.ts +++ b/packages/rest/src/form-routes.ts @@ -434,8 +434,9 @@ export function registerFormEndpoints(this: FormRouteHost, basePath: string): vo // columns (owner_id, status, internal_notes, …) into the // row. What the form does not collect, the object fills: a // field's `defaultValue` (`crm_lead.status` in - // `examples/app-crm`), or a `beforeInsert` hook where the value - // needs logic (`StampInquiryDefaultsHook` in + // `examples/app-crm`) or, on a select that declares none, its + // `default: true` option; or a `beforeInsert` hook where the + // value needs logic (`StampInquiryDefaultsHook` in // `examples/app-showcase/src/data/hooks/index.ts`). const allowedFields = new Set(); // [#24131] …and, off the SAME entries, the fields the form From 91ca7f3b79fb3cb1fb0da70ce74f9c64ab0a695a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 01:13:07 +0000 Subject: [PATCH 3/4] docs(forms,app-crm): forms.mdx says what the guest_portal name does; crm_lead.status says which default the write uses forms.mdx said the guest_portal set and the anonymous flag are attached "for back-compat" with object hooks, that hooks may branch on the guest_portal permission, and that a registered set must be kept INSERT-only. No hook sees either key, and a registered set's object bits are not read on the submit path; only its field permissions feed the result masker over the record the route gets back, and the visitor gets the id only. Section 2 is retitled and rewritten to say that, and drops the INSERT-only snippet whose object bits nothing reads. The crm_lead.status comment called a select option's `default` "only a UI preselect"; the engine falls back to it on write when no defaultValue is declared. The comment now says the declared defaultValue is what the write uses and why it takes precedence. Text only; no behaviour changes. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01QyoWKK9RwUNxBxmevy9Neb --- content/docs/ui/forms.mdx | 56 ++++++++++----------- examples/app-crm/src/objects/lead.object.ts | 8 +-- 2 files changed, 31 insertions(+), 33 deletions(-) diff --git a/content/docs/ui/forms.mdx b/content/docs/ui/forms.mdx index a53a5589fd3..7a20ecc56f1 100644 --- a/content/docs/ui/forms.mdx +++ b/content/docs/ui/forms.mdx @@ -47,9 +47,10 @@ Only the spec'd whitelist of form fields is accepted; everything else (status, o > exactly that object, never anything else and never the anonymous fall-open. So > public forms work under the unconditional anonymous-deny posture (v17 > retired the `requireAuth: false` opt-out, #3963) with **no** -> `guest_portal` permission set. The `guest_portal` permission set + `anonymous` flag -> are still attached for **back-compat** (object hooks that detect a guest via a -> falsy `ctx.user?.id`), but they are no longer the authorization mechanism. +> `guest_portal` permission set. The submit's context also carries +> `permissions: ['guest_portal']` and `anonymous: true`. Neither authorizes +> anything and neither reaches an object hook; section 2 says the one thing the +> `guest_portal` name does. ## 1. Declare the form view @@ -102,32 +103,27 @@ export default defineView({ > - Multiple form views per object are fine — only the one(s) with `sharing.enabled === true` and `sharing.allowAnonymous === true` are exposed. > - On a **walled** tenancy posture (`group` or `isolated` in force), a form whose object is walled by an organization column is **not offered**. An anonymous submission carries no organization, and an insert without one into a walled object is refused, so both anonymous endpoints answer the form exactly as they answer a withdrawn one (`404 FORM_NOT_FOUND`). The administrator is told why, at the form's `sharing`: the view's read (`GET /api/v1/meta/view/:name`) carries it in `_diagnostics.warnings`, and a save or publish of the view answers success with a `public-form-intake-unavailable` warning in `advisories`. If the object's rows belong to no organization, declare `tenancy: { enabled: false }` on it and the form is offered again. -## 2. (Optional) Create the `guest_portal` permission set - -The submit's authorization no longer depends on this set — the -declaration-derived `publicFormGrant` (see the note above) is what permits the -insert. For the submit you only need a `guest_portal` permission set if you rely -on the legacy back-compat path (e.g. an older runtime, or object hooks that -branch on the `guest_portal` permission). When present the set is attached to -the anonymous context, so keep it INSERT-only on the form's target object — the -guest-safe shape (ADR-0090 D9). - -{/* os:check */} -```ts -// hotcrm/src/security/guest-portal.permission.ts -import { definePermissionSet } from '@objectstack/spec/security'; - -// Guest-safe capability: INSERT-only on the intake objects. (`isProfile` no -// longer exists — the Profile concept was removed by ADR-0090 D2.) -export default definePermissionSet({ - name: 'guest_portal', - label: 'Public Form Submitters', - objects: { - lead: { allowCreate: true }, // no read/edit/delete - case: { allowCreate: true }, - }, -}); -``` +## 2. No permission set to create — what the `guest_portal` name does + +The submit is authorized by the declaration-derived `publicFormGrant` (see the +note above). The grant is decided before any permission set resolves, so the +form works the same whether or not a `guest_portal` permission set exists. The +submit's execution context still names `guest_portal` in `permissions`, and that +name reaches one thing only: + +- **The result masker, where a deployment registers a set of that name.** Only + the set's field permissions (`fields`, such as `readable: false`) and the + capabilities it holds against a field's `requiredPermissions` are read, to mask + the created record the server hands back to the route. The set's object + permissions (`allowCreate`, `allowRead`, …) are not read on this path, so they + neither grant nor limit the insert. +- **The visitor never sees that record.** The submit answers with the new + record's `id` only (section 4). Register a `guest_portal` set only if you want + fields masked on the record the route gets back; nothing else needs one. +- **No object hook can read it.** A hook's `ctx.session` and `ctx.user` carry + neither `permissions` nor `anonymous`, and on a public submission both are + unset because the context carries no user. Tell a public submission apart by + the missing `ctx.user` (section 3), not by a permission. ## 3. Stamp server-controlled defaults in a hook @@ -247,7 +243,7 @@ lookup route. ### Auth model - Neither route calls `enforceAuth`, so both work under the always-on anonymous-deny default (there is no `requireAuth` knob to configure since v17). -- The execution context the **submit** route hands to ObjectQL is `{ publicFormGrant: { object }, permissions: ['guest_portal'], anonymous: true }` with no `userId`. The Security plugin honors `publicFormGrant` first — a create + read-back grant scoped to exactly the declared object — so authorization holds even without a `guest_portal` profile. `permissions: ['guest_portal']` is retained for back-compat. +- The execution context the **submit** route hands to ObjectQL is `{ publicFormGrant: { object }, permissions: ['guest_portal'], anonymous: true }` with no `userId`. The Security plugin admits on `publicFormGrant` alone — a create + read-back grant scoped to exactly the declared object, decided before any permission set resolves — so no `guest_portal` set is needed. Neither `permissions` nor `anonymous` authorizes anything or reaches an object hook; where a set named `guest_portal` is registered, the result masker reads its field permissions (section 2). - No CSRF or auth header is needed; embed the form on any domain. ## 5. Embedding from a front-end diff --git a/examples/app-crm/src/objects/lead.object.ts b/examples/app-crm/src/objects/lead.object.ts index b5cae4f850c..10c9e9906f7 100644 --- a/examples/app-crm/src/objects/lead.object.ts +++ b/examples/app-crm/src/objects/lead.object.ts @@ -41,9 +41,11 @@ export const Lead = ObjectSchema.create({ status: Field.select({ label: 'Status', required: true, - // Write-path default so minimal creates (e.g. the public Web-to-Lead form, - // which doesn't expose status) satisfy `required` — the option `default` - // below is only a UI preselect. + // The write-path default: a create that omits `status` (e.g. the public + // Web-to-Lead form, which doesn't collect it) is stored as 'new', so it + // satisfies `required`. A declared `defaultValue` takes precedence over + // the option marked `default: true` below, which the engine falls back to + // on write only for a select that declares no `defaultValue`. defaultValue: 'new', options: [ { label: 'New', value: 'new', default: true, color: '#94A3B8' }, From 860ca38a92bb1c8cb1b7d63fb193a3bea528cf86 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 01:39:35 +0000 Subject: [PATCH 4/4] docs(forms): the submit's 403 row names the refusals that answer it, not a profile The error table said `403 PERMISSION_DENIED` means "the resolved profile does not allow create on the target object". No permission set admits or refuses the public submit's create. Measured through the real route, it answers 403 when the security layer fails closed (permission sets or the object's security posture cannot be resolved), when a write under the submission's context leaves the grant (an afterInsert hook writing another object through ctx.api), or when a code-registered hook throws a 403 refusal; a declarative hook's throw answers 400. Text only; no behaviour changes. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01QyoWKK9RwUNxBxmevy9Neb --- content/docs/ui/forms.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/content/docs/ui/forms.mdx b/content/docs/ui/forms.mdx index 7a20ecc56f1..68cbc3ccd0d 100644 --- a/content/docs/ui/forms.mdx +++ b/content/docs/ui/forms.mdx @@ -222,7 +222,7 @@ Errors: | `400 INVALID_REQUEST` | missing / blank slug (an empty body is coerced to `{}` and surfaces as `VALIDATION_FAILED` below, not here) | | `400 VALIDATION_ERROR` | the form's sections declare **no** fields, so it collects nothing — wire the fields and resubmit (#6920) | | `400 VALIDATION_FAILED` | object schema validators fail (`required`, `format`, `length`, …) | -| `403 PERMISSION_DENIED` | the resolved profile does not allow create on the target object | +| `403 PERMISSION_DENIED` | never a permission set refusing the create (section 2). It answers when the security layer fails closed: the permission sets for the submission could not be resolved ("permission subsystem unavailable"), or the target object's security posture could not be resolved. It also answers when a write made under the submission's context leaves the grant, such as an `afterInsert` hook writing a **different** object through `ctx.api` (the grant covers only the form's object), or when a hook registered in code throws a `403` refusal of its own (`PermissionDeniedError`). A declarative hook's `throw` answers `400`, not `403`. | | `404 FORM_NOT_FOUND` | slug not registered on any view whose form has `sharing.enabled: true` and `sharing.allowAnonymous: true` | | `5xx` (generic) | driver / hook threw — submit errors are mapped by `mapDataError`; there is no dedicated `FORM_SUBMIT_FAILED` code |