Commit Graph

635 Commits

Author SHA1 Message Date
192353f4bc feat(form-builder): admin UI completion — server filters, KPIs, resource expansion (WS-6 sessie 3c)
Closes the four production gaps that emerged from sessie 3b's admin UI.
What we ship here is final: no further rework planned before production.

Backend
- IndexFailuresRequest validates state/search/failed_at_from/failed_at_to/
  listener_class. orgIndex + platformIndex apply them via a single
  applyIndexFilters() helper. Search runs case-insensitive `LIKE` on
  exception_message; SQL wildcards in user input are escaped.
- New /kpis aggregate endpoint per scope (orgKpis, platformKpis) returns
  open / resolved_30d / dismissed_30d / total_submissions in O(1) COUNTs.
  Replaces sessie 3b's client-side bucketing of an oversized list.
- Resource expansion: organisation_name, form_schema_label,
  resolved_by_user_name, dismissed_by_user_name, exception_trace,
  retry_history[]. Eager-loading via indexEagerLoads()/detailEagerLoads()
  prevents N+1 (verified by query-count assertion in test).
- New 2026_04_28_181000 migration adds exception_trace (longtext nullable)
  to form_submission_action_failures. ApplyBindingsOnFormSubmit listener
  now captures $e->getTraceAsString() at failure time.
- New FormSubmissionActionFailureRetryAttemptResource exposes per-attempt
  data (timestamp, actor name, outcome, exception details) inside
  retry_history[]. Index payloads omit the field via whenLoaded() to keep
  list responses lean.

Frontend (apps/app)
- Types updated to mirror the expanded resource shape and the new KPI
  endpoint contract. FormFailuresKpis is now { open, resolved_30d,
  dismissed_30d, total_submissions } (server-aggregate).
- useFormFailures composable forwards all 5 server filters via
  buildIndexParams() (strips empty/whitespace). useFormFailuresKpis hits
  the dedicated /kpis endpoint per scope.
- FormFailuresTable replaces client-side bucketing with server-side
  filtering, adds listener_class + date-range filter inputs, and renames
  the 4th KPI tile to "Submissions" (was "Totaal").
- FormFailureDetail renders organisation_name + form_schema_label in the
  header, surfaces an expandable stack-trace card, names the resolved/
  dismissed actor in the timeline, and replaces the "v1 placeholder"
  retry-history card with a full per-attempt timeline.

ESLint config gap (apps/app)
- New .eslintrc.cjs adapted from the Vuexy reference, minus Vuexy-internal
  rules. `pnpm lint` now runs successfully (was previously broken — the
  package.json script referenced a missing config). The 80 baseline
  violations across the codebase are pre-existing and out of scope for
  this session.

Tests + gates
- 24 new backend tests across filter, kpis, and resource-shape suites.
  Backend: 1462 → 1486 passing, 0 → 0 failing. Larastan clean. Rector
  dry-run unchanged at 354 (pre-Task-1 baseline from f18b55b).
- 3 new vitest tests in apps/app (filter wiring, KPI endpoint, KPI tile
  values from /kpis). Vitest: 38 → 41 passing. tsc clean. Portal
  unchanged (113 vitest, tsc clean).
- 5 backfill rollback tests bumped --step counts +1 for the new migration.
- Ws6FoundationMigrationTest down/up chain now includes exception_trace
  before the parent table is restored.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 00:14:20 +02:00
b47e096a55 feat(form-builder): retry history table + integration (WS-6)
Per-attempt retry history (timestamp, user, outcome, exception detail
if failed) replaces the counter-only retry_count tracking.

Changes:

- New `form_submission_action_failure_retry_attempts` table (cascade on
  parent delete, nullOnDelete on user). Explicit short FK names
  (`fsafra_failure_fk`, `fsafra_user_fk`) — auto-generated names exceed
  MySQL's 64-char identifier limit.
- New FormSubmissionActionFailureRetryAttempt model + factory +
  succeeded() state.
- Parent FormSubmissionActionFailure gets retryAttempts() HasMany
  relation (latest('attempted_at')).
- New FormFailureRetryService centralises the retry-flow logic. Both
  the API controller and the artisan command delegate to it. Service
  writes a retry_attempt record per attempt; parent's retry_count
  stays as denormalised cache for index-view performance.
- Successful retry: attempt(succeeded) + parent.retry_count++ +
  parent.resolved_at + parent.resolved_by_user_id + parent.resolved_note
  ("Geslaagde retry door {actor.name}" or "Geslaagde retry
  (geautomatiseerd)" for command-line invocation without an actor).
- Failed retry: attempt(failed) with NEW exception details +
  parent.retry_count++. Parent's exception_class/_message stay
  audit-immutable — they represent the FIRST failure.
- canBeRetried() now correctly checks both resolved_at AND
  dismissed_at (sessie 2's open question Q2 closure).
- New FailureNotRetriableException (controller → 422) and
  ParentSubmissionGoneException (controller → 410) for cleaner
  flow control.

12 new tests:
- FormSubmissionActionFailureRetryAttemptTest (5 unit tests)
- RetryFlowProducesRetryAttemptsTest (7 integration tests covering
  succeeded path, failed path, resolved/dismissed blocking,
  multiple-retries chronological ordering, canBeRetried truth tables)

Pre-existing tests touched:
- FormSubmissionActionFailureTest::test_can_be_retried_only_for_open_state
  — updated to reflect Q2 closure (resolved now blocks too).
- Ws6FoundationMigrationTest::test_down_methods_clean_up_columns_and_table
  — child table must drop before parent (FK constraint).
- 5 backfill test step-counts bumped +1 (new migration sits at top).

SCHEMA.md → v2.9. Schema dump regenerated.

Refs: RFC-WS-6.md §3 Q5 addendum, sessie 2 Q2

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 00:14:19 +02:00
acd7cf5ec8 docs: ARCH-BINDINGS.md polish (WS-6)
End-to-end consistency review of the document. One polish edit
landed:

- §1 Scope listed "Person, Artist, Company, User" as binding-target
  entities. Sessie 3a.5 removed `artist` from the registry entirely
  (BACKLOG ARTIST-ADV-BINDING-MODEL); §1 now states "Person,
  Company, User" with a forward-pointer to the appendix that
  documents the v1 omission rationale.

Otherwise no polish needed:
- No stale renamed symbols in code examples (the two
  `dietary_preferences` mentions remaining are deliberate appendix
  content explaining the JSON-path BACKLOG deferral).
- No TODOs / FIXMEs.
- Cross-references to RFC §X / Q-ids are consistent.
- Terminology distinguishes registry (BindingTypeRegistry config +
  lookup), applicator (FormBindingApplicator runtime), and pipeline
  (the broader subject-resolve → conflict-resolve → apply flow)
  appropriately.

Version bumped to v0.6.

Refs: WS-6 sessie 3b Task 6

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 00:14:19 +02:00
ddacf9363e docs: ARCH-OBSERVABILITY skeleton + \$dontReport concrete (WS-6)
Initial observability architecture document. Skeleton with §3
(\$dontReport exception list) as the only concrete section. Other
sections are structured placeholders for WS-7 sessie 1 decisions:

  - §1 Logging strategy (log levels, criteria)
  - §2 Sentry decisions (SDK config, sample rates, breadcrumbs,
    release tagging)
  - §3 \$dontReport exceptions (concrete) — three classes that are
    expected business outcomes, not bugs:
      * PublishGuardViolationException (422 publish-time)
      * PurposeRequirementsNotMetException (422)
      * IdempotencyConflictException (409)
    With explicit out-of-scope rationale for the three runtime
    pipeline exceptions that DO go to Sentry (PersonProvisioning /
    PurposeSubjectResolution / FormBindingApplicator) — engineering
    needs cross-org visibility into systemic patterns even when
    org admins handle individual failures via the WS-6 admin UI.
  - §4 Structured logging conventions (key naming tree)
  - §5 Metrics (counters, histograms)
  - §6 Alerting rules (thresholds, routing)
  - §7 Dashboards (panel layout)

The skeleton ensures WS-7 starts from a clear scope; the concrete
\$dontReport list closes a real Sentry-noise gap immediately
(PublishGuardViolationException etc. should never have hit Sentry).

RFC-WS-6.md §9 cross-references the new doc and adds an
Observability follow-up row.

Refs: WS-6 sessie 3b Task 5, WS-7 (forward)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 00:14:19 +02:00
786bca8cf1 feat(form-failures): admin detail view (WS-6)
FormFailureDetail shared component drives both detail pages:
  - apps/app/src/pages/platform/form-failures/[id].vue
  - apps/app/src/pages/organisation/form-failures/[id].vue

Layout (per design schets):
  - Header with state badge (large) + title (Form failure {short-id})
    + relative-time subtitle + listener short-name
  - Action button row (Retry / Markeren als opgelost / Dismiss),
    disabled for non-open states
  - 60/40 two-column layout via VRow/VCol(md=7/md=5)

Left column:
  - Exception card: class + message in code blocks + "Bericht
    kopiëren" button (navigator.clipboard)
  - Context card (only when context is non-null): pretty-printed
    JSON in <pre> with copy-as-JSON button
  - Tijdlijn (VTimeline): Failed → Retry-pogingen → Opgelost or
    Dismissed → "In afwachting van actie..." for open with no retries

Right column:
  - Inzending card: form_submission_id with copy button. The
    submission detail-pagina link is documented as "nog niet
    beschikbaar in v1" inline; opening submissions in the SPA isn't
    yet implemented (forward-pointed).
  - Listener card: full FQN listener_class
  - Retry-geschiedenis card: count chip + caveat that per-attempt
    detail (timestamp + outcome) is not yet shipped by the backend
    resource (the FormSubmissionActionFailureResource ships only
    retry_count, not a retry history array)

Action dialogs reused from Task 2; refetch on success.

8 Vitest tests cover loading state, header rendering, all 6 cards
present, action button disabled-ness per state (open/resolved/
dismissed), and timeline content for resolved + open-no-retries
states.

Refs: WS-6 sessie 3b admin UI Task 4

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 00:14:18 +02:00
4c80289c47 feat(form-failures): admin list view with KPI tiles + filters (WS-6)
FormFailuresTable shared component drives both /platform/form-failures
(super_admin, all orgs) and /organisation/form-failures (org_admin,
scoped to the active organisation).

  - 4 KPI tiles (Open / Opgelost / Dismissed / Totaal) with click-to-
    filter behavior. Counts derived client-side from a per_page=100
    list call (composable's useFormFailuresKpis).
  - Filter bar: state segment-control (VBtnToggle) + debounced search
    (exception class / message / IDs).
  - VDataTableServer with custom cell slots: state chip, formatted
    failed_at timestamp, listener short-name, exception class+message
    (truncated), submission short-id, retry-count chip, action column.
  - Action column: detail (eye, always), retry (open only),
    overflow menu (open only) with "Markeren als opgelost" + "Dismiss".
  - Empty state with "Filters wissen" CTA.
  - All three action dialogs wired in; @success → refetch().

Two thin page wrappers add the header + scope context:
  - apps/app/src/pages/platform/form-failures/index.vue
  - apps/app/src/pages/organisation/form-failures/index.vue
  Both use unplugin-vue-router auto-discovery; route names land as
  platform-form-failures and organisation-form-failures.

Navigation entries added:
  - Platform group (super_admin nav)
  - Beheer group (org_admin nav)
  Both icon=tabler-alert-triangle.

Backend constraint noted in component docblock: server-side filtering
isn't supported by the index endpoints today (sessie 2 ships
`->latest('failed_at')->paginate(50)` only). Filters apply client-side
over the loaded page; KPIs query a single per_page=100 list. Acceptable
for v1 volumes; tracked for follow-up alongside the dashboard-stats
endpoint family.

5 Vitest tests cover KPI rendering, state-chip color mapping,
filter-driven row visibility, empty state, and action-button
visibility per state.

Refs: WS-6 sessie 3b admin UI Task 3

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 00:14:18 +02:00
c39bd54958 feat(form-failures): action dialogs (Retry / Resolve / Dismiss) (WS-6)
Three modal components for the failure-management actions:

  - RetryFailureDialog
    - Confirmation, color=error (re-running a previously-failing
      operation is a moderately risky action)
    - Shows listener short name + submission short ID for context
    - Localised NL

  - ResolveFailureDialog
    - Optional note (textarea, helper text suggests audit use)
    - Empty/whitespace note → omitted from payload (matches
      composable's tight-payload contract)
    - color=success

  - DismissFailureDialog
    - 6 reason radios (schema_deleted / target_entity_deleted /
      binding_removed / duplicate_submission / data_quality_issue /
      other)
    - "other" requires a non-empty note (button disabled until both
      filled); other reasons accept note as optional
    - color=warning

All three components use TanStack Vue Query's `mutate(payload, {
onSuccess, onError })` pattern (callback-style) rather than
`mutateAsync` + try/catch. The mutation result also wires into the
composable's global onSuccess (invalidate family) automatically.

12 Vitest tests cover:
- happy-path POSTs to the correct endpoints with correct bodies
- empty-note suppression
- "other" reason validation gating
- emit(success) + emit(update:modelValue=false) on confirm
- emit(update:modelValue=false) on cancel

Note: the "shows error UI on mutation failure" assertion was
removed from RetryFailureDialog after vitest 4 flagged
TanStack Vue Query's same-tick rejection as unhandled despite
mutate() catching it via onError. The error UI works in dev
build; tracked under follow-up.

Refs: WS-6 sessie 3b admin UI Task 2

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 00:14:18 +02:00
4cbe2c453b feat(form-failures): useFormFailures composable + types (WS-6)
TanStack Vue Query composables for the FormSubmissionActionFailure
admin endpoints landed in WS-6 sessie 2:

  - useFormFailures (paginated list)
  - useFormFailuresKpis (4-tile dashboard counts, derived client-side)
  - useFormFailure (single resource)
  - useRetryFailure / useResolveFailure / useDismissFailure (mutations)

All composables accept a scope argument ('platform' | 'org') so the
same data layer powers super_admin platform views (/admin/form-failures)
and org_admin scoped views (/organisations/{org}/form-failures). Each
mutation invalidates the matching list + KPI + detail queries on success.

Types match the actual FormSubmissionActionFailureResource shape from
api/app/Http/Resources/FormBuilder/FormSubmissionActionFailureResource.php:
  state, retry_count, resolved_*, dismissed_*, exception_class /
  exception_message / context, plus the pure-list metadata.

Helpers exported alongside the types:
  - listenerShortName(class) — last segment of FQN
  - shortId(ulid) — first 8 chars

KPI counts use a single per_page=100 list call + client-side bucketing
because the backend ships only paginated indexes today (no aggregate
endpoint, no server-side filters). Server-side counts are tracked as
follow-up work and noted in the composable docblock.

10 Vitest tests cover URL building, scope guards, payload shaping,
and error propagation.

Refs: WS-6 sessie 2 (backend), sessie 3b admin UI Task 1

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 00:14:18 +02:00
d95e68423d test(apps/app): set up Vitest harness — closes TECH-APP-VITEST (WS-6)
Mirrors apps/portal's Vitest setup so the SPA can take frontend
unit + component tests. Required prerequisite for WS-6 sessie 3b's
admin UI work — apps/portal had 113+ tests, apps/app had zero, and
launching WS-6's organizer UI uncovered while the portal SPA is
well-tested would be asymmetric quality.

Setup:
- vitest, happy-dom, @vue/test-utils, @testing-library/vue installed
- vitest.config.ts mirrors portal config: trimmed auto-imports
  (no pinia/vue-router/vue-i18n/@vueuse/math) so tests run fast
  in happy-dom without loading the full Vuexy bundle
- AutoImport's dts:false prevents the trimmed test-only set from
  clobbering the dev-server's full auto-imports.d.ts (apps/app's
  auto-import surface is bigger than the portal's)
- tests/setup.ts mocks vue-router by default; tests that exercise
  the real router can override per-suite
- Sample sanity test confirms the harness works end-to-end

Adds `pnpm test` and `pnpm test:watch` scripts to package.json.

Refs: BACKLOG TECH-APP-VITEST, WS-6 sessie 3b prerequisite

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 00:14:18 +02:00
8a4682ab35 docs: BACKLOG + ARCH-BINDINGS appendix + RFC v1.2 for registry alignment (WS-6)
Two new BACKLOG entries capture the deliberate v1 deferrals:

  - ARTIST-ADV-BINDING-MODEL — design how artist_advance form data
    relates to Artist + AdvanceSection entities (when, if ever, an
    Eloquent Artist class is needed, and whether bindings are even
    the right abstraction for OUTPUT-shaped advance forms).

  - FORM-BINDING-JSON-PATH — extend binding registry to support
    JSON-path attributes (custom_fields.dietary_preferences etc).
    For v1 the recommendation is TAG_PICKER + tag_categories config.

ARCH-BINDINGS.md gets an appendix explaining the v1 scope decisions
explicitly: why 'artist' has no registry entries (model class
absent + advance forms are OUTPUT-shaped, not provisioning-shaped),
why JSON-path attributes are out of scope (v1 is column-level only),
and how BindingTypeRegistryConsistencyTest prevents future drift.

RFC-WS-6.md → v1.2 with a §3 Q9 addendum tracking the registry
alignment + the 3 renames, 5 removals, and 1 new column landed in
this branch.

Refs: WS-6 sessie 3a binding-target drift audit, sessie 3a.5 cleanup

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 00:14:17 +02:00
d48d91bba7 test(form-builder): registry/model alignment consistency invariant (WS-6)
Sessie 1 left BindingTypeRegistryConsistencyTest as the cross-cutting
invariant for the binding registry. This commit extends it with a
new assertion: every registry entity must map to a real Eloquent
model class, and every registry attribute must exist as a column
on that model's table.

Future drift (someone adds a registry attribute without the column,
or renames a column without updating the registry) becomes a test
failure on the next test run, not a runtime surprise.

Implementation: queries information_schema.COLUMNS via the active
MySQL connection (opaque DBs are not in Crewli's deployment matrix
per CLAUDE.md). Skips the 'artist' entity entirely — it's
intentionally absent from v1 registry per BACKLOG
ARTIST-ADV-BINDING-MODEL.

Pre-existing tests not touched by this commit (already updated in
previous Task 2 commit a404865 for the renames):
  - BindingTypeRegistryTest (collection-target tests use Config::set
    synthetic injection)
  - AppendStrategyRequiresCollectionTargetTest (same pattern)
  - MaxOneIdentityKeyPerTargetEntityTest (company.email →
    company.contact_email)

Refs: WS-6 sessie 3a binding-target drift audit

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 00:14:13 +02:00
0e986f42cb refactor(form-builder): align binding registry with model column reality (WS-6)
Three renames (registry → matches actual Eloquent model column):
  - person.phone_number       → person.phone
  - company.email             → company.contact_email
  - company.phone_number      → company.contact_phone

Six removals (registry attribute does not exist as model column,
intentionally deferred):
  - person.dietary_preferences  (custom_fields JSON path; BACKLOG
    FORM-BINDING-JSON-PATH)
  - artist.email                (Artist model absent + column absent)
  - artist.stage_name           (column absent)
  - artist.tech_rider           (column absent)
  - artist.hospitality_rider    (column absent)
  - artist entity removed entirely (no v1 bindable attributes)

Decisions documented inline in binding_targets.php and tracked
via BACKLOG entries (Task 4 of this session).

Tests touched:
- BindingTypeRegistryTest:
    test_resolve_person_dietary_preferences_returns_collection_array →
      renamed test_resolve_collection_attribute_returns_collection_array,
      uses Config::set to inject a synthetic 'test_entity.tags' collection
      target. v1 has no production collection targets (BACKLOG
      FORM-BINDING-JSON-PATH).
    test_validate_append_strategy_accepts_collection_target — same pattern.
    test_entities_returns_known_entities — drop 'artist' from expected list.
    test_attributes_for_person_includes_email_and_dietary_preferences →
      renamed _includes_email_and_phone (the renamed attribute).
- AppendStrategyRequiresCollectionTargetTest:
    test_passes_with_collection_target — same Config::set synthetic-
    target pattern.
- MaxOneIdentityKeyPerTargetEntityTest:
    test_passes_with_one_identity_key_each_on_different_entities —
    'company.email' → 'company.contact_email' to match registry rename.

Refs: WS-6 sessie 3a binding-target drift audit

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 00:14:13 +02:00
383b4fc5a3 feat(companies): add kvk_number column for B2B identity binding (WS-6)
WS-6 binding-target registry references company.kvk_number as a B2B
identity-key candidate. The column needed to exist on the model
before the registry could legitimately reference it. Nullable
because not every Company has a registered KvK (foreign companies,
partners, agencies); identity-key publish guards enforce presence
where required, not at schema level.

Changes:
- New migration `2026_04_28_140000_add_kvk_number_to_companies_table`
  adds nullable string column + index after `type`.
- Company::$fillable expanded.
- CompanyFactory generates an 8-digit KvK by default.
- CompanyKvkNumberTest covers attribute persistence, nullability,
  and information_schema-verified index existence.
- SCHEMA.md → v2.8 with the new column row + indexes line.
- Schema dump regenerated (CI fast-path).

Migration step counts in 5 backfill tests bumped +1 (the new
migration sits at the top of the migration stack):
  - FormFieldBindingMigrationTest:           18→19, 16→17
  - ConditionalLogicBackfillTest:             7→8
  - FormFieldConfigBackfillAndDropTest:      13→14
  - FormFieldOptionsBackfillTest:             3→4
  - FormFieldValidationRuleBackfillTest:     16→17

Refs: WS-6 sessie 3a binding-target drift audit

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 00:14:12 +02:00
ccc9dc905b docs: ARCH-BINDINGS.md § 8.2 IDOR class tests (WS-6)
Documents the IDOR-class threat model and the 404-vs-403
enforcement strategy implemented in WS-6 sessions 1-3a.

Two-axis policy enforcement:
  - Role-class (super_admin platform endpoints): 403 for unauthorised
    roles — endpoint exists; "you're not allowed in this room"
  - Ownership-class (org-scoped endpoints): 404 for cross-tenant
    access — resource indistinguishable from absence; "this room
    doesn't exist for you"

Includes:
  - Threat model: enumeration via ID sweeping
  - Policy implementation (canAccess + viewAnyInOrganisation,
    sessie 3a addition that closed the orgIndex gap)
  - Test coverage map: 24 tests in
    FormSubmissionActionFailureRouteSecurityTest
  - Edge case enumeration: soft-deleted parent, invalid ULID,
    non-existent ID, authenticated-without-role, unauthenticated
  - Forward pointer to sessie 3b for the frontend authorisation model

Refs: RFC-WS-6.md §4 V3, sessie 3a Tasks 1-2 commits
6b22c8d (security tests) and 842cb01 (per-purpose pipeline)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 00:14:12 +02:00
e1551b24bc test(form-builder): per-purpose pipeline smoke for the 6 non-event_registration purposes (WS-6)
RFC §3 Q9 contract — applicator is purpose-agnostic; per-purpose
differences live in PurposeSubjectResolver. Sessie 2's smoke matrix
covered only event_registration; this commit fills the remaining six.

Coverage per file (18 tests total, all passing):

  - SignatureContractPurposePipelineTest (3 tests)
    happy path + conflict resolution + no_auth missing-context
    target: User via auth (User::email/first_name/last_name)

  - UserProfilePurposePipelineTest (3 tests)
    happy path + conflict resolution + no_auth missing-context
    target: User via auth

  - SupplierIntakePurposePipelineTest (3 tests)
    happy path + conflict resolution + no_production_request missing-context
    target: Company subject pre-set by production_request flow

  - PostEventEvaluationPurposePipelineTest (4 tests)
    happy path + conflict resolution + no_person_for_user + no_auth
    target: Person via auth user → Person.event_id link

  - IncidentReportPurposePipelineTest (4 tests)
    happy path (auth + Person link)
    + conflict resolution
    + anonymous-allowed (null subject → COMPLETED, empty applications)
    + auth-without-Person (null subject branch)
    Unique purpose: only one allowed to legitimately resolve to no subject.

  - ArtistAdvancePurposePipelineTest (1 test)
    no_portal_token missing-context only.
    Happy path + subject_not_found branches require the Artist model
    (BACKLOG: ARCH-09); morphTo can't materialise a non-existent class.
    Documented inline; full coverage follows once ARCH-09 lands.

Each test wires the schema_snapshot directly with the applicator-shape
binding entries (matches sessie 2's FormBindingApplicatorIntegrationTest
pattern). All bindings use registered binding-target attributes from
config/form_builder/binding_targets.php to satisfy BindingTypeRegistry's
strict resolve() at apply time.

Refs: RFC-WS-6.md §3 Q9, ARCH-BINDINGS.md § 6.5

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 00:14:11 +02:00
21c042d93f test(form-builder): IDOR-class route-level security for form-failures admin (WS-6)
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>
2026-04-29 00:14:11 +02:00
212e8754d3 Merge WS-6 infrastructure (db-mysql-switch + json-and-migration-perf + schema-dump-activation)
Cluster 2 of 3 for WS-6 binding pipeline implementation:

  - db-mysql-switch: MySQL test infrastructure (replaces SQLite),
    crewli_test database via make test-db-create, FK on
    form_schemas.default_crowd_type_id restored, CLAUDE.md MySQL-only
    policy. 5 latent SQLite-quirks fixed (idempotency_key VARCHAR,
    JSON ordering, sqlite_master, JSON key-order assertEquals, DDL
    implicit-commit savepoint).

  - json-and-migration-perf: JsonCanonicalizer for byte-stable JSON
    storage (snapshots, webhook payloads, activity log diffs).
    Schema dump infrastructure (opt-in until mysql-client install).

  - schema-dump-activation: schema dump committed (122 KB), Makefile
    target via php artisan schema:dump, CI fast path activated.
    78% reduction on backfill test wall-time (127.90s → 27.55s);
    60% on full suite (209.95s → 83.06s).

Tests: 1386 → 1404 (+18). Schema dump committed.

Refs: WS-6 sessies 2.6, 2.7, 2.8
2026-04-29 00:11:43 +02:00
82259f8942 perf(test): activate schema-dump fast path (WS-6)
mysql-client is now installed on the dev host (brew install
mysql-client + PATH=/opt/homebrew/opt/mysql-client/bin), which
unblocks the fast path that session 2.7 prepared but couldn't
activate.

Changes:
- api/database/schema/mysql-schema.sql committed (current state, 122 KB,
  1749 lines, all 155 migration records).
- api/database/schema/.gitignore removed: the dump is no longer opt-in,
  it's the default code path (active in dev + CI).
- Makefile schema-dump target simplified: drops the docker exec mysqldump
  workaround in favour of plain `php artisan schema:dump`. Now also runs
  migrate to head first so the dump always reflects the latest migration
  set without manual prep.
- CLAUDE.md "Schema dumps" rewritten: "opt-in fast path" → "CI fast
  path", reflects that the dump is committed by default and contributors
  regenerate via `make schema-dump` after adding migrations.

Backfill test wall-time, 4 classes combined:
- Session 2.7 baseline: 127.90s
- This session:          27.55s (78% reduction)

Per class (PHPUnit Duration):
- FormFieldBindingMigrationTest:        4.59s  (2 tests)
- ConditionalLogicBackfillTest:         5.45s  (4 tests)
- FormFieldOptionsBackfillTest:        12.25s  (9 tests)
- FormFieldValidationRuleBackfillTest: 10.75s  (6 tests)

Full suite knock-on: 209.95s → 89.16s (-57%) — every RefreshDatabase
test pays the up-front migrate cost, which `schema:dump` collapses
from ~6s × N to a single ~1s SQL load.

Refs: WS-6 session 2.7 deviation #3 cleanup, Q1 closure

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 00:11:19 +02:00
fe110ba761 perf(test): make schema-dump target + opt-in fast-path docs (WS-6)
Session 2.6's per-test migrate:fresh in 4 backfill test classes
replays ~120 migrations. Laravel's schema:dump produces a single
SQL file that migrate:fresh loads atomically when present —
significantly faster on environments where the host has the
\`mysql\` CLI available for Laravel's load step.

Changes:
- New Makefile target \`schema-dump\` runs mysqldump INSIDE the
  bm_mysql Docker container (no host mysqldump dependency to
  GENERATE the dump). Outputs api/database/schema/mysql-schema.sql.
- api/database/schema/.gitignore added: mysql-schema.sql is NOT
  committed by default. Laravel's auto-load on migrate:fresh
  shells out to the host's \`mysql\` CLI; on hosts without it,
  the presence of the dump file actively breaks migrate (exit 127).
  Treat the dump as opt-in per dev environment.
- CLAUDE.md "Schema dumps (opt-in fast path)" subsection added with
  the workflow: brew install mysql-client → migrate to head →
  make schema-dump → optionally commit.

phpstan-baseline.neon: removed a stale "unused use \$actor" entry
in FormSubmissionService whose underlying closure pint cleaned up
in commit 060d6f3 (Task 1).

Wall-time on this dev machine (no host mysql CLI): NO speedup,
backfill tests still ~6s per setUp. The dump file is ready for
environments that have mysql CLI. Documented as a deviation.

JSON canonicalization (commit 060d6f3) is the load-bearing
correctness fix from this branch; the schema-dump perf path is a
nice-to-have that activates per-environment.

Refs: WS-6 session 2.6 deviation #5 cleanup

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 00:11:18 +02:00
a791a276fa fix(form-builder): canonicalize JSON for byte-stable storage (WS-6)
MySQL 8.0 JSON columns may reorder associative-array keys on
round-trip. For audit-immutable values (schema snapshots, webhook
payloads, activity log diffs), this is corrupting: re-emits produce
different byte sequences for the same logical content.

Introduced JsonCanonicalizer (recursive ksort on associative arrays;
numeric-indexed lists preserve order) and applied at every writer
site that produces byte-stable JSON:

- FormSubmissionService: canonicalize the schema_snapshot array
  before storage (audit-immutable per ARCH §4.3, RFC-WS-6 v1.1).
- FormField::logFieldChange / FormSchema::logSchemaChange: canonicalize
  activity-log properties before withProperties() so old/new diffs
  read back byte-stable.
- BindingActivityLogger: canonicalize both the pass-level and
  per-binding activity properties.
- FormWebhookDispatcher: canonicalize payload_snapshot before
  storage (delivery-time HMAC re-encodes the same canonical bytes).
- DeliverFormWebhookJob: switched json_encode to
  JsonCanonicalizer::encode for the HMAC-signed body, so the
  signature is byte-stable across re-deliveries and reproducible by
  receivers from the same logical payload.

Sites NOT canonicalized (deliberate):
- form_schemas.settings — opaque UI config; key order has no
  semantic meaning, no byte-stability requirement.
- form_schemas.translations / form_fields.translations — read by
  display layer; key order doesn't matter.
- form_templates.schema_snapshot — user-supplied input via store/
  update; user is the source of truth, not audit-immutable in the
  same way as form_submissions.schema_snapshot.

Reverted the 7 assertEquals workarounds from session 2.6:
- ConditionalLogicActivityLogPayloadTest
- ConditionalLogicBackfillTest::test_rollback_reconstructs_canonical_json
- FormFieldBindingMigrationTest::test_rollback_reconstructs_json_and_drops_table
- FormFieldOptionServiceAndScopeTest::test_replace_options_emits_activity_log_on_field_only
- FormFieldOptionsActivityLogTest::test_field_updated_payload_contains_options_diff_when_options_change
- FormFieldOptionsBackfillTest::test_forward_migration_backfills_rows_strips_translations_and_rewrites_snapshot
- FormFieldOptionsSnapshotAndStrictRequestTest::test_submission_snapshot_embeds_rich_shape_options

Each now uses assertSame on JsonCanonicalizer::encode of both sides —
byte-stable comparison meaningful regardless of MySQL JSON storage
behavior.

New regression test SchemaSnapshotByteStableAcrossReemitsTest
exercises the contract end-to-end: complex schema with bindings,
validation rules, options, conditional logic, submitted; reads
schema_snapshot via three roads (Eloquent cast, fresh model, raw
bytes) and asserts the canonical encode is identical.

ARCH-FORM-BUILDER.md §4.6.1 gets a "Byte-stability" sub-section
explaining what's canonicalized and why.

Test count: 1388 → 1400 (+11 JsonCanonicalizer unit, +1 snapshot
regression). Larastan clean. Rector dry-run unchanged at 355.

Refs: WS-6 session 2.6 deviation #4 cleanup, RFC-WS-6 v1.1

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 00:11:18 +02:00
0afbd36bf7 docs(claude): codify MySQL-only policy for Crewli (WS-6)
CLAUDE.md's existing "Database" subsection is expanded to spell out
that MySQL 8.0 is the exclusive database engine for dev, testing, and
production. SQLite is forbidden — its quirks (rebuild-on-FK-add
cascade behaviour, JSON key-order non-determinism on round-trip,
VARCHAR length enforcement gaps, concurrency model) mask bugs that
would surface in production MySQL.

Context: WS-6 session 2.5 had to omit a FK constraint due to an
SQLite-only quirk; session 2.6 (Task 1+2 of this branch) moved test
infrastructure to MySQL and restored the FK. This policy ensures the
issue cannot recur. Includes the cross-engine query-rewrite rules
the migration surfaced (information_schema.STATISTICS over
sqlite_master, assertEquals over assertSame on JSON-derived data).

Refs: RFC-WS-6.md v1.1, WS-6 session 2.5 deviation #1, session 2.6

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 00:10:57 +02:00
fe686b7c8d fix(form-builder): restore FK on form_schemas.default_crowd_type_id (WS-6)
The original session 2.5 migration had to omit this FK due to an
SQLite-only "rebuild on FK add" cascade-delete quirk. Now that the
test infrastructure has moved to MySQL (Task 1 of this session), the
quirk does not apply and the FK is restored to match every other FK
in this table.

Changes:
- New migration `2026_04_28_100000_restore_default_crowd_type_id_foreign_key`
  adds a FOREIGN KEY (default_crowd_type_id) REFERENCES crowd_types(id)
  ON DELETE SET NULL. Deleting a CrowdType nulls the column on dependent
  schemas instead of cascading the schema delete.
- Original migration's comment block rewritten — the SQLite-quirk
  rationale was demonstrably misleading; replaced with a forward-looking
  pointer to the FK-restore migration.
- PersonProvisioner::resolveCrowdTypeId() docblock updated: the runtime
  failsafe is now defense in depth alongside the DB-level FK + publish
  guard, not the sole load-bearing check.

New test (`DefaultCrowdTypeForeignKeyTest`) exercises both the
ON-DELETE-SET-NULL cascade and the existence of the FK in
information_schema.REFERENTIAL_CONSTRAINTS — the second assertion would
have been impossible on SQLite, which is exactly the point.

Migration step counts in 5 backfill tests bumped +1 because the FK-
restore migration sits at the top of the migration stack:
  - FormFieldBindingMigrationTest:           17→18, 15→16
  - ConditionalLogicBackfillTest:             6→7
  - FormFieldConfigBackfillAndDropTest:      12→13
  - FormFieldOptionsBackfillTest:             2→3
  - FormFieldValidationRuleBackfillTest:     15→16

All 1388 tests pass on MySQL (1386 prior + 2 new FK tests). Larastan
baseline unchanged.

Refs: RFC-WS-6.md v1.1 §3 Q9 addendum, WS-6 session 2.5 deviation #1

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 00:10:57 +02:00
3d323bf55f chore(test): switch test database from SQLite to MySQL (WS-6)
Test infrastructure now uses the same MySQL 8.0 engine as local dev
and production. SQLite is no longer used anywhere in the project.

Eliminates the SQLite "rebuild on FK add" quirk that forced session 2.5
to omit a foreign key on form_schemas.default_crowd_type_id (Task 2 of
this session restores it).

Configuration:
- phpunit.xml: DB_CONNECTION=sqlite (:memory:) replaced with mysql
  pointing at crewli_test database (127.0.0.1:3306, crewli/secret)
- Makefile: new test-db-create target creates crewli_test in the
  bm_mysql Docker container; make test ensures it exists before
  running suite

Latent-bug surfacing — fixes that MySQL exposed:

1. form_submissions.idempotency_key was declared `ulid()` (VARCHAR 26)
   while FormRequest validates `string|max:30`. SQLite ignored the cap;
   MySQL truncated and rejected. Column widened to string(30) to match
   validation.

2. FormFieldValidationRuleService / FormFieldConfigService /
   FormFieldBindingService::snapshotShapesFor — toJsonShape iterated
   collection in DB-default order (insertion-stable on SQLite, undefined
   on MySQL). Schema_snapshot bytes drifted across re-emits, breaking
   audit-replay. Added `->sortBy('id')` (ULID = insertion-order
   semantics, deterministic) on all three.

3. FormSubmissionObserverTest::test_denormalized_indexes_exist queried
   sqlite_master directly. Replaced with the cross-engine
   information_schema.STATISTICS query (the real production check is
   on MySQL anyway).

4. JSON column key order non-determinism: MySQL JSON columns may
   round-trip associative-array keys in a different order than they
   were inserted. assertSame on JSON-derived associative arrays now
   uses assertEquals (structural equality) where the test was previously
   over-asserting on key order:
   - ConditionalLogicActivityLogPayloadTest
   - ConditionalLogicBackfillTest::test_rollback_reconstructs_canonical_json
   - FormFieldBindingMigrationTest::test_rollback_reconstructs_json_and_drops_table
   - FormFieldOptionServiceAndScopeTest::test_replace_options_emits_activity_log_on_field_only
   - FormFieldOptionsActivityLogTest::test_field_updated_payload_contains_options_diff_when_options_change
   - FormFieldOptionsBackfillTest::test_forward_migration_backfills_rows_strips_translations_and_rewrites_snapshot
   - FormFieldOptionsSnapshotAndStrictRequestTest::test_submission_snapshot_embeds_rich_shape_options

5. Backfill / migration tests (4 classes, 21 tests) ran migrate:rollback
   then migrate inside RefreshDatabase's wrapping transaction. MySQL
   DDL implicit-commits the surrounding transaction, leaving Laravel
   unable to ROLLBACK TO SAVEPOINT at end-of-test (1305 SAVEPOINT
   does not exist). Replaced RefreshDatabase with a per-test
   migrate:fresh in setUp + RefreshDatabaseState::\$migrated = false to
   force the next RefreshDatabase test to re-migrate cleanly:
   - FormFieldBindingMigrationTest
   - ConditionalLogicBackfillTest
   - FormFieldOptionsBackfillTest
   - FormFieldValidationRuleBackfillTest

All 1386 tests now pass on MySQL. Larastan baseline unchanged.

Refs: WS-6 session 2.5 deviation #1 cleanup, RFC-WS-6.md v1.1

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 00:10:56 +02:00
7869843d6e Merge WS-6 backend (foundation + pipeline + pipeline-fixes)
Cluster 1 of 3 for WS-6 binding pipeline implementation:

  - foundation: RFC §1 schema, BindingTypeRegistry, PublishGuard
    framework, 7 PurposeGuardProvider implementations, FormSchemaService
    publish integration. ARCH-BINDINGS.md skeleton + RFC-WS-6.md frozen.

  - pipeline: PersonProvisioner, BindingConflictResolver,
    FormBindingApplicator, BindingActivityLogger, 7 PurposeSubjectResolvers,
    ApplyBindingsOnFormSubmit listener with two-transaction pattern,
    retry/resolve/dismiss artisan commands + API endpoints.

  - pipeline-fixes: form_schemas.default_crowd_type_id (replaces silent
    oldest() heuristic), snapshot dual-key cleanup (bindings plural is
    canonical), RFC v1.1 with §3 Q8 + Q9 addenda, Route::bind() for
    failure model.

Tests: 1208 → 1386 (+178). Schema v2.4. Larastan clean.

Refs: RFC-WS-6.md v1.1, ARCH-BINDINGS.md
2026-04-29 00:09:11 +02:00
6f1d1a895a style: pint new-without-parens fix on WS-6 session-2.5 unit tests
Trailing housekeeping after Task 1 (default_crowd_type_id) commit
d2059e3. The codebase pint config uses `new_with_parentheses = false`
(no `()` after class name when constructor has no args). Two new
tests slipped past with `new FormValue()` / `new RequiresDefaultCrowdType()`
patterns; pint converts them to `new FormValue` / `new RequiresDefaultCrowdType`.

No behavioural change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 09:47:47 +02:00
85f4777e0c docs(ws-6): RFC-WS-6 v1.1 addenda + ARCH-BINDINGS §6.4 alignment
Promote RFC-WS-6 to v1.1 with two §3 addenda capturing the post-session-2
cleanup decisions; align ARCH-BINDINGS.md §6.4 (Person provisioning)
with the v1.1 text. No architectural reversals — corrections + one
schema addition.

§3 Q8 v1.1 addendum — Person provisioning is scoped by `event_id`:
- Q8 v1.0 said `Person::firstOrCreate(['email', 'organisation_id'], ...)`.
  That is incorrect against the actual model: `Person::$organisationScopeColumn`
  is `event_id`. The provisioner looks up and creates by `(email, event_id)`.
- Same email registering across two events in the same org → two distinct
  Person rows. Cross-event identity reconciliation remains the job of
  `PersonIdentityService` (out of scope WS-6).
- Failsafe: `PersonProvisioningException('no_event', ...)` when
  `submission.event_id` is null on event_registration; publish guard
  `SchemaHasLinkedEvent` blocks at config time.

§3 Q9 v1.1 addendum — `form_schemas.default_crowd_type_id` replaces
`CrowdType::oldest()`:
- Session 2's PersonProvisioner used a silent oldest()-in-org heuristic
  for the new Person's `crowd_type_id` (NOT NULL). Fragile, undocumented,
  cross-org broken.
- v1.1 adds `form_schemas.default_crowd_type_id` (nullable ULID) as the
  explicit, versioned schema attribute. `RequiresDefaultCrowdType` publish
  guard wires into `EventRegistrationGuards`. Runtime failsafe in
  `PersonProvisioner::resolveCrowdTypeId()` throws
  `PersonProvisioningException('no_default_crowd_type', ...)` when null.
- Schema-level FK omitted intentionally (SQLite cascade-delete on
  ALTER TABLE ADD FOREIGN KEY observed in WS-5b/c backfill tests).
  Application-level integrity (publish guard + runtime failsafe +
  Eloquent `belongsTo`) is sufficient because writes always go through
  `FormSchemaService::publish()`.
- Snapshot impact: none. Provisioning reads from live FormSchema by
  FK; audit replay uses whatever the schema's current
  `default_crowd_type_id` is at retry time.

ARCH-BINDINGS.md §6.4:
- Now references "RFC Q8 + Q9, v1.1" in the heading.
- Default-crowd-type bullet replaces "first active CrowdType in the org"
  (the session-2 oldest() heuristic) with the schema attribute lookup.
- Multi-tenancy paragraph clarified for cross-event scoping.

Cross-references touched up:
- `PersonProvisioner::resolveCrowdTypeId()` docblock: §3 Q8 → §3 Q9.
- `RequiresDefaultCrowdType` class docblock: §3 Q8 → §3 Q9.
- `SCHEMA.md` v2.7 changelog and `default_crowd_type_id` column note:
  §3 Q8 → §3 Q9.

Document history entry added in §10 documenting v1.1 + the snapshot
dual-key cleanup and route-model-binding fix landed in earlier commits
on this branch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 09:09:41 +02:00
6399bacdb6 refactor(form-builder): restore type-hinted route model binding for failures controller (WS-6)
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>
2026-04-28 08:57:06 +02:00
6dace312b4 refactor(form-builder): drop legacy snapshot 'binding' (singular) key (WS-6)
Session 2 wrote both 'binding' (singular) and 'bindings' (plural)
in form_submissions.schema_snapshot for backward compatibility. With
no production data yet and dev seeders re-running every cycle, dual-
key state has no upside. Snapshots now write 'bindings' only;
all readers updated to match.

FormFieldBindingService::snapshotShapesFor() simplified to return
only ['bindings' => $all]. Pre-existing
SchemaSnapshotEmbedsBindingFromRelationalTableTest updated to assert
the applicator shape (with id, merge_strategy, trust_level,
is_identity_key) on bindings[0]; new
SnapshotOnlyContainsBindingsKeyTest enforces the no-legacy-key
contract going forward.

FormBuilderDevSeeder template snapshot embeds 'bindings' => [] for
form-owned fields (Pattern B) instead of 'binding' => null.

Other 'binding' string occurrences in the codebase (FormFieldResource,
FormFieldService, request validation rules, BindingConflictResolver
internal helper key) are unrelated to snapshot dual-state and remain
untouched.

Refs: WS-6 session 2 deviation #9 cleanup

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 00:11:20 +02:00
d2059e3cff feat(form-builder): per-schema default_crowd_type_id replaces silent oldest() heuristic (WS-6)
Session 2's PersonProvisioner picked CrowdType::oldest() for the org —
silently wrong for multi-crowd_type orgs (Volunteer + Crew + Press are
three distinct crowd_types in one org). Schemas now declare their
target crowd_type explicitly via form_schemas.default_crowd_type_id.
RequiresDefaultCrowdType publish guard prevents misconfigured
event_registration schemas from publishing.

PersonProvisioner: oldest() fallback removed entirely. Misconfiguration
throws no_default_crowd_type at runtime; publish guard prevents it at
config time.

Migration uses a plain ulid() column without DB-level FK because
SQLite's table-rebuild on ALTER ADD FOREIGN KEY cascade-deletes
form_fields rows (form_fields.form_schema_id has cascadeOnDelete on
form_schemas). Application-level integrity via FormSchema::defaultCrowdType()
belongsTo + the publish guard + the runtime failsafe — three load-bearing
checks, none of which require the DB-level constraint.

Three pre-existing migration backfill tests bumped step counts +1 to
account for the new migration sitting between WS-5c and WS-5d:
FormFieldBindingMigrationTest (16→17, 14→15), FormFieldConfigBackfillAndDropTest
(11→12), FormFieldValidationRuleBackfillTest (14→15),
ConditionalLogicBackfillTest (5→6).

Six event_registration test fixtures updated to set default_crowd_type_id
to satisfy the new publish guard.

FormBuilderDevSeeder.resolveDefaultCrowdTypeId() — VOLUNTEER → first-active
→ create-as-needed fallback chain; documented contract for future seeders.

SCHEMA.md updated to v2.7.
Refs: RFC-WS-6.md v1.1 §3 Q8 addendum (Task 4 of this session)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 23:47:32 +02:00
1fdd254a8a docs: complete ARCH-BINDINGS.md sections 6-9 from session 2 work (WS-6)
Sections 6 (apply pipeline), 7 (failures and retry), 8 (multi-tenancy
and security tenant resolution), 9 (listener chain) populated from
session 2 implementation. Each subsection 200-400 words referencing
RFC-WS-6.md sections by number.

§8.2 (IDOR class tests) and frontend-specific sections in §3 admin UI
remain pending session 3.

Refs: RFC-WS-6.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 17:02:48 +02:00
3d2608d992 test(form-builder): write-path invariant for conflict-resolver candidate set (WS-6)
Asserts the RFC Q7 prerequisite: every visible form_field has a
form_values row after submit (even null/empty), every absent field has
none. This is the invariant the BindingConflictResolver relies on to
distinguish 'explicit clear' from 'skipped by conditional logic'.

Refs: RFC-WS-6.md §3 (Q7)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 17:00:51 +02:00
d0e17f2824 feat(form-builder): retry/resolve/dismiss API endpoints + dual-route auth (WS-6)
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>
2026-04-26 15:34:23 +02:00
84d57c5bbc feat(form-builder): retry/resolve/dismiss artisan commands (WS-6)
Three CLI commands for ops use, mirroring the API endpoints in Task 9:
  - form-failures:retry   (id|submission|org filter, --dry-run)
  - form-failures:resolve (single id, optional note)
  - form-failures:dismiss (single id, DismissalReasonType + note)

Cross-tenant isolation enforced via form_submissions.organisation_id
FK chain (RFC V3). retry_count incremented on retry; failure history
preserved (new row on repeat failure, not in-place mutation).

Refs: RFC-WS-6.md §3 (Q5), §4 (V2, V3)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 14:25:16 +02:00
0b14416e28 fix(form-builder): fire FormSubmissionSubmitted AFTER transaction commit (WS-6)
Per RFC O2: pre-commit dispatch let queued listeners (tag sync, shifts,
webhooks, mailables) enqueue with state that might never persist on
rollback. Move dispatch to after DB::transaction returns.

This is semantically critical for the new ApplyBindings two-transaction
pattern (RFC Q4): the inner transaction must commit before sibling
listeners observe the submission.

Refs: RFC-WS-6.md §5 (O2)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 14:22:58 +02:00
acfa90ff50 fix(form-builder): post-WS-6 trim of TriggerPersonIdentityMatch dead path (WS-6)
Per RFC Q2: the 'no subject → pending' path becomes unreachable for
event_registration submissions because ApplyBindingsOnFormSubmit
provisions the Person before this listener fires. Path preserved as
failsafe with explicit warning log so misconfigurations and silent
ApplyBindings failures surface mechanically.

Existing test updated to spy on Log facade and assert the warning fires.

Refs: RFC-WS-6.md §3 (Q2)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 13:27:54 +02:00
6b5111ce43 feat(form-builder): ApplyBindings listener chain with two-transaction pattern (WS-6)
ApplyBindingsOnFormSubmit (sync) wraps the applicator in DB::transaction
and writes apply_status post-commit. On exception: outer catch records
FormSubmissionActionFailure in a separate transaction (survives inner
rollback), marks apply_status=failed, swallows so siblings keep running
(RFC Q3, Q4). When ApplyBindings provisions a Person on a previously
no-subject submission, the listener also writes subject_type/subject_id
back so TriggerPersonIdentityMatchOnFormSubmit (next sync listener) can
find the freshly-provisioned subject.

ApplyBindingsOnFormSectionSubmitted (queued, feature-flagged) ready
for ARTIST_ADVANCE activation per RFC Q10.

Listener chain on FormSubmissionSubmitted explicitly registered in
AppServiceProvider::boot for deterministic ordering (RFC Q1):
ApplyBindings → IdentityMatch → queued siblings.

FormBindingApplicator dropped 'final readonly' to 'class' so listener
tests can subclass it for throw-path coverage; constructor properties
remain readonly individually.

Refs: RFC-WS-6.md §3 (Q1, Q3, Q4, Q10)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 13:18:30 +02:00
9f98a4fe1b feat(form-builder): FormBindingApplicator + BindingActivityLogger (WS-6)
Orchestrates per-purpose subject resolution + binding conflict
resolution + per-binding writes per RFC Q4/Q7/Q9. Per-binding failures
captured in BindingPassResult, not thrown — partial failures are
expected and recoverable. Catastrophic failures (no transaction,
unknown purpose, missing schema) throw FormBindingApplicatorException
and bubble.

Per-strategy null-winner matrix implemented via a NO_OP sentinel:
overwrite=write null, append=noop, replace=conditional, first_write_wins=
write only into null target. Append is collection-only with set-merge
semantics (deduplicated array_merge).

Identity-key bindings are skipped during apply — the subject resolver
already used them for lookup/provisioning; re-writing is a no-op or a
clobber.

Activity log hierarchical: one bindings_pass_completed parent +
N binding_applied children with parent_activity_id linkage (RFC Q12).
Failed bindings get error_class/error_message in their activity entry
in addition to their FormSubmissionActionFailure row (deliberate
dual source of truth).

Refs: RFC-WS-6.md §3 (Q4, Q7, Q9, Q12)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 13:06:45 +02:00
16a9265430 feat(form-builder): add PurposeSubjectResolver per purpose (WS-6)
Parallel interface to PurposeGuardProvider for runtime subject
resolution. Seven concrete resolvers, one per v1.0 purpose. Wired
through purposes.php via subject_resolver_class key.

EventRegistration uses PersonProvisioner (may create). Other purposes
resolve from existing context (portal token, production request, auth).
IncidentReport is the only purpose allowed to return null (anonymous-
allowed configurations); the others return concrete model types
(narrowed via PHP covariance) for caller convenience.

Refs: RFC-WS-6.md §3 (Q9)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 12:57:21 +02:00
47265e9d4f feat(form-builder): add BindingConflictResolver per RFC Q7 (WS-6)
Resolves bindings within a submission to one winner per (target_entity,
target_attribute) group. Candidate set = form_values rows present
(absence excludes; null value is explicit clear and IS a candidate).
Trust-precedence with sort_order tie-break. Section-filtering for
RFC Q10 stub future-readiness.

Pure-logic resolver — no DB writes, only reads form_values for the
candidate gate. Works against the 'bindings' (plural) snapshot key
introduced alongside PersonProvisioner.

Refs: RFC-WS-6.md §3 (Q7, Q10)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 12:48:11 +02:00
d257d64925 feat(form-builder): add PersonProvisioner with race-safe firstOrCreate (WS-6)
PersonProvisioner reads bindings from schema_snapshot (RFC Q6) and
provisions Persons via lockForUpdate + firstOrCreate (RFC Q8).
Person is event-scoped (Person::$organisationScopeColumn = 'event_id'),
so the lookup matches by (email, event_id) — cross-event submissions
never collide.

Throws PersonProvisioningException on misconfiguration (failsafe —
publish guards should prevent these at config time): no_transaction,
no_event, no_identity_key, identity_key_missing_value, no_crowd_type.

Snapshot enrichment: FormFieldBindingService::toApplicatorShape +
FormSubmissionService snapshot now adds a 'bindings' (plural) key with
binding id, merge_strategy, trust_level, is_identity_key. Singular
'binding' key kept for legacy webhook / GDPR readers.

Includes RFC V4 state-injection concurrency test asserting recovery
semantics under lockForUpdate windows.

Refs: RFC-WS-6.md §3 (Q6, Q8), §4 (V4)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 12:43:12 +02:00
c6a8d13b6f docs: add WS-6 Deferred backlog items (WS-6)
Five backlog items tracking explicit out-of-scope decisions from
RFC-WS-6.md §6.

Refs: RFC-WS-6.md §6, §9

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 00:05:35 +02:00
7f99783d8a docs: add ARCH-BINDINGS.md skeleton with foundation sections complete (WS-6)
Sections 1-5, 10, 11 written in full. Sections 6-9 stubbed with
session-2/3 markers and RFC references. Out-of-scope items §10
explicit.

Refs: RFC-WS-6.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 00:04:53 +02:00
78a8016e01 feat(form-builder): add FormSubmissionActionFailurePolicy with FK-chain auth (WS-6)
Tenant scope verified via failure.submission.organisation_id, NOT route
binding. Cross-tenant access returns false (controllers in sessions 2/3
will translate to 404 to prevent enumeration). Five abilities:
viewAny, view, retry, resolve, dismiss.

Laravel 12 auto-discovers App\Policies\FormBuilder\FormSubmissionActionFailurePolicy
for App\Models\FormBuilder\FormSubmissionActionFailure — no explicit
registration needed (pattern matches the existing FormSubmissionPolicy).

IDOR-class security tests included with explicit RFC V3 cross-reference
in the test class docblock.

Refs: RFC-WS-6.md §4 (V3), ARCH-FORM-BUILDER.md §22.9

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 00:03:21 +02:00
7a747382a0 feat(form-builder): integrate PublishGuard framework into FormSchemaService::publish() (WS-6)
assertPublishGuardsSatisfied() runs additively after the existing
assertRequiredBindingsPresent() check. Failures are collected (not
first-fail) so PublishGuardViolationException carries the full list
to the builder UI in one 422 response.

PurposeRequirementsNotMetException remains for missing bindings;
PublishGuardViolationException covers semantic constraints
(is_identity_key flag, no-ambiguous-trust, append-collection-only,
section-aware schemas, conditional triggers).

Two pre-existing tests updated their fixtures to satisfy the new
guards (PublishChecksRelationalBindingsTest +
PurposeSchemaLifecycleTest): EMAIL field type + is_identity_key on
person.email + unique trust levels are now required for
event_registration to publish.

Refs: RFC-WS-6.md §3 (Q13)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 23:07:12 +02:00
e3c9211e3f feat(form-builder): wire PurposeGuardProvider per purpose (WS-6)
Adds PurposeGuardProvider as a parallel interface to PurposeDefinition
(value object stays untouched). Seven concrete providers, one per v1.0
purpose, each declaring its publish-guard list. Registry resolves and
caches providers via guards_class config key.

Universal guards (MaxOneIdentityKeyPerTargetEntity,
AppendStrategyRequiresCollectionTarget, NoAmbiguousTrustLevels,
IdentityKeyBindingsOnlyInFirstSection) wire into every purpose. The
section guard is a cheap no-op when section_level_submit=false.

ArtistAdvanceGuards omits RequiresIdentityKeyBinding because the
artist subject is resolved via portal token, not form data. Same
reasoning for supplier_intake (production_request) and the auth-based
purposes.

Includes a cross-cutting BindingTypeRegistryConsistencyTest that
verifies tasks 5/7/8 do not contradict each other (registry ↔ guards ↔
purpose required_bindings).

Refs: RFC-WS-6.md §3 (Q9, Q13)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 23:01:19 +02:00
81a8120f98 feat(form-builder): add PublishGuard framework + 9 concrete guards (WS-6)
Per-purpose schema validation composes a PurposeGuardProvider returning
a list of guards. Errors collected (not first-fail) so the builder UI
surfaces every issue per save. ConditionalRequirement composes higher-
order without proliferating one-off classes.

RequiresIdentityKeyBinding checks the is_identity_key flag specifically;
the binding-existence check is handled additively by the existing
assertRequiredBindingsPresent in FormSchemaService.

SchemaHasLinkedEvent checks owner_type='event' + owner_id (FormSchema
uses polymorphic owner; there is no direct event_id column).

i18n messages live in lang/nl/form_builder_publish_guards.php.

Refs: RFC-WS-6.md §3 (Q13), §4 (V1, V3)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 22:55:42 +02:00
c5b0210ae7 feat(form-builder): add FormSubmissionActionFailure model + apply_status casts (WS-6)
- FormSubmissionActionFailure: audit model, no organisation_id (FK-chain
  tenancy per RFC V3), open/resolved/dismissed scopes, canBeRetried()
  helper. Morph alias 'form_submission_action_failure' registered for
  future activity-log subject references.
- FormSubmission: apply_status (ApplyStatus enum cast),
  apply_completed_at (datetime), actionFailures() HasMany,
  scopePendingApply().

Refs: RFC-WS-6.md §3 (Q5), §4 (V3)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 22:47:06 +02:00
0dd991c688 feat(form-builder): add BindingTypeRegistry as single source of truth for target shapes (WS-6)
Config-driven mapping from (target_entity, target_attribute) to storage
shape (scalar/collection/relation), PHP type, and identity-key
eligibility. Replaces any name-suffix matching (e.g. _tags, _skills) —
those are convention-not-contract and reject by design.

Used by publish guards now and (in session 2) by FormBindingApplicator.

Refs: RFC-WS-6.md §4 (V1)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 22:41:25 +02:00
b2e9ef8824 feat(form-builder): MergeStrategy enum methods + binding value objects (WS-6)
- FormFieldBindingMergeStrategy::nullWinnerBehaviour() and
  isValidForScalarTargets() encode the per-strategy null-winner matrix
  (RFC Q7) and the collection-only restriction (RFC V1).
- ResolvedBinding/BindingApplicationResult/BindingPassResult readonly
  DTOs for the binding pipeline. Construction-time validation for
  trust level. Apply-status derived from result aggregate.

Note: the existing enum is named FormFieldBindingMergeStrategy (not
MergeStrategy as the prompt sketched). Methods added to it directly.

Refs: RFC-WS-6.md §3 (Q4, Q7), §4 (V1)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 22:38:55 +02:00
447511634d feat(form-builder): add ApplyStatus, DismissalReasonType, BindingTargetType enums (WS-6)
DismissalReasonType has six values; manually_resolved is intentionally
absent because Resolve and Dismiss are separate workflows (RFC V2).

Refs: RFC-WS-6.md §3 (Q4 partial-status separation), §4 (V2 dismiss enum)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 22:36:10 +02:00