Updates API.md and S3a discovery doc to reflect submitter-details handling and the draft/submit split. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
30 KiB
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 athttp://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):
- Match
form_schemas.public_token→ return as current. - Else match
form_schemas.public_token_previous:- If
public_token_rotated_atis null → return (grace=previous). - If
rotated_at + 7 daysis in the past → 410 Gone (grace=expired). - Else → return (grace=
previous). Grace window is hard-coded to 7 days, not configurable throughconfig/form_builder.php.
- If
- Else → 404.
Live response (dev token 01KPEG9D9YZ69MJRJQWXMY7XWG):
{
"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 authenticatedFormFieldResource,PublicFormSchemaResourceinlines its own projection that does NOT callavailableTags(). The TAG_PICKER in the live response above has"options": nulland 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 withschema_version_at_submitbeing 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):
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):
- Resolve schema via the same token-resolution path as GET (404 / 410).
- Reject if
schema.is_published = false→410 Gonewith 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. - Captcha gate — only runs when
schema.purpose ∈ config('form_builder.captcha.required_for_purposes'). Default config lists['public_complaint', 'public_press_request']—event_registrationandpublic_rsvpdo NOT require captcha by default. If required and missing/invalid →422with message"Captcha validation failed.". - Rate limit (in addition to the route-level throttle) via
RateLimiter::tooManyAttempts("form-submit:{public_token}:{ip}", N)whereN = config('form_builder.limits.max_submissions_per_public_schema_per_ip_per_hour', 5). 1-hour decay. On exceed →429JSON{ success: false, message: "Too many submissions." }+ aRetry-Afterresponse header with the seconds-until-available. 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.- If
valuesnon-empty →submissionService->saveDraft(submission, values, actor). submissionService->submit(submission, actor)— always runs. There is no public "save draft without submitting" path. Every POST ends asstatus=submitted. See Gaps (§9 item 4).
Idempotency (verified against the running server):
-
Pass
idempotency_key—FormSubmissionService::createDraftdoes aSELECT * FROM form_submissions WHERE form_schema_id=? AND idempotency_key=?and returns the existing row if found. -
Then
saveDraft+submitare called on the returned submission. If it was already submitted andschema.freeze_on_submit = false, both calls succeed and thesubmitted_at/submission_duration_secondsget refreshed. Iffreeze_on_submit = true, the retry throwsFrozenSchemaExceptionwhich bubbles as500.Probed live — replaying POST with the same
idempotency_keyreturned201+ 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):
{
"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:
// 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):
{
"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):
{
"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):
{
"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
FormSubmissionSubmittedisSyncTagPickerSelectionsOnSubmit(ARCH §31.10 — tag sync).AppServiceProvider::boot()does not register anything else. - ARCH §31.1 calls for a
TriggerIdentityMatchOnRegistrationlistener that invokesPersonIdentityService::detectMatches. It is not implemented. No file exists underapp/Listeners/FormBuilder/matching that name. FormSubmissionResourcecarries nopending_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):
{
"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_keyis a top-level string field on the POST body (max:30). Optional — nullable perPublicSubmissionRequest.- 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::createDraftreturns the existing submission (verified live — second POST with the same key returned the sameid,201). - The full pipeline (
saveDraft→submit) still runs on the returned-existing submission. When the original submission is alreadysubmittedandschema.freeze_on_submit = false(the S0.5 seeded schema), this is a harmless no-op that refreshessubmitted_at. Whenfreeze_on_submit = trueit throwsFrozenSchemaException, 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):
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:
# 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
-
available_tagsmissing from public TAG_PICKER response.PublicFormSchemaResourcereimplements the field projection inline and omitsavailableTags(). Frontend cannot render thevaardighedenfield from the dev token without hitting another endpoint — which doesn't exist publicly. Easiest fix: inline a public-safeavailable_tagslookup on every TAG_PICKER field (same logic asFormFieldResource::availableTags(), which already filters byis_activeandvalidation_rules.tag_categories— nothing PII there). Either that or expose a sibling public endpoint likeGET /public/forms/{token}/tag-pickers/{slug}/tags. -
AVAILABILITY_PICKERdependency 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 toowner_idevent and children). - Ship a public sub-endpoint
GET /public/forms/{token}/time-slots?include_children=true. Either needs to walk the festival hierarchy: formowner_id→ event → (children events if festival) →time_slots.
- Embed per-field on public response (include
-
SECTION_PRIORITYdependency data missing. Same shape of gap:festival_sectionsfor the form's event (or its festival's children) are not exposed. The authenticated API has/organisations/{org}/events/{event}/sectionsbut not a public variant. -
No public draft-save endpoint. Every
POST /public/forms/…/submissionsends atstatus=submitted.PublicFormController::submitalways runscreateDraft → saveDraft → submitin 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}/draftsthat stops aftersaveDraft(status staysdraft, 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_keyreplays (current behaviour). Ugly for auto-save because each auto-save hitssubmit()and firesFormSubmissionSubmitted(→ TAG_PICKER sync runs 20× per form fill-in on a chatty form).
- Add
-
No identity-match signal in POST response.
TriggerIdentityMatchOnRegistrationlistener from ARCH §31.1 is unimplemented.PersonIdentityService::detectMatchesis 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
-
Schema
versionnot in public GET response. Included on authenticatedFormSchemaResource, dropped fromPublicFormSchemaResource. Theschema_version_at_submitfield 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"), surfaceversionin the public GET. -
Inconsistent error envelope. 404/410/429 (in-controller) return
{success: false, message}. Laravel's FormRequest validation (422) returns{message, errors}withoutsuccess. The route-level throttle 429 returns Laravel default{message: "Too Many Attempts."}(nosuccess). The frontend needs branching logic to handle both shapes. -
410 vs 410 ambiguity. "Token expired past grace" and "schema is not published" both return 410 with different
.messagestrings. Frontend must string-match to tell them apart (or accept that both are effectively "form unavailable"). -
Grace window hard-coded to 7 days. The
rotatePublicTokenadmin endpoint accepts agrace_daysrequest param (stored nowhere — just passed to activity log).PublicFormControllerunconditionally uses 7 days. If a tenant setsgrace_days=3they still effectively get 7. Not S3a-blocking; worth documenting in backlog. -
Public POST response leaks a surprising amount of metadata. The submission response uses the authenticated
FormSubmissionResourcewith no public-facing trim. It includesopened_at,first_interacted_at,submission_duration_seconds,submitted_in_locale,is_test, and the fullvaluesarray. Nothing PII-dangerous for a submitter receiving their own submission back, but it does echopublic_submitter_email— fine for a confirmation screen, worth knowing. -
No per-field type validation at the request layer.
PublicSubmissionRequestonly validatesvalues: required|array. Everything else lands inFormValueService::upsertManywhich doesn't honourform_fields.validation_rulesfor 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. -
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 sendcaptcha_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.