# ARCH-BINDINGS.md — Form Builder Binding Pipeline ## Status - v0.1 (skeleton) — 2026-04-25 - 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 The binding pipeline turns a submitted form (`form_submissions`) into durable writes against domain entities — Person, Artist, Company, User — via per-field bindings configured in `form_field_bindings` (WS-5a). 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. 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`. ## 2. Schema ### 2.1 form_field_bindings (WS-5a) 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`. ### 2.2 form_submissions.apply_status / apply_completed_at (WS-6) Two new columns added in `2026_04_25_140000_extend_form_submissions_with_apply_status`: - `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). Indexed on `(form_schema_id, apply_status)` and `(organisation_id, apply_status)` for dashboard queries. ### 2.3 form_submission_action_failures (WS-6) 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) > Populated in session 2 — see RFC-WS-6.md §3 Q6. ### 6.2 Conflict resolution (RFC Q7) > Populated in session 2 — see RFC-WS-6.md §3 Q7. ### 6.3 Apply algorithm and merge-strategy null matrix (RFC V1, Q7) > Populated in session 2 — see RFC-WS-6.md §4 V1 + §3 Q7. ### 6.4 Person provisioning (RFC Q8) > Populated in session 2 — see RFC-WS-6.md §3 Q8. ### 6.5 Per-purpose subject resolution (RFC Q9) > Populated in session 2 — see RFC-WS-6.md §3 Q9. ### 6.6 Section-level apply stub (RFC Q10) > Populated in session 2 — see RFC-WS-6.md §3 Q10. ### 6.7 Activity log granularity (RFC Q12) > Populated in session 2 — see RFC-WS-6.md §3 Q12. ## 7. Failures and retry ### 7.1 Two-transaction pattern (RFC Q4) > Populated in session 2 — see RFC-WS-6.md §3 Q4. ### 7.2 Retry, resolve, dismiss flows (RFC V2) > Populated in session 2 — see RFC-WS-6.md §4 V2. ## 8. Multi-tenancy and security ### 8.1 FK-chain tenant resolution (RFC V3) > Populated in session 2 — see RFC-WS-6.md §4 V3. ### 8.2 IDOR class tests > Populated in session 3 — see RFC-WS-6.md §4 V3. ## 9. Listener chain > Populated in session 2 — see RFC-WS-6.md §3 Q1, Q2, Q3. ## 10. Out of scope (v1) - **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` ## 11. Related docs - `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