diff --git a/dev-docs/discovery/S3a-public-form-api.md b/dev-docs/discovery/S3a-public-form-api.md new file mode 100644 index 00000000..d0bfa99b --- /dev/null +++ b/dev-docs/discovery/S3a-public-form-api.md @@ -0,0 +1,654 @@ +# Discovery — Public Form Builder API surface (S3a prep) + +> **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.