docs: add ARCH-BINDINGS.md skeleton with foundation sections complete (WS-6)

Sections 1-5, 10, 11 written in full. Sections 6-9 stubbed with
session-2/3 markers and RFC references. Out-of-scope items §10
explicit.

Refs: RFC-WS-6.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-26 00:04:53 +02:00
parent 78a8016e01
commit 7f99783d8a

237
dev-docs/ARCH-BINDINGS.md Normal file
View File

@@ -0,0 +1,237 @@
# 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<string>`
- `attributesFor(entity): list<string>`
- `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