docs(discovery): S3a public Form Builder API surface report

Carried over from the prior discovery session. Lists the 12 gaps (4 hard
blockers, 8 soft) that S2c closes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-17 22:55:16 +02:00
parent 79d834cb1d
commit 8dd874916f

View File

@@ -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
4852 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.