feat(form-builder): add apply_status columns and action-failures table (WS-6)

- 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>
This commit is contained in:
2026-04-25 22:33:39 +02:00
parent 47a0dc875b
commit c033dc6cd2
7 changed files with 337 additions and 23 deletions

View File

@@ -2336,12 +2336,14 @@ that aggregates the user's submitted, non-test `form_submissions`.
| `idempotency_key` | ULID nullable | Duplicate-submit guard — UNIQUE `(form_schema_id, idempotency_key)` since v2.1 |
| `anonymised_at` | timestamp nullable | |
| `identity_match_status` | string(20) null | **v2.1** null\|pending\|matched\|none — written by `TriggerPersonIdentityMatchOnFormSubmit` (ARCH §31.1) |
| `apply_status` | string(20) null | **v2.3 — RFC-WS-6 §3 (Q4) + §5 (O1)** null\|pending\|completed\|partial\|failed — written by `FormBindingApplicator`. NULL for legacy rows by design (no DB default) |
| `apply_completed_at` | timestamp nullable | **v2.3 — RFC-WS-6 §3 (Q4)** Set when applicator finishes (success or fail) |
| `search_index` | mediumText null | Concatenated text of text-type values; FULLTEXT-indexed on MySQL when supported |
| `created_at`, `updated_at` | timestamps | |
| `deleted_at` | timestamp nullable | Soft delete |
**Relations:** `belongsTo` schema, organisation, event, submittedBy / reviewedBy (User); `morphsTo` subject; `hasMany` values, section statuses, delegations
**Indexes:** `(form_schema_id, status)`, `(organisation_id, status)` (v2.2 — addendum Q2), `(event_id, status)` (v2.2 — addendum Q2), `(subject_type, subject_id)`, `(submitted_by_user_id)`, `(form_schema_id, review_status)`, **UNIQUE** `(form_schema_id, idempotency_key)` (v2.1; replaced the non-unique composite index from v2.0), `(form_schema_id, identity_match_status)` (v2.1), `FULLTEXT(search_index)` (MySQL/InnoDB — best-effort, skipped gracefully on SQLite)
**Indexes:** `(form_schema_id, status)`, `(organisation_id, status)` (v2.2 — addendum Q2), `(event_id, status)` (v2.2 — addendum Q2), `(subject_type, subject_id)`, `(submitted_by_user_id)`, `(form_schema_id, review_status)`, **UNIQUE** `(form_schema_id, idempotency_key)` (v2.1; replaced the non-unique composite index from v2.0), `(form_schema_id, identity_match_status)` (v2.1), `(form_schema_id, apply_status)` (v2.3 — RFC-WS-6), `(organisation_id, apply_status)` (v2.3 — RFC-WS-6), `FULLTEXT(search_index)` (MySQL/InnoDB — best-effort, skipped gracefully on SQLite)
**Events fired:** `FormSubmissionCreated`, `FormSubmissionDraftUpdated`, `FormSubmissionSubmitted`, `FormSubmissionReviewed`, `FormSubmissionSectionSubmitted`, `FormSubmissionSectionReviewed`, `FormSubmissionAnonymised`, `FormSubmissionArchived`, `FormSubmissionDeleted`
**Soft delete:** yes
@@ -2544,6 +2546,46 @@ that aggregates the user's submitted, non-test `form_submissions`.
---
### `form_submission_action_failures`
> **v2.3 — RFC-WS-6 Q5** Audit table for binding-pipeline failures.
> Populated by `ApplyBindingsOnFormSubmit` (and any future listener
> that wraps a `FormBindingApplicator` invocation) when the inner
> apply transaction rolls back. Retry / Resolve / Dismiss workflows
> consume this table from session 2 onward.
>
> No `organisation_id` column — tenant scope flows via
> `form_submission_id → form_submissions.organisation_id`. Enforced
> at access time by `FormSubmissionActionFailurePolicy` (RFC V3,
> IDOR-class FK-chain pattern). Do NOT register `OrganisationScope`
> directly on this table.
| Column | Type | Notes |
| ------------------------- | ------------------- | ------------------------------------------------------------------ |
| `id` | ULID | PK |
| `form_submission_id` | ULID FK | → form_submissions, cascade delete |
| `listener_class` | string(255) | e.g. `App\Listeners\FormBuilder\ApplyBindingsOnFormSubmit` |
| `binding_id` | ULID FK nullable | → form_field_bindings, null on delete (binding may be edited later) |
| `failed_at` | timestamp | |
| `exception_class` | string(255) | |
| `exception_message` | text | |
| `context` | json | Free-form: `{target_entity, target_attribute, value_excerpt, merge_strategy}` |
| `retry_count` | tinyint unsigned | default: 0 |
| `resolved_at` | timestamp nullable | Set when retry succeeds OR organiser marks resolved |
| `resolved_by_user_id` | ULID FK nullable | → users, null on delete |
| `resolved_note` | text nullable | Optional human note on resolution |
| `dismissed_at` | timestamp nullable | Mutex with `resolved_at` |
| `dismissed_by_user_id` | ULID FK nullable | → users, null on delete |
| `dismissed_reason_type` | string(40) nullable | `DismissalReasonType` enum (RFC V2). Constrained at request layer |
| `dismissed_reason_note` | string(500) nullable | Required at request layer when reason_type = `other` |
| `created_at`, `updated_at` | timestamps | |
**Relations:** `belongsTo` submission, binding (nullable), resolvedBy / dismissedBy (User)
**Indexes:** `(form_submission_id)`, `(listener_class, failed_at)`, `(resolved_at)`, `(dismissed_at)`, `(binding_id)`, `(dismissed_reason_type)` (analytics)
**Soft delete:** no — audit table; retention via parent submission cascade-delete
---
**Activity log strategy:** explicit calls via
`FormSchema::logSchemaChange()` and `FormField::logFieldChange()` — no
`LogsActivity` trait (would produce noise). Only impactful events