From a4281df021a083f05516781970546e576c4ee057 Mon Sep 17 00:00:00 2001 From: "bert.hausmans" Date: Tue, 5 May 2026 18:54:30 +0200 Subject: [PATCH] =?UTF-8?q?docs(arch):=20add=20ARCH-BINDINGS.md=20?= =?UTF-8?q?=E2=80=94=20canonical=20reference=20for=20FormBindingApplicator?= =?UTF-8?q?=20pipeline=20(WS-8a)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- dev-docs/ARCH-BINDINGS.md | 2016 ++++++++++++++++++++++++++----------- 1 file changed, 1420 insertions(+), 596 deletions(-) diff --git a/dev-docs/ARCH-BINDINGS.md b/dev-docs/ARCH-BINDINGS.md index a9d2c1dd..18dabe57 100644 --- a/dev-docs/ARCH-BINDINGS.md +++ b/dev-docs/ARCH-BINDINGS.md @@ -1,657 +1,1481 @@ -# ARCH-BINDINGS.md — Form Builder Binding Pipeline +# ARCH-BINDINGS.md — FormBindingApplicator Pipeline -## Status +**Version:** v1.0 — initial canonical reference +**Aligned to:** `main` HEAD `4a84b9e` (post WS-6 closure, 2026-05-04) +**Authoritative:** Yes. This document supersedes `RFC-WS-6.md` for ongoing maintenance — RFC-WS-6 remains as a historical record of the 2026-04-25 design session that produced this architecture. -- v0.1 (skeleton) — 2026-04-25 -- v0.4 — 2026-04-28 — § 8.2 IDOR class tests (sessie 3a backend hardening) -- v0.5 — 2026-04-28 — Appendix on v1 registry scope (sessie 3a.5 model alignment) -- v0.6 — 2026-04-28 — §1 polish: align with v1 registry scope (sessie 3b polish review) -- Owner: Bert -- Authoritative for the binding pipeline architecture, complementing - ARCH-FORM-BUILDER.md §17 and §31. -- Decisions originate from RFC-WS-6.md. +--- -## 1. Scope +## 1. Status & how to use this doc -The binding pipeline turns a submitted form (`form_submissions`) into -durable writes against domain entities — Person, Company, User — via -per-field bindings configured in `form_field_bindings` (WS-5a). The -`artist` entity is intentionally absent from the v1 binding-target -registry; see the appendix for the rationale. +This document is the canonical reference for the FormBindingApplicator pipeline: the system that turns a submitted form into mutations on domain entities (persons, users, artists, companies, etc.). The pipeline lives behind the `FormSubmissionSubmitted` event and runs synchronously where ordering matters and queued where it does not. -This document describes the end-to-end flow from -`FormSubmissionSubmitted` event to the post-pipeline state where the -domain has been updated, identity-match has been triggered, queued -listeners (tag sync, shifts, webhooks, mail) have fired, and any -binding-level failures have been recorded as -`form_submission_action_failures` rows. +ARCH-BINDINGS is the **second-tier** form-builder reference. ARCH-FORM-BUILDER is the umbrella doc covering the full form-builder domain (schemas, fields, sections, submissions, values, library, webhooks, integrations). ARCH-BINDINGS narrows in on the pipeline that connects submissions to domain entities. -The library is a **template-source, not a live link.** When a -`form_field` is created from a `FormFieldLibrary` entry with bindings, -the library's bindings are **copied** into `form_field_bindings` rows -with `owner_type = 'form_field'`. Library updates do not propagate to -existing field instances (RFC §3 Q11). Future re-sync admin action -tracked in BACKLOG: `FORM-LIBRARY-RESYNC`. +### Read this for… -## 2. Schema +| Goal | Read | +|---|---| +| Modify the apply pipeline (add a strategy, change a listener) | §2, §5, §6, §17 | +| Build the Form Builder UI bindings editor | §3, §4, §8, §9 | +| Wire observability for binding events | §7, §11, §14 | +| Add a new FormPurpose | §3, §9 | +| Diagnose a production binding failure | §11, §12, §14 | +| Understand why a guard rejected a publish | §8 | +| Implement library-bindings UI | §15 | +| Audit GDPR compliance of a submission | §14, §16 | -### 2.1 form_field_bindings (WS-5a) +### Cross-document relations -Polymorphic owner (`owner_type` ∈ {`form_field`, `form_field_library`}, -`owner_id` ULID), `target_entity` × `target_attribute` columns, -`merge_strategy` (Overwrite/Append/Replace/FirstWriteWins), `trust_level` -(0-100, default 50), `is_identity_key` bool. UNIQUE on -`(owner_type, owner_id, target_entity, target_attribute)`. See -`SCHEMA.md §3.5.12`. +- **ARCH-FORM-BUILDER.md** — sibling, authoritative for `form_field_bindings` table shape (§17), per-purpose lifecycles (§3.2), integration contracts to non-binding listeners (§31), purpose registry mechanics (§17.3) +- **SCHEMA.md §3.5.12** — authoritative for `form_submissions`, `form_field_bindings`, `form_submission_action_failures` table shape +- **RFC-WS-6.md** — historical design source; cited in this doc as `RFC-WS-6 §X` for the "why" behind any decision +- **ARCH-CONSOLIDATION-2026-04.md** §6.2 — WS-6 charter and post-WS-6 deliverables list +- **ARCH-CONSOLIDATION-ADDENDUM-2026-04-24.md** Q1, Q2 — ULID exception retired, denormalized `organisation_id` +- **ARCH-OBSERVABILITY.md** (WS-8b, forthcoming) — how pipeline events surface in GlitchTip and Telescope, PII-scrubbing contract for failure exports +- **BACKLOG.md** — deferred items referenced throughout this doc -### 2.2 form_submissions.apply_status / apply_completed_at (WS-6) +### Stability commitment -Two new columns added in `2026_04_25_140000_extend_form_submissions_with_apply_status`: +The contracts in §3 (`BindingTypeRegistry`), §4 (merge strategies), §5 (listener chain), §6 (two-transaction pattern), §7 (status columns), §8 (PublishGuard interface), §9 (PurposeGuardProvider) are stable. Changes require an RFC and a coordinated update of this doc + tests + ARCH-FORM-BUILDER cross-refs. -- `apply_status` — string(20) nullable, no default. Values: - `pending|completed|partial|failed`. Drives admin "open work" filtering. - NULL legacy rows are excluded from open-work views by design (RFC O1). -- `apply_completed_at` — timestamp nullable, set when applicator - finishes (success or fail). +The contracts in §10 (section-level apply), §11 (`form_submission_action_failures`), §12 (retry/resolve/dismiss flows) are stable but feature-flagged or admin-only — additive changes are expected. -Indexed on `(form_schema_id, apply_status)` and -`(organisation_id, apply_status)` for dashboard queries. +--- -### 2.3 form_submission_action_failures (WS-6) +## 2. The pipeline at a glance -Audit table for binding-pipeline failures (RFC §3 Q5). One row per -listener invocation that failed, with retry / resolve / dismiss state -fields. No `organisation_id` column — tenant scope flows via -`form_submission_id → form_submissions.organisation_id`. Cascade-delete -through the parent submission. Resolve and Dismiss are mutually -exclusive workflows (RFC V2): a failure has at most one of -`resolved_at` or `dismissed_at`. - -Full DDL in `SCHEMA.md §3.5.12`. - -## 3. Value objects and enums - -`App\FormBuilder\Bindings\` (final readonly classes): - -- `ResolvedBinding` — output of session 2's `BindingConflictResolver`. - One winning binding per `(target_entity, target_attribute)` group. - Carries `valueIsExplicit` to distinguish "user explicitly cleared" - from "field skipped by conditional logic" (RFC §3 Q7). -- `BindingApplicationResult` — result of applying one resolved binding. - Sealed via `succeeded()` / `failed()` named constructors so consumers - cannot synthesise impossible states. -- `BindingPassResult` — aggregate result of one applicator pass. - `applyStatus()` maps to `ApplyStatus` enum per RFC §3 Q4 rules: - empty applications → `COMPLETED`, all OK → `COMPLETED`, all failed - → `FAILED`, mixed → `PARTIAL`. -- `BindingTargetMeta` — single config row from - `config/form_builder/binding_targets.php`. - -Enums in `App\Enums\FormBuilder\`: - -- `ApplyStatus` (RFC §3 Q4) — `PENDING|COMPLETED|PARTIAL|FAILED`. - Helpers: `isTerminal()`, `isOpen()`, `label()`. -- `DismissalReasonType` (RFC §4 V2) — six cases. `manually_resolved` - is intentionally absent: Resolve and Dismiss are different workflows. -- `BindingTargetType` (RFC §4 V1) — `SCALAR|COLLECTION|RELATION`. - Storage shape only; PHP type and identity-key eligibility live on - `BindingTargetMeta`. -- `FormFieldBindingMergeStrategy` (existing, WS-5a) — extended in WS-6 - with `nullWinnerBehaviour()` and `isValidForScalarTargets()`. - -## 4. BindingTypeRegistry - -`App\FormBuilder\Bindings\BindingTypeRegistry` is the single source of -truth for the storage shape of binding-target attributes. Config-driven -(`config/form_builder/binding_targets.php`), singleton-bound in -`AppServiceProvider`. - -Public surface: - -- `resolve(entity, attribute): BindingTargetMeta` -- `isKnown(entity, attribute): bool` -- `isIdentityKeyEligible(entity, attribute): bool` -- `entities(): list` -- `attributesFor(entity): list` -- `validateAppendStrategy(entity, attribute, strategy)` — throws - `InvalidBindingTargetException` when `strategy=Append` is paired - with a non-COLLECTION target. - -The registry is **not** name-suffix matching (e.g. "ends with `_tags`"). -Convention-not-contract is rejected because it silently misclassifies -attributes that don't follow the convention or accidentally match it. - -## 5. PublishGuard framework - -Per-purpose schema validation. `FormSchemaService::publish()` calls, -in order: - -1. `assertRequiredBindingsPresent()` (existing) — every required - binding path on the purpose's `required_bindings` list is bound. - Throws `PurposeRequirementsNotMetException`. -2. `assertPublishGuardsSatisfied()` (new) — every guard returned by - the purpose's `PurposeGuardProvider` evaluates to passed. Throws - `PublishGuardViolationException` carrying ALL violations sorted - lexicographically by `code()`. - -### Guard catalog - -| Class | code() | Universal? | -|---|---|---| -| `RequiresIdentityKeyBinding(entity, attribute)` | `requires_identity_key_binding:{entity}:{attribute}` | no | -| `MaxOneIdentityKeyPerTargetEntity` | `max_one_identity_key_per_target_entity` | yes | -| `RequiresFieldType(type, minCount)` | `requires_field_type:{type}` | no | -| `SchemaHasLinkedEvent` | `schema_has_linked_event` | sub-guard | -| `TagCategoriesConfiguredOnAllPickers` | `tag_categories_configured_on_all_pickers` | sub-guard | -| `IdentityKeyBindingsOnlyInFirstSection` | `identity_key_bindings_only_in_first_section` | yes | -| `AppendStrategyRequiresCollectionTarget` | `append_strategy_requires_collection_target` | yes | -| `NoAmbiguousTrustLevels` | `no_ambiguous_trust_levels` | yes | -| `ConditionalRequirement(predicate, sub, code)` | `conditional:{caller-supplied}` | composer | - -### Per-purpose providers - -| Purpose | Provider | Purpose-specific guards | -|---|---|---| -| `event_registration` | `EventRegistrationGuards` | `RequiresIdentityKeyBinding(person, email)`, `RequiresFieldType(EMAIL,1)`, `Conditional(AVAILABILITY_PICKER → SchemaHasLinkedEvent)`, `Conditional(TAG_PICKER → TagCategoriesConfiguredOnAllPickers)` | -| `artist_advance` | `ArtistAdvanceGuards` | (universal only — artist resolved via portal token) | -| `supplier_intake` | `SupplierIntakeGuards` | (universal only — company via production_request) | -| `post_event_evaluation` | `PostEventEvaluationGuards` | (universal only — person via auth) | -| `incident_report` | `IncidentReportGuards` | (universal only — anonymous-allowed) | -| `signature_contract` | `SignatureContractGuards` | (universal only — user via auth) | -| `user_profile` | `UserProfileGuards` | (universal only — user via auth) | - -Universal guards (`MaxOneIdentityKeyPerTargetEntity`, -`AppendStrategyRequiresCollectionTarget`, `NoAmbiguousTrustLevels`, -`IdentityKeyBindingsOnlyInFirstSection`) wire into every purpose. The -section guard is a cheap no-op when `section_level_submit=false`, but -remains active at publish time for schemas that flip it on later. - -i18n message keys live in -`api/lang/nl/form_builder_publish_guards.php`. Dutch only for v1. - -## 6. Apply pipeline - -### 6.1 Snapshot vs. live (RFC Q6) - -The `FormBindingApplicator` reads bindings from -`form_submissions.schema_snapshot.fields[*].bindings` (plural), not from -the live `form_field_bindings` table. WS-6 expanded the snapshot's -binding shape to carry every applicator-relevant field — -`{id, mode, entity, column, merge_strategy, trust_level, is_identity_key, sync_direction?}` -— via `FormFieldBindingService::toApplicatorShape()`. The legacy -singular `binding` key is preserved for webhook / GDPR readers; the new -plural `bindings` key is what the pipeline consumes. - -This guarantees that a retry executed days after the original -submission applies bindings as they were configured at submit time, not -as they may have been edited since. Reproducibility for audit. The -`PersonProvisioner` and `BindingConflictResolver` both read the -snapshot exclusively; the live table is only consulted by the publish -guards (config time) and BindingTypeRegistry (target shape). Tests -include a `snapshot_is_truth_ignores_post_submit_binding_edits` -assertion that mutates the live table after submission and verifies the -provisioner ignores the change. - -### 6.2 Conflict resolution (RFC Q7) - -`BindingConflictResolver::resolve(submission, sectionId?)` is pure -logic, no DB writes. It walks `schema_snapshot.fields[*].bindings`, -filters by section when `sectionId` is non-null (Q10 future), and -groups candidates by `(target_entity, target_attribute)`. Within each -group it sorts `trust_level DESC` then `form_field.sort_order ASC`, -picks the first as winner, and returns a `list`. - -Candidate-set rule: a binding is a candidate **iff** the source -form_field has a row in `form_values` for this submission. Absence -excludes; null value is included with `valueIsExplicit = true`. The -write-path invariant test (Task 10) asserts the necessary -precondition: every visible field has a `form_values` row after -submit, every absent field has none. Without this invariant Q7 -collapses — "explicit clear" becomes indistinguishable from -"skipped by conditional logic". - -### 6.3 Apply algorithm and merge-strategy null matrix (RFC V1, Q7) - -`FormBindingApplicator::apply($submission, ?$sectionId)` is the -orchestrator: - -1. Assert `DB::transactionLevel() > 0` — caller MUST own the - transaction (RFC Q4). Catastrophic violations throw - `FormBindingApplicatorException`. -2. Resolve subject via `PurposeSubjectResolver` (Q9). -3. If subject is null (incident_report anonymous path), return a - COMPLETED `BindingPassResult` with no applications. -4. Resolve bindings via `BindingConflictResolver` filtered by - `sectionId` (Q10) and the candidate-set rule (Q7). -5. Skip identity-key bindings during apply — the subject resolver - already used them for lookup; re-writing is at-best a no-op, - at-worst a clobber. -6. For each non-identity binding compute the new value via the merge - matrix (below), call `$subject->setAttribute()` + `save()`. - Per-binding failures are captured in `BindingApplicationResult::failed()` - inside the result and do NOT throw — partial passes are expected. -7. Return `BindingPassResult` with `applications`, `successCount`, - `failureCount`, derived `applyStatus()`. -8. Pass result to `BindingActivityLogger::logPass()` for the - hierarchical activity-log (§6.7). - -Merge strategy × null winner matrix (Q7 + V1): - -| Strategy | Winner non-null | Winner null | -|---|---|---| -| `overwrite` | write | write null | -| `append` (collection only — V1) | merge with set semantics | no-op | -| `replace` | write only when target null | no-op | -| `first_write_wins` | write only when target null | write null when target null | - -`Append` on a scalar target is a defensive runtime check via -`BindingTypeRegistry::validateAppendStrategy()` — publish guards -catch this at config time, but the runtime check protects against -live-table edits between publish and apply. - -### 6.4 Person provisioning (RFC Q8 + Q9, v1.1) - -`PersonProvisioner::provisionFromSubmission()` (called from -`EventRegistrationSubjectResolver`): - -1. Reads bindings from `schema_snapshot.fields[*].bindings`. -2. Finds the unique `is_identity_key=true` binding for - `target_entity='person'` (single-key invariant — composite identity - tracked in BACKLOG `FORM-BINDING-COMPOSITE-IDENTITY`). -3. Reads the form_value for that field (raises if missing/null — - publish guards prevent this at config time). -4. `Person::query()->where('email', $value)->where('event_id', $eventId) - ->lockForUpdate()->first()` → returns existing if found. -5. Otherwise builds attributes from the OTHER (non-identity-key) - bindings filtered to `Person::$fillable`, resolves - `crowd_type_id` from `$submission->schema->default_crowd_type_id` - (RFC Q9 v1.1 addendum — replaces the silent `CrowdType::oldest()` - heuristic), and calls - `Person::firstOrCreate(['email' => ..., 'event_id' => ...], $attrs)`. - -`firstOrCreate` semantics resolve the -"transaction A's lockForUpdate window vs. transaction B's insert" -race — the unique-constraint surfaces and re-reads the existing row. -Tested by `PersonProvisionerConcurrencyTest` with state-injection -under a real DB transaction (RFC V4 — wall-clock load testing is -deferred to BACKLOG `LOAD-TEST-FOUNDATION`). - -Multi-tenancy: Person's `organisationScopeColumn` is `event_id` -(not `organisation_id` directly). The provisioner scopes by -`event_id` only — cross-event submissions never collide. Same email -registering across two events in the same org → two distinct Person -rows; identity reconciliation is `PersonIdentityService`'s job -(out of scope for WS-6, RFC §6 / RFC Q8 v1.1 addendum). - -Default crowd type: - -- `form_schemas.default_crowd_type_id` (nullable ULID) is the single - source of truth for the freshly-provisioned Person's `crowd_type_id`. -- `RequiresDefaultCrowdType` publish guard blocks publish when null on - an `event_registration` schema. -- `PersonProvisioner::resolveCrowdTypeId()` throws - `PersonProvisioningException('no_default_crowd_type', ...)` when - null at apply time (failsafe for live-table edits between publish - and apply). -- No DB-level FK — application-level integrity only (SQLite cascade - problem, see RFC Q9 v1.1 addendum). The Eloquent - `FormSchema::defaultCrowdType()` `belongsTo` relation handles - read-side correctness. - -### 6.5 Per-purpose subject resolution (RFC Q9) - -Each purpose declares a `subject_resolver_class` in -`config/form_builder/purposes.php` implementing -`App\FormBuilder\Purposes\PurposeSubjectResolver`. The interface is -parallel to `PurposeGuardProvider` from session 1 — `PurposeDefinition` -remains a frozen value object; both interfaces hang off the registry. - -| Purpose | Resolver | Mechanism | -|---|---|---| -| `event_registration` | `EventRegistrationSubjectResolver` | `PersonProvisioner` (may create) | -| `artist_advance` | `ArtistAdvanceSubjectResolver` | Portal token; subject preset on submission | -| `supplier_intake` | `SupplierIntakeSubjectResolver` | Production-request → Company subject | -| `post_event_evaluation` | `PostEventEvaluationSubjectResolver` | Auth user → linked Person | -| `incident_report` | `IncidentReportSubjectResolver` | Anonymous-allowed; may return null | -| `signature_contract` | `SignatureContractSubjectResolver` | Auth user | -| `user_profile` | `UserProfileSubjectResolver` | Auth user | - -Concrete resolvers narrow the return type via PHP covariance -(`Person`, `Company`, `User`) so callers don't need to assert. Only -`IncidentReportSubjectResolver` may return null; the others throw -`PurposeSubjectResolutionException` with a typed `reasonCode`. - -### 6.6 Section-level apply stub (RFC Q10) - -`ApplyBindingsOnFormSectionSubmitted` is a queued listener registered -on `FormSubmissionSectionSubmitted`. Its `handle()` early-returns when -`config('form_builder.section_apply_enabled')` is false (default). -When enabled it forwards to `FormBindingApplicator::apply($submission, -sectionId:
)` inside a `DB::transaction`. - -The publish-time guard `IdentityKeyBindingsOnlyInFirstSection` is -active **regardless** of the runtime flag — schema structure is gated -at publish, runtime behaviour is gated by the flag. This means -section-aware schemas can't ship structurally-unsafe configurations -even before the runtime path activates. - -Removal trigger documented in `config/form_builder.php`: when -ARTIST_ADVANCE feature work begins, set -`FORM_BUILDER_SECTION_APPLY=true`, write section-scoped tests, remove -the early-return guard. Tracking: `ARTIST-ADV-SECTION-APPLY` in -BACKLOG.md. - -### 6.7 Activity log granularity (RFC Q12) - -`BindingActivityLogger::logPass()` writes one parent activity -(`form_submission.bindings_pass_completed` with -`{binding_count, succeeded, failed, apply_status, person_provisioned, subject_type, subject_id}`) -plus one child activity per binding -(`form_submission.binding_applied` with -`{parent_activity_id, binding_id, target_entity, target_attribute, success, old_value, new_value, source_submission_id}`). -Failed bindings get `error_class` / `error_message` in their child -activity in addition to a `FormSubmissionActionFailure` row. - -Two sources of truth for failures (activity_log + action_failures) is -intentional: activity_log is the human-readable timeline, -action_failures is the machine-replayable workflow. Sessions 3's UI -renders pass-level visible with per-binding expand-on-demand. - -## 7. Failures and retry - -### 7.1 Two-transaction pattern (RFC Q4) - -`ApplyBindingsOnFormSubmit::handle()` uses two distinct DB -transactions: +### 2.1 Diagram ``` +┌─────────────────────────────────────────────────────────────────┐ +│ FormSubmissionService::submit($submission) │ +│ │ +│ DB::transaction (inner): │ +│ 1. validate │ +│ 2. write form_values │ +│ 3. update submission.status │ +│ 4. write submission.schema_snapshot (canonicalized) │ +│ commit │ +│ │ +│ event(new FormSubmissionSubmitted($submission->refresh())) │ +└─────────────────────────────────────────────────────────────────┘ + │ + ┌───────────────────────┴────────────────────────┐ + │ │ + ▼ SYNC chain (registration order) ▼ QUEUED listeners + (parallel, no ordering) + ┌─────────────────────────────────┐ + │ ApplyBindingsOnFormSubmit │ ┌──────────────────────────────┐ + │ ├─ resolveOrProvisionSubject │ │ SyncTagPickerSelectionsOnSubmit + │ ├─ DB::transaction (inner) │ ├──────────────────────────────┤ + │ │ ├─ resolve bindings │ │ CreateProvisionalShift…OnReg.│ + │ │ ├─ apply per binding │ ├──────────────────────────────┤ + │ │ ├─ writeApplyStatus(OK) │ │ FormWebhookDispatcher │ + │ │ └─ triggerIdentityMatch │ │ → DeliverFormWebhookJob │ + │ │ (status update only) │ ├──────────────────────────────┤ + │ ├─ catch → DB::transaction │ │ AddPersonToApplicableCrowd… │ + │ │ (outer, separate) │ ├──────────────────────────────┤ + │ │ ├─ FormSubmissionAction… │ │ RegistrationConfirmation │ + │ │ │ Failure::create │ │ (purpose-specific mailable)│ + │ │ └─ writeApplyStatus(FAIL) │ └──────────────────────────────┘ + │ └─ swallow exception │ + └─────────────────────────────────┘ + │ + ▼ + ┌─────────────────────────────────┐ + │ TriggerPersonIdentityMatch… │ + │ (sync, runs second) │ + │ ├─ if subject_type=null: │ + │ │ log warning, set 'pending' │ + │ └─ else: │ + │ PersonIdentityService:: │ + │ detectMatches($person) │ + └─────────────────────────────────┘ +``` + +### 2.2 What the pipeline is — and is not + +The pipeline **is**: +- A mechanism for translating form values into domain-entity attribute mutations under purpose-specific rules +- A pre-publish validation framework that prevents structurally unsafe schemas from going live +- An audit infrastructure (apply_status, action_failures, activity log) +- A failure-recovery surface (retry / resolve / dismiss flows) + +The pipeline **is not**: +- A workflow engine — there are no per-step states, approvals, or human-in-the-loop branches +- A scheduler — all work fires from `FormSubmissionSubmitted` in real time (sync) or near-real-time (queued) +- A data-transformation layer — values are written as-resolved, not transformed mid-flight +- A notification system — mailables are siblings of the pipeline, not part of it + +### 2.3 Glossary + +| Term | Meaning | +|---|---| +| **binding** | A row in `form_field_bindings` connecting a `form_field` to a `target_entity.target_attribute` with a `merge_strategy`, `trust_level`, and optional `is_identity_key` flag | +| **target_entity** | The Eloquent model class (morph alias) that receives the mutation: `person`, `user`, `artist`, `company`, etc. | +| **target_attribute** | The attribute path on the target_entity, e.g. `email`, `profile.t_shirt_size`, `tags` (collection) | +| **trust_level** | Integer 0–100 expressing how authoritative this binding is when multiple bindings target the same attribute | +| **merge_strategy** | One of `overwrite`, `append`, `replace`, `first_write_wins` — see §4 | +| **is_identity_key** | Boolean flag marking a binding as the lookup key for `firstOrCreate` semantics; max one per `target_entity` per schema | +| **apply_status** | Column on `form_submissions` tracking pipeline-completion state: `null` (not applicable), `pending`, `completed`, `failed` | +| **identity_match_status** | Existing column on `form_submissions` tracking person-identity-match progress: `pending`, `matched`, `no_match`, `auto_linked` | +| **PublishGuard** | A pre-publish check that returns `PublishGuardResult` (passed or one or more violation messages); see §8 | +| **PurposeGuardProvider** | A service interface returning the `list` for a given purpose; see §9 | +| **action failure** | A row in `form_submission_action_failures` recording a binding-pipeline exception that survived the inner-transaction rollback | +| **DismissalReasonType** | Enum of 6 reasons an action failure can be dismissed without retry | + +--- + +## 3. Configuration: BindingTypeRegistry & config files + +### 3.1 `config/form_builder/bindings.php` + +The `BindingTypeRegistry` is the **single source of truth** for what shape a target attribute has at runtime. The file maps every supported `target_entity.target_attribute` pair to a `BindingTargetType`: + +```php +return [ + 'targets' => [ + 'person' => [ + 'email' => BindingTargetType::SCALAR, + 'first_name' => BindingTargetType::SCALAR, + 'last_name' => BindingTargetType::SCALAR, + 'date_of_birth' => BindingTargetType::SCALAR, + 'phone' => BindingTargetType::SCALAR, + 'tags' => BindingTargetType::COLLECTION, + 'crowd_type_id' => BindingTargetType::RELATION, + // ... + ], + 'user' => [ + 'email' => BindingTargetType::SCALAR, + 'profile.t_shirt_size' => BindingTargetType::SCALAR, + 'profile.dietary_preferences' => BindingTargetType::COLLECTION, + // ... + ], + // ... + ], +]; +``` + +`BindingTargetType` (PHP backed enum) has three cases: + +| Case | Semantics | Example | +|---|---|---| +| `SCALAR` | Single value; `merge_strategy` choice meaningful for conflict resolution | `person.email`, `user.first_name` | +| `COLLECTION` | Set of values with deduplication; `append` becomes idempotent | `person.tags`, `user.profile.languages` | +| `RELATION` | Foreign-key reference; `merge_strategy` of `overwrite` is the only sane choice | `person.crowd_type_id` | + +**Convention-not-contract is forbidden.** The registry is queried explicitly. Name-suffix matching (e.g. attributes ending in `_tags` are collection) was rejected per RFC-WS-6 §4 V1 because it would silently misclassify edge cases (`user_tags_count` would falsely qualify). + +### 3.2 `config/form_builder/purposes.php` + +Each purpose entry has a `guards_class` key alongside the existing `subject_type`, `requiredBindings`, `submission_mode`, and `public_token_supported`: + +```php +'event_registration' => [ + 'subject_type' => 'person', + 'submission_mode' => SubmissionMode::DRAFT_SINGLE, + 'public_token_supported' => true, + 'requiredBindings' => [ + ['target_entity' => 'person', 'target_attribute' => 'email', 'is_identity_key' => true], + ], + 'guards_class' => EventRegistrationGuardProvider::class, +], +``` + +`PurposeRegistry::guardProviderFor(string $slug): PurposeGuardProvider` instantiates and caches the class. See §9. + +### 3.3 `config/form_builder/section_apply.php` + +Section-level binding apply is feature-flagged. The config file consists of one boolean and a comment block documenting the removal trigger: + +```php +return [ + /* + * Section-level binding apply is gated until ARTIST_ADVANCE feature + * work begins (post-S5). + * + * To enable: + * 1. Set 'enabled' => true (or via FORM_BUILDER_SECTION_APPLY env var) + * 2. Activate ApplyBindingsOnFormSectionSubmitted listener registration + * in EventServiceProvider + * 3. Write section-scoped tests against FormBindingApplicator::apply($s, sectionId: $id) + * 4. Remove the early-return guard from the listener + * 5. Remove this entire feature-flag config — section-level apply + * becomes the default for purposes with section_level_submit=true. + * + * Tracking: BACKLOG.md → ARTIST-ADV-SECTION-APPLY + */ + 'enabled' => env('FORM_BUILDER_SECTION_APPLY', false), +]; +``` + +### 3.4 Adding a new target attribute + +To extend the registry with a new bindable attribute (e.g. `person.country_code`): + +1. Add the entry under `targets.person.country_code` in `config/form_builder/bindings.php` with the correct `BindingTargetType` +2. Add a fixture row in `database/factories/FormFieldBindingFactory.php` if test coverage uses the new attribute +3. Add a single positive test asserting `BindingTypeRegistry::resolve('person', 'country_code')->equals(BindingTargetType::SCALAR)` (or whichever) +4. **Optional:** add a `PublishGuard` if the new attribute imposes constraints on schema authors (e.g. `RequiresFieldType('text', minCount: 1)` for a country-code field that must appear in the schema) + +No migration is needed — the registry is config, and the `form_field_bindings` table already has the columns. + +--- + +## 4. Merge strategies & target-type contract + +### 4.1 The four strategies + +`MergeStrategy` (PHP backed enum): + +| Strategy | Semantics | +|---|---| +| `overwrite` | Winner value (after trust precedence) replaces the target attribute unconditionally | +| `append` | Winner value is added to the target collection; duplicates deduplicated by collection semantics. **Restricted to `BindingTargetType::COLLECTION` per V1.** | +| `replace` | Winner value replaces the target only if the target is currently null. Non-null targets are preserved. | +| `first_write_wins` | Winner value writes only if the target is null AND the winner value is non-null. | + +### 4.2 Strategy × target-type validity matrix + +| | SCALAR | COLLECTION | RELATION | +|---|---|---|---| +| `overwrite` | ✅ valid | ⚠ valid but unusual (overwrites entire collection) | ✅ valid | +| `append` | ❌ rejected at publish-time | ✅ valid (idempotent on retry) | ❌ rejected at publish-time | +| `replace` | ✅ valid | ✅ valid (only writes if collection is null/empty) | ✅ valid | +| `first_write_wins` | ✅ valid | ✅ valid | ✅ valid | + +The `AppendStrategyRequiresCollectionTarget` PublishGuard (§8) enforces the two ❌ cells at publish time. Runtime apply assumes publish has succeeded. + +### 4.3 Null-winner matrix (Q7 conflict resolution) + +After trust precedence, the winning binding may have a null value. Behaviour per strategy: + +| `merge_strategy` | Winner value = null | Behaviour | +|---|---|---| +| `overwrite` | null | Writes null to target — explicit clear | +| `append` | null | No-op — nothing to append, target unchanged | +| `replace` | null | No-op when target is non-null; no-op when target is null (no value to write) | +| `first_write_wins` | null | Writes null when target is null (claims the slot); skipped when target has any value | + +The candidate-set definition that supports this matrix lives in §17. The short version: a `form_values` row with `value=null` is an explicit clear by the user (multi-step edit-resubmit), distinct from absence of a row (skipped by conditional logic). + +### 4.4 Why `append` is collection-only + +Appending to scalars (string concatenation, comma-separated lists) requires a fingerprint mechanism to detect duplicate-append on retry. Embedding fingerprints in domain data is an architectural smell. Collection types with set semantics (deduplicated JSON arrays, pivot relations) make retry naturally idempotent. Restricting `append` to collections eliminates the entire problem class. + +If a use-case ever surfaces for "append a string segment to a text field", model it as a collection with later projection to a single string at read-time, not as an `append` on a scalar. + +--- + +## 5. Listener chain — sync, queued, ordering + +### 5.1 Registration + +`EventServiceProvider::$listen` is the single source of truth for listener registration. Two entries fire on `FormSubmissionSubmitted`: + +```php +protected $listen = [ + FormSubmissionSubmitted::class => [ + // SYNC chain — order matters + ApplyBindingsOnFormSubmit::class, + TriggerPersonIdentityMatchOnFormSubmit::class, + // QUEUED — order does not matter + SyncTagPickerSelectionsOnSubmit::class, + CreateProvisionalShiftAssignmentsFromRegistration::class, + AddPersonToApplicableCrowdListsOnRegistration::class, + FormWebhookDispatcher::class, + // Plus purpose-specific mailables (RegistrationConfirmation etc.) + ], +]; +``` + +The two SYNC listeners do not implement `ShouldQueue`. The four QUEUED listeners do. Laravel's listener-array order is the ordering primitive; no `Subscriber` pattern, no `$priority` flag. + +### 5.2 Listener catalogue + +| Listener | Sync/Queued | Trigger condition | What it does | Failure mode | +|---|---|---|---|---| +| `ApplyBindingsOnFormSubmit` | sync | Any submission | Provisions or resolves subject; applies all bindings; writes `apply_status` | Catches strict `FormBindingApplicator` throws, writes `form_submission_action_failures` row in outer transaction, swallows | +| `TriggerPersonIdentityMatchOnFormSubmit` | sync | `subject_type='person'` (incl. just-provisioned by ApplyBindings) | Calls `PersonIdentityService::detectMatches($person)`, writes `identity_match_status` | Logs at error, swallows; does not block siblings | +| `SyncTagPickerSelectionsOnSubmit` | queued | `purpose='event_registration'` AND TAG_PICKER values present | Rebuilds `user_organisation_tags` (source=self_reported) for the linked Person | Logs at error, swallows; idempotent on retry | +| `CreateProvisionalShiftAssignmentsFromRegistration` | queued | `purpose='event_registration'` AND AVAILABILITY_PICKER values present | Creates `claim_pending` ShiftAssignments for matching shifts | Logs at error, swallows | +| `AddPersonToApplicableCrowdListsOnRegistration` | queued | `purpose='event_registration'` AND new Person created | Adds Person to crowd_lists matching auto_add_criteria | Logs at error, swallows | +| `FormWebhookDispatcher` | queued | Any submission with active `form_schema_webhooks` | Dispatches `DeliverFormWebhookJob` per active webhook | Per-webhook job has its own retry chain (§17.5 in ARCH-FORM-BUILDER) | +| `RegistrationConfirmation` (and siblings) | queued | Per-purpose mailable trigger conditions | Sends purpose-specific email to submitter | Failed sends logged via existing CrewliMailable infrastructure | + +### 5.3 Why SYNC for the first two listeners + +Two reasons. + +**First**, `identity_match_status` must be in the database before the HTTP response serializes the submission resource. The portal IdentityMatchBanner (and the organizer review UI) reads this column directly from the resource payload. Queueing `TriggerPersonIdentityMatchOnFormSubmit` would mean the user sees `pending` until a queue worker processes the job, then must reload to see `matched`. A sync listener guarantees the response carries the right state. + +**Second**, `ApplyBindingsOnFormSubmit` runs first because `TriggerPersonIdentityMatchOnFormSubmit` needs the just-provisioned Person to detect matches against. Queueing ApplyBindings would race against the identity-match listener. + +Both listeners cost two synchronous calls — a few hundred milliseconds total in the common case. The simplicity is worth more than the latency saving of asynchronous handoff. + +### 5.4 Why `SyncTagPickerSelectionsOnSubmit` is not folded into ApplyBindings + +TAG_PICKER → `user_organisation_tags` is semantically different from a binding-target-attribute write. Tag sync rebuilds a pivot table with source-discrimination (`source=self_reported` is rebuildable; `source=organiser_assigned` is preserved). Bindings write attribute mutations. Conflating them would introduce a special-case path inside `FormBindingApplicator::applyAll` that handles "this isn't really a target attribute, it's a tag-pivot rebuild" — exactly the kind of branch that turns simple code into legacy code over time. + +The two are deliberate parallel paths. Future contributors should not consolidate them. See ARCH-FORM-BUILDER §31.10 for the tag-sync listener contract. + +### 5.5 Listener ordering test + +`tests/Feature/FormBuilder/EventServiceProviderListenerOrderTest.php` asserts that: + +1. `ApplyBindingsOnFormSubmit` and `TriggerPersonIdentityMatchOnFormSubmit` are both registered +2. `ApplyBindingsOnFormSubmit` precedes `TriggerPersonIdentityMatchOnFormSubmit` in the array +3. Neither implements `ShouldQueue` +4. `SyncTagPickerSelectionsOnSubmit`, `CreateProvisionalShiftAssignmentsFromRegistration`, `AddPersonToApplicableCrowdListsOnRegistration`, `FormWebhookDispatcher` all implement `ShouldQueue` + +This is a structural test — if a future change reorders listeners, the test fails before any behaviour test would. + +--- + +## 6. Atomicity — two-transaction failure-write pattern + +### 6.1 The pattern + +```php +// Inside ApplyBindingsOnFormSubmit::handle($event) +$submission = $event->submission; + try { - DB::transaction(function () { - // Inner: applicator + apply_status update + (when ApplyBindings - // provisioned a Person) subject_type/subject_id sync to submission. + DB::transaction(function () use ($submission) { + $subject = $this->purposeDef + ->resolveOrProvisionSubject($submission, $this->personProvisioner); + + $resolved = $this->applicator->resolveBindings($submission); + $this->applicator->applyAll($subject, $resolved); + + $submission->update(['apply_status' => ApplyStatus::COMPLETED->value]); + + // identity-match status update, NOT the matching itself + // (PersonIdentityService runs in the next sync listener) + $this->markIdentityMatchPending($submission); }); -} catch (Throwable $e) { - // OUTSIDE the failed transaction — survives inner rollback. - DB::transaction(function () { - FormSubmissionActionFailure::create([...]); - FormSubmission::query()->whereKey(...) - ->update(['apply_status' => FAILED, 'apply_completed_at' => now()]); +} catch (\Throwable $e) { + DB::transaction(function () use ($submission, $e) { + FormSubmissionActionFailure::create([ + 'form_submission_id' => $submission->id, + 'listener_class' => self::class, + 'failed_at' => now(), + 'exception_class' => $e::class, + 'exception_message' => $e->getMessage(), + 'context' => [ + 'apply_phase' => $this->applicator->lastPhase() ?? 'unknown', + 'subject_type' => $submission->subject_type, + 'subject_id' => $submission->subject_id, + ], + ]); + + FormSubmission::query() + ->whereKey($submission->id) + ->update(['apply_status' => ApplyStatus::FAILED->value]); }); - Log::error('form-builder.apply.transaction_rolled_back', [...]); + + Log::error('form-builder.apply.transaction_rolled_back', [ + 'submission_id' => $submission->id, + 'exception_class' => $e::class, + ]); } ``` -The inner transaction owns the apply pass. On exception it rolls -back atomically (any provisioned Person, any partial writes) — but -the outer catch then opens a SECOND transaction and writes the -failure record, which survives because it's not part of the inner -rollback. The second transaction's failure path is Sentry-only with -an explicit error log line for filterability. +### 6.2 Why two transactions -The listener does not rethrow (RFC Q3) so sibling listeners -(TriggerPersonIdentityMatch, queued tag-sync, queued webhooks, -queued mailables) keep running. +The inner transaction wraps subject provisioning, binding application, and status writes. If anything inside throws, the rollback is total — no half-applied state. But that rollback also discards any failure-record we might have tried to write. -### 7.2 Retry, resolve, dismiss flows (RFC V2) +The outer transaction is independent. It writes `form_submission_action_failures` plus the `apply_status=failed` update on the submission, in a small two-statement transaction that can succeed even when the inner transaction has rolled back the entire binding-apply attempt. -Three admin actions on a `FormSubmissionActionFailure` row: +If the outer transaction itself fails (database connection lost between inner rollback and outer commit), GlitchTip captures the `Throwable` from the inner-`Log::error` call and the failure becomes invisible to the admin UI — but still captured in error tracking. This is a degenerate case; the system does not pretend to handle infrastructure-level failures gracefully beyond logging. -- **Retry** — replay the applicator. Idempotent. Increments - `retry_count`. On success: sets `resolved_at = now()`. On repeat - failure: appends a NEW row preserving the audit trail (with - `context.retry_of` pointing to the original). -- **Mark as resolved** — manual close, optional `resolved_note`. - Used when an admin fixed the data via a different path. -- **Dismiss** — final close, requires `dismissed_reason_type` - (DismissalReasonType enum), `dismissed_reason_note` mandatory only - when reason is `OTHER`. `manually_resolved` is intentionally absent - from the enum — Resolve and Dismiss are different workflows. +### 6.3 Event-firing timing -Three artisan commands mirror the API endpoints with the same FK-chain -isolation (`form-failures:retry|resolve|dismiss`). Bulk-retry by -organisation is supported only via the API endpoint and the -`--org=` artisan flag; no in-place mutation, history is preserved. - -## 8. Multi-tenancy and security - -### 8.1 FK-chain tenant resolution (RFC V3) - -`form_submission_action_failures` has no `organisation_id` column by -design. Tenant scope flows via -`failure.submission.organisation_id`. The -`FormSubmissionActionFailurePolicy` resolves the chain at access time -with `withoutGlobalScopes()` so cross-tenant access reaches the -policy (which then translates denied → 404, never 403, to prevent -resource-existence enumeration). - -The controller's `resolveFailure()` helper performs the same -`withoutGlobalScopes` lookup. Soft-delete on the parent submission is -checked explicitly (`$submission->deleted_at !== null`) since -`withoutGlobalScopes` bypasses the SoftDeletes scope too. - -The policy is registered explicitly in `AppServiceProvider::boot()` -because Laravel's auto-discovery doesn't reliably resolve -`App\Models\FormBuilder\FormSubmissionActionFailure` to -`App\Policies\FormBuilder\FormSubmissionActionFailurePolicy`. - -### 8.2 IDOR class tests - -#### Threat model - -An org_admin from organisation A attempts to access organisation B's -failure resources via crafted URLs: - -``` -GET /api/v1/organisations/{orgB}/form-failures/{failure-from-orgA-id} -POST /api/v1/organisations/{orgA}/form-failures/{failure-from-orgA-id}/dismiss -``` - -Even if the policy correctly denies the action, the response status code -itself is information leakage: - -- **403 Forbidden** confirms the resource exists; only the caller's - authorisation is missing. An attacker can enumerate which IDs exist - on other tenants by sweeping the namespace and recording 403 vs 404. -- **404 Not Found** makes existence indistinguishable from absence — - the attacker can't distinguish a real-but-forbidden resource from a - random non-existent ID. - -RFC §4 V3 mandates 404 for this endpoint family. Confirm-by-existence -(403) is replaced with deny-by-invisibility (404). - -#### Two-axis policy enforcement - -Two distinct denial axes, each with its own correct status code: - -- **Role-class** — the `super_admin` platform endpoints - (`/api/v1/admin/form-failures/...`) are gated by Laravel's - `role:super_admin` middleware. An authenticated org_admin who hits - these endpoints gets **403** because the role gate fails. The endpoint - exists; the user is just forbidden ("you're not allowed in this - room"). Enumeration via this axis is moot — the URL set is fixed and - documented; failing role check on a known endpoint reveals nothing. -- **Ownership-class** — the org-scoped endpoints - (`/api/v1/organisations/{org}/form-failures/...`) are gated by - `FormSubmissionActionFailurePolicy`'s FK-chain resolution. A denied - policy translates to **404** in the controller helpers - (`authorizeOrNotFound` / `authorizeViewAnyInOrgOrNotFound`). Cross- - tenant access becomes "this room doesn't exist for you" rather than - "this room exists but you can't enter." - -The distinction is the prompt: in role-class, the endpoint URL itself -is the universe under test; in ownership-class, individual resource IDs -are the universe — and that universe must remain unobservable to -unauthorised callers. - -#### Implementation - -`FormSubmissionActionFailurePolicy` is the single tenant gate. Two -abilities for the IDOR-class enforcement: - -- `view` / `retry` / `resolve` / `dismiss(User, FormSubmissionActionFailure)` - — calls `canAccess()` which loads the parent submission with - `withoutGlobalScopes()`, returns false on absent or soft-deleted - parent (sessie 2 deviation #7), and otherwise checks that the user - is `super_admin` OR an `org_admin` on the failure's organisation - (resolved via `submission.organisation_id`). -- `viewAnyInOrganisation(User, Organisation)` — sessie 3a addition. - The bare `viewAny(User)` permits any org_admin in any org, which - was a real IDOR gap on the `orgIndex` endpoint: orgB's admin hitting - `/organisations/{orgA}/form-failures` would receive orgA's failure - list because `viewAny` passed and the query's `whereHas` filtered - to orgA. `viewAnyInOrganisation` requires the user to have the - `org_admin` role on the URL's specific organisation; denied → 404. - -The controller's two-helper pattern keeps the 404-translation explicit: +`FormSubmissionSubmitted` fires **after** the `FormSubmissionService::submit()` transaction commits, not from inside the transaction: ```php -private function authorizeOrNotFound(string $ability, FormSubmissionActionFailure $failure): void; -private function authorizeViewAnyInOrgOrNotFound(Organisation $organisation): void; +// Inside FormSubmissionService::submit() +$submission = DB::transaction(function () use ($payload) { + // ... build submission, write form_values, snapshot schema, etc. + return $submission; +}); + +event(new FormSubmissionSubmitted($submission->refresh())); ``` -`->withoutScopedBindings()` on the org-scoped routes prevents Laravel's -implicit-binding scoped-relation lookup (Organisation has no -`formSubmissionActionFailures` relation; the policy is the gate). +This is non-negotiable. Eloquent observers that fire events from inside a transaction have a known race: queued listeners enqueue with state that may never commit. By dispatching the event explicitly after `DB::transaction()` returns, all listeners see committed state. -#### Test coverage +Do not move event firing into a model observer. Do not use `DB::afterCommit()` — explicit ordering inside `FormSubmissionService` is clearer and unambiguous. -`Tests\Feature\FormBuilder\Api\Security\FormSubmissionActionFailureRouteSecurityTest` -exercises the contract end-to-end (24 tests, all passing on the -schema-dump fast path): +### 6.4 Person provisioning races -- 5 org-scoped endpoints (index/show/retry/resolve/dismiss) × cross- - tenant scenarios → 404 for every endpoint -- 5 platform endpoints × role-class scenarios → 401 unauthenticated, - 403 for org_admin without super_admin role, 200/204 for super_admin -- Edge cases: - - **Soft-deleted parent submission** — failure exists but its - `form_submission_id` points to a row with `deleted_at IS NOT NULL`. - Policy treats parent-gone as resource-gone → 404. - - **Invalid ULID format** in the URL → Laravel's route binding fails - cleanly, returns 404 (not 500). - - **Non-existent ID** → 404 regardless of role. - - **Authenticated but no role on org** → 404 (IDOR-class: a non-org - user enumerating IDs on a real org's URL must not be able to - distinguish real vs fabricated IDs). - - **Unauthenticated** → 401 on every endpoint. +`PersonProvisioner::provisionByEmailBinding` uses `Person::firstOrCreate()` with `lockForUpdate()` semantics on the email lookup. The MySQL InnoDB driver guarantees a serializable-equivalent read for `SELECT ... FOR UPDATE` inside a transaction. Two concurrent submissions with the same email resolve to the same Person, with the first to commit creating the row and the second seeing it on its lookup. -The 403-vs-404 distinction is documented in the test class docblock -and exercised explicitly by the platform-endpoint tests -(`test_platform_*_org_admin_returns_403`) — those tests would fail if -a future refactor accidentally translated role-class denials to 404 -"to be consistent," because that would actually weaken the role-gate's -clarity for legitimate UX (an org_admin should know they're forbidden, -not be misled into thinking the platform endpoint doesn't exist). +A unique index on `(organisation_id, email)` on the `persons` table backs the guarantee — if both transactions reach the insert path simultaneously (rare under `FOR UPDATE`), the second fails with a duplicate-key error which `firstOrCreate` catches and retries with the now-existing row. -#### Frontend implications +### 6.5 Outer transaction failure-mode -Frontend admin UI in WS-6 sessie 3b applies the same authorisation -model client-side: org-scoped views are rendered only for authenticated -users with the appropriate role on that organisation, and platform -admin views only for `super_admin`. Backend remains the source of -truth — the frontend's role check is a UX optimisation (avoid showing -links the user can't follow), not a security boundary. Direct API -hits without going through the SPA must still hit the backend gates -documented above. +If the outer (failure-record) transaction throws, the `Log::error` call still fires before the listener swallows the exception. The submission ends with `apply_status=null` (the original value) which is misleading — the apply did fail, the record just didn't land. GlitchTip captures the `Throwable`. This is acceptable because: -## 9. Listener chain +- The outer transaction is two statements (one INSERT, one UPDATE). Failure is rare. +- The `Log::error` line is filterable in GlitchTip with `form-builder.apply.transaction_rolled_back` as the message tag. +- ARCH-OBSERVABILITY.md (§ TBD) defines an alert on this log line at threshold "more than 5 in 24h", which is conservative enough to not page-flap but tight enough to surface infrastructure issues. -`FormSubmissionSubmitted` listeners are registered explicitly in -`AppServiceProvider::boot()` (RFC Q1) — Laravel auto-discovery's -filesystem-traversal order is fragile cross-platform. +--- -Sync chain (registration order is execution order): +## 7. Status columns — apply_status and identity_match_status -1. `ApplyBindingsOnFormSubmit` — provisions subject + applies bindings - (Q4 two-transaction, swallows exceptions per Q3). -2. `TriggerPersonIdentityMatchOnFormSubmit` — runs identity-match - detection against the freshly-provisioned Person. Per RFC Q2 the - "no person subject → pending" path is now a logged-warning failsafe; - it should never fire for event_registration submissions post-WS-6 - because ApplyBindings runs first. +### 7.1 Two independent concerns -Queued (parallel, post-sync): +`form_submissions` carries two status columns that the pipeline manages: -- `SyncTagPickerSelectionsOnSubmit` — TAG_PICKER → user_organisation_tags - rebuild. Implements `ShouldQueue`. Deliberately NOT folded into - ApplyBindings: TAG_PICKER → pivot-table-with-source-discrimination - is semantically distinct from a binding-target-attribute write - (RFC Q3). -- (future) FormWebhookDispatcher, RegistrationConfirmation mailable. +| Column | Type | Default | Managed by | +|---|---|---|---| +| `apply_status` | string(20) nullable | NULL | `ApplyBindingsOnFormSubmit` (sync), retry/resolve flows | +| `identity_match_status` | string(20) nullable | NULL | `TriggerPersonIdentityMatchOnFormSubmit` (sync), `PersonIdentityService::confirmMatch` (manual) | -`FormSubmissionSectionSubmitted` listener: `ApplyBindingsOnFormSectionSubmitted` -(queued, feature-flagged, currently a no-op). +They are **independent**. A submission can be `apply_status=completed` AND `identity_match_status=pending` — the bindings landed, but the organizer hasn't confirmed which existing Person this submission's just-provisioned Person should link to. Both states must be visible in the UI as separate signals, not collapsed into a single "submission status" indicator. -`FormSubmissionSubmitted` itself is dispatched **after** the -`FormSubmissionService::submit()` transaction commits (RFC O2). -Pre-commit dispatch let queued listeners enqueue with state that -might never persist on rollback — fixed in WS-6. +### 7.2 `ApplyStatus` enum -## 10. Out of scope (v1) +```php +enum ApplyStatus: string +{ + case PENDING = 'pending'; + case COMPLETED = 'completed'; + case FAILED = 'failed'; +} +``` -- **Composite identity-key resolution** (single-key only) — BACKLOG: - `FORM-BINDING-COMPOSITE-IDENTITY` -- **Library-binding runtime cascade** — BACKLOG: `FORM-LIBRARY-RESYNC` -- **Append on scalar targets** — collection-only by design (RFC V1) -- **Active section-level apply** — stub only, activated when - ARTIST_ADVANCE feature work begins (BACKLOG: `ARTIST-ADV-SECTION-APPLY`) -- **Daily failure digest mailable** — depends on notification framework -- **Wall-clock concurrent load testing** — BACKLOG: `LOAD-TEST-FOUNDATION` +The column itself defaults to `NULL` rather than `'pending'`. This is deliberate: NULL means **this schema has no bindings to apply** (e.g., a `feedback` submission with no entity-bound fields). Using `pending` as the default would force every unrelated submission into a pipeline-state it has no business in. -## Appendix — v1 binding registry scope +State transitions: -The `App\FormBuilder\Bindings\BindingTypeRegistry` covers the four -binding-target entities active in WS-6 v1: `person`, `company`, -`user`, and an intentionally-empty `artist` (omitted from registry). +``` +NULL ──(submission of binding-bearing schema)──▶ pending +pending ──(apply succeeds)──▶ completed +pending ──(apply throws)──▶ failed +failed ──(retry succeeds)──▶ completed +failed ──(retry throws)──▶ failed (action_failure row gets new attempts entry) +``` -### Why `artist` has no registry entries in v1 +`pending` is a transient state during the inner transaction; if the response serializes the submission resource with `apply_status=pending`, something is wrong (the inner transaction should have completed before the response leaves `FormSubmissionService::submit`). A monitoring rule (ARCH-OBSERVABILITY) flags submissions stuck in `pending` for more than 60 seconds. -The `artists` table exists (since 2026-04-08) and `subject_type='artist'` -is a valid `form_submissions` value. But: +### 7.3 `identity_match_status` -1. No Eloquent `Artist` model class exists yet. Polymorphic subject - relations work via the morph map (string → table) but cannot be - Eloquent-loaded without a class. -2. The `artist_advance` purpose is OUTPUT-shaped: the advance form - **gathers** information FROM the artist (rider, hospitality, - technical needs) — it does not provision Artist attributes the way - `event_registration` provisions Person attributes. -3. Bindings as a concept may not be the correct abstraction for advance - forms. v2 work tracked via BACKLOG `ARTIST-ADV-BINDING-MODEL`. +This column predates WS-6 and is documented in ARCH-FORM-BUILDER §31.1. Pipeline-relevant points: -In v1: `artist_advance` schemas may exist with `required_bindings: []`. -PublishGuards enforce only the cross-cutting invariants (no identity -key conflicts, no append-on-scalar, etc). The applicator runs (per -RFC §3 Q4 two-transaction pattern) but resolves bindings to an empty -list, completing in COMPLETED state with zero applications. The -Person/Company/User-level effects of WS-6 (apply_status, action_failures, -retry/dismiss workflows) all apply uniformly. +- `TriggerPersonIdentityMatchOnFormSubmit` writes the initial state (`pending` if matches found, `no_match` if `PersonIdentityService::detectMatches` returns empty). +- The listener can run BEFORE `ApplyBindingsOnFormSubmit` succeeds for non-event-registration purposes where Person is not the subject. In those cases the listener is a no-op (subject_type != 'person' early-return). +- For `event_registration` post-WS-6, `subject_type='person'` is always set by `ApplyBindingsOnFormSubmit` before this listener runs. RFC-WS-6 §3 Q2 documents the "no subject → pending" path that survives as a logged warning failsafe. -### Why JSON-path attributes are not in v1 +### 7.4 UI surface -`persons.custom_fields` is a JSON column; semantically a Person can -have `dietary_preferences` (etc.) inside that column. But binding -targets in v1 are **column-level** scalars, lists, and relations — -not JSON-path. Adding JSON-path support is tracked via BACKLOG -`FORM-BINDING-JSON-PATH`. +The platform admin "Form failures" page (`/platform/form-failures`) and organisation admin equivalent (`/orgs/{org}/form-failures`) filter by `apply_status=failed`. -For v1 the recommendation: model `dietary_preferences` (and similar -`custom_fields` properties) as a `TAG_PICKER` form_field with a -`tag_categories` config. The TAG_PICKER → user_organisation_tags sync -(per ARCH-FORM-BUILDER §31.10) handles this without requiring -binding-target column mapping. +The submission-detail review UI (organizer side) shows both columns as separate badges. PR-FORM-BUILDER-UI defines the badge styling. -### Drift prevention +--- -`BindingTypeRegistryConsistencyTest::test_every_registry_entity_maps_to_an_eloquent_model_with_the_attribute` -asserts that every registry entry corresponds to a real Eloquent model -class plus a real column on that model's table. Future drift (renamed -column without registry update, or registry entry without column) -becomes a test failure, not a runtime surprise. +## 8. Pre-publish validation — PublishGuard framework -## 11. Related docs +### 8.1 Why pre-publish, not runtime -- `RFC-WS-6.md` — design decisions -- `ARCH-FORM-BUILDER.md` §17, §31 -- `ARCH-CONSOLIDATION-2026-04.md` §6.1, §6.2 -- `SCHEMA.md` §3.5.12 +Most binding-pipeline failures are configuration errors: the schema author wrote a binding to `person.shoe_size` (typo for `t_shirt_size`), set `merge_strategy=append` on a SCALAR target, or built a section-aware schema with an `is_identity_key` binding outside section 1. These errors are caught at publish-time by the PublishGuard framework rather than at submit-time. + +Publish-time guards have three advantages over runtime errors: + +1. The schema author sees the error immediately, not days later when the first submission lands +2. The error message can be specific and actionable ("Field 'Schoenmaat' has merge_strategy=append but person.shoe_size is SCALAR. Use overwrite or first_write_wins.") +3. Production submissions never see the error class — apply-time exceptions are reserved for "DB modified out from under us" rare cases + +### 8.2 The interface + +```php +interface PublishGuard +{ + public function check(FormSchema $schema): PublishGuardResult; +} + +final readonly class PublishGuardResult +{ + public function __construct( + public bool $passed, + /** @var list */ + public array $violations = [], + ) {} + + public static function passed(): self + { + return new self(true); + } + + public static function failed(string $message, ?string $fieldSlug = null, array $context = []): self + { + return new self(false, [new PublishGuardViolation($message, $fieldSlug, $context)]); + } +} + +final readonly class PublishGuardViolation +{ + public function __construct( + public string $message, + public ?string $fieldSlug = null, + public array $context = [], + ) {} +} +``` + +Returning a typed object (not a bool) lets the framework collect multiple violations from a single guard. A `MaxOneIdentityKeyPerTargetEntity` check can return three violations in one `PublishGuardResult` if the schema has three offending fields. + +### 8.3 The collection contract + +`FormSchemaService::publish(FormSchema $schema)`: + +```php +public function publish(FormSchema $schema): void +{ + $provider = $this->purposeRegistry->guardProviderFor($schema->purpose); + $guards = $provider->publishGuards(); + + /** @var list $violations */ + $violations = []; + foreach ($guards as $guard) { + $result = $guard->check($schema); + if (! $result->passed) { + array_push($violations, ...$result->violations); + } + } + + if (count($violations) > 0) { + throw new PublishGuardViolationException($violations); + } + + // ... continue with publish (existing logic) +} +``` + +The framework collects **all** violations before throwing. First-fail would be hostile: a schema with five publish-blocking issues should surface all five in one round-trip, not require five publish attempts. + +### 8.4 The four universal guards + +These four wire into every `PurposeGuardProvider`, regardless of purpose: + +#### `MaxOneIdentityKeyPerTargetEntity` +At most one binding per `(target_entity)` may have `is_identity_key=true`. Multiple identity keys on the same entity create ambiguity in `PersonProvisioner::provisionFromBindings` — which value defines "this is the same entity"? + +#### `IdentityKeyBindingsOnlyInFirstSection` +For schemas with `section_level_submit=true`, all `is_identity_key=true` bindings must live on fields in the first section. Otherwise sections 2+ might submit before section 1 has provisioned the entity, causing apply to run with no subject. No-op for non-section schemas. + +#### `AppendStrategyRequiresCollectionTarget` +A binding with `merge_strategy=append` must target a `BindingTargetType::COLLECTION` per the `BindingTypeRegistry`. Per V1, append-on-scalar is architecturally rejected. + +#### `NoAmbiguousTrustLevels` +Two bindings targeting the same `(target_entity, target_attribute)` must have distinct `trust_level` values — or distinct `form_field.sort_order` values as the documented tie-break. This guard catches "two bindings, both at trust 50, both at sort_order 0" which would resolve in arbitrary order across MySQL versions. + +### 8.5 Purpose-specific guards (catalogue) + +These wire only into providers where they apply: + +| Guard | Wires into | Behaviour | +|---|---|---| +| `RequiresIdentityKeyBinding(entity, attribute)` | `event_registration` (`person`, `email`), `artist_advance` (`artist`, `id`), etc. | Asserts schema has at least one binding with `target_entity=entity`, `target_attribute=attribute`, `is_identity_key=true` | +| `RequiresFieldType(type, minCount)` | `event_registration` (`AVAILABILITY_PICKER`, 0 or more), `signature_contract` (`SIGNATURE`, 1) | Asserts at least `minCount` fields of the given type exist | +| `SchemaHasLinkedEvent` | `event_registration`, `artist_advance` | Asserts `form_schemas.linked_event_id` is non-null | +| `TagCategoriesConfiguredOnAllPickers` | `event_registration`, `user_profile` | Asserts every TAG_PICKER field has at least one `tag_category` configured | + +The `ConditionalRequirement(predicate, subGuard)` higher-order composer wraps a guard with a predicate that only runs the inner guard when the predicate matches the schema: + +```php +new ConditionalRequirement( + predicate: fn (FormSchema $s) => $s->public_token !== null, + subGuard: new RequiresIdentityKeyBinding('person', 'email'), +); +``` + +This lets `EventRegistrationGuardProvider` enforce "if you make this schema public, you must have an email-keyed Person identity binding" without imposing the requirement on private event_registration schemas. + +### 8.6 Adding a new guard + +1. Add the class under `app/FormBuilder/Publishing/{GuardName}.php` implementing `PublishGuard` +2. Wire it into the appropriate `PurposeGuardProvider::publishGuards()` return (or add it to a universal-guards trait if it applies to all purposes) +3. Write one positive test (passing case) and one negative test (failing case with expected violation message format) under `tests/Unit/FormBuilder/Publishing/` +4. If the guard introduces new error UX, add a copy-catalogue entry under ARCH-FORM-BUILDER §30 with the violation message template + +The framework is open-closed: a new guard never modifies `FormSchemaService::publish()`. + +--- + +## 9. Purpose-specific behaviour — PurposeGuardProvider & subject resolution + +### 9.1 `PurposeGuardProvider` interface + +```php +interface PurposeGuardProvider +{ + /** @return list */ + public function publishGuards(): array; +} +``` + +Each concrete provider returns the guard list for its purpose. The four universal guards are typically composed via a trait or shared base class: + +```php +final class EventRegistrationGuardProvider implements PurposeGuardProvider +{ + use HasUniversalPublishGuards; + + public function publishGuards(): array + { + return [ + ...$this->universalGuards(), + new RequiresIdentityKeyBinding('person', 'email'), + new SchemaHasLinkedEvent(), + new TagCategoriesConfiguredOnAllPickers(), + new ConditionalRequirement( + fn (FormSchema $s) => $s->public_token !== null, + new RequiresFieldType('email', minCount: 1) + ), + ]; + } +} +``` + +### 9.2 Subject resolution per purpose + +`PurposeDefinition::resolveOrProvisionSubject(FormSubmission, PersonProvisioner): ?Model` is the entry point for every purpose-specific resolver. The `?Model` return type acknowledges purposes (incident_report, public_complaint) where anonymous submission is allowed. + +Subject resolution table: + +| Purpose | Resolver | Behaviour | +|---|---|---| +| `event_registration` | `PersonProvisioner::provisionByEmailBinding` | May create a new Person if no match found by `(organisation_id, email)`. Returns existing Person otherwise. | +| `artist_advance` | `ArtistResolver::fromPortalToken` | Resolves Artist from `form_submissions.portal_token` context. Throws `MissingArtistContextException` if absent. | +| `supplier_intake` | `CompanyResolver::fromProductionRequest` | Resolves Company from the `production_request` row whose token is in the submission context. Throws if production_request absent. | +| `post_event_evaluation` | `PersonResolver::fromAuth` | Resolves Person from authenticated user's linked Person. Throws if user has no linked Person. | +| `incident_report` | `PersonResolver::fromAuthOrNull` | Returns null for anonymous submissions. | +| `signature_contract` | `UserResolver::fromAuth` | Resolves User from auth context. | +| `user_profile` | `UserResolver::fromAuth` | Same as signature_contract. | + +### 9.3 Adding a new purpose + +1. Add the slug-keyed entry to `config/form_builder/purposes.php` with `subject_type`, `submission_mode`, `public_token_supported`, `requiredBindings`, `guards_class` +2. If the new purpose has a new `subject_type` not already present, register the FQCN in `AppServiceProvider::PURPOSE_SUBJECT_FQCN`. `MorphMapAlignmentTest` enforces this step. +3. Add the new `PurposeGuardProvider` class under `app/FormBuilder/Purposes/Guards/` +4. Add the new resolver class under `app/FormBuilder/Purposes/Resolvers/` if reuse of existing resolvers is not possible +5. Wire any purpose-specific listeners in `EventServiceProvider::$listen` (identity-match, tag sync, mailables, etc.) +6. Add a lifecycle paragraph to ARCH-FORM-BUILDER §3.2 and a row to the purpose catalogue table in §3.1 +7. Add at least 4 pipeline tests (happy-path, missing required binding, conflict-resolution, anonymous-when-applicable) under `tests/Feature/FormBuilder/Pipeline/Purposes/` + +### 9.4 Open-closed property + +Adding a purpose modifies: + +- `config/form_builder/purposes.php` (one new array entry) +- New PurposeGuardProvider class +- Optionally new resolver class +- Optionally new listeners +- Documentation + +It does **not** modify: + +- `FormSchemaService::publish()` — generic, walks the guard list +- `FormBindingApplicator::apply()` — purpose-agnostic, delegates to PurposeDefinition +- Existing `PurposeGuardProvider` classes +- Existing resolvers + +This is the open-closed contract that the framework enforces. Reviewers should reject any PR that adds a purpose by branching inside `publish()` or `apply()` instead of adding a new provider. + +--- + +## 10. Section-level apply — stub structure, feature flag, removal trigger + +### 10.1 Why stubbed + +Section-level submit is a feature primarily relevant for `artist_advance` and `supplier_intake` schemas with `section_level_submit=true`. These purposes let the submitter complete and submit one section at a time over weeks (typical for artist advancing), with each section-submit triggering its own pipeline pass. + +Building section-aware apply is straightforward (the `FormBindingApplicator::apply()` method already accepts an optional `sectionId` parameter). However, the queued listener that consumes `FormSubmissionSectionSubmitted` events is gated until artist-advance feature work begins post-S5. Building it now and gating it costs little; activating it without artist-advance UX support would expose a half-built workflow. + +### 10.2 The listener + +```php +class ApplyBindingsOnFormSectionSubmitted implements ShouldQueue +{ + public function __construct( + private FormBindingApplicator $applicator, + ) {} + + public function handle(FormSubmissionSectionSubmitted $event): void + { + if (! config('form_builder.section_apply.enabled', false)) { + return; + } + $this->applicator->apply( + $event->submission, + sectionId: $event->sectionId + ); + } +} +``` + +The listener is registered in `EventServiceProvider::$listen` from session 2 onward. The early-return guard is the activation gate. + +### 10.3 The applicator signature + +```php +public function apply( + FormSubmission $submission, + ?string $sectionId = null, +): BindingApplicationResult +``` + +Null `sectionId` (default) applies all bindings whose source `form_field.section_id` is null OR matches the schema. Set `sectionId` filters bindings to only those whose source `form_field.section_id` equals the parameter. + +### 10.4 Publish guards land regardless of feature flag + +The `IdentityKeyBindingsOnlyInFirstSection` universal guard runs for every schema with `section_level_submit=true`, even with the feature flag disabled. A schema is structurally unsafe regardless of whether the runtime is gated; publish must reject it. + +### 10.5 Removal trigger + +`config/form_builder/section_apply.php` documents the removal trigger inline (see §3.3 above). When artist-advance feature work begins: + +1. Set `'enabled' => true` +2. Write section-scoped tests against `FormBindingApplicator::apply($s, sectionId: $id)` +3. Remove the early-return guard from the listener +4. Remove the entire feature-flag config — section-level apply becomes the default for purposes with `section_level_submit=true` + +BACKLOG item: `ARTIST-ADV-SECTION-APPLY`. + +### 10.6 Wiring test + +`tests/Feature/FormBuilder/Pipeline/SectionLevelApplyStubTest.php` asserts: + +1. `ApplyBindingsOnFormSectionSubmitted` is registered as a `FormSubmissionSectionSubmitted` listener +2. With the feature flag disabled, dispatching the event does not invoke `FormBindingApplicator::apply()` +3. With the feature flag enabled (test-only override), dispatching the event invokes `apply()` with the correct `sectionId` + +No business-logic test — those land when the flag goes live. + +--- + +## 11. Failure management — `form_submission_action_failures` lifecycle + +### 11.1 Table shape + +See SCHEMA.md §3.5.12 for the canonical column list. Pipeline-relevant columns: + +| Column | Type | Notes | +|---|---|---| +| `id` | ULID | PK | +| `form_submission_id` | ULID FK | → `form_submissions`, cascade delete | +| `listener_class` | string | The listener that caught the throw, e.g. `ApplyBindingsOnFormSubmit` | +| `failed_at` | timestamp | When the inner transaction rolled back | +| `exception_class` | string | FQCN of the throw | +| `exception_message` | text | Pruned to ~2000 chars | +| `context` | JSON | `apply_phase`, `subject_type`, `subject_id`, etc. | +| `apply_status` | string(20) | Mirrors the submission's status at failure time. `pending` while a retry is in flight; `failed` after exhaustion. | +| `resolved_at` | timestamp nullable | Set by Resolve action | +| `resolved_by_user_id` | ULID FK nullable | → `users` | +| `resolved_note` | text nullable | Free-text, optional | +| `dismissed_at` | timestamp nullable | Set by Dismiss action | +| `dismissed_by_user_id` | ULID FK nullable | → `users` | +| `dismissed_reason` | string(40) nullable | `DismissalReasonType` enum value | +| `dismissed_reason_note` | text nullable | Required only when `dismissed_reason='other'` | + +No `organisation_id` column. Tenant scope flows via `form_submission_id → form_submissions.organisation_id`. Enforced by `FormSubmissionActionFailurePolicy` (§13). + +### 11.2 Lifecycle + +``` + ┌──────────────────────────────┐ + │ ApplyBindingsOnFormSubmit │ + │ catches throw, writes row │ + └──────────────┬───────────────┘ + │ + ▼ + ┌──────────────────┐ + │ state = failed │ + └──────────────────┘ + │ │ │ + ┌───────────┘ │ └────────────┐ + ▼ ▼ ▼ + ┌────────────────┐ ┌────────────────┐ ┌────────────────┐ + │ resolved │ │ dismissed │ │ retried │ + │ (manual fix) │ │ (DismissalType │ │ (re-run apply │ + │ resolved_at + │ │ + note if │ │ with snapshot)│ + │ resolved_note │ │ 'other') │ └───────┬────────┘ + └────────────────┘ └────────────────┘ │ + ▼ + ┌──────────────────┐ + │ retry succeeded │ + │ OR │ + │ retry failed │ + │ (state cycles) │ + └──────────────────┘ +``` + +`resolved` and `dismissed` are terminal states. `retried` is transient — the row exits to either `resolved` (apply succeeded) or back to `failed` (apply threw again, with new `exception_class`/`exception_message` updates and incremented internal attempts counter). + +### 11.3 `DismissalReasonType` enum + +```php +enum DismissalReasonType: string +{ + case SCHEMA_DELETED = 'schema_deleted'; + case TARGET_ENTITY_DELETED = 'target_entity_deleted'; + case BINDING_REMOVED = 'binding_removed'; + case DUPLICATE_SUBMISSION = 'duplicate_submission'; + case DATA_QUALITY_ISSUE = 'data_quality_issue'; + case OTHER = 'other'; +} +``` + +Six cases. Per RFC-WS-6 §4 V2, `OTHER` is the only case that requires `dismissed_reason_note`. The other five are self-explanatory categories that drive aggregate reporting (e.g., "60% of dismissals are `duplicate_submission` — let's investigate the duplicate-detection code"). + +### 11.4 `resolved_note` vs `dismissed_reason_note` + +- `resolved_note`: free-text, **always optional**. The user fixed the underlying issue (renamed the binding's target attribute, restored the deleted target entity, fixed bad data) and Resolve closes the failure-record without retry. The note is for future reference if the same class of failure recurs. + +- `dismissed_reason_note`: free-text, **required only when `dismissed_reason='other'`**. The five other DismissalReasonType cases speak for themselves. + +The two are deliberately separate columns so reports can aggregate "% resolved with notes" vs "% dismissed-as-other with notes" cleanly. + +### 11.5 No soft delete + +The table is append-only at the row level; rows never delete. Resolved and dismissed rows are filtered out of the active failure-management UI but retained for audit. GDPR purge on Person deletion cascades through `form_submission_id` (form_submissions has its own anonymisation flow per ARCH-FORM-BUILDER §31.2). + +--- + +## 12. Retry / Resolve / Dismiss flows + +### 12.1 Retry + +#### Artisan + +```bash +php artisan form-builder:retry-failures {failure-id?} {--dry-run} +``` + +- Without `failure-id`: walks all `state=failed` rows, dispatches retry per row, reports counts. +- With `failure-id`: retries the single row. +- `--dry-run`: walks but does not dispatch — useful for "how many would retry" counts. + +#### HTTP + +``` +POST /api/v1/orgs/{org}/form-failures/{failure}/retry +POST /api/v1/platform/form-failures/{failure}/retry +``` + +Both routes use the same controller logic; only the policy gates differ (org-scoped vs platform-wide). + +#### Behaviour + +``` +1. Load the failure row + its parent FormSubmission +2. Acquire SELECT ... FOR UPDATE on the failure row inside a small DB transaction +3. If state != 'failed': abort 409 Conflict with current state +4. Mark state = 'pending' (visible to the UI as "retry in flight") +5. Dispatch ApplyBindingsOnFormSubmit::dispatchSync(new FormSubmissionSubmitted($submission)) +6. Catch the same exception classes as the original handler: + - On success: mark state = 'resolved', set resolved_at/resolved_by_user_id, leave resolved_note null (or copy from request payload if provided) + - On throw: update exception_class / exception_message, mark state = 'failed' again, increment internal attempts counter (in context JSON for now; no dedicated column unless we observe operational need) +``` + +Retry uses the submission's frozen `schema_snapshot` for binding configuration, not the live `form_field_bindings`. See §16. + +#### Idempotency + +Retrying an already-resolved or already-dismissed failure returns 200 with the current state. No-op. Internal attempts counter does not increment. + +### 12.2 Resolve + +#### HTTP + +``` +POST /api/v1/orgs/{org}/form-failures/{failure}/resolve +POST /api/v1/platform/form-failures/{failure}/resolve + +Request body: +{ + "note": "Renamed the t_shirt_size binding to match the latest schema." // optional +} +``` + +Marks the failure `resolved` without retry. Used when the operator has manually applied the fix outside the pipeline (e.g., fixed the binding configuration, manually updated the target entity attribute) and the failure-record no longer represents an open issue. + +If the operator wants the pipeline to re-apply, they Retry instead. + +### 12.3 Dismiss + +#### HTTP + +``` +POST /api/v1/orgs/{org}/form-failures/{failure}/dismiss +POST /api/v1/platform/form-failures/{failure}/dismiss + +Request body: +{ + "reason": "duplicate_submission", + "note": null // required iff reason='other' +} +``` + +Form Request `DismissFormFailureRequest`: + +```php +public function rules(): array +{ + return [ + 'reason' => ['required', Rule::enum(DismissalReasonType::class)], + 'note' => [ + 'nullable', + 'string', + 'max:5000', + Rule::requiredIf(fn () => $this->input('reason') === DismissalReasonType::OTHER->value), + ], + ]; +} +``` + +### 12.4 Resource shape + +`FormSubmissionActionFailureResource`: + +```json +{ + "id": "01HX...", + "form_submission_id": "01HX...", + "submission_summary": { + "schema_name": "Volunteer Registration 2026", + "schema_purpose": "event_registration", + "submitter_label": "Jan Janssen ", + "submitted_at": "2026-04-30T14:22:01Z" + }, + "listener_class": "ApplyBindingsOnFormSubmit", + "failed_at": "2026-04-30T14:22:02Z", + "exception_class": "App\\FormBuilder\\Exceptions\\InvalidBindingTargetException", + "exception_message": "Target attribute 'shoe_size' not registered on entity 'person'", + "context": { + "apply_phase": "binding_resolution", + "subject_type": "person", + "subject_id": null + }, + "state": "failed", + "resolved_at": null, + "resolved_by_user": null, + "resolved_note": null, + "dismissed_at": null, + "dismissed_by_user": null, + "dismissed_reason": null, + "dismissed_reason_note": null, + "abilities": { + "can_retry": true, + "can_resolve": true, + "can_dismiss": true + } +} +``` + +The `abilities` block reflects policy-evaluated permissions for the requesting user in the requesting context. UI uses these to enable/disable action buttons rather than hard-coding role checks. + +--- + +## 13. Authorization — IDOR-class FK-chain pattern + +### 13.1 The threat + +`form_submission_action_failures` has no `organisation_id` column. An IDOR-class threat scenario: + +1. Org A admin authenticates, gets a list of their org's failures +2. Org B admin can hit `GET /api/v1/orgs/{org-a}/form-failures/{failure-id}/retry` with a failure ID from org A's list +3. If authorization is naive, the failure retries even though org B has no business with it + +Naive authorization would be: scope route to `{org}`, fetch failure by ID, retry. The `{org}` route segment is unverified against the failure's actual organisation. + +### 13.2 The pattern + +`FormSubmissionActionFailurePolicy` resolves the failure's organisation via FK-chain explicitly: + +```php +final class FormSubmissionActionFailurePolicy +{ + public function viewAny(User $user, ?Organisation $organisation = null): bool + { + if ($organisation === null) { + // Platform route + return $user->hasRole('super_admin'); + } + // Org-scoped route + return $user->isMemberOf($organisation) + && $user->can('manage_form_failures', $organisation); + } + + public function view(User $user, FormSubmissionActionFailure $failure, ?Organisation $organisation = null): bool + { + if (! $this->viewAny($user, $organisation)) { + return false; + } + + $failureOrgId = $failure->submission->form_schema->organisation_id; + + if ($organisation === null) { + return true; // super_admin can view any + } + + return $failureOrgId === $organisation->id; + } + + public function retry(User $user, FormSubmissionActionFailure $failure, ?Organisation $organisation = null): bool + { + return $this->view($user, $failure, $organisation) + && $failure->state === 'failed'; + } + + public function resolve(User $user, FormSubmissionActionFailure $failure, ?Organisation $organisation = null): bool + { + return $this->view($user, $failure, $organisation) + && in_array($failure->state, ['failed', 'pending'], true); + } + + public function dismiss(User $user, FormSubmissionActionFailure $failure, ?Organisation $organisation = null): bool + { + return $this->view($user, $failure, $organisation) + && $failure->state === 'failed'; + } +} +``` + +The `$failure->submission->form_schema->organisation_id` walk is eager-loaded by the controller (`FormSubmissionActionFailure::with(['submission.form_schema'])`) so the policy method does not trigger N+1. + +### 13.3 Why not `OrganisationScope` + +`OrganisationScope` is FK-chain-resolver-aware (per WS-4 Q2 addendum). It could be configured to walk `form_submission_action_failure → form_submission → form_schema → organisation_id`. But: + +1. The chain is three hops; every query against the table would carry three implicit JOINs +2. Platform-wide super_admin queries would need to bypass the scope, adding `withoutGlobalScope` boilerplate at the platform layer +3. Policy-level checks already need to walk the chain for ability evaluation; doing it once in the policy avoids duplication + +The IDOR-class FK-chain pattern is the documented preference for tables where: +- Tenant-scoping is essential +- The table has no direct `organisation_id` column (because the data is scoped through a parent) +- Platform-wide views exist and need clean bypass + +### 13.4 Tests + +`tests/Feature/Api/FormFailureIdorClassTest.php` covers, per policy method: + +- Happy path (org A admin acting on org A failure → 200) +- IDOR attempt (org A admin acting on org B failure → 403) +- Platform happy path (super_admin acting on any org's failure → 200) +- Wrong-state attempts (acting on already-resolved failure → 422 / 409 depending on action) + +Five policy methods × four cases = twenty tests. The test class is the canonical regression suite for the pattern; reviewers should add a row when any change touches `FormSubmissionActionFailurePolicy`. + +--- + +## 14. Activity log — hierarchical pass + per-binding entries + +### 14.1 Two activity levels + +The pipeline writes activity-log entries at two granularities: + +**Pass level** — one entry per `FormBindingApplicator::apply()` invocation: + +``` +log_name: form-builder +description: form_submission.bindings_pass_completed +subject: FormSubmission Z +causer: User who submitted (or null for public) +properties: { + binding_count: 12, + succeeded: 11, + failed: 1, + person_provisioned: true +} +``` + +**Binding level** — one entry per binding (success or failure): + +``` +log_name: form-builder +description: form_submission.binding_applied +subject: Person X (or whichever target_entity) +causer: same as pass-level entry +properties: { + parent_activity_id: , + target_entity: 'person', + target_attribute: 'email', + old_value: null, + new_value: 'jan@example.nl', + trust_level: 80, + merge_strategy: 'overwrite', + source_form_field_id: 01HX..., + source_submission_id: Z +} +``` + +For failed bindings, additional properties: + +``` +properties: { + ..., + error_class: 'TypeError', + error_message: 'Expected string, got array' +} +``` + +### 14.2 `parent_activity_id` is a properties field + +`spatie/laravel-activitylog` does not natively support hierarchical activity. The hierarchical structure is encoded as a properties-key reference: each binding-level entry stores the pass-level entry's ID under `properties.parent_activity_id`. + +This avoids schema changes to the `activity_log` table while supporting hierarchical UI rendering. The trade-off is that reverse traversal ("show me all bindings under this pass") requires a JSON query (`properties->>'$.parent_activity_id' = ?`) which MySQL 8 indexes well via virtual columns if needed (no virtual columns yet — query volume is low and read-pattern is admin-UI not user-facing). + +### 14.3 Two sources of truth + +Failed bindings appear in both: + +- `activity_log` — human-readable timeline +- `form_submission_action_failures` — machine-replayable workflow row + +This duplication is deliberate. `activity_log` is for narrative reading: "what happened to this submission". `action_failures` is for state-machine action: "what failed, what state is it in, how do we move it to resolved or dismissed". + +A failed binding generates one `activity_log` entry (binding-level with error properties) AND one `form_submission_action_failures` row (only if the entire pass throws — partial-binding failures are TODO for future work; current architecture is all-or-nothing per pass). + +### 14.4 UI rendering + +The submission-detail review UI renders activity hierarchically: + +``` +[2026-04-30 14:22:02] Bindings applied (11 succeeded, 1 failed) ▼ expand + ├ ✓ person.email = "jan@example.nl" (trust 80, overwrite, was null) + ├ ✓ person.first_name = "Jan" (trust 60, overwrite, was null) + ├ ✓ person.last_name = "Janssen" (trust 60, overwrite, was null) + ├ ... + └ ✗ person.shoe_size = "42" (TypeError) [view failure] +[2026-04-30 14:22:01] Submission submitted by jan@example.nl +``` + +The `[view failure]` link navigates to `/orgs/{org}/form-failures/{id}` with the failure-record details. + +RFC-FORM-BUILDER-UI specifies the styling and interaction details for this view. + +--- + +## 15. Library bindings — copy-at-instantiation semantics + +### 15.1 Template-source semantics + +`FormFieldLibrary` entries can carry bindings (rows in `form_field_bindings` with `owner_type='form_field_library'`). When a `form_field` is instantiated from a library entry, those bindings are **copied** as new rows with `owner_type='form_field'` and the new field's ULID as `owner_id`. + +```php +// Inside FormFieldService::insertFromLibrary +$field = FormField::create([...]); + +$libraryBindings = FormFieldBinding::query() + ->where('owner_type', 'form_field_library') + ->where('owner_id', $libraryEntry->id) + ->get(); + +foreach ($libraryBindings as $libraryBinding) { + $this->bindingService->copyBindingFor($field, $libraryBinding); +} +``` + +`copyBindingFor` writes a new row referencing the new field's ULID with the same `target_entity`, `target_attribute`, `merge_strategy`, `trust_level`, `is_identity_key`. The library binding is unchanged. + +### 15.2 No runtime cascade + +Subsequent edits to a library binding do **not** propagate to existing field instances. If an organisation edits the library entry's `default_binding` (now the relational binding row) to change `merge_strategy=overwrite` to `merge_strategy=replace`, only future field instantiations from that library entry receive the new strategy. Existing fields remain on the old strategy. + +This matches the WS-5d decision for `form_field_options` (option lists also copy-at-instantiation, no runtime cascade) and the general template-source pattern documented in ARCH-FORM-BUILDER §17. + +### 15.3 Why no cascade + +Three reasons: + +1. **Predictability for versioning.** A schema published in April uses bindings as they were configured in April. A library edit in May should not silently change the behaviour of April's published schemas. + +2. **Audit reproducibility.** A retry of an April submission must apply April's bindings (this is also enforced at the snapshot layer; see §16). Cascade would create a window where the snapshot disagrees with the live state. + +3. **Operational safety.** A library admin should be able to fix a typo in a library binding without worrying about which field instances across all organisations will mutate. + +The trade-off is that library updates require a manual "re-sync" admin action to propagate to existing instances. That action is BACKLOG `FORM-LIBRARY-RESYNC` — implemented when organisations report friction. + +### 15.4 Library audit log + +Library binding changes log to `activity_log` under the library entry's subject. Tracking: BACKLOG `FORM-BUILDER-LIBRARY-AUDIT-LOG`. Currently, library edits are scoped to library admins (super_admin or library-manager role); the audit-log entries fire automatically via the existing `LogsActivity` trait on `FormFieldLibrary` and `FormFieldBinding`. + +--- + +## 16. Reproducibility — schema_snapshot and binding contract freezing + +### 16.1 The principle + +A retry of an April submission must apply the bindings as they were configured at submission time, not as they may have been edited since. This is reproducibility-for-audit. + +The mechanism is `form_submissions.schema_snapshot`: a JSON column populated at submission time with the canonicalized schema state. Binding configuration is part of that snapshot. + +### 16.2 Snapshot shape (binding-relevant subset) + +See ARCH-FORM-BUILDER §4.6.1 for the full snapshot structure. Binding-relevant fields per snapshot field: + +```json +{ + "fields": [ + { + "id": "01HX...", + "slug": "email", + "field_type": "email", + "label": "Emailadres", + "section_slug": null, + "binding": { + "target_entity": "person", + "target_attribute": "email", + "merge_strategy": "overwrite", + "trust_level": 80, + "is_identity_key": true + }, + "validation_rules": [...], + "options": null, + "conditional_logic": null + } + ] +} +``` + +Note that the snapshot uses an inlined `binding` object per field, not a reference to `form_field_bindings.id`. The canonical contract is "what binding-config this field had at submission time", not "which binding-row backed this field". + +### 16.3 Canonicalization + +JSON content stored in `form_submissions.schema_snapshot` is canonicalized on write via `App\Support\Json\JsonCanonicalizer::canonicalize()`. The transformation: + +- Recursive `ksort` on associative arrays (alphabetical by key) +- Numeric-indexed lists preserve order +- All other content unchanged + +This guarantees byte-stability across re-emits of the same logical content. Critical for: + +- **Audit-replay diffs** — without canonicalization, MySQL JSON-column round-trip can reorder keys non-deterministically, making "did the snapshot change" hard to answer +- **Webhook payload signing** — HMAC over a JSON string requires byte-stable input +- **Activity log diff regression tests** — `field.updated` activity entries land via `FormField::logFieldChange` which canonicalizes before `withProperties()` + +The `SchemaSnapshotByteStableAcrossReemitsTest` test class is the end-to-end contract. + +### 16.4 Retry uses the snapshot + +`form-builder:retry-failures` does not re-query `form_field_bindings`. It loads the submission's `schema_snapshot`, extracts the per-field `binding` objects, and feeds them into `FormBindingApplicator::applyAll($subject, $resolvedFromSnapshot)`. + +Live `form_field_bindings` is only consulted at: + +- Pre-publish validation (§8) — checking the current state of the schema against guards +- Snapshot writing (§4.6.1 in ARCH-FORM-BUILDER) — emitting the canonical snapshot at submission time +- Library copying (§15) — instantiating fields from library entries + +Apply-time **never** queries `form_field_bindings` directly. Reproducibility is the invariant. + +### 16.5 Configuration-only fields excluded + +`form_schemas.settings`, `form_schemas.translations`, and similar opaque-config columns are NOT canonicalized — key order has no semantic meaning there. Bindings, fields, sections, and the schema-level metadata that webhooks emit ARE canonicalized. + +--- + +## 17. Conflict resolution & trust precedence + +### 17.1 The problem + +Two fields can bind to the same `(target_entity, target_attribute)`. Example: a registration schema with both a "Name" field bound to `person.first_name` (trust 80) and a "Display name" field bound to `person.first_name` (trust 50). What happens at apply time? + +### 17.2 Candidate set + +The candidate set for a given target_attribute is: + +> Bindings whose source `form_field` has a row in `form_values` for this submission, regardless of whether the value is null. + +The "row exists" gate is binary. Once a binding qualifies, its value is decided by §17.3 below. + +The distinction matters because of multi-step / edit-resubmit flows: + +- **No row in form_values for this field** → the field was skipped by conditional logic, or the user never reached it. The binding is not a candidate. +- **Row in form_values with `value=null`** → the user explicitly cleared the field (e.g., they set a value, came back to the section, deleted it, and resubmitted). The binding IS a candidate; it may end up writing null to the target depending on the strategy. + +### 17.3 Sort order + +Candidates are sorted: + +1. `trust_level DESC` — higher trust wins +2. `form_field.sort_order ASC` — earlier-in-form wins on ties + +The winner is the first row after sorting. + +### 17.4 Per-strategy resolution + +Once the winner is identified, the merge_strategy applies to determine what (if anything) writes to the target attribute. See §4.3 for the null-winner matrix. + +For non-null winner values, the strategy applies straightforwardly: + +- `overwrite` — winner value replaces target unconditionally +- `append` — winner value added to collection (deduplicated) +- `replace` — winner value replaces target if target is null +- `first_write_wins` — winner value writes if target is null + +### 17.5 Write-path invariant test + +For every form_field that should be visible after conditional-logic evaluation at submit time, a `form_values` row must exist after submit, even with null value. This is the invariant that distinguishes "explicit null" from "skipped by conditional logic". + +`tests/Feature/FormBuilder/FormSubmissionWritePathInvariantTest.php` asserts this for the seeded fixtures. The test fails if `FormValueService::writeValues` silently drops null values on visible fields. + +### 17.6 Why not "first binding wins" + +The trust-level + sort-order ordering is more expressive than "first binding wins" because: + +- Schema authors can express authority gradients (data from a trusted upstream system at trust 90, user-self-reported at trust 50) +- Renaming or reordering fields in the builder UI does not silently change resolution semantics — the `trust_level` is explicit +- The tie-break on `sort_order` is documented and stable + +The cost is that schema authors must understand `trust_level`. The Form Builder UI surfaces this in the binding editor with a tooltip explaining the semantics; defaults are 50 (medium trust). + +--- + +## 18. Test contract & coverage + +### 18.1 Categories + +Pipeline tests fall into five categories. Each category has a coverage target and a canonical test-class location. + +#### Category 1 — Pipeline-agnostic (~20 tests) + +Located: `tests/Feature/FormBuilder/Pipeline/` + +Coverage: + +- Listener registration order test (§5.5) +- Two-transaction failure-write pattern (inner rollback + outer success; outer failure with Sentry capture) +- Post-commit event firing (no listener sees pre-commit state) +- Schema snapshot byte-stability across re-emits +- ApplyStatus state transitions (NULL → pending → completed; NULL → pending → failed; failed → pending → completed via retry; failed → resolved; failed → dismissed) +- IdentityMatchStatus update interplay with apply (apply must complete before identity-match writes status) +- Section-level apply stub (registered, gated, signature forwarding) + +#### Category 2 — Per-purpose pipeline (~28 tests) + +Located: `tests/Feature/FormBuilder/Pipeline/Purposes/{Purpose}/` + +Coverage: 4 cases × 7 purposes = 28 tests. + +Per purpose (`event_registration`, `artist_advance`, `supplier_intake`, `post_event_evaluation`, `incident_report`, `signature_contract`, `user_profile`): + +- Happy path — submission with all required bindings resolves, applies, persists +- Missing required binding — submission rejected at submit time (via existing form-validation), or apply fails gracefully if validation slipped +- Conflict resolution — two bindings on same target attribute, trust precedence wins +- Anonymous-when-applicable — for purposes where `subject_type` can be null, anonymous submission goes through the stub-resolver path + +#### Category 3 — PublishGuard (~18 tests) + +Located: `tests/Unit/FormBuilder/Publishing/{Guard}Test.php` + +Coverage: 1 passing case + 1 failing case per guard × 9 guards = 18 tests. + +Plus a smoke test asserting the four universal guards wire into every `PurposeGuardProvider` (catches "I added a new purpose provider but forgot the universal guards"). + +#### Category 4 — Failure management (~24 tests) + +Located: `tests/Feature/FormBuilder/FailureManagement/` and `tests/Feature/Api/FormFailure*Test.php` + +Coverage: + +- Retry happy path (failed → resolved) +- Retry that throws again (failed → failed with new exception) +- Retry of resolved/dismissed (no-op, idempotent) +- Resolve happy path +- Resolve with optional note +- Dismiss with each `DismissalReasonType` (6 cases) +- Dismiss with `OTHER` requiring `dismissed_reason_note` +- IDOR-class tests (§13.4) — 5 policy methods × 4 cases = 20 tests + +#### Category 5 — Activity log (~10 tests) + +Located: `tests/Feature/FormBuilder/ActivityLog/` + +Coverage: + +- Pass-level entry written per applicator invocation +- Per-binding entries written, each with `parent_activity_id` +- Failed binding writes an entry with error properties +- Hierarchical query: given a pass entry ID, retrieve all child binding entries +- Activity log entries reference correct subject types (Person for person bindings, User for user bindings, etc.) + +### 18.2 Coverage baseline + +Pre-WS-6: 1208 backend tests. + +WS-6 added ~278 tests (sessions 1, 2, 3 combined). + +Post-WS-6 (main HEAD `4a84b9e`): ~1486 backend tests. + +Future binding-pipeline changes should add at least one test per new decision-point and one test per new guard. Reviewers should reject PRs that add new branches in `FormBindingApplicator::apply()` or `FormSchemaService::publish()` without corresponding test additions. + +### 18.3 Test data + +`FormBuilderDevSeeder` provides fixtures for one event_registration schema per dev-org with 5 fields, 1 draft submission, 1 submitted submission. The submitted submission triggers the §31.10 tag-sync listener inline via `queue.connection=sync` flip in seeder context. + +For pipeline tests, factories live under `database/factories/FormFieldBindingFactory.php`, `FormSubmissionFactory.php`, `FormSubmissionActionFailureFactory.php`. They support fluent state methods like `->withApplyStatus(ApplyStatus::FAILED)` and `->forPurpose('event_registration')`. + +--- + +## 19. Out of scope — explicit non-goals + BACKLOG references + +The pipeline does not handle: + +| Non-goal | BACKLOG item | Rationale | +|---|---|---| +| Composite identity-key resolution (e.g. `email OR (first_name + last_name + DOB)`) | `FORM-BINDING-COMPOSITE-IDENTITY` | V1 enforces single identity-key per target_entity. Multi-attribute matching requires query-language design beyond `firstOrCreate`. | +| Cross-event submission deduplication (one Person, multiple events with separate registrations) | — | Handled by existing `PersonIdentityService` flow, not a binding concern. | +| Library-binding runtime cascade (updates propagate to instantiated fields) | `FORM-LIBRARY-RESYNC` | Copy-at-instantiation semantics chosen for predictability + audit reproducibility. | +| Append strategy on scalar targets | — | Architecturally rejected (V1). Eliminates duplicate-append-on-retry problem class. | +| Active section-level apply | `ARTIST-ADV-SECTION-APPLY` | Stubbed structure with feature flag; activated when artist_advance feature work begins. | +| Daily failure digest mailable | `FORM-FAILURE-DAILY-DIGEST` | Depends on notification framework (post-accreditation engine). | +| Wall-clock concurrent load testing | `LOAD-TEST-FOUNDATION` | Separate workstream; pre-release hardening. | +| ARTIST_ADV-specific binding-target registry entries | `ARTIST-ADV-BINDING-MODEL` | V1 omits artist binding-target registry entries entirely; landing in artist_advance feature work. | +| Partial-binding failure recovery | — | Current architecture is all-or-nothing per pass: any binding throw rolls back the whole apply. Granular partial-success is a future RFC topic. | + +When an item moves from out-of-scope to in-scope (e.g., artist_advance work begins), the corresponding BACKLOG item is the entry point. Each BACKLOG item references back to this doc and to RFC-WS-6. + +--- + +## 20. Cross-references & document history + +### 20.1 Document references + +| Document | Relevance | +|---|---| +| ARCH-FORM-BUILDER.md §17 | `form_field_bindings` table shape + binding semantics in the form-builder domain | +| ARCH-FORM-BUILDER.md §17.3 | PurposeRegistry mechanics, adding a new purpose | +| ARCH-FORM-BUILDER.md §17.4 | Validation rules table (sibling to bindings, same WS-5 split pattern) | +| ARCH-FORM-BUILDER.md §31.1–§31.10 | Integration contracts to non-binding listeners (identity match, GDPR, shifts, mail, code-of-conduct, supplier intake, accreditation hooks, crowd lists, tag sync) | +| ARCH-FORM-BUILDER.md §3.2 | Per-purpose lifecycles | +| ARCH-FORM-BUILDER.md §4.6.1 | Schema snapshot structure + byte-stability addendum | +| SCHEMA.md §3.5.12 | `form_submissions`, `form_field_bindings`, `form_submission_action_failures` table shapes | +| RFC-WS-6.md (frozen 2026-04-25) | Historical design source; cited as `RFC-WS-6 §X` for "why" decisions | +| ARCH-CONSOLIDATION-2026-04.md §6.2 | WS-6 charter | +| ARCH-CONSOLIDATION-ADDENDUM-2026-04-24.md Q1, Q2 | ULID exception retired, denormalized organisation_id pattern | +| ARCH-OBSERVABILITY.md (forthcoming WS-8b) | How pipeline events surface in GlitchTip + Telescope; PII-scrubbing contract for failure exports | +| BACKLOG.md | All deferred items referenced in §19 | + +### 20.2 Document history + +| Version | Date | Author | Notes | +|---|---|---|---| +| v1.0 | 2026-05-05 | Bert Hausmans (architect), Claude Chat (drafting) | Initial canonical reference, post-WS-6. Aligned to main HEAD `4a84b9e`. | + +### 20.3 Update protocol + +This document updates when: + +- A pipeline contract changes (any of §3, §4, §5, §6, §7, §8, §9 — RFC required) +- A failure-management flow changes (§11, §12, §13) +- An out-of-scope item is moved into scope (§19) +- WS-8b lands and §20.1 needs the ARCH-OBSERVABILITY reference filled in concretely + +Section-level edits are auto-committed per CLAUDE.md Git Commit Policy. Major contract changes route through RFC + ARCH update + test update + ARCH-FORM-BUILDER cross-ref update as a single commit chain.