32 new tests covering D1 deliverables:
- Migration shape (3): failure_response_code column presence,
type/length/nullability, index name. MySQL information_schema
introspection.
- Exception hierarchy (11): abstract base, RuntimeException ancestor,
per-subclass constructor + reasonCode (named-args asserting
submissionId is preserved structurally), Timeout extends Infra and
inherits temporary_error, all subclasses extend base, previous-throwable
chaining works, IdentityMatchInvariantViolation is NOT in the
binding-applicator hierarchy and IS a DomainException.
- FormBindingExceptionClassifier matrix (6): each subclass maps to its
reason code; Timeout dispatches to inherited 'temporary_error';
arbitrary RuntimeException -> 'unknown_error'; IdentityMatchInvariantViolation
-> 'unknown_error' (intentional fallback per docstring).
- FormFieldBindingMergeStrategy::validForTargetType (4 tests covering
the full 4 strategies x 3 target types matrix).
- FormSubmissionIdentityMatchResolved (4): ShouldBroadcast contract,
private channel naming ('private-submission.{id}'), broadcast-as
string, payload assignment.
- FormSubmission failure_response_code cast (4): persists as plain
string, NULL by default, factory state composes with apply_status,
round-trips for all four canonical codes.
Baseline regenerated to absorb new tautological-assertion entries (48
lines) — these are class-hierarchy regression guards that Larastan
correctly flags as statically known. The pattern is established in the
codebase per existing baseline entries for similar tests.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
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>
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>
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>
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>
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>
- form_submissions: apply_status (nullable, NO default for legacy rows
per RFC O1), apply_completed_at, indexed on (form_schema_id, apply_status)
and (organisation_id, apply_status)
- form_submission_action_failures: ULID PK, FK to submission + binding,
resolve/dismiss state separated (RFC V2), retention via parent
cascade-delete
- Migration rehearsal test added (invokes down() directly because the new
migrations land between WS-5a and WS-5b chronologically, not at the tail
of the migration list)
Three pre-existing WS-5 backfill tests also bump their --step rollback
counts by +2 (FormFieldBindingMigrationTest, FormFieldConfigBackfillAndDropTest,
FormFieldValidationRuleBackfillTest) to account for the two new migrations
sitting in the chronological middle of the WS-5 stack — required to keep
those tests' pre-WS-5b rollback target reachable.
SCHEMA.md updated to v2.3.
Refs: RFC-WS-6.md §3 (Q4, Q5), §4 (V2), §5 (O1)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>