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

9.5 KiB
Raw Blame History

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