Files
crewli/dev-docs/discovery/S3a-public-form-api.md
bert.hausmans d67502eaec docs(api): refresh Form Builder public API surface notes
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>
2026-04-23 17:21:15 +02:00

30 KiB
Raw Blame History

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):

{
  "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.
  • versionmissing. 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):

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 = false410 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_keyFormSubmissionService::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):

{
  "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 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):

{
  "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 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):

{
  "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 (saveDraftsubmit) 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):

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

  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

  1. 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.

  2. 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.

  3. 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").

  4. 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.

  5. 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.

  6. 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.

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