RFC §4 V3 compliance — cross-tenant access to FormSubmissionActionFailure
endpoints returns 404, not 403, to prevent resource-existence
enumeration. The FormSubmissionActionFailurePolicy is the single tenant
gate; these tests assert the route-level integration end-to-end.
Production-code finding (in scope per "security gaps zijn altijd urgent"):
the orgIndex endpoint had a real IDOR gap. Original implementation called
`Gate::authorize('viewAny', ...)` which permits any org_admin in any org,
then filtered the result set by the URL's `{organisation}` param. orgB's
admin hitting `/organisations/{orgA}/form-failures` would get back orgA's
failures — leakage.
Fix:
- New policy method `viewAnyInOrganisation(User, Organisation)` that
requires super_admin OR org_admin on THIS specific organisation.
- Controller `orgIndex` calls `authorizeViewAnyInOrgOrNotFound()` which
translates a denied policy → 404 (matches the show/retry/resolve/dismiss
pattern).
- viewAny on the class level stays as the platformIndex gate (super_admin
+ any-org_admin enumeration is acceptable on the platform endpoint
because the role middleware already restricts to super_admin).
Test coverage (24 tests, all passing):
- 5 org-scoped endpoints × cross-tenant scenarios (all return 404)
- 5 platform endpoints × role-class scenarios (org_admin gets 403, never 404)
- Edge cases: soft-deleted parent submission, invalid ULID format,
non-existent ID, unauthenticated, authenticated-without-role on org
The 403 vs 404 distinction matters: role-gated endpoints return 403
(auth-class — "not allowed in this room"); ownership-gated endpoints
return 404 (IDOR-class — "this room doesn't exist for you").
Refs: RFC-WS-6.md §4 V3, ARCH-BINDINGS.md §8.2 (Task 3 of this session)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replace the manual `$request->route('formSubmissionActionFailure')` workaround
with type-hinted parameters. Implicit route model binding now resolves
FormSubmissionActionFailure correctly on both the platform admin route
(/admin/form-failures/{id}) and the org-scoped route
(/organisations/{organisation}/form-failures/{id}).
Root cause:
On the nested org-scoped route, Laravel's implicit binding triggers its
scoped-binding code path: for the second URL segment, it tries to resolve
the failure as a relation of the route's parent ({organisation}) by calling
`$organisation->formSubmissionActionFailures()`. Organisation has no such
relation (failures live under FormSubmission, not Organisation directly),
so the lookup silently fell through and the controller received a raw
string. PHP then raised a TypeError on the type-hinted parameter.
A second issue compounded it: with the controller method declaring
`(FormSubmissionActionFailure $formSubmissionActionFailure, ?Organisation $organisation)`
the parameter order did NOT match the URL parameter order
(/{organisation}/.../{formSubmissionActionFailure}), so Laravel's
resolveMethodDependencies — which falls back to positional binding when
parameter counts diverge — bound them to the wrong slots.
Fix:
- Register an explicit `Route::bind('formSubmissionActionFailure', ...)`
in AppServiceProvider that loads the model `withoutGlobalScopes()` and
throws ModelNotFoundException on miss. This sidesteps the scoped-binding
parent-relation lookup entirely.
- Add `->withoutScopedBindings()` to all four org-scoped routes (show,
retry, resolve, dismiss) as a belt-and-braces guarantee that Laravel
never enters the scoped-binding path for these nested routes.
- Reorder controller method signatures to put `?Organisation $organisation`
FIRST, matching URL parameter order so positional binding lands the
ULID strings on the correct method parameters.
- Drop the now-unused private `resolveFailure()` helper.
- Tenant scoping continues to be enforced by FormSubmissionActionFailurePolicy
via the failure.submission.organisation_id FK chain (RFC V3); cross-
tenant access still translates denied → 404, never 403.
Tests: all 9 controller tests pass (cross-tenant 404 contract verified for
view, dismiss, and resolve).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two route groups: /api/v1/admin/form-failures (super_admin platform) and
/api/v1/organisations/{organisation}/form-failures (org_admin scoped).
Same controller, policy authorises via FK chain (RFC V3). Cross-tenant
access returns 404 not 403 to prevent enumeration.
Resolve takes optional note; Dismiss requires DismissalReasonType
enum with conditional note (mandatory for 'other'). Both via
FormRequest validation with explicit i18n message keys.
Implementation note: Laravel implicit model binding for nested-namespace
ULID models doesn't pick up reliably across nested route groups. Using
manual resolveFailure() helper that loads withoutGlobalScopes() (so
cross-tenant access still reaches the policy, which translates denied →
404 per V3). Policy explicitly checks soft-delete via deleted_at since
withoutGlobalScopes bypasses SoftDeletes too. Policy registered
explicitly in AppServiceProvider — auto-discovery doesn't reliably
resolve App\Models\FormBuilder\* → App\Policies\FormBuilder\*.
NOT: admin UI (session 3). Not: public form routes (no API contract
notification needed).
Refs: RFC-WS-6.md §3 (Q5), §4 (V2, V3)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Atomic reader switch. All call paths that previously read
form_fields.options / form_field_library.options from the JSON column
now read through FormFieldOptionService::toJsonShape() via the
morphMany relation:
- FormFieldResource + FormFieldLibraryResource +
PublicFormSchemaResource emit the rich-shape array
- FilterRegistryController emits rich shape uniformly (no flat-array
carve-out for filter-UI compatibility — preflight scan confirmed
zero portal/app consumers, S5 territory)
- FormFieldRuleBuilder plucks values from the relation for in:options
rule construction
- FormSubmissionService::buildSnapshot writes rich-shape options into
snapshots and strips translations.{locale}.options from each field's
translations bag (defensive — commit 2 backfill already did the
bulk strip)
- Four FormFieldRequest variants accept array-of-spec-objects,
validate shape in after() via FormFieldOptionService::assertSpecsValid,
and hand off to FormFieldOptionService::replaceOptions for writes
- FormFieldService::create + update extract option specs from the
request data and route through the service after the FormField row
is persisted
FormField and FormFieldLibrary $casts no longer include 'options'; the
JSON column is no longer cast. Options removed from $fillable on both
models so ::create() / ::fill() / mass assignment can no longer touch
the legacy column. Both models gain a getOptionsAttribute() accessor
that resolves $model->options to the eager-loaded morphMany collection
— required because Eloquent's getAttribute() prefers a real DB column
over a relation method, and the JSON column lives on the table until
WS-5d commit 5 drops it.
Activity log — dual emit per §6.7 / §17.4.2 / §17.6.3:
- field.updated carries old.options / new.options diff via
toJsonShape() reconstruction, byte-equal JSON compare to avoid
cosmetic false positives. Field updates that don't touch options
omit the key entirely
- field.options_replaced emits inside replaceOptions() on FormField
subject only; library subject writes silent (mirrors the WS-5b /
WS-5c convention)
JSON columns (form_fields.options, form_field_library.options) remain
present but unread — column drops land atomically in commit 5.
Two pre-existing test fixtures that seeded options via the JSON column
(FormFieldApiTest + PublicFormValidationTest) migrated to the
spec-array path: FormField::factory()->withOptions([...]) where the
options live on the field, or explicit spec-array request bodies for
HTTP tests.
Tests: 1193 → 1206 green (+13 tests / +28 assertions).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
WS-5c commit 2 of 4 — the service layer, backfill migration, and
read-path switch. Per addendum Q3, conditional_logic applies to
FormField only — no library mirror and no copyLogic on
FormFieldService::insertFromLibrary.
FormFieldConditionalLogicService owns every write:
- logicFor(field): depth-limited eager-load of the tree
- replaceLogic(field, tree): transactional structure + operator +
field_slug validation + cycle check + activity-log emit
(field.conditional_logic_replaced)
- toJsonShape(root): reconstructs the canonical ARCH §8
`{show_when: {...}}` shape — single source of truth for the
snapshot writer + API resources
- assertSpecsValid(tree): public boundary guard for the FormRequest
strict validator (WS-5c commit 3 wires this up)
- assertNoCycles(field, tree): contract preserved from
FormFieldService::assertNoConditionalCycle, implementation now
reads the relational adjacency.
Backfill migration translates pre-WS-5c conditional_logic JSON to
rows. Strict dispatch: unknown operators / unknown top-level keys /
malformed groups FAIL the migration — Phase A seed-scan confirmed
the catalogue parity, so any drift is a data bug to fix at source,
not silently absorb. Rollback rebuilds canonical JSON and clears
the relational tree.
FormFieldService.create/update route `conditional_logic` through
the new service (matching the extract-and-delegate pattern from
WS-5a bindings and WS-5b validation rules). Snapshot writer + both
resources (FormFieldResource, PublicFormSchemaResource) read via
`toJsonShape(rootConditionalLogicGroup())` — byte-for-byte parity
with the pre-WS-5c JSON contract.
InvalidConditionalLogicSpecException handled in FormFieldController
as 422, same as FrozenSchemaException / CyclicDependencyException.
Tests: 20 new under tests/Feature/FormBuilder/ConditionalLogic/
(service, cycle detection, backfill forward+rollback+failure cases,
snapshot + resource parity). FormFieldApiTest cyclic rejection test
rewritten to use the new factory state. Rollback step counts in
WS-5a/b migration tests bumped +1 for the new backfill migration.
Baseline 1122 → 1142 green (3032 → 3085 assertions).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Q6 of ARCH-CONSOLIDATION-ADDENDUM-2026-04-24: the allowed
`form_submissions.subject_type` values are now derived from
`PurposeRegistry::allSubjectTypes()` instead of the parallel
`config/form_subjects.php` file.
- CreateFormSubmissionRequest validates `subject_type` against the
registry via constructor-injected PurposeRegistry.
- FormSubmissionController and FormValueService resolve the subject
FQCN through `Relation::getMorphedModel()` — the morph-map is the
single source of truth for alias → model mapping.
- `config/form_subjects.php` is deleted. `MorphMapAlignmentTest` keeps
the registry and morph-map aligned going forward.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Informational hint on the confirmation page when the same email has
already submitted the form. Not a block — the submission proceeds
normally. Privacy-safe: only shown to the submitter themselves.
Scope: same form_schema_id only. Cross-form/cross-event detection
would leak info about other forms.
- New FormSubmissionDuplicateDetector service queries by
form_submissions.public_submitter_email (trim + case-insensitive)
scoped to the schema, status=submitted, excluding the current
submission. Errors are swallowed + logged so a detector failure
never blocks the submit response.
- PublicFormSubmissionController enriches the submit response by
setting a transient duplicate_submission_data attribute on the
submission before resource serialisation.
- PublicFormSubmissionResource serialises a duplicate_submission
block with count, first_submitted_at, plus backend-authored
Dutch title + body (plural-agreement + IntlDateFormatter for
"23 april 2026"-style long-form dates). Null when no priors,
no email, or detector error.
- DuplicateSubmissionHint.vue (warning-typed tonal VAlert) above
IdentityMatchBanner on FormConfirmation. Prefers backend copy
with Intl-based Dutch date fallback for safety.
- 16 new backend assertions across the detector and the full
submit-response flow; 5 new Vitest assertions for the hint.
Note on scope: spec suggested extracting email from values via
schema binding; the codebase's public flow captures submitter
email in a guaranteed column (public_submitter_email) populated
by the stepper's Contactgegevens step. Using that directly is
both simpler and more correct for the duplicate-by-submitter
semantic. When FORM-05's binding-based extractor lands, this
detector can migrate without changing its public API.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
S3a PR 1 frontend sends public_submitter_name and public_submitter_email
on draft saves (PUT) and final submit (POST /submit), but the matching
SavePublicDraftRequest and SubmitPublicSubmissionRequest did not whitelist
these fields — Laravel's validated() silently stripped them, preventing
mid-form name/email updates from persisting.
Align both form requests with StartPublicDraftRequest to accept the same
submitter fields with identical rules (string, max:150 / email, max:255,
nullable). Controller copies present keys onto the submission model and
saves when dirty, matching standard Laravel update() semantics — missing
keys leave prior values untouched.
Closes the backend gap identified in PR 1 smoke test.
Five models that the public form endpoints touch carry a global
OrganisationScope: FormSchema, Event, TimeSlot, FestivalSection,
PersonTag. The initial S2c implementation relied on the scope no-opping
because /public/forms/* has no `{organisation}` route parameter and
OrganisationScope::resolveOrganisationId returns null in that case.
That's accidentally-correct. Any middleware that sets an implicit org
context later (route model binding for platform admin, impersonation,
default-org fallback on an authed Sanctum session) would start
filtering public schema resolution by the wrong org.
- PublicFormTokenResolver: both FormSchema::query() calls now pass
withoutGlobalScope(OrganisationScope::class). public_token is
globally unique so this is safe.
- PublicFormController::timeSlots() / sections() / festivalEventIds():
Event, TimeSlot, FestivalSection queries all explicit now, including
the eager-loaded event relation on time-slots.
- PublicFormController::ownerEvent(): narrowed from
Event::withoutGlobalScopes() to withoutGlobalScope(OrganisationScope)
so future scopes (soft-delete, archived) aren't accidentally
stripped.
- PublicFormSchemaResource::availableTagsByCategory: same narrowing on
the PersonTag query.
PublicFormCrossOrgScopeTest pins the expectation — 4 cases hit every
public endpoint under a stashed foreign-org route parameter and assert
the owner-org data still surfaces. Verified the tests fail when the
fix is reverted (all 4 return `SCHEMA_NOT_FOUND` with the bypass
absent).
Full suite 893 → 897 green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
S2c D5 completion: schema_version_at_open column + drift semantics.
- Migration 2026_04_22_100002 adds unsignedInteger schema_version_at_open.
Recorded by FormSubmissionService::createDraft at the moment the
portal first renders the form.
- PublicFormSubmissionResource.schema_drift now compares
schema_version_at_open vs schema_version_at_submit (or
schema.version for active drafts) so organiser edits during an
open draft surface as drift on subsequent PUT/submit responses.
- PublicFormSubmissionController::update routes through
FormSubmissionService::saveDraft so auto_save_count increments
and the FormSubmissionDraftUpdated event fires per PUT.
- bootstrap/app.php: FormRequest ValidationException on
/api/v1/public/forms/* is now re-wrapped into the D6 envelope with
code=VALIDATION_FAILED, so public endpoints emit one consistent
error shape regardless of layer.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
S2c D2, D3, D4, D8 — the meat of the public API rewrite.
Draft / save / submit split (D4):
- POST /public/forms/{public_token}/submissions
Creates a draft. idempotency_key is now REQUIRED; second POST with
the same key returns the existing draft (HTTP 200 vs 201 for fresh).
UniqueConstraintViolationException caught for race-safe replay.
- PUT /public/forms/{public_token}/submissions/{submission_id}
Auto-save. Partial updates only — each PUT writes just the
slugs in the body. Status stays 'draft'; auto_save_count++.
- POST /public/forms/{public_token}/submissions/{submission_id}/submit
Final submission. Merges body values with already-saved values,
runs strict rule set against the merged map, then calls
FormSubmissionService::submit which fires the lifecycle events
(tag sync, identity match). Rate-limited per IP per token per hour.
Access rules: submission must belong to the resolved schema; status
must be 'draft' (409 SUBMISSION_ALREADY_SUBMITTED otherwise); schema
still accepting submissions.
Sub-endpoints (D2, D3):
- GET /public/forms/{public_token}/time-slots
Volunteer-only, festival-aware (parent + children). Reads straight
from TimeSlot model — no org-coupled service to extract from. Out:
{id, name, date, start_time, end_time, duration_hours, event_id,
event_name}.
- GET /public/forms/{public_token}/sections
show_in_registration=true, type=standard, deduplicated by name
across festival children.
Dynamic per-field validation (D8):
- FormFieldRuleBuilder builds Laravel rule arrays from form_fields.
strict() enforces is_required + in:options + type rules (email,
url, numeric, date, boolean, phone regex); relaxed() is the
auto-save variant that drops required-ness.
- StartPublicDraftRequest (required idempotency_key),
SavePublicDraftRequest (relaxed rules, values optional),
SubmitPublicSubmissionRequest (relaxed rules at body level — the
controller merges the body with saved values and runs the strict
validator on the full map so submit with an empty body still
passes when everything was auto-saved).
- FormValueService backs the request layer up with deeper enforcement
of validation_rules JSON (min/max/regex) + is_unique. Throws
FieldValidationException (422) which renders via the D6 envelope.
PublicFormTokenResolver centralises the grace-window logic; every
public endpoint resolves through it so the standardised exceptions
bubble uniformly.
Routes: 6 total under /public/forms/ (up from 2). Tests:
PublicFormApiTest's existing submit test retrofitted to the three-step
flow; 857 tests still green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 5 of S2b. Ten thin controllers plus route registration under the
existing organisations/{organisation} prefix and two unauthenticated
public endpoints.
Controllers (api/app/Http/Controllers/Api/V1/FormBuilder/):
- FormSchemaController: CRUD + duplicate/publish/unpublish/rotate-token/
edit-lock. Returns 410 via PublicFormController when a rotated token is
past its 7-day grace window.
- FormFieldController: CRUD + reorder + insert-from-library. 422 on
binding-change / frozen / cyclic conditional_logic.
- FormSubmissionController: index/store/show/submit/destroy.
- FormValueController: bulk upsert draft values; 403 when
FieldAccessService rejects a write.
- FormSubmissionReviewController, FormSubmissionDelegationController.
- FormTemplateController, FormFieldLibraryController (deactivate on
DELETE for is_active records).
- FormSchemaWebhookController (url/secret never leak — only url_host +
has_secret in responses).
- FilterRegistryController: cached entity_column + tags + form_field
source list for Personen-module (ARCH §7.3–§7.5).
- PublicFormController: GET schema + POST submission. Turnstile captcha
for public_complaint/public_press_request. Rate-limited per
IP+public_token. 410 when token expired.
Routes: grouped under organisations/{organisation}/forms/ for auth'd
routes and public/forms/{public_token}/... with throttle:30,1 for the
public pair. Policies auto-discovered from the namespaced location.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>