# Discovery — Public Form Builder API surface (S3a prep) > **Superseded 2026-04-18** > Written against commit 79d834c (S2b + S0.5) for S3a discovery. > S2c standardised the error envelope to `{ message, code, errors? }` > — see /dev-docs/API.md § Public Form Endpoints for the current > contract. Route inventory and response shapes below remain > accurate; only the error envelope changed. > **Purpose:** ground a follow-up S3a frontend prompt in the exact shapes > the current backend exposes. Written by inspecting code + hitting the > live endpoints against the S0.5-seeded dev token, not from ARCH or > `API.md`. ARCH and SCHEMA.md are directionally right; `API.md`'s > "Form Builder" section is ahead of implementation in places — **trust > the code, not the docs**, for anything below. > > **State sampled:** commit `79d834c` (S2b + S0.5 landed, 857 tests green). > Dev server at `http://localhost:8000`. Dev org: `Stichting Feestfabriek`. --- ## 1. Route inventory ### 1.1 Authenticated `/api/v1/organisations/{organisation}/forms/*` Full list (40 routes), middleware `api + auth:sanctum + HandleImpersonation`: | Method | URI | | -------- | --- | | GET | `organisations/{organisation}/forms/field-library` | | POST | `organisations/{organisation}/forms/field-library` | | GET | `organisations/{organisation}/forms/field-library/{field_library}` | | PUT | `organisations/{organisation}/forms/field-library/{field_library}` | | DELETE | `organisations/{organisation}/forms/field-library/{field_library}` | | GET | `organisations/{organisation}/forms/filter-registry` | | GET | `organisations/{organisation}/forms/schemas` | | POST | `organisations/{organisation}/forms/schemas` | | GET | `organisations/{organisation}/forms/schemas/{form_schema}` | | PUT | `organisations/{organisation}/forms/schemas/{form_schema}` | | DELETE | `organisations/{organisation}/forms/schemas/{form_schema}` | | POST | `organisations/{organisation}/forms/schemas/{form_schema}/duplicate` | | POST | `organisations/{organisation}/forms/schemas/{form_schema}/publish` | | POST | `organisations/{organisation}/forms/schemas/{form_schema}/unpublish` | | POST | `organisations/{organisation}/forms/schemas/{form_schema}/rotate-public-token` | | POST | `organisations/{organisation}/forms/schemas/{form_schema}/edit-lock` | | DELETE | `organisations/{organisation}/forms/schemas/{form_schema}/edit-lock` | | GET/POST/PUT/DELETE | `organisations/{organisation}/forms/schemas/{form_schema}/fields[/{form_field}]` | | POST | `organisations/{organisation}/forms/schemas/{form_schema}/fields/reorder` | | POST | `organisations/{organisation}/forms/schemas/{form_schema}/fields/insert-from-library` | | GET/POST | `organisations/{organisation}/forms/schemas/{form_schema}/submissions` | | GET | `organisations/{organisation}/forms/submissions/{form_submission}` | | PUT | `organisations/{organisation}/forms/submissions/{form_submission}/field-values` | | POST | `organisations/{organisation}/forms/submissions/{form_submission}/submit` | | POST | `organisations/{organisation}/forms/submissions/{form_submission}/review` | | POST | `organisations/{organisation}/forms/submissions/{form_submission}/delegate` | | DELETE | `organisations/{organisation}/forms/submissions/{form_submission}/delegations/{delegation}` | | DELETE | `organisations/{organisation}/forms/submissions/{form_submission}` | | GET/POST/PUT/DELETE | `organisations/{organisation}/forms/templates[/{form_template}]` | | GET/POST/PUT/DELETE | `organisations/{organisation}/forms/schemas/{form_schema}/webhooks[/{webhook}]` | ### 1.2 Public `/api/v1/public/forms/*` Exactly **2 routes**, middleware `api + ThrottleRequests:30,1` (30 req/min per IP per route — **Laravel throttle**, no Sanctum, no session, no CSRF, no impersonation): | Method | URI | Action | | ------ | --- | ------ | | GET | `public/forms/{public_token}` | `PublicFormController@show` | | POST | `public/forms/{public_token}/submissions` | `PublicFormController@submit` | --- ## 2. Public endpoint deep-dive ### 2.1 `GET /api/v1/public/forms/{public_token}` **Controller:** `App\Http\Controllers\Api\V1\FormBuilder\PublicFormController::show` **FormRequest:** none (no body — just the token path segment) **Resource:** `App\Http\Resources\FormBuilder\PublicFormSchemaResource` **Middleware:** `api + throttle:30,1` **Extra auth:** none. Public. **Token resolution order** (`PublicFormController::resolveSchema`): 1. Match `form_schemas.public_token` → return as current. 2. Else match `form_schemas.public_token_previous`: - If `public_token_rotated_at` is null → return (grace=`previous`). - If `rotated_at + 7 days` is in the past → 410 Gone (grace=`expired`). - Else → return (grace=`previous`). Grace window is hard-coded to 7 days, **not configurable** through `config/form_builder.php`. 3. Else → 404. **Live response (dev token `01KPEG9D9YZ69MJRJQWXMY7XWG`):** ```json { "success": true, "data": { "id": "01kpeg9d9yz69mjrjqwxmy7xwh", "name": "Vrijwilligersregistratie Echt Feesten 2026", "slug": "vrijwilligersregistratie-echt-feesten-2026", "purpose": "event_registration", "description": "Demo-formulier voor het end-to-end doorlopen van de vrijwilligersregistratie voor Echt Feesten 2026.", "locale": "nl", "consent_version": null, "submission_deadline": null, "section_level_submit": false, "sections": [], "fields": [ { "id": "...", "slug": "over-jou", "field_type": "HEADING", "label": "Over jou", "help_text": null, "options": null, "validation_rules": null, "is_required": false, "display_width": "full", "conditional_logic": null, "sort_order": 1, "form_schema_section_id": null }, { "id": "...", "slug": "shirtmaat", "field_type": "SELECT", "label": "Shirtmaat", "help_text": null, "options": ["XS","S","M","L","XL","XXL"], "validation_rules": null, "is_required": true, "display_width": "half", "conditional_logic": null, "sort_order": 2, "form_schema_section_id": null }, { "id": "...", "slug": "dieetwensen", "field_type": "CHECKBOX_LIST", "label": "Dieetwensen", "help_text": null, "options": ["Vegetarisch","Veganistisch","Glutenvrij","Lactosevrij","Halal","Kosher"], "validation_rules": null, "is_required": false, "display_width": "half", "conditional_logic": null, "sort_order": 3, "form_schema_section_id": null }, { "id": "...", "slug": "vaardigheden", "field_type": "TAG_PICKER", "label": "Vaardigheden en certificaten", "help_text": null, "options": null, "validation_rules": null, "is_required": false, "display_width": "full", "conditional_logic": null, "sort_order": 4, "form_schema_section_id": null }, { "id": "...", "slug": "opmerkingen", "field_type": "TEXTAREA", "label": "Opmerkingen", "help_text": null, "options": null, "validation_rules": null, "is_required": false, "display_width": "full", "conditional_logic": null, "sort_order": 5, "form_schema_section_id": null } ] }, "message": "Success" } ``` **What's included per field:** `id, slug, field_type, label, help_text, options, validation_rules, is_required, display_width, conditional_logic, sort_order, form_schema_section_id`. **What's deliberately excluded per field** (see §9 Gaps): - `available_tags` — despite being documented in ARCH + available on the authenticated `FormFieldResource`, `PublicFormSchemaResource` inlines its own projection that does NOT call `availableTags()`. The TAG_PICKER in the live response above has `"options": null` and no tag list. **Frontend blocker.** - `binding`, `role_restrictions`, `is_admin_only`, `is_pii`, `is_filterable`, `is_unique`, `translations`, `review_required` — correctly hidden, matches ARCH §10. **What's deliberately excluded at schema-level** (compared to the authenticated `FormSchemaResource`): - `public_token` (current and previous) — fine; the client already has it. - `version` — **missing.** The authenticated resource returns it; the public one doesn't. Combined with `schema_version_at_submit` being in the POST response (§2.2), the frontend has no way to detect drift **before** submitting. See Gaps. - `fields_count`, `submissions_count`, `has_submissions`, `is_locked`, `public_form_url`, `public_token_rotated_at`, `submission_mode`, `snapshot_mode`, `freeze_on_submit`, `retention_days`, `auto_save_enabled`, `max_submissions`, `organisation_id`, `owner_type`, `owner_id` — all hidden. Matches intent of ARCH §10. ### 2.2 `POST /api/v1/public/forms/{public_token}/submissions` **Controller:** `App\Http\Controllers\Api\V1\FormBuilder\PublicFormController::submit` **FormRequest:** `App\Http\Requests\Api\V1\FormBuilder\PublicSubmissionRequest` **Resource returned on success:** `FormSubmissionResource` (the authenticated one — **not** a public-trimmed variant) **Middleware:** `api + throttle:30,1` **FormRequest rules (verbatim):** ```php return [ 'values' => ['required', 'array'], 'public_submitter_name' => ['nullable', 'string', 'max:150'], 'public_submitter_email' => ['nullable', 'email', 'max:255'], 'captcha_token' => ['nullable', 'string', 'max:2000'], 'idempotency_key' => ['nullable', 'string', 'max:30'], ]; ``` No per-field validation. `values` is a free-form array; individual field rules are NOT enforced at the request layer. The service layer's `FormValueService::upsertMany` validates slug membership + write-access only — no type-coercion on public form values before storage. **Controller logic (order):** 1. Resolve schema via the same token-resolution path as GET (404 / 410). 2. Reject if `schema.is_published = false` → `410 Gone` with message `"Form is not currently accepting submissions."`. **Same 410 as token-expired** — the frontend can't tell them apart from status code alone; must parse `.message`. 3. Captcha gate — only runs when `schema.purpose ∈ config('form_builder.captcha.required_for_purposes')`. Default config lists `['public_complaint', 'public_press_request']` — **`event_registration` and `public_rsvp` do NOT require captcha** by default. If required and missing/invalid → `422` with message `"Captcha validation failed."`. 4. Rate limit (in addition to the route-level throttle) via `RateLimiter::tooManyAttempts("form-submit:{public_token}:{ip}", N)` where `N = config('form_builder.limits.max_submissions_per_public_schema_per_ip_per_hour', 5)`. 1-hour decay. On exceed → `429` JSON `{ success: false, message: "Too many submissions." }` + a `Retry-After` response header with the seconds-until-available. 5. `submissionService->createDraft(schema, subject=null, submitter=request->user(), context)` — subject is always null on public flow; submitter is null unless Sanctum ever ends up authenticated. 6. If `values` non-empty → `submissionService->saveDraft(submission, values, actor)`. 7. `submissionService->submit(submission, actor)` — always runs. **There is no public "save draft without submitting" path.** Every POST ends as `status=submitted`. See Gaps (§9 item 4). **Idempotency (verified against the running server):** - Pass `idempotency_key` — `FormSubmissionService::createDraft` does a `SELECT * FROM form_submissions WHERE form_schema_id=? AND idempotency_key=?` and returns the existing row if found. - Then `saveDraft` + `submit` are called on the returned submission. If it was already submitted and `schema.freeze_on_submit = false`, both calls succeed and the `submitted_at` / `submission_duration_seconds` get refreshed. If `freeze_on_submit = true`, the retry throws `FrozenSchemaException` which bubbles as `500`. Probed live — replaying POST with the same `idempotency_key` returned `201` + the **same** submission id (different status fields untouched because nothing changed). The column has no DB-level UNIQUE constraint, so absence of the key means Laravel will freely create duplicates. **Success response (201, live sample):** ```json { "success": true, "data": { "id": "01kpej4c2v0pp8tdq72528va9e", "form_schema_id": "01kpeg9d9yz69mjrjqwxmy7xwh", "subject_type": null, "subject_id": null, "submitted_by_user_id": null, "public_submitter_name": "Anonieme Tester", "public_submitter_email": "test@example.nl", "status": "submitted", "review_status": null, "submitted_at": "2026-04-17T20:29:16+00:00", "schema_version_at_submit": 1, "submitted_in_locale": "nl", "opened_at": "2026-04-17T20:29:16+00:00", "first_interacted_at": "2026-04-17T20:29:16+00:00", "submission_duration_seconds": 0, "is_test": false, "values": { "shirtmaat": { "value": "S", "value_anonymised": false }, "dieetwensen": { "value": ["Halal"], "value_anonymised": false }, "opmerkingen": { "value": "Test inzending", "value_anonymised": false } }, "section_statuses": [], "delegations": [], "created_at": "2026-04-17T20:29:16+00:00", "updated_at": "2026-04-17T20:29:16+00:00" }, "message": "Created" } ``` **`schema_version_at_submit` is returned on submit** — frontend can compare against the `id` in the GET response, but not against a `version` field (see §9 Gaps). ### 2.3 Draft save / update endpoints There is **no** `PUT /public/forms/{public_token}/submissions/{id}`. There is **no** public "save draft" endpoint at all. Both the authenticated `PUT /organisations/{org}/forms/submissions/{submission}/field-values` and `POST .../submit` require `auth:sanctum`. See Gaps (§9 item 4) for the frontend implication on auto-save. ### 2.4 Error-shape catalogue | Scenario | Status | Envelope | Body | | --- | --- | --- | --- | | Unknown token | 404 | `{success, message}` | `{"success": false, "message": "Form not found."}` | | Expired token (past grace) | 410 | `{success, message}` | `{"success": false, "message": "This form link has expired."}` | | Schema not published (POST) | 410 | `{success, message}` | `{"success": false, "message": "Form is not currently accepting submissions."}` | | Missing `values` / malformed request | 422 | **Laravel default** `{message, errors}` | `{"message": "The values field is required.", "errors": {"values": ["The values field is required."]}}` | | Captcha required + missing/invalid | 422 | `{success, message}` | `{"success": false, "message": "Captcha validation failed."}` | | Rate-limited (in-controller) | 429 | `{success, message}` | `{"success": false, "message": "Too many submissions."}` + `Retry-After` header | | Rate-limited (route throttle) | 429 | Laravel default | `{"message": "Too Many Attempts."}` + `Retry-After` header | **Frontend caveat:** the envelope is inconsistent — 404/410 and most 422s use `{success, message}` while Laravel's FormRequest-validation 422 uses `{message, errors}`. The client needs to branch on `.success === false` vs presence of `.errors`. See Gaps (§9 item 7). --- ## 3. Dependency-data shape per field type ### 3.1 `SELECT` / `RADIO` / `MULTISELECT` / `CHECKBOX_LIST` Options are served as `form_fields.options` JSON **directly** in the public response. `form_value_options` is not consulted on read (that pivot is write-side only: `FormValueObserver` rebuilds it for filter joining on admin list endpoints). Live fragments from the seeded schema: ```json // SELECT { "slug": "shirtmaat", "field_type": "SELECT", "options": ["XS","S","M","L","XL","XXL"] } // CHECKBOX_LIST { "slug": "dieetwensen", "field_type": "CHECKBOX_LIST", "options": ["Vegetarisch","Veganistisch","Glutenvrij","Lactosevrij","Halal","Kosher"] } ``` Options are flat strings. `RADIO` variants can also carry richer objects — see `FormBuilderDevSeeder::canonicalFields()` line 48–52 where `vergoedingstype` uses `[{ label, description }, ...]`. Neither shape is documented in `validation_rules` — the frontend has to union-type on the raw JSON. **MULTISELECT is NOT in the seeded showcase** (only `CHECKBOX_LIST`). Assumption: same shape, reads the same `options` JSON. ### 3.2 `TAG_PICKER` **Not exposed on the public endpoint.** The authenticated `FormFieldResource` exposes an `available_tags` array gated by `validation_rules.tag_categories` (filtering against `person_tags.category`). The public `PublicFormSchemaResource` re-implements the field projection inline and omits `available_tags` entirely. Live fragment from the seeded schema (TAG_PICKER field — the shape the frontend actually receives): ```json { "id": "01kpeg9da0rkq87qjvh6cfvnjy", "slug": "vaardigheden", "field_type": "TAG_PICKER", "label": "Vaardigheden en certificaten", "help_text": null, "options": null, "validation_rules": null, "is_required": false, "display_width": "full", "conditional_logic": null, "sort_order": 4, "form_schema_section_id": null } ``` For comparison — what the same field looks like via the **authenticated** `FormFieldResource` (has `available_tags`): ```json { "id": "01kpeg9da0rkq87qjvh6cfvnjy", "slug": "vaardigheden", "field_type": "TAG_PICKER", "available_tags": [ { "id": "01k...", "name": "Tapper", "category": "Horeca" }, { "id": "01k...", "name": "Barista", "category": "Horeca" }, { "id": "01k...", "name": "EHBO", "category": "Veiligheid" }, { "id": "01k...", "name": "BHV", "category": "Veiligheid" }, { "id": "01k...", "name": "Rijbewijs B", "category": "Logistiek" }, { "id": "01k...", "name": "Heftruck", "category": "Logistiek" }, { "id": "01k...", "name": "Duits", "category": "Taal" }, { "id": "01k...", "name": "Engels", "category": "Taal" }, { "id": "01k...", "name": "Podiumervaring","category": "Techniek" } ], ... } ``` The data is flat — the client needs to `group_by('category')` to render it as a grouped picker. **Gap: this array is not in the public response.** See Gaps (§9 item 1). ### 3.3 `AVAILABILITY_PICKER` **Not exposed.** No time slots embedded in the response; no sub-endpoint exists; no response field would surface them. The seeded showcase does not include this field type. Synthesised example of what a bare `AVAILABILITY_PICKER` field currently looks like when in a schema (same shape as other fields — **no dependency data**): ```json { "slug": "beschikbaarheid", "field_type": "AVAILABILITY_PICKER", "options": null, "validation_rules": null, "conditional_logic": null } ``` Per ARCH §3.2.1 / §5.1 the stored value is `ULID[]` of `time_slots.id`, and per §31.3 the time-slot selection triggers `ShiftAssignment` creation downstream. But the public client has no way to render the picker. See Gaps (§9 item 2). ### 3.4 `SECTION_PRIORITY` Same story as `AVAILABILITY_PICKER`. Stored value is `{ section_id, priority }[]` over `festival_sections.id`. No embedded list of sections in the public response; no sub-endpoint. See Gaps (§9 item 3). ### 3.5 Field types not covered by the S0.5 seeder The showcase seeds only: `HEADING, SELECT, CHECKBOX_LIST, TAG_PICKER, TEXTAREA`. S3a MVP scope likely includes at least also `TEXT, EMAIL, PHONE, NUMBER, DATE, BOOLEAN, RADIO, PARAGRAPH, URL`. The bigger 16-field `FormBuilderDevSeeder::seedEventSchema` does cover `TEXT, BOOLEAN, RADIO, TEXTAREA, MULTISELECT, SELECT, HEADING, TAG_PICKER` — but NOT `EMAIL, PHONE, NUMBER, DATE, DATETIME, URL, FILE_UPLOAD, IMAGE_UPLOAD, SIGNATURE, SECTION_PRIORITY, AVAILABILITY_PICKER, TABLE_ROWS, PARAGRAPH`. File-upload / image-upload / signature handling is **completely out of scope** for the current public endpoint — `PublicSubmissionRequest` accepts `values` as a plain array (no multipart), and the controller doesn't process uploads. Those field types need a separate upload-then-reference flow that does not exist yet. --- ## 4. Identity-match signal **None surfaced in the POST response.** Specifically: - The only listener registered on `FormSubmissionSubmitted` is `SyncTagPickerSelectionsOnSubmit` (ARCH §31.10 — tag sync). `AppServiceProvider::boot()` does not register anything else. - ARCH §31.1 calls for a `TriggerIdentityMatchOnRegistration` listener that invokes `PersonIdentityService::detectMatches`. **It is not implemented.** No file exists under `app/Listeners/FormBuilder/` matching that name. - `FormSubmissionResource` carries no `pending_identity_match`, `identity_match`, or equivalent field. So on a public `event_registration` submission the response tells the frontend nothing about identity-match state. Known gap. See §9 item 5. --- ## 5. Conditional logic shape `form_fields.conditional_logic` is passed through **verbatim** by `PublicFormSchemaResource` as plain JSON on each field, no transform. Shape per ARCH §8 (sampled from test fixtures + service code): ```json { "show_when": { "all": [ { "field_slug": "has_allergies", "operator": "equals", "value": true } ] } } ``` - Operators: `equals, not_equals, contains, not_contains, in, not_in, greater_than, less_than, empty, not_empty`. - Groups: `all` (AND), `any` (OR), nestable. See §7 below re: slug references. --- ## 6. Draft save semantics + idempotency - `idempotency_key` is a top-level string field on the POST body (`max:30`). Optional — nullable per `PublicSubmissionRequest`. - No DB-level UNIQUE constraint on `form_submissions.idempotency_key` (the migration adds only a composite index `(form_schema_id, idempotency_key)`). Duplicate prevention is application-only. - Behaviour on retry with same key: `FormSubmissionService::createDraft` returns the existing submission (verified live — second POST with the same key returned the same `id`, `201`). - The full pipeline (`saveDraft` → `submit`) still runs on the returned-existing submission. When the original submission is already `submitted` and `schema.freeze_on_submit = false` (the S0.5 seeded schema), this is a harmless no-op that refreshes `submitted_at`. When `freeze_on_submit = true` it throws `FrozenSchemaException`, bubbling as 500. Recommendation for the frontend: always send an `idempotency_key` — ideally a client-generated ULID that survives retries — because the server can't otherwise dedupe at POST time. --- ## 7. Conditional logic field references — by `slug` ✓ Confirmed via `FormFieldService::extractConditionSlugs` (walks the `conditional_logic` tree collecting every `field_slug` key): ```php if (isset($node['field_slug'])) { $slugs[] = (string) $node['field_slug']; } ``` and `buildConditionalAdjacency` which builds the cycle-detection graph keyed by `$field->slug`, not `$field->id`. Frontend can resolve references using slugs only — no ID lookup table needed. --- ## 8. Dev-seeded token + curl Seeder: `Database\Seeders\FormBuilderDevSeeder::seedEventRegistrationShowcase()` called from `DevSeeder::seedEchtFeesten()`, scoped to **`Stichting Feestfabriek`** (the only dev org) and anchored to the top-level festival event `Echt Feesten 2026`. After `php artisan migrate:fresh --seed`: ```bash # Grab the token TOKEN=$(php artisan tinker --execute="echo \App\Models\FormBuilder\FormSchema::where('slug','like','vrijwilligersregistratie-%')->value('public_token');" 2>/dev/null) echo $TOKEN # e.g. 01KPEG9D9YZ69MJRJQWXMY7XWG # Fetch schema curl -s "http://localhost:8000/api/v1/public/forms/$TOKEN" | jq '.' # Extract field shapes only curl -s "http://localhost:8000/api/v1/public/forms/$TOKEN" | jq '.data.fields[] | {slug, field_type}' # POST a submission curl -s -X POST "http://localhost:8000/api/v1/public/forms/$TOKEN/submissions" \ -H 'Accept: application/json' -H 'Content-Type: application/json' \ -d '{ "values": {"shirtmaat":"S","dieetwensen":["Halal"],"opmerkingen":"Test"}, "public_submitter_name": "Anonieme Tester", "public_submitter_email": "test@example.nl", "idempotency_key": "my-client-ulid-here" }' | jq '.' ``` Draft person (pre-seeded): Jan de Vries. Submitted person (TAG_PICKER sync exercised): Lisa Bakker. --- ## 9. Gaps **Hard blockers for an end-to-end public-form frontend** 1. **`available_tags` missing from public TAG_PICKER response.** `PublicFormSchemaResource` reimplements the field projection inline and omits `availableTags()`. Frontend cannot render the `vaardigheden` field from the dev token without hitting another endpoint — which doesn't exist publicly. **Easiest fix:** inline a public-safe `available_tags` lookup on every TAG_PICKER field (same logic as `FormFieldResource::availableTags()`, which already filters by `is_active` and `validation_rules.tag_categories` — nothing PII there). Either that or expose a sibling public endpoint like `GET /public/forms/{token}/tag-pickers/{slug}/tags`. 2. **`AVAILABILITY_PICKER` dependency data missing.** No time slots embedded; no public endpoint to fetch them. The authenticated API doesn't even have a dedicated "time slots for this form's event" route. Resolution options: - Embed per-field on public response (include `time_slots: [{ id, name, start_time, end_time, date, person_type }]` scoped to `owner_id` event and children). - Ship a public sub-endpoint `GET /public/forms/{token}/time-slots?include_children=true`. Either needs to walk the festival hierarchy: form `owner_id` → event → (children events if festival) → `time_slots`. 3. **`SECTION_PRIORITY` dependency data missing.** Same shape of gap: `festival_sections` for the form's event (or its festival's children) are not exposed. The authenticated API has `/organisations/{org}/events/{event}/sections` but not a public variant. 4. **No public draft-save endpoint.** Every `POST /public/forms/…/submissions` ends at `status=submitted`. `PublicFormController::submit` always runs `createDraft → saveDraft → submit` in one transaction. For the S3a "save as draft" / "auto-save on blur" requirement there is no surface at all. Options: - Add `POST /public/forms/{token}/drafts` that stops after `saveDraft` (status stays `draft`, returns the submission id). - Add `PUT /public/forms/{token}/submissions/{submission_id}` for subsequent auto-save writes (would need a private draft-resume token — plain submission_id on the public surface is a discovery risk). - Keep submit-only and rely on `idempotency_key` replays (current behaviour). **Ugly for auto-save** because each auto-save hits `submit()` and fires `FormSubmissionSubmitted` (→ TAG_PICKER sync runs 20× per form fill-in on a chatty form). 5. **No identity-match signal in POST response.** `TriggerIdentityMatchOnRegistration` listener from ARCH §31.1 is unimplemented. `PersonIdentityService::detectMatches` is never called from the form submission pipeline. Frontend cannot know "this email matches an existing volunteer — claim it?" from the response. Either build the listener or defer this piece to a later sprint — but the S3a UX should be aware the signal is absent today. **Soft gaps / pending polish** 6. **Schema `version` not in public GET response.** Included on authenticated `FormSchemaResource`, dropped from `PublicFormSchemaResource`. The `schema_version_at_submit` field IS in the POST response, so the frontend can detect drift **after** submitting but not **before**. If drift detection matters (e.g. "refresh — the form changed since you opened it"), surface `version` in the public GET. 7. **Inconsistent error envelope.** 404/410/429 (in-controller) return `{success: false, message}`. Laravel's FormRequest validation (422) returns `{message, errors}` without `success`. The route-level throttle 429 returns Laravel default `{message: "Too Many Attempts."}` (no `success`). The frontend needs branching logic to handle both shapes. 8. **410 vs 410 ambiguity.** "Token expired past grace" and "schema is not published" both return 410 with different `.message` strings. Frontend must string-match to tell them apart (or accept that both are effectively "form unavailable"). 9. **Grace window hard-coded to 7 days.** The `rotatePublicToken` admin endpoint accepts a `grace_days` request param (stored nowhere — just passed to activity log). `PublicFormController` unconditionally uses 7 days. If a tenant sets `grace_days=3` they still effectively get 7. Not S3a-blocking; worth documenting in backlog. 10. **Public POST response leaks a surprising amount of metadata.** The submission response uses the authenticated `FormSubmissionResource` with no public-facing trim. It includes `opened_at`, `first_interacted_at`, `submission_duration_seconds`, `submitted_in_locale`, `is_test`, and the full `values` array. Nothing PII-dangerous for a submitter receiving their own submission back, but it does echo `public_submitter_email` — fine for a confirmation screen, worth knowing. 11. **No per-field type validation at the request layer.** `PublicSubmissionRequest` only validates `values: required|array`. Everything else lands in `FormValueService::upsertMany` which doesn't honour `form_fields.validation_rules` for public submissions either. An EMAIL field accepts `"not-an-email"`. A SELECT field accepts any string. Client-side validation must hold the line until this is wired up. 12. **CAPTCHA dependency is unconditional on the non-required path too.** `PublicFormController::captchaValid()` is only called when the purpose matches the config allowlist. That part is correct. But if the purpose is gated and the client doesn't send `captcha_token`, the controller returns a generic 422 message — the frontend can't distinguish "captcha required but missing" from "captcha present but failed verification". Consider returning distinct error codes / messages. --- **Summary verdict:** the current public surface serves a linear `TEXT / TEXTAREA / SELECT / CHECKBOX_LIST / RADIO / BOOLEAN / HEADING / PARAGRAPH` form end-to-end (render + submit), modulo client-side validation. **Anything that needs external-data rendering (TAG_PICKER / AVAILABILITY_PICKER / SECTION_PRIORITY) or a separate draft-save step is not currently possible against the public surface.** Those are the four hard blockers to close before S3a.