Files
crewli/dev-docs/ARCH-BINDINGS.md
bert.hausmans b2558791e6 docs(rfc-ws-6): v1.3.1 + ARCH-BINDINGS v1.2 — drift closure pre-D1 implementation
Three code-vs-docs drifts surfaced by the 2026-05-08 v1.3-delta audit.
None changes architecture; all three close the gap between code on main
(845b6e6) and the v1.3 amendment text.

- RFC §3 (Q1): apply_status enumerations updated to four cases (added
  PARTIAL alongside PENDING/COMPLETED/FAILED). PARTIAL is the
  BindingPassResult outcome when the pass committed with mixed
  per-binding outcomes; not a separate runtime path. Long-term direction
  remains BACKLOG PARTIAL-BINDING-SUCCESS.
- ARCH-BINDINGS §5.6: new "PARTIAL handling" subsection clarifying the
  gate treats PARTIAL identically to FAILED until partial-success work
  lands. The gate code itself was already correct (strict equality on
  COMPLETED); this closes the explanatory gap.
- ARCH-BINDINGS §7.1: status-columns table extended with apply_completed_at
  row. Intro line updated. Retry-service asymmetry noted as D2 follow-up
  (FormFailureRetryService::recordFailure currently does not write
  apply_completed_at; D2 fixes this).

RFC v1.3 -> v1.3.1; ARCH-BINDINGS v1.1 -> v1.2.

Refs: dev-docs/RFC-WS-6.md, dev-docs/ARCH-BINDINGS.md, dev-docs/BACKLOG.md (PARTIAL-BINDING-SUCCESS, unchanged)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 01:32:19 +02:00

89 KiB
Raw Permalink Blame History

ARCH-BINDINGS.md — FormBindingApplicator Pipeline

Version: v1.2 — incorporates RFC-WS-6 v1.3.1 drift closure (2026-05-08) 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.


1. Status & how to use this doc

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.

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.

Read this for…

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

Cross-document relations

  • 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

Stability commitment

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.

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.


2. The pipeline at a glance

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 0100 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<PublishGuard> 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:

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:

'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:

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. One synchronous listener fires on FormSubmissionSubmitted; all others are queued and run in parallel:

protected $listen = [
    FormSubmissionSubmitted::class => [
        // SYNC — only ApplyBindings, because subject_id must be on the
        // submission before the HTTP response serializes
        ApplyBindingsOnFormSubmit::class,
        // QUEUED — order does not matter; all gated on apply_status=COMPLETED
        TriggerPersonIdentityMatchOnFormSubmit::class,
        SyncTagPickerSelectionsOnSubmit::class,
        CreateProvisionalShiftAssignmentsFromRegistration::class,
        AddPersonToApplicableCrowdListsOnRegistration::class,
        FormWebhookDispatcher::class,
        // Plus purpose-specific mailables (RegistrationConfirmation etc.)
    ],
];

The single SYNC listener does not implement ShouldQueue. All others do. Laravel's listener-array order is the ordering primitive; no Subscriber pattern, no $priority flag.

v1.0 → v1.1: v1.0 had two synchronous listeners (ApplyBindingsOnFormSubmitTriggerPersonIdentityMatchOnFormSubmit). RFC-WS-6 v1.3 (2026-05-07 review) reduced the SYNC chain to one: identity-match joins against persons scoped to organisation and runs in seconds at scale; blocking PHP-FPM workers on it during peak public registration is operationally unacceptable. See §5.6 for the gating invariant that queued listeners must include.

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; writes initial identity_match_status='pending' for subject_type='person'. Runs inside a 5s deadline wrapper — exceeded deadline throws FormBindingApplicatorTimeoutException routed via outer transaction. Catches strict FormBindingApplicator throws, writes form_submission_action_failures row in outer transaction (with failure_response_code mirrored onto form_submissions), swallows
TriggerPersonIdentityMatchOnFormSubmit queued Gated on apply_status=COMPLETED (per §5.6); subject_type='person'. Throws IdentityMatchInvariantViolation if subject_id IS NULL despite COMPLETED state — that condition is a structural defect, not a failsafe (RFC-WS-6 §Q2 v1.3). Calls PersonIdentityService::detectMatches($person), writes final identity_match_status + identity_match_count, broadcasts FormSubmissionIdentityMatchResolved on the submission.{id} private channel Throw routed via Laravel queue worker → GlitchTip + form_submission_action_failures row; does not block siblings
SyncTagPickerSelectionsOnSubmit queued Gated on apply_status=COMPLETED (per §5.6); 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 Gated on apply_status=COMPLETED (per §5.6); purpose='event_registration' AND AVAILABILITY_PICKER values present Creates claim_pending ShiftAssignments for matching shifts Logs at error, swallows
AddPersonToApplicableCrowdListsOnRegistration queued Gated on apply_status=COMPLETED (per §5.6); purpose='event_registration' AND new Person created Adds Person to crowd_lists matching auto_add_criteria Logs at error, swallows
FormWebhookDispatcher queued Gated on apply_status=COMPLETED (per §5.6); 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 Gated on apply_status=COMPLETED (per §5.6); per-purpose mailable trigger conditions Sends purpose-specific email to submitter Failed sends logged via existing CrewliMailable infrastructure

5.3 Why SYNC for ApplyBindings only

ApplyBindingsOnFormSubmit is synchronous because subject_id must be on the submission before the HTTP response serializes — the submission resource needs a non-null subject reference, and the frontend immediately routes on the resolved Person. Provisioning is the only operation that must complete before the response leaves FormSubmissionService::submit().

The portal IdentityMatchBanner reads identity_match_status from the resource payload. To keep first-paint copy correct ("we're checking matches…"), ApplyBindingsOnFormSubmit::handle writes identity_match_status='pending' inside the inner transaction, immediately after subject resolution. This satisfies the FormSubmissionResource S3a contract for the identity_match block. The final state (matched / no_match / multiple_candidates) is written by the queued TriggerPersonIdentityMatchOnFormSubmit, and propagated to the portal via an Echo broadcast on the submission.{id} private channel; until the frontend follow-up subscribes to that channel, TanStack Query refetch-on-window-focus is the interim path.

Sync-chain hard timeout

ApplyBindingsOnFormSubmit runs inside a 5-second deadline wrapper ($applicator->withDeadline(seconds: 5)). The deadline is configurable via config('form_builder.apply_deadline_seconds') with default 5. On exceeded deadline, the wrapper throws FormBindingApplicatorTimeoutException, caught by the outer transaction handler and written as a form_submission_action_failures row with apply_status='failed'. This guarantees no submission can hang the public flow for more than a bounded interval — slow paths surface as failures, not as hung connections.

v1.0 → v1.1: v1.0 had two SYNC listeners with rationale "ordering primitive + response carries identity-match state." RFC-WS-6 v1.3 reduced SYNC to one (ApplyBindings) and added the deadline wrapper. The "response carries identity-match state" requirement is satisfied by writing pending inside the inner transaction; the final state lands asynchronously via Echo broadcast.

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 is registered as the first listener for FormSubmissionSubmitted and does NOT implement ShouldQueue
  2. TriggerPersonIdentityMatchOnFormSubmit is registered AFTER ApplyBindingsOnFormSubmit AND implements ShouldQueue
  3. SyncTagPickerSelectionsOnSubmit, CreateProvisionalShiftAssignmentsFromRegistration, AddPersonToApplicableCrowdListsOnRegistration, FormWebhookDispatcher all implement ShouldQueue
  4. Every ShouldQueue listener for this event includes the §5.6 gating invariant as the first executable statement of handle() — verified via ReflectionMethod introspection of the AST first node, asserting it is the canonical early-return on apply_status !== ApplyStatus::COMPLETED

This is a structural test — if a future change reorders listeners, queues a previously sync listener wrong, or omits the gating invariant on a new queued listener, the test fails before any behaviour test would.

5.6 Queued-listener gating invariant

Every queued listener for FormSubmissionSubmitted begins with this gate as the first executable statement of handle():

if ($event->submission->fresh()->apply_status !== ApplyStatus::COMPLETED) {
    Log::info('form-builder.queued-listener.skipped_apply_failed', [
        'listener' => static::class,
        'submission_id' => $event->submission->id,
    ]);
    return;
}

This is a hard invariant, not a recommendation. ApplyBindings runs synchronously before any queued listener; if its inner transaction throws, the outer-transaction failure-record path leaves the submission with apply_status=failed and subject_id=null. Without this gate, queued listeners would still trigger and assume a valid Person exists — leading to cascading failures whose root cause is hard to trace.

The gate is enforced by the §5.5 listener-ordering test: a contributor adding a queued listener without the gate sees a structural test failure before code review. Adding a queued listener that intentionally needs to run on FAILED submissions is a structural-defect signal — that listener belongs on a different event (or its trigger condition belongs in the gate's positive branch, not as an exception to the gate).

The gate uses fresh() rather than the in-memory event submission because the inner-transaction commit + outer-transaction failure-record write happens between event dispatch and queued-listener execution. The DB has the canonical apply_status value; the in-memory model does not.

PARTIAL handling

apply_status=partial means the inner transaction committed but at least one binding in the pass failed (per BindingPassResult::applyStatus()). The gate skips PARTIAL submissions identically to FAILED submissions: queued listeners cannot safely assume the subject + bindings are coherent. For example, SyncTagPickerSelectionsOnSubmit rebuilds user_organisation_tags against a Person whose tag-binding may have been the binding that failed; running the listener would propagate the partial state into derived data.

Until BACKLOG PARTIAL-BINDING-SUCCESS lands, the gate's positive branch is restricted to COMPLETED only. The PARTIAL row stays visible in the failures UI through the per-binding failure rows that FormBindingApplicator writes inside BindingPassResult::failures(), so admins can triage without losing audit data.

If a future contributor extends the gate to accept PARTIAL, the structural test asserting "skip on every non-COMPLETED state" must be updated alongside the listener changes — never one without the other.


6. Atomicity — two-transaction failure-write pattern

6.1 The pattern

// Inside ApplyBindingsOnFormSubmit::handle($event)
$submission = $event->submission;

try {
    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) {
    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', [
        'submission_id' => $submission->id,
        'exception_class' => $e::class,
    ]);
}

6.2 Why two transactions

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.

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.

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.

v1.0 → v1.1 — deadline wrapper. The inner transaction runs inside a 5-second deadline wrapper ($applicator->withDeadline(seconds: 5), configurable via config('form_builder.apply_deadline_seconds')). On exceeded deadline the wrapper throws FormBindingApplicatorTimeoutException, which is caught by the outer transaction handler exactly like any other applicator throw. The failure-record row carries exception_class=FormBindingApplicatorTimeoutException and apply_status='failed' — the public flow never hangs longer than the deadline bound. See §5.3 for the rationale; RFC-WS-6 §Q1 v1.3 addition 4 for the source decision.

v1.0 → v1.1 — failure_response_code mirror. Per RFC-WS-6 §Q3 v1.3 addition 2, the outer-transaction failure path also writes failure_response_code onto the form_submissions row (schema_config_error / temporary_error / data_integrity_error / unknown_error), driven by which subclass of FormBindingApplicatorException was thrown. The controller serializes this into the API response body when apply_status=failed; frontend renders contextual copy keyed on error_code.

6.3 Event-firing timing

FormSubmissionSubmitted fires after the FormSubmissionService::submit() transaction commits, not from inside the transaction:

// Inside FormSubmissionService::submit()
$submission = DB::transaction(function () use ($payload) {
    // ... build submission, write form_values, snapshot schema, etc.
    return $submission;
});

event(new FormSubmissionSubmitted($submission->refresh()));

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.

Do not move event firing into a model observer. Do not use DB::afterCommit() — explicit ordering inside FormSubmissionService is clearer and unambiguous.

6.4 Person provisioning races

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.

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.

6.5 Outer transaction failure-mode

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:

  • 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.

7. Status columns — apply_status and identity_match_status

7.1 Two independent concerns

form_submissions carries two status columns, one failure-classification column, and one apply-finalisation timestamp that the pipeline manages:

Column Type Default Indexed Managed by
apply_status string(20) nullable NULL yes ApplyBindingsOnFormSubmit (sync), retry/resolve flows
identity_match_status string(20) nullable NULL yes ApplyBindingsOnFormSubmit writes initial 'pending' for subject_type='person'; TriggerPersonIdentityMatchOnFormSubmit (queued) writes final state; PersonIdentityService::confirmMatch (manual)
failure_response_code string(40) nullable NULL yes ApplyBindingsOnFormSubmit outer-transaction failure path. Values: schema_config_error, temporary_error, data_integrity_error, unknown_error (per RFC-WS-6 §Q3 v1.3 addition 2). Serialized into API response body when apply_status=failed so the frontend can render contextual copy.
apply_completed_at timestamp nullable NULL no ApplyBindingsOnFormSubmit::handle (writes now() in both the inner-transaction success path AND the outer-transaction failure path, paired with the corresponding apply_status write) and FormFailureRetryService::retry (success path). Set whenever apply_status transitions to a terminal state (COMPLETED, PARTIAL, or FAILED). NULL means apply has never run on this submission. Used by the failures UI to render "Failed at …" / "Completed at …" copy without joining to form_submission_action_failures.

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. failure_response_code is null on success and populated only on apply_status=failed.

FormFailureRetryService::recordFailure does NOT currently write apply_completed_at on the retry-failure path; this asymmetry is fixed as part of the v1.3-delta D2 work (RFC v1.3 §Q3 addition 2 implementation).

7.2 ApplyStatus enum

enum ApplyStatus: string
{
    case PENDING = 'pending';
    case COMPLETED = 'completed';
    case PARTIAL = 'partial';
    case FAILED = 'failed';
}

PARTIAL is not a separate runtime path through ApplyBindingsOnFormSubmit::handle — it is the value BindingPassResult::applyStatus() returns when the pass committed but at least one individual binding failed (mixed per-binding outcomes inside a single applicator pass). Per RFC-WS-6 §Q3 v1.3 addition 3, granular partial-success handling is BACKLOG PARTIAL-BINDING-SUCCESS. Until that work lands, PARTIAL is treated identically to FAILED by the queued-listener gate (see §5.6).

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.

State transitions:

NULL ──(submission of binding-bearing schema)──▶ pending
pending ──(apply succeeds, all bindings)──▶ completed
pending ──(apply commits, mixed per-binding outcomes)──▶ partial
pending ──(apply throws / deadline exceeded)──▶ failed
failed ──(retry succeeds)──▶ completed
failed ──(retry throws)──▶ failed (action_failure row gets new attempts entry)

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.

7.3 identity_match_status

This column predates WS-6 and is documented in ARCH-FORM-BUILDER §31.1. Pipeline-relevant points:

  • ApplyBindingsOnFormSubmit writes the initial 'pending' state inside the inner transaction immediately after subject resolution, for any submission with subject_type='person'. This satisfies the FormSubmissionResource S3a contract — the HTTP response carries 'pending', never null, for person-typed submissions.
  • TriggerPersonIdentityMatchOnFormSubmit (queued, gated on apply_status=COMPLETED per §5.6) writes the final state (matched / no_match / multiple_candidates) plus identity_match_count, then broadcasts FormSubmissionIdentityMatchResolved on the submission.{id} private channel.
  • For non-person purposes (signature_contract, user_profile, incident_report, post_event_evaluation, supplier_intake, artist_advance), the column stays NULL. FormSubmissionResource.identity_match is null for these — RFC-WS-6 §Q2 v1.3 makes this contract explicit, with a contract test in tests/Feature/Api/FormSubmissionResourceTest.

Invariant (RFC-WS-6 §Q2 v1.3)

Post ApplyBindingsOnFormSubmit::handle for event_registration purpose: subject_type='person' AND subject_id IS NOT NULL, OR apply_status=ApplyStatus::FAILED. No third state exists. Violation is a structural defect.

TriggerPersonIdentityMatchOnFormSubmit enforces the invariant via a strict throw — IdentityMatchInvariantViolation — when it observes subject_type='person' AND subject_id IS NULL AND apply_status=COMPLETED. The throw routes through Laravel's queue-worker exception handler to GlitchTip and writes a form_submission_action_failures row, exactly like any other applicator failure. There is no "no subject → pending" failsafe path. The RequiresIdentityKeyBinding('person', 'email') publish guard wires unconditionally for event_registration (see §8.5), preventing the only schema configuration that could have produced this state from ever reaching publish.

7.4 UI surface

The platform admin "Form failures" page (/platform/form-failures) and organisation admin equivalent (/orgs/{org}/form-failures) filter by apply_status=failed.

The submission-detail review UI (organizer side) shows both columns as separate badges. PR-FORM-BUILDER-UI defines the badge styling.


8. Pre-publish validation — PublishGuard framework

8.1 Why pre-publish, not runtime

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

interface PublishGuard
{
    public function check(FormSchema $schema): PublishGuardResult;
}

final readonly class PublishGuardResult
{
    public function __construct(
        public bool $passed,
        /** @var list<PublishGuardViolation> */
        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):

public function publish(FormSchema $schema): void
{
    $provider = $this->purposeRegistry->guardProviderFor($schema->purpose);
    $guards = $provider->publishGuards();

    /** @var list<PublishGuardViolation> $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) — unconditional, both public and private schemas; 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. The composer is retained for purposes that genuinely need conditional wiring; it is not used for RequiresIdentityKeyBinding on event_registration:

v1.0 → v1.1 — RequiresIdentityKeyBinding becomes unconditional for event_registration. v1.0 wrapped this guard in ConditionalRequirement(predicate: $s->public_token !== null, …) — meaning private (organizer-driven) event_registration schemas could publish without an email-keyed Person identity binding. Post-publish, ApplyBindings would have nothing to provision against, and the TriggerPersonIdentityMatch failsafe-pad would absorb the consequences. RFC-WS-6 §Q2 v1.3 closes this gap: every event_registration schema requires the binding regardless of visibility, because the purpose itself creates or matches a Person — that requirement is intrinsic to the purpose, not to the schema's visibility. The failsafe-pad in TriggerPersonIdentityMatch is removed in the same change (§7.3).

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

interface PurposeGuardProvider
{
    /** @return list<PublishGuard> */
    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:

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

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

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).

v1.0 → v1.1 — failure_response_code companion column on form_submissions. Per RFC-WS-6 §Q3 v1.3 addition 2, when ApplyBindings catches a FormBindingApplicatorException subclass, the outer-transaction handler also mirrors a classification onto form_submissions.failure_response_code (schema_config_error / temporary_error / data_integrity_error / unknown_error). The action-failures row is the machine-replayable workflow artefact; the form_submissions.failure_response_code field is the response-shape driver — read by the controller to render contextual user-facing copy when the submission is queried with apply_status=failed. Both rows reference the same submission.id ULID. See §7.1 for the column listing on form_submissions.

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

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

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:

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:

{
  "id": "01HX...",
  "form_submission_id": "01HX...",
  "submission_summary": {
    "schema_name": "Volunteer Registration 2026",
    "schema_purpose": "event_registration",
    "submitter_label": "Jan Janssen <jan@example.nl>",
    "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:

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: <pass entry 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.

// 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:

{
  "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 testsfield.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 PARTIAL-BINDING-SUCCESS Current architecture is all-or-nothing per pass: any binding throw rolls back the whole apply. v1.0 deliberate limitation; partial-success requires a SAGA pattern or per-binding-transaction redesign. Trigger: first enterprise customer reports it as a UX issue.
Schema-drift detection on migration FORM-SCHEMA-DRIFT-DETECTION The 5% of runtime applicator throws that the architecture attributes to "DB modified out from under us" deserve dedicated detection. Trigger: first production incident where a runtime-throw is traced to a stale binding after a migration, OR pre-emptive trigger on a large-scale column-renaming.

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.
v1.1 2026-05-07 Bert Hausmans (architect), Claude Chat (drafting) Incorporates RFC-WS-6 v1.3 refinements (architectural review 2026-05-07). Twelve section edits, one new section: header version note; §5.1 SYNC chain reduced to one listener; §5.2 listener catalogue updated (TriggerPersonIdentityMatch sync→queued, gating-invariant cross-refs, deadline wrapper, failure_response_code mirror); §5.3 rewritten as "Why SYNC for ApplyBindings only" with deadline-wrapper note; §5.5 listener-ordering test extended for queueing + gating introspection; §5.6 (new) Queued-listener gating invariant; §6.2 deadline-wrapper + failure_response_code notes; §7.1 storage table extended with failure_response_code; §7.3 failsafe-pad replaced with explicit invariant statement; §8.5 RequiresIdentityKeyBinding becomes unconditional for event_registration; §11.1 cross-ref to failure_response_code companion column; §19 Partial-binding row points at PARTIAL-BINDING-SUCCESS, new row for FORM-SCHEMA-DRIFT-DETECTION. Spine unchanged (pre-publish guards, strict service / log-and-swallow listener, two-transaction pattern, sync ApplyBindings, snapshot-isolation, single-identity-key per target_entity).
v1.2 2026-05-08 Bert Hausmans (architect), Claude Code (drafting) §5.6 gained the PARTIAL-handling subsection; §7.1 gained the apply_completed_at row + intro update + retry-service asymmetry note; §7.2 enum block updated to four cases (added PARTIAL) with explanatory paragraph + state-transition diagram updated. No structural changes. Companion: RFC-WS-6 v1.3.1.

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.