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:
237
dev-docs/ARCH-BINDINGS.md
Normal file
237
dev-docs/ARCH-BINDINGS.md
Normal 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
|
||||
Reference in New Issue
Block a user