Commit Graph

31 Commits

Author SHA1 Message Date
1afe11609a test(form-builder): WS-6 v1.3-delta D2 tests
~30 new tests + 6 modified covering D2 deliverables.

NEW test files:
- FormSubmissionSubmittedListenerOrderTest: rewritten — flips
  identity-match assertion from sync to ShouldQueue + adds AST-level
  structural guard that every queued listener has the
  apply_status=COMPLETED gate as an early statement
  (form-builder.queued-listener.skipped_apply_failed log line + ApplyStatus::COMPLETED check).
- TriggerPersonIdentityMatchOnFormSubmitTest: rewritten — drops
  failsafe-pad assertions; adds gate-skip tests (null/PENDING/PARTIAL/FAILED);
  invariant-violation throw test; broadcast-dispatch test.
- ApplyBindingsOnFormSubmitTest: extended — initial
  identity_match_status='pending' write, apply_completed_at on both
  paths, classifier-derived failure_response_code per exception subclass,
  unknown_error fallback, deadline wrapper invocation captured by
  test double, outer-transaction failure record.
- SyncTagPickerSelectionsOnSubmitGateTest (NEW): canonical skip-log
  assertion for null/PENDING/PARTIAL/FAILED apply_status; no-skip-log
  assertion for COMPLETED. Uses Log::spy because FormTagSyncService
  is final and can't be Mockery-mocked.
- FormBindingApplicatorDeadlineTest (NEW): withDeadline returns clone;
  no-deadline path; generous-deadline path; timeout exception thrown
  with correct submissionId + reasonCode (temporary_error inherited
  via FormBindingInfraException). Uses incident_report purpose for
  anonymous-allowed branch to avoid PersonProvisioner constraints.
- RetryServiceFailureClassifierTest (NEW): per-subclass
  failure_response_code mapping in recordFailure; apply_completed_at
  symmetry-fix coverage.
- SubmissionChannelAuthTest (NEW): submitter authorised, other user
  denied, missing submission denied, org admin currently denied
  (locks v1 contract per BACKLOG TECH-CHANNEL-AUTH-ORG-ADMIN).
- FormSubmissionResourceIdentityMatchTest: extended — DataProvider
  iterates over all six non-person purposes asserting
  identity_match=null per RFC §Q2 v1.3 contract.

MODIFIED to fit v1.3 layout:
- IdentityMatchOnSubmitTest: rewritten — directly invokes the listener
  with apply_status=COMPLETED pre-set, mirroring ApplyBindings'
  happy-path output (the test fixtures lack an identity-key binding
  so going through full event dispatch fails at PersonProvisioner).
  Drops the failsafe-pad assertion in test_public_submission_marked_pending;
  replaces with v1.3 contract: subject_type=null leaves
  identity_match_status untouched.
- TagPickerSyncListenerTest: same fix — sets apply_status=COMPLETED
  on the submission and invokes the listener directly.

Full suite: 1621 passing (4281 assertions). Larastan: 0 errors.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 03:20:27 +02:00
f94b3fb329 feat(form-builder): exception hierarchy for binding-apply pipeline
Per RFC-WS-6 §Q3 v1.3 addition 2 (binding hierarchy) + §Q2 (invariant exception).

- Refactored FormBindingApplicatorException from concrete final to abstract
  base. Constructor (submissionId, message, previous?) preserves submissionId
  as a public readonly property so D2's outer-transaction handler can write
  it structurally to form_submission_action_failures.context JSON without
  regex-parsing the message. Replaced public-readonly reasonCode property
  with abstract reasonCode(): string method.
- Added 3 reason-coded subclasses:
  - FormBindingSchemaConfigException -> 'schema_config_error' (422)
  - FormBindingInfraException -> 'temporary_error' (503, NOT final because
    Timeout extends it)
  - FormBindingDataIntegrityException -> 'data_integrity_error' (422)
- Added FormBindingApplicatorTimeoutException extending FormBindingInfraException
  (timeout = temporary infra issue from user perspective; reasonCode inherited).
- Added IdentityMatchInvariantViolation as a sibling DomainException — NOT
  in the FormBindingApplicatorException hierarchy because it's thrown
  outside the binding-applicator pipeline.
- Migrated 3 existing throw sites in FormBindingApplicator::apply():
  - 'no_transaction' -> FormBindingInfraException (developer-error wants
    infra-triage workflow: GlitchTip alert + retry-after)
  - 'no_schema' -> FormBindingSchemaConfigException
  - 'unknown_purpose' -> FormBindingSchemaConfigException
- Updated FormBindingApplicatorIntegrationTest::test_no_transaction_guard_present
  to assert against the new throw shape (FormBindingInfraException + new
  message string) while preserving the test's intent (guard exists in source).

Wiring (deadline wrapper, classifier integration in listener catch +
retry-service recordFailure) lands in D2.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 01:58:11 +02:00
832375b086 test(form-builder): bump migration step counts for WS-6 v1.3-delta D1 migration
The forward + rollback migration tests pin --step to a fixed count to
walk the WS-5/WS-6 stack back to known pre-states. The new
2026_05_08_000001_add_failure_response_code_to_form_submissions
migration sits at the top of that stack, so both rollback step counts
need +1 to reach the same destinations.

- pre-WS-5a rollback: --step 21 -> 22 (used twice)
- pre-WS-5b rollback (from fully-forward): --step 19 -> 20 (used once)

Comments updated to enumerate the v1.3-delta D1 migration in the WS-6
group.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 01:57:44 +02:00
192353f4bc feat(form-builder): admin UI completion — server filters, KPIs, resource expansion (WS-6 sessie 3c)
Closes the four production gaps that emerged from sessie 3b's admin UI.
What we ship here is final: no further rework planned before production.

Backend
- IndexFailuresRequest validates state/search/failed_at_from/failed_at_to/
  listener_class. orgIndex + platformIndex apply them via a single
  applyIndexFilters() helper. Search runs case-insensitive `LIKE` on
  exception_message; SQL wildcards in user input are escaped.
- New /kpis aggregate endpoint per scope (orgKpis, platformKpis) returns
  open / resolved_30d / dismissed_30d / total_submissions in O(1) COUNTs.
  Replaces sessie 3b's client-side bucketing of an oversized list.
- Resource expansion: organisation_name, form_schema_label,
  resolved_by_user_name, dismissed_by_user_name, exception_trace,
  retry_history[]. Eager-loading via indexEagerLoads()/detailEagerLoads()
  prevents N+1 (verified by query-count assertion in test).
- New 2026_04_28_181000 migration adds exception_trace (longtext nullable)
  to form_submission_action_failures. ApplyBindingsOnFormSubmit listener
  now captures $e->getTraceAsString() at failure time.
- New FormSubmissionActionFailureRetryAttemptResource exposes per-attempt
  data (timestamp, actor name, outcome, exception details) inside
  retry_history[]. Index payloads omit the field via whenLoaded() to keep
  list responses lean.

Frontend (apps/app)
- Types updated to mirror the expanded resource shape and the new KPI
  endpoint contract. FormFailuresKpis is now { open, resolved_30d,
  dismissed_30d, total_submissions } (server-aggregate).
- useFormFailures composable forwards all 5 server filters via
  buildIndexParams() (strips empty/whitespace). useFormFailuresKpis hits
  the dedicated /kpis endpoint per scope.
- FormFailuresTable replaces client-side bucketing with server-side
  filtering, adds listener_class + date-range filter inputs, and renames
  the 4th KPI tile to "Submissions" (was "Totaal").
- FormFailureDetail renders organisation_name + form_schema_label in the
  header, surfaces an expandable stack-trace card, names the resolved/
  dismissed actor in the timeline, and replaces the "v1 placeholder"
  retry-history card with a full per-attempt timeline.

ESLint config gap (apps/app)
- New .eslintrc.cjs adapted from the Vuexy reference, minus Vuexy-internal
  rules. `pnpm lint` now runs successfully (was previously broken — the
  package.json script referenced a missing config). The 80 baseline
  violations across the codebase are pre-existing and out of scope for
  this session.

Tests + gates
- 24 new backend tests across filter, kpis, and resource-shape suites.
  Backend: 1462 → 1486 passing, 0 → 0 failing. Larastan clean. Rector
  dry-run unchanged at 354 (pre-Task-1 baseline from f18b55b).
- 3 new vitest tests in apps/app (filter wiring, KPI endpoint, KPI tile
  values from /kpis). Vitest: 38 → 41 passing. tsc clean. Portal
  unchanged (113 vitest, tsc clean).
- 5 backfill rollback tests bumped --step counts +1 for the new migration.
- Ws6FoundationMigrationTest down/up chain now includes exception_trace
  before the parent table is restored.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 00:14:20 +02:00
b47e096a55 feat(form-builder): retry history table + integration (WS-6)
Per-attempt retry history (timestamp, user, outcome, exception detail
if failed) replaces the counter-only retry_count tracking.

Changes:

- New `form_submission_action_failure_retry_attempts` table (cascade on
  parent delete, nullOnDelete on user). Explicit short FK names
  (`fsafra_failure_fk`, `fsafra_user_fk`) — auto-generated names exceed
  MySQL's 64-char identifier limit.
- New FormSubmissionActionFailureRetryAttempt model + factory +
  succeeded() state.
- Parent FormSubmissionActionFailure gets retryAttempts() HasMany
  relation (latest('attempted_at')).
- New FormFailureRetryService centralises the retry-flow logic. Both
  the API controller and the artisan command delegate to it. Service
  writes a retry_attempt record per attempt; parent's retry_count
  stays as denormalised cache for index-view performance.
- Successful retry: attempt(succeeded) + parent.retry_count++ +
  parent.resolved_at + parent.resolved_by_user_id + parent.resolved_note
  ("Geslaagde retry door {actor.name}" or "Geslaagde retry
  (geautomatiseerd)" for command-line invocation without an actor).
- Failed retry: attempt(failed) with NEW exception details +
  parent.retry_count++. Parent's exception_class/_message stay
  audit-immutable — they represent the FIRST failure.
- canBeRetried() now correctly checks both resolved_at AND
  dismissed_at (sessie 2's open question Q2 closure).
- New FailureNotRetriableException (controller → 422) and
  ParentSubmissionGoneException (controller → 410) for cleaner
  flow control.

12 new tests:
- FormSubmissionActionFailureRetryAttemptTest (5 unit tests)
- RetryFlowProducesRetryAttemptsTest (7 integration tests covering
  succeeded path, failed path, resolved/dismissed blocking,
  multiple-retries chronological ordering, canBeRetried truth tables)

Pre-existing tests touched:
- FormSubmissionActionFailureTest::test_can_be_retried_only_for_open_state
  — updated to reflect Q2 closure (resolved now blocks too).
- Ws6FoundationMigrationTest::test_down_methods_clean_up_columns_and_table
  — child table must drop before parent (FK constraint).
- 5 backfill test step-counts bumped +1 (new migration sits at top).

SCHEMA.md → v2.9. Schema dump regenerated.

Refs: RFC-WS-6.md §3 Q5 addendum, sessie 2 Q2

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 00:14:19 +02:00
d48d91bba7 test(form-builder): registry/model alignment consistency invariant (WS-6)
Sessie 1 left BindingTypeRegistryConsistencyTest as the cross-cutting
invariant for the binding registry. This commit extends it with a
new assertion: every registry entity must map to a real Eloquent
model class, and every registry attribute must exist as a column
on that model's table.

Future drift (someone adds a registry attribute without the column,
or renames a column without updating the registry) becomes a test
failure on the next test run, not a runtime surprise.

Implementation: queries information_schema.COLUMNS via the active
MySQL connection (opaque DBs are not in Crewli's deployment matrix
per CLAUDE.md). Skips the 'artist' entity entirely — it's
intentionally absent from v1 registry per BACKLOG
ARTIST-ADV-BINDING-MODEL.

Pre-existing tests not touched by this commit (already updated in
previous Task 2 commit a404865 for the renames):
  - BindingTypeRegistryTest (collection-target tests use Config::set
    synthetic injection)
  - AppendStrategyRequiresCollectionTargetTest (same pattern)
  - MaxOneIdentityKeyPerTargetEntityTest (company.email →
    company.contact_email)

Refs: WS-6 sessie 3a binding-target drift audit

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 00:14:13 +02:00
383b4fc5a3 feat(companies): add kvk_number column for B2B identity binding (WS-6)
WS-6 binding-target registry references company.kvk_number as a B2B
identity-key candidate. The column needed to exist on the model
before the registry could legitimately reference it. Nullable
because not every Company has a registered KvK (foreign companies,
partners, agencies); identity-key publish guards enforce presence
where required, not at schema level.

Changes:
- New migration `2026_04_28_140000_add_kvk_number_to_companies_table`
  adds nullable string column + index after `type`.
- Company::$fillable expanded.
- CompanyFactory generates an 8-digit KvK by default.
- CompanyKvkNumberTest covers attribute persistence, nullability,
  and information_schema-verified index existence.
- SCHEMA.md → v2.8 with the new column row + indexes line.
- Schema dump regenerated (CI fast-path).

Migration step counts in 5 backfill tests bumped +1 (the new
migration sits at the top of the migration stack):
  - FormFieldBindingMigrationTest:           18→19, 16→17
  - ConditionalLogicBackfillTest:             7→8
  - FormFieldConfigBackfillAndDropTest:      13→14
  - FormFieldOptionsBackfillTest:             3→4
  - FormFieldValidationRuleBackfillTest:     16→17

Refs: WS-6 sessie 3a binding-target drift audit

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 00:14:12 +02:00
e1551b24bc test(form-builder): per-purpose pipeline smoke for the 6 non-event_registration purposes (WS-6)
RFC §3 Q9 contract — applicator is purpose-agnostic; per-purpose
differences live in PurposeSubjectResolver. Sessie 2's smoke matrix
covered only event_registration; this commit fills the remaining six.

Coverage per file (18 tests total, all passing):

  - SignatureContractPurposePipelineTest (3 tests)
    happy path + conflict resolution + no_auth missing-context
    target: User via auth (User::email/first_name/last_name)

  - UserProfilePurposePipelineTest (3 tests)
    happy path + conflict resolution + no_auth missing-context
    target: User via auth

  - SupplierIntakePurposePipelineTest (3 tests)
    happy path + conflict resolution + no_production_request missing-context
    target: Company subject pre-set by production_request flow

  - PostEventEvaluationPurposePipelineTest (4 tests)
    happy path + conflict resolution + no_person_for_user + no_auth
    target: Person via auth user → Person.event_id link

  - IncidentReportPurposePipelineTest (4 tests)
    happy path (auth + Person link)
    + conflict resolution
    + anonymous-allowed (null subject → COMPLETED, empty applications)
    + auth-without-Person (null subject branch)
    Unique purpose: only one allowed to legitimately resolve to no subject.

  - ArtistAdvancePurposePipelineTest (1 test)
    no_portal_token missing-context only.
    Happy path + subject_not_found branches require the Artist model
    (BACKLOG: ARCH-09); morphTo can't materialise a non-existent class.
    Documented inline; full coverage follows once ARCH-09 lands.

Each test wires the schema_snapshot directly with the applicator-shape
binding entries (matches sessie 2's FormBindingApplicatorIntegrationTest
pattern). All bindings use registered binding-target attributes from
config/form_builder/binding_targets.php to satisfy BindingTypeRegistry's
strict resolve() at apply time.

Refs: RFC-WS-6.md §3 Q9, ARCH-BINDINGS.md § 6.5

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 00:14:11 +02:00
a791a276fa fix(form-builder): canonicalize JSON for byte-stable storage (WS-6)
MySQL 8.0 JSON columns may reorder associative-array keys on
round-trip. For audit-immutable values (schema snapshots, webhook
payloads, activity log diffs), this is corrupting: re-emits produce
different byte sequences for the same logical content.

Introduced JsonCanonicalizer (recursive ksort on associative arrays;
numeric-indexed lists preserve order) and applied at every writer
site that produces byte-stable JSON:

- FormSubmissionService: canonicalize the schema_snapshot array
  before storage (audit-immutable per ARCH §4.3, RFC-WS-6 v1.1).
- FormField::logFieldChange / FormSchema::logSchemaChange: canonicalize
  activity-log properties before withProperties() so old/new diffs
  read back byte-stable.
- BindingActivityLogger: canonicalize both the pass-level and
  per-binding activity properties.
- FormWebhookDispatcher: canonicalize payload_snapshot before
  storage (delivery-time HMAC re-encodes the same canonical bytes).
- DeliverFormWebhookJob: switched json_encode to
  JsonCanonicalizer::encode for the HMAC-signed body, so the
  signature is byte-stable across re-deliveries and reproducible by
  receivers from the same logical payload.

Sites NOT canonicalized (deliberate):
- form_schemas.settings — opaque UI config; key order has no
  semantic meaning, no byte-stability requirement.
- form_schemas.translations / form_fields.translations — read by
  display layer; key order doesn't matter.
- form_templates.schema_snapshot — user-supplied input via store/
  update; user is the source of truth, not audit-immutable in the
  same way as form_submissions.schema_snapshot.

Reverted the 7 assertEquals workarounds from session 2.6:
- ConditionalLogicActivityLogPayloadTest
- ConditionalLogicBackfillTest::test_rollback_reconstructs_canonical_json
- FormFieldBindingMigrationTest::test_rollback_reconstructs_json_and_drops_table
- FormFieldOptionServiceAndScopeTest::test_replace_options_emits_activity_log_on_field_only
- FormFieldOptionsActivityLogTest::test_field_updated_payload_contains_options_diff_when_options_change
- FormFieldOptionsBackfillTest::test_forward_migration_backfills_rows_strips_translations_and_rewrites_snapshot
- FormFieldOptionsSnapshotAndStrictRequestTest::test_submission_snapshot_embeds_rich_shape_options

Each now uses assertSame on JsonCanonicalizer::encode of both sides —
byte-stable comparison meaningful regardless of MySQL JSON storage
behavior.

New regression test SchemaSnapshotByteStableAcrossReemitsTest
exercises the contract end-to-end: complex schema with bindings,
validation rules, options, conditional logic, submitted; reads
schema_snapshot via three roads (Eloquent cast, fresh model, raw
bytes) and asserts the canonical encode is identical.

ARCH-FORM-BUILDER.md §4.6.1 gets a "Byte-stability" sub-section
explaining what's canonicalized and why.

Test count: 1388 → 1400 (+11 JsonCanonicalizer unit, +1 snapshot
regression). Larastan clean. Rector dry-run unchanged at 355.

Refs: WS-6 session 2.6 deviation #4 cleanup, RFC-WS-6 v1.1

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 00:11:18 +02:00
fe686b7c8d fix(form-builder): restore FK on form_schemas.default_crowd_type_id (WS-6)
The original session 2.5 migration had to omit this FK due to an
SQLite-only "rebuild on FK add" cascade-delete quirk. Now that the
test infrastructure has moved to MySQL (Task 1 of this session), the
quirk does not apply and the FK is restored to match every other FK
in this table.

Changes:
- New migration `2026_04_28_100000_restore_default_crowd_type_id_foreign_key`
  adds a FOREIGN KEY (default_crowd_type_id) REFERENCES crowd_types(id)
  ON DELETE SET NULL. Deleting a CrowdType nulls the column on dependent
  schemas instead of cascading the schema delete.
- Original migration's comment block rewritten — the SQLite-quirk
  rationale was demonstrably misleading; replaced with a forward-looking
  pointer to the FK-restore migration.
- PersonProvisioner::resolveCrowdTypeId() docblock updated: the runtime
  failsafe is now defense in depth alongside the DB-level FK + publish
  guard, not the sole load-bearing check.

New test (`DefaultCrowdTypeForeignKeyTest`) exercises both the
ON-DELETE-SET-NULL cascade and the existence of the FK in
information_schema.REFERENTIAL_CONSTRAINTS — the second assertion would
have been impossible on SQLite, which is exactly the point.

Migration step counts in 5 backfill tests bumped +1 because the FK-
restore migration sits at the top of the migration stack:
  - FormFieldBindingMigrationTest:           17→18, 15→16
  - ConditionalLogicBackfillTest:             6→7
  - FormFieldConfigBackfillAndDropTest:      12→13
  - FormFieldOptionsBackfillTest:             2→3
  - FormFieldValidationRuleBackfillTest:     15→16

All 1388 tests pass on MySQL (1386 prior + 2 new FK tests). Larastan
baseline unchanged.

Refs: RFC-WS-6.md v1.1 §3 Q9 addendum, WS-6 session 2.5 deviation #1

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 00:10:57 +02:00
3d323bf55f chore(test): switch test database from SQLite to MySQL (WS-6)
Test infrastructure now uses the same MySQL 8.0 engine as local dev
and production. SQLite is no longer used anywhere in the project.

Eliminates the SQLite "rebuild on FK add" quirk that forced session 2.5
to omit a foreign key on form_schemas.default_crowd_type_id (Task 2 of
this session restores it).

Configuration:
- phpunit.xml: DB_CONNECTION=sqlite (:memory:) replaced with mysql
  pointing at crewli_test database (127.0.0.1:3306, crewli/secret)
- Makefile: new test-db-create target creates crewli_test in the
  bm_mysql Docker container; make test ensures it exists before
  running suite

Latent-bug surfacing — fixes that MySQL exposed:

1. form_submissions.idempotency_key was declared `ulid()` (VARCHAR 26)
   while FormRequest validates `string|max:30`. SQLite ignored the cap;
   MySQL truncated and rejected. Column widened to string(30) to match
   validation.

2. FormFieldValidationRuleService / FormFieldConfigService /
   FormFieldBindingService::snapshotShapesFor — toJsonShape iterated
   collection in DB-default order (insertion-stable on SQLite, undefined
   on MySQL). Schema_snapshot bytes drifted across re-emits, breaking
   audit-replay. Added `->sortBy('id')` (ULID = insertion-order
   semantics, deterministic) on all three.

3. FormSubmissionObserverTest::test_denormalized_indexes_exist queried
   sqlite_master directly. Replaced with the cross-engine
   information_schema.STATISTICS query (the real production check is
   on MySQL anyway).

4. JSON column key order non-determinism: MySQL JSON columns may
   round-trip associative-array keys in a different order than they
   were inserted. assertSame on JSON-derived associative arrays now
   uses assertEquals (structural equality) where the test was previously
   over-asserting on key order:
   - ConditionalLogicActivityLogPayloadTest
   - ConditionalLogicBackfillTest::test_rollback_reconstructs_canonical_json
   - FormFieldBindingMigrationTest::test_rollback_reconstructs_json_and_drops_table
   - FormFieldOptionServiceAndScopeTest::test_replace_options_emits_activity_log_on_field_only
   - FormFieldOptionsActivityLogTest::test_field_updated_payload_contains_options_diff_when_options_change
   - FormFieldOptionsBackfillTest::test_forward_migration_backfills_rows_strips_translations_and_rewrites_snapshot
   - FormFieldOptionsSnapshotAndStrictRequestTest::test_submission_snapshot_embeds_rich_shape_options

5. Backfill / migration tests (4 classes, 21 tests) ran migrate:rollback
   then migrate inside RefreshDatabase's wrapping transaction. MySQL
   DDL implicit-commits the surrounding transaction, leaving Laravel
   unable to ROLLBACK TO SAVEPOINT at end-of-test (1305 SAVEPOINT
   does not exist). Replaced RefreshDatabase with a per-test
   migrate:fresh in setUp + RefreshDatabaseState::\$migrated = false to
   force the next RefreshDatabase test to re-migrate cleanly:
   - FormFieldBindingMigrationTest
   - ConditionalLogicBackfillTest
   - FormFieldOptionsBackfillTest
   - FormFieldValidationRuleBackfillTest

All 1386 tests now pass on MySQL. Larastan baseline unchanged.

Refs: WS-6 session 2.5 deviation #1 cleanup, RFC-WS-6.md v1.1

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 00:10:56 +02:00
6dace312b4 refactor(form-builder): drop legacy snapshot 'binding' (singular) key (WS-6)
Session 2 wrote both 'binding' (singular) and 'bindings' (plural)
in form_submissions.schema_snapshot for backward compatibility. With
no production data yet and dev seeders re-running every cycle, dual-
key state has no upside. Snapshots now write 'bindings' only;
all readers updated to match.

FormFieldBindingService::snapshotShapesFor() simplified to return
only ['bindings' => $all]. Pre-existing
SchemaSnapshotEmbedsBindingFromRelationalTableTest updated to assert
the applicator shape (with id, merge_strategy, trust_level,
is_identity_key) on bindings[0]; new
SnapshotOnlyContainsBindingsKeyTest enforces the no-legacy-key
contract going forward.

FormBuilderDevSeeder template snapshot embeds 'bindings' => [] for
form-owned fields (Pattern B) instead of 'binding' => null.

Other 'binding' string occurrences in the codebase (FormFieldResource,
FormFieldService, request validation rules, BindingConflictResolver
internal helper key) are unrelated to snapshot dual-state and remain
untouched.

Refs: WS-6 session 2 deviation #9 cleanup

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 00:11:20 +02:00
d2059e3cff feat(form-builder): per-schema default_crowd_type_id replaces silent oldest() heuristic (WS-6)
Session 2's PersonProvisioner picked CrowdType::oldest() for the org —
silently wrong for multi-crowd_type orgs (Volunteer + Crew + Press are
three distinct crowd_types in one org). Schemas now declare their
target crowd_type explicitly via form_schemas.default_crowd_type_id.
RequiresDefaultCrowdType publish guard prevents misconfigured
event_registration schemas from publishing.

PersonProvisioner: oldest() fallback removed entirely. Misconfiguration
throws no_default_crowd_type at runtime; publish guard prevents it at
config time.

Migration uses a plain ulid() column without DB-level FK because
SQLite's table-rebuild on ALTER ADD FOREIGN KEY cascade-deletes
form_fields rows (form_fields.form_schema_id has cascadeOnDelete on
form_schemas). Application-level integrity via FormSchema::defaultCrowdType()
belongsTo + the publish guard + the runtime failsafe — three load-bearing
checks, none of which require the DB-level constraint.

Three pre-existing migration backfill tests bumped step counts +1 to
account for the new migration sitting between WS-5c and WS-5d:
FormFieldBindingMigrationTest (16→17, 14→15), FormFieldConfigBackfillAndDropTest
(11→12), FormFieldValidationRuleBackfillTest (14→15),
ConditionalLogicBackfillTest (5→6).

Six event_registration test fixtures updated to set default_crowd_type_id
to satisfy the new publish guard.

FormBuilderDevSeeder.resolveDefaultCrowdTypeId() — VOLUNTEER → first-active
→ create-as-needed fallback chain; documented contract for future seeders.

SCHEMA.md updated to v2.7.
Refs: RFC-WS-6.md v1.1 §3 Q8 addendum (Task 4 of this session)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 23:47:32 +02:00
3d2608d992 test(form-builder): write-path invariant for conflict-resolver candidate set (WS-6)
Asserts the RFC Q7 prerequisite: every visible form_field has a
form_values row after submit (even null/empty), every absent field has
none. This is the invariant the BindingConflictResolver relies on to
distinguish 'explicit clear' from 'skipped by conditional logic'.

Refs: RFC-WS-6.md §3 (Q7)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 17:00:51 +02:00
9f98a4fe1b feat(form-builder): FormBindingApplicator + BindingActivityLogger (WS-6)
Orchestrates per-purpose subject resolution + binding conflict
resolution + per-binding writes per RFC Q4/Q7/Q9. Per-binding failures
captured in BindingPassResult, not thrown — partial failures are
expected and recoverable. Catastrophic failures (no transaction,
unknown purpose, missing schema) throw FormBindingApplicatorException
and bubble.

Per-strategy null-winner matrix implemented via a NO_OP sentinel:
overwrite=write null, append=noop, replace=conditional, first_write_wins=
write only into null target. Append is collection-only with set-merge
semantics (deduplicated array_merge).

Identity-key bindings are skipped during apply — the subject resolver
already used them for lookup/provisioning; re-writing is a no-op or a
clobber.

Activity log hierarchical: one bindings_pass_completed parent +
N binding_applied children with parent_activity_id linkage (RFC Q12).
Failed bindings get error_class/error_message in their activity entry
in addition to their FormSubmissionActionFailure row (deliberate
dual source of truth).

Refs: RFC-WS-6.md §3 (Q4, Q7, Q9, Q12)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 13:06:45 +02:00
d257d64925 feat(form-builder): add PersonProvisioner with race-safe firstOrCreate (WS-6)
PersonProvisioner reads bindings from schema_snapshot (RFC Q6) and
provisions Persons via lockForUpdate + firstOrCreate (RFC Q8).
Person is event-scoped (Person::$organisationScopeColumn = 'event_id'),
so the lookup matches by (email, event_id) — cross-event submissions
never collide.

Throws PersonProvisioningException on misconfiguration (failsafe —
publish guards should prevent these at config time): no_transaction,
no_event, no_identity_key, identity_key_missing_value, no_crowd_type.

Snapshot enrichment: FormFieldBindingService::toApplicatorShape +
FormSubmissionService snapshot now adds a 'bindings' (plural) key with
binding id, merge_strategy, trust_level, is_identity_key. Singular
'binding' key kept for legacy webhook / GDPR readers.

Includes RFC V4 state-injection concurrency test asserting recovery
semantics under lockForUpdate windows.

Refs: RFC-WS-6.md §3 (Q6, Q8), §4 (V4)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 12:43:12 +02:00
7a747382a0 feat(form-builder): integrate PublishGuard framework into FormSchemaService::publish() (WS-6)
assertPublishGuardsSatisfied() runs additively after the existing
assertRequiredBindingsPresent() check. Failures are collected (not
first-fail) so PublishGuardViolationException carries the full list
to the builder UI in one 422 response.

PurposeRequirementsNotMetException remains for missing bindings;
PublishGuardViolationException covers semantic constraints
(is_identity_key flag, no-ambiguous-trust, append-collection-only,
section-aware schemas, conditional triggers).

Two pre-existing tests updated their fixtures to satisfy the new
guards (PublishChecksRelationalBindingsTest +
PurposeSchemaLifecycleTest): EMAIL field type + is_identity_key on
person.email + unique trust levels are now required for
event_registration to publish.

Refs: RFC-WS-6.md §3 (Q13)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 23:07:12 +02:00
e3c9211e3f feat(form-builder): wire PurposeGuardProvider per purpose (WS-6)
Adds PurposeGuardProvider as a parallel interface to PurposeDefinition
(value object stays untouched). Seven concrete providers, one per v1.0
purpose, each declaring its publish-guard list. Registry resolves and
caches providers via guards_class config key.

Universal guards (MaxOneIdentityKeyPerTargetEntity,
AppendStrategyRequiresCollectionTarget, NoAmbiguousTrustLevels,
IdentityKeyBindingsOnlyInFirstSection) wire into every purpose. The
section guard is a cheap no-op when section_level_submit=false.

ArtistAdvanceGuards omits RequiresIdentityKeyBinding because the
artist subject is resolved via portal token, not form data. Same
reasoning for supplier_intake (production_request) and the auth-based
purposes.

Includes a cross-cutting BindingTypeRegistryConsistencyTest that
verifies tasks 5/7/8 do not contradict each other (registry ↔ guards ↔
purpose required_bindings).

Refs: RFC-WS-6.md §3 (Q9, Q13)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 23:01:19 +02:00
c033dc6cd2 feat(form-builder): add apply_status columns and action-failures table (WS-6)
- form_submissions: apply_status (nullable, NO default for legacy rows
  per RFC O1), apply_completed_at, indexed on (form_schema_id, apply_status)
  and (organisation_id, apply_status)
- form_submission_action_failures: ULID PK, FK to submission + binding,
  resolve/dismiss state separated (RFC V2), retention via parent
  cascade-delete
- Migration rehearsal test added (invokes down() directly because the new
  migrations land between WS-5a and WS-5b chronologically, not at the tail
  of the migration list)

Three pre-existing WS-5 backfill tests also bump their --step rollback
counts by +2 (FormFieldBindingMigrationTest, FormFieldConfigBackfillAndDropTest,
FormFieldValidationRuleBackfillTest) to account for the two new migrations
sitting in the chronological middle of the WS-5 stack — required to keep
those tests' pre-WS-5b rollback target reachable.

SCHEMA.md updated to v2.3.
Refs: RFC-WS-6.md §3 (Q4, Q5), §4 (V2), §5 (O1)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 22:33:39 +02:00
e7c9482474 refactor(form-field): drop form_fields.options + form_field_library.options
Final WS-5d cleanup. The JSON columns that have been unread since
commit 3 are now physically dropped on both source tables. Their
canonical rich-shape lives in form_field_options, accessed
exclusively through the morphMany relation.

Defensive sweep: any lingering translations.{locale}.options key in
either source table's translations bag is stripped. Commit 2's
backfill should already have done so exhaustively; this is
belt-and-braces.

Rollback re-creates the columns as nullable JSON but leaves them
empty. Pair with commit 2's rollback to restore the pre-WS-5d data
shape on every owner row.

The commit-3 getOptionsAttribute accessor-bridge on FormField +
FormFieldLibrary is removed — Eloquent's getAttribute() resolution
now naturally falls through to the morphMany relation since there's
no underlying column to shadow it. New regression test
FormFieldOptionsAccessTest asserts $field->options resolves to an
Eloquent Collection of FormFieldOption instances and lazy-loads in
exactly 2 queries (1 parent + 1 lazy-load options) on a fresh fetch
without with() preload. Same trio for FormFieldLibrary.

Migration step-count tests in WS-5a/b/c bumped by 1 to account for
the new drop_form_field_options_json_columns migration on the
rollback stack.

Documentation:
  - SCHEMA.md v2.6: form_field_options table documented; options row
    removed from form_fields and form_field_library; morphMany
    relations updated; cross-references to ARCH-FORM-BUILDER §17.6
    and addendum §Q3 WS-5d Uitvoering added on both source-table
    docblocks.
  - ARCH-FORM-BUILDER.md v1.8: new §17.6 "Field options (relational)"
    mirrors the §17.4 / §17.5 relational-sibling structure with
    sub-sections 17.6.1 rationale, 17.6.2 table + catalogue, 17.6.3
    service / scope / cascade / activity log, 17.6.4 snapshot
    embedding, 17.6.5 external API contract. Existing Webhooks
    section renumbered from §17.6 to §17.7.
  - ARCH-CONSOLIDATION-ADDENDUM-2026-04-24.md: "Uitvoering — WS-5d
    (2026-04-27)" section added. Eight paragraphs covering the
    snapshot atomic rewrite, strict-fail backfill dispatch, dual
    activity-log emit, four-sibling base-class extraction warrant,
    commit 0 dead-code precondition, the temporary getOptionsAttribute
    accessor-bridge pattern (with reusability note for future
    JSON→relational refactors), the dev-seeder vergoedingstype RADIO
    normalisation (drift correction explicitly distinguished from the
    parallel apps/app RegistrationFieldTemplate description domain),
    and the WS-5 family completion note.
  - BACKLOG.md: FORM-BUILDER-LIBRARY-AUDIT-LOG entry extended to four
    services (adds library.options_replaced); new
    FORM-BUILDER-MORPH-SCOPE-BASE-CLASS entry added as the WS-5d
    follow-up now that all four concrete morph-scope siblings exist.

Tests: 1193 → 1208 green (+15 across commits 3+4+5; this commit alone:
+2 from the regression test).

This completes the WS-5 family.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 03:00:20 +02:00
15e4e49d8c feat(form-field): backfill form_fields.options to form_field_options
Atomic data migration. Every options datum in the database — in
form_fields and form_field_library, their translations bags, and the
form_submissions.schema_snapshot + form_templates.schema_snapshot JSON
blobs — is converted to the new relational rich-shape representation.

Strict dispatch per §17.4.4 / §8.7 convention:
  - Fail on field_type ∉ {RADIO, SELECT, MULTISELECT, CHECKBOX_LIST}
    carrying non-null options (post-WS-5b TAG_PICKER seed-bug indicator)
  - Fail on non-flat-string-array options shape
  - Fail on translations.{locale}.options[] length mismatch
  - Fail on non-string / >255-char translated labels
  - Fail on any residual translations.{locale}.options key after
    step C migration

Snapshot rewrite in-place: both form_submissions.schema_snapshot and
form_templates.schema_snapshot walk fields[*] and rewrite options to
the new rich-shape, strip per-locale options[] from the parallel
translations bag. Zero-compromise directive — no reader tolerance for
pre-WS-5d shape in commit 3 onwards.

Rollback reconstructs JSON column shapes plus translations bags.
Forward+back pair safe as a unit; partial rollback unsupported.

FormFieldService::insertFromLibrary switches from JSON-copy to
FormFieldOptionService::copyOptions row-clone per addendum Q3 row-copy
mandate. The field's own translations bag no longer carries
{locale}.options keys — those live on option rows now.

Seeders and factories switch to service-level option creation:
  - FormBuilderDevSeeder.canonicalFields keeps flat-string options as
    its data shape; FormField::create no longer receives an options
    key, the post-create FormFieldOptionService::replaceOptions call
    inserts the rich rows. The same applies to
    seedEventRegistrationShowcaseSchema. The vergoedingstype field's
    legacy {label, description} object shape (a pre-WS-5d seed-bug
    that the strict backfill would reject) is normalised to flat
    strings; the descriptions are dropped.
  - seedSystemTemplates embeds rich-shape options in the template
    snapshot — no flat-array snapshot data remains in newly-seeded
    rows.
  - FormFieldFactory + FormFieldLibraryFactory drop the options
    default; new ::withOptions() helper accepts either flat strings
    (each becomes value+label) or full spec arrays and routes through
    the service.

JSON columns (form_fields.options, form_field_library.options) remain
present and writable via fillable; column-drop lands in commit 5.
Reads from the JSON column still exist in resources, snapshot writer,
FormRequests, FormValueService, and FilterRegistryController — commit
3 switches those all atomically.

Migration step-count tests in WS-5a/b/c bumped by 1 to account for
the new backfill_form_field_options migration on the migration stack.

Tests: 1182 → 1193 green (+11 tests / +56 assertions).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 02:21:26 +02:00
11588623c5 feat(form-field): add form_field_options table, service, scope, cascade
Fourth and final WS-5 sibling. Polymorphic morph-owned table for the
RADIO / SELECT / MULTISELECT / CHECKBOX_LIST option rows, shared
between form_fields and form_field_library via the owner_type
discriminator. Matches the WS-5a (bindings) / WS-5b (validation_rules
+ configs) pattern one-for-one: dedicated service as single writer,
UNION-over-two-owner-chains scope, shared cascade observer.

Row shape:
  - value         canonical storage value (string ≤255, UNIQUE per owner)
  - label         default-locale display label (string ≤255)
  - sort_order    int unsigned
  - translations  JSON { "<locale>": "<translated label>" }

The UNIQUE(owner_type, owner_id, value) index ffo_owner_value_unique
is the seed-bug guard — duplicate values per field have no semantic
meaning and must fail at both the service layer (assertSpecsValid)
and the DB level.

Activity log: field.options_replaced emits on FormField subject only,
per the §6.7 WS-5a / §17.4.2 WS-5b convention that library-level
changes are silent in activity log.

No production reads yet. The form_fields.options and
form_field_library.options JSON columns remain the active source of
truth until the commit-3 reader switch — accessing $field->options
still resolves through the JSON cast in commit 1, so model tests
exercise the new morphMany via $field->options() (explicit relation
call). Both FormField and FormFieldLibrary now carry an `options`
morphMany alongside `bindings`, `validation_rules`, and `configs`.

Cascade: FormFieldChildTablesCascadeObserver gains form_field_options
as the fourth child cleaned on owner delete (both FormField soft/
force-delete and FormFieldLibrary delete).

Migration step-count tests in WS-5a/b/c bumped by 1 to account for
the new create_form_field_options_table on the migration stack.

Base scope-class extraction across the four siblings — deliberately
deferred to a follow-up work package per addendum §17.4.3 / §17.5.3.
Now that all four concrete implementations exist, the "what actually
varies" question can be answered empirically.

Tests: 1158 → 1182 green (+24 tests / +42 assertions).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 02:07:53 +02:00
079d10975b refactor(form-builder): strict validator + drop form_fields.conditional_logic JSON column
WS-5c commit 3 of 4. FormRequests (Store/Update) now reject bad
conditional_logic trees at the HTTP boundary — the `after()` hook
unwraps the `show_when` envelope, normalises legacy `{all|any: [...]}`
group shape to the service's internal form, and delegates to
`FormFieldConditionalLogicService::assertSpecsValid()`. Unknown
operators, root conditions, empty groups, and unknown field_slug
references produce a 422 with a readable error before any write.

`form_fields.conditional_logic` JSON column dropped. FormField model
`$fillable` and `$casts` no longer mention the column; factory default
no longer writes `null` to it. Snapshot fixtures in the dev seeder and
the legacy-forms migration command keep `conditional_logic` in their
snapshot JSON shape — that's the schema_snapshot contract, not the DB
column.

FormFieldController now maps InvalidConditionalLogicSpecException to
422 alongside FrozenSchemaException / CyclicDependencyException.

Rollback path: roll back WS-5c commits 1–3 together. Partial rollback
(drop-column reversed but backfill still applied) is not a supported
state — matching the WS-5a/b precedent on the family's full-rollback
contract.

Tests: 6 new (strict FormRequest rejection cases + JSON-column drop
assertion). Rollback step counts in WS-5a/b migration tests bumped +1
for the drop_conditional_logic_json_column migration. Baseline
1142 → 1148 green (3085 → 3099 assertions).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 00:03:21 +02:00
d06ea01b09 feat(form-builder): FormFieldConditionalLogicService + cycle detection + legacy backfill + snapshot
WS-5c commit 2 of 4 — the service layer, backfill migration, and
read-path switch. Per addendum Q3, conditional_logic applies to
FormField only — no library mirror and no copyLogic on
FormFieldService::insertFromLibrary.

FormFieldConditionalLogicService owns every write:
  - logicFor(field): depth-limited eager-load of the tree
  - replaceLogic(field, tree): transactional structure + operator +
    field_slug validation + cycle check + activity-log emit
    (field.conditional_logic_replaced)
  - toJsonShape(root): reconstructs the canonical ARCH §8
    `{show_when: {...}}` shape — single source of truth for the
    snapshot writer + API resources
  - assertSpecsValid(tree): public boundary guard for the FormRequest
    strict validator (WS-5c commit 3 wires this up)
  - assertNoCycles(field, tree): contract preserved from
    FormFieldService::assertNoConditionalCycle, implementation now
    reads the relational adjacency.

Backfill migration translates pre-WS-5c conditional_logic JSON to
rows. Strict dispatch: unknown operators / unknown top-level keys /
malformed groups FAIL the migration — Phase A seed-scan confirmed
the catalogue parity, so any drift is a data bug to fix at source,
not silently absorb. Rollback rebuilds canonical JSON and clears
the relational tree.

FormFieldService.create/update route `conditional_logic` through
the new service (matching the extract-and-delegate pattern from
WS-5a bindings and WS-5b validation rules). Snapshot writer + both
resources (FormFieldResource, PublicFormSchemaResource) read via
`toJsonShape(rootConditionalLogicGroup())` — byte-for-byte parity
with the pre-WS-5c JSON contract.

InvalidConditionalLogicSpecException handled in FormFieldController
as 422, same as FrozenSchemaException / CyclicDependencyException.

Tests: 20 new under tests/Feature/FormBuilder/ConditionalLogic/
(service, cycle detection, backfill forward+rollback+failure cases,
snapshot + resource parity). FormFieldApiTest cyclic rejection test
rewritten to use the new factory state. Rollback step counts in
WS-5a/b migration tests bumped +1 for the new backfill migration.
Baseline 1122 → 1142 green (3032 → 3085 assertions).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 23:56:39 +02:00
2064b9901e feat(form-builder): form_field_conditional_logic_{groups,conditions} tables + OrganisationScope cap raise to 5
WS-5c commit 1 of 4 — relational infrastructure for the conditional-
logic tree that replaces form_fields.conditional_logic JSON (ARCH-
FORM-BUILDER §8; addendum Q3 WS-5c).

Tables: groups (nesting via parent_group_id) + conditions (leaves,
value JSON nullable for empty/not_empty). Simple FK to form_fields —
addendum Q3 explicitly excludes form_field_library from conditional_
logic scope, so no polymorphic morph here.

OrganisationScope cap raised 3 → 5 hops. The conditions chain is
4 hops (condition → group → field → schema → organisation_id column)
and the new cap gives headroom for future deeper trees without
denormalising form_field_id onto conditions.

Cascade observer (FormFieldChildTablesCascadeObserver) extended to
physically delete the new groups table on FormField delete (hard or
soft). Conditions cascade automatically via the group_id FK on the
groups table.

Factories: FormFieldConditionalLogicGroupFactory, FormFieldConditional
LogicConditionFactory, and FormFieldFactory::withConditionalLogic($tree)
for concise test fixtures.

Tests: 16 new under tests/Feature/FormBuilder/ConditionalLogic/
(relation, scope, cascade, enum catalogue). 3 new scope-cap tests in
ScopeLeakageTest verify 4/5-hop chains pass and 6-hop throws. Hardcoded
rollback step counts in WS-5a/b migration tests bumped for the 2 new
WS-5c migrations. Baseline 1104 → 1122 green (2988 → 3032 assertions).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 23:43:34 +02:00
d494478c08 feat(form-builder): form_field_configs relational table + non-validation key split + drop validation_rules JSON columns 2026-04-24 22:42:35 +02:00
800b1b6c01 feat(form-builder): FormFieldValidationRuleService + legacy backfill + snapshot + library row-copy 2026-04-24 22:12:08 +02:00
fedaed1b32 feat(form-builder): form_field_validation_rules table + polymorphic owner + scope + cascade 2026-04-24 22:01:36 +02:00
61719bf8bf refactor(form-builder): pre-publish check reads form_field_bindings; drop binding JSON columns 2026-04-24 20:09:27 +02:00
6933e6d700 feat(form-builder): FormFieldBindingService + library-to-field row copy + snapshot writer
WS-5a commit 2 of 4.

FormFieldBindingService owns all writes to the relational binding table.
Validation against config/form_binding.php entity-column registry lives here
(ARCH §6.2).

FormFieldService::insertFromLibrary now calls copyBindings instead of
hydrating JSON — the Q3 row-copy mandate. Library and field bindings share
the same table; insertion is a row-clone operation.

Snapshot writer (FormSubmissionService::buildSnapshot) serialises bindings
via toJsonShape so schema_snapshot JSON keeps its ARCH §4.6.1 / §6.3
contract. No snapshot format change.
API resources source binding output from the relational table via the same
serialiser — external shape preserved.

Tests: service transactional behaviour, copyBindings preservation,
snapshot parity, API resource parity.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 18:48:47 +02:00
af8a9da038 feat(form-builder): form_field_bindings table + polymorphic owner + cascade observer
WS-5a commit 1 of 4 per ARCH-CONSOLIDATION-ADDENDUM-2026-04-24 Q3.

Creates the relational home for what was form_fields.binding JSON and
form_field_library.default_binding JSON. Owner discriminator is polymorphic
morph (owner_type/owner_id) — the pattern the rest of WS-5 (5b validation_rules,
5d options) will reuse.

Migration backfills rows from both JSON sources in a single transaction and
is genuinely reversible (rollback reconstructs the JSON). Old columns remain
in place until commit 3 has switched all readers.

Pattern B (binding=null) is represented by absence of row. mode enum covers
entity_owned / mirrored only.

Cascade on owner delete via observer — bindings are physical state, not
historical audit. FormFieldBindingScope enforces multi-tenancy via UNION over
both owner chains (form_field → schema → org OR form_field_library → org) —
Q2's declarative tenantScopeStrategy() can't walk morph parents.

Tests: migration forward/back, morph relation, cascade observer, scope
isolation, enum coverage.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 18:43:11 +02:00