Files
crewli/dev-docs/ARCH-BINDINGS.md
bert.hausmans 7f99783d8a 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>
2026-04-26 00:04:53 +02:00

238 lines
9.5 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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