Files
crewli/dev-docs/ARCH-CONSOLIDATION-WS1-REPORT.md

440 lines
41 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.
---
title: ARCH Consolidation — WS-1 Discovery Report
description: Read-only architecture scan output for the April 2026 consolidation sprint
status: draft
owner: architect
created: 2026-04-24
---
# WS-1 — Discovery Pass Report
> Output of the read-only architecture scan kicked off by
> `/dev-docs/ARCH-CONSOLIDATION-2026-04.md` WS-1. Read together with the
> consolidation charter before WS-2 starts. This report is discovery-only:
> **no code, no migrations, no tests were changed.**
## 0. Scan scope and method
- **Date of scan:** 2026-04-24
- **Branch:** `main`
- **Commit scanned:** `f9cc8c0a1be4093c7508df90dcdbe12ebbdb390a`
- **Tools used:** `grep` (via Bash), targeted `Read` on migrations/models/services, four parallel `Explore` sub-agents for breadth. No AST-grep, no static-analysis tooling.
- **In scope:** `api/database/migrations/`, `api/app/Models/` (incl. `FormBuilder/`), `api/app/Models/Scopes/`, `api/app/Services/`, `api/app/Http/Controllers/`, `api/app/Http/Resources/`, `api/app/Http/Requests/`, `api/app/Listeners/`, `api/app/Observers/`, `api/app/Policies/`, `api/app/Providers/`, `api/app/Events/`, `api/app/FormBuilder/`, `api/config/`.
- **Explicitly NOT scanned:** `apps/app/`, `apps/portal/`, `packages/`, `vendor/`, `node_modules/`, `resources/vuexy-admin-*/`, `api/tests/`, VitePress docs at `/docs/`.
---
## 1. Summary table
| ID | Category | Target | Classification | Proposed WS |
|------|----------|------------------------------------------------------------------------|---------------------------------------|-------------|
| A-01 | A | `organisation_user` | documented-exception-confirm-in-ws4 | ws-4 |
| A-02 | A | `event_user_roles` | documented-exception-confirm-in-ws4 | ws-4 |
| A-03 | A | `crowd_list_persons` | documented-exception-confirm-in-ws4 | ws-4 |
| A-04 | A | `event_person_activations` | documented-exception-confirm-in-ws4 | ws-4 |
| A-05 | A | `user_organisation_tags` | documented-exception-confirm-in-ws4 | ws-4 |
| A-06 | A | `person_section_preferences` | documented-exception-confirm-in-ws4 | ws-4 |
| A-07 | A | `mfa_backup_codes` | documented-exception-confirm-in-ws4 | ws-4 |
| A-08 | A | `mfa_email_codes` | documented-exception-confirm-in-ws4 | ws-4 |
| A-09 | A | `form_submission_section_statuses` | documented-exception-confirm-in-ws4 | ws-4 |
| A-10 | A | `form_values` | documented-exception-confirm-in-ws4 | ws-4 |
| A-11 | A | `form_value_options` | documented-exception-confirm-in-ws4 | ws-4 |
| B-01 | B | `form_fields.binding` | scheduled-ws5 | ws-5a |
| B-02 | B | `form_fields.validation_rules` | scheduled-ws5 | ws-5b |
| B-03 | B | `form_fields.conditional_logic` | scheduled-ws5 | ws-5c |
| B-04 | B | `form_fields.options` | scheduled-ws5 | ws-5d |
| B-05 | B | `form_field_library.options` | needs-architect-decision | ws-5d? |
| B-06 | B | `form_field_library.validation_rules` | needs-architect-decision | ws-5b? |
| B-07 | B | `form_field_library.default_binding` | needs-architect-decision | ws-5a? |
| B-08 | B | `form_fields.role_restrictions` | needs-architect-decision | ws-5 / backlog |
| B-09 | B | `form_fields.translations` | opaque-keep | — |
| B-10 | B | `form_field_library.translations` | opaque-keep | — |
| B-11 | B | `form_schemas.settings` | opaque-keep | — |
| B-12 | B | `form_submissions.schema_snapshot` | opaque-keep | — |
| B-13 | B | `form_templates.schema_snapshot` | opaque-keep | — |
| B-14 | B | `form_values.value` | opaque-keep | — |
| B-15 | B | `form_webhook_deliveries.payload_snapshot` | opaque-keep | — |
| B-16 | B | `shifts.events_during_shift` | opaque-keep | — |
| B-17 | B | `persons.custom_fields` | opaque-keep | — |
| B-18 | B | `person_identity_matches.match_details` | opaque-keep | — |
| B-19 | B | `user_profiles.settings` | opaque-keep | — |
| B-20 | B | `organisations.settings` | opaque-keep | — |
| B-21 | B | `events.recurrence_exceptions` | opaque-keep | — |
| B-22 | B | `activity_log.properties` / `.attribute_changes` | opaque-keep (Spatie-owned) | — |
| C-01 | C | `form_schemas.owner_type / owner_id` | domain-justified-keep | — |
| C-02 | C | `form_submissions.subject_type / subject_id` | domain-justified-keep | — |
| C-03 | C | `activity_log.subject/causer_type` | domain-justified-keep (Spatie) | ws-7 (index) |
| C-04 | C | `personal_access_tokens.tokenable_*` (Sanctum) | needs-architect-decision | ws-8 |
| D-01 | D | Missing migrations for ~30 SCHEMA-documented tables | scope-addition-ws-8 | ws-8 |
| D-02 | D | `OrganisationScope` has no `form_schema_id` / chain-via-FK strategy | scope-addition-ws-4 / needs-decision | ws-4 / new-ws |
| D-03 | D | Form-builder child models not applying `OrganisationScope` | scope-addition-ws-4 | ws-4 |
| D-04 | D | `ShiftAssignment` / `ShiftWaitlist` / `VolunteerAvailability` without scope | scope-addition-ws-4 | ws-4 |
| D-05 | D | `Person` has no `SoftDeletes` reachable in model (verify) | standalone-fix (verify) | ws-4 |
| D-06 | D | `activity_log` polymorph columns lack `(type, id)` index | scope-addition-ws-7 | ws-7 |
| D-07 | D | Morph map in `AppServiceProvider` registers 6 owner types, only `event` used | standalone-fix (docs) | ws-8 |
| D-08 | D | `form_submissions.subject_type` allow-list lives in two places (`config/form_subjects.php` + morph map) | standalone-fix | ws-2 / ws-8 |
**Counts per category:** `A: 11, B: 22, C: 4, D: 8` (total findings: 45).
---
## 2. Category A — Non-ULID primary keys
ULID baseline established by SCHEMA §3.5.11 Rule 1 and ARCH §3 besluit 5 ("ULID consistent overal, ook pivots"). All other business tables (40+ tables) use ULID correctly and are not listed individually. Only the non-ULID tables appear below.
### A-01 — `organisation_user`
- **Current PK type:** `int AI` (via `$table->id()`)
- **Source of truth:** `api/database/migrations/2026_04_07_210000_create_organisation_user_table.php:14` — inline comment `// int AI for join performance`. No model (pure pivot).
- **SCHEMA.md section:** §3.5.1 — documented as pivot, no ULID mandate.
- **Classification:** `documented-exception-confirm-in-ws4`
- **Note:** Pure pivot (user ↔ organisation). ARCH §3 besluit 5 demands ULID everywhere including pivots; this is the canonical example of a decision to re-confirm or reverse.
### A-02 — `event_user_roles`
- **Current PK type:** `int AI`
- **Source of truth:** `api/database/migrations/2026_04_07_240000_create_event_user_roles_table.php:14` — comment `// int AI for join performance`. No model.
- **SCHEMA.md section:** §3.5.1 — pivot-like (event ↔ user ↔ role).
- **Classification:** `documented-exception-confirm-in-ws4`
### A-03 — `crowd_list_persons`
- **Current PK type:** `int AI`
- **Source of truth:** `api/database/migrations/2026_04_08_240000_create_crowd_list_persons_table.php:14`. No model.
- **SCHEMA.md section:** §3.5.5 — pivot (crowd list ↔ person).
- **Classification:** `documented-exception-confirm-in-ws4`
### A-04 — `event_person_activations`
- **Current PK type:** `int AI`
- **Source of truth:** `api/database/migrations/2026_04_08_410000_create_event_person_activations_table.php:14`. No model.
- **SCHEMA.md section:** §3.5.5 — join table (event ↔ person) tracking sub-event activation.
- **Classification:** `documented-exception-confirm-in-ws4`
### A-05 — `user_organisation_tags`
- **Current PK type:** `int AI`
- **Source of truth:** `api/database/migrations/2026_04_10_110000_create_user_organisation_tags_table.php:14`. Model: `api/app/Models/UserOrganisationTag.php`**does not** use `HasUlids`.
- **SCHEMA.md section:** §3.5.5a — pivot (user ↔ organisation ↔ person_tag).
- **Classification:** `documented-exception-confirm-in-ws4`
- **Note:** A model *exists* here, unlike A-01/A-02/A-03/A-04. If WS-4 confirms the ULID mandate for pivots, this migration is non-trivial: existing keys referenced by the model must be rewritten.
### A-06 — `person_section_preferences`
- **Current PK type:** `int AI`
- **Source of truth:** `api/database/migrations/2026_04_12_200003_create_person_section_preferences_table.php:14`. Model: `api/app/Models/PersonSectionPreference.php` — no `HasUlids`.
- **SCHEMA.md section:** §3.5.5b.
- **Classification:** `documented-exception-confirm-in-ws4`
### A-07 — `mfa_backup_codes`
- **Current PK type:** `int AI`
- **Source of truth:** `api/database/migrations/2026_04_15_200001_create_mfa_backup_codes_table.php:14`. Model: `api/app/Models/MfaBackupCode.php` — no `HasUlids`.
- **SCHEMA.md section:** §3.5.1a.
- **Classification:** `documented-exception-confirm-in-ws4`
- **Note:** Security-adjacent table. Potential rationale: ephemeral rows, high turnover, single-owner lookup. Flag for WS-4 explicit decision.
### A-08 — `mfa_email_codes`
- **Current PK type:** `int AI`
- **Source of truth:** `api/database/migrations/2026_04_15_200003_create_mfa_email_codes_table.php:14`. Model: `api/app/Models/MfaEmailCode.php` — no `HasUlids`.
- **SCHEMA.md section:** §3.5.1a.
- **Classification:** `documented-exception-confirm-in-ws4`
### A-09 — `form_submission_section_statuses`
- **Current PK type:** `int AI`
- **Source of truth:** `api/database/migrations/2026_04_19_100007_create_form_submission_section_statuses_table.php:14`. Model: `api/app/Models/FormBuilder/FormSubmissionSectionStatus.php` — no `HasUlids`.
- **SCHEMA.md section:** §3.5.12.
- **Classification:** `documented-exception-confirm-in-ws4`
### A-10 — `form_values`
- **Current PK type:** `int AI`
- **Source of truth:** `api/database/migrations/2026_04_19_100009_create_form_values_table.php:15` — explicit comment: `// Integer AI PK: EAV table, joined heavily — int joins faster than ULID joins (ARCH §4.4).` Model: `api/app/Models/FormBuilder/FormValue.php` — no `HasUlids`.
- **SCHEMA.md section:** §3.5.12 (form_values).
- **Classification:** `documented-exception-confirm-in-ws4`
- **Note:** The migration's inline comment references `ARCH §4.4` — a rationale predating the consolidation charter. ARCH-CONSOLIDATION §3 besluit 5 overrides that rationale unless the architect confirms the EAV performance exception.
### A-11 — `form_value_options`
- **Current PK type:** `int AI`
- **Source of truth:** `api/database/migrations/2026_04_19_100010_create_form_value_options_table.php:14`. Model: `api/app/Models/FormBuilder/FormValueOption.php` — no `HasUlids`.
- **SCHEMA.md section:** §3.5.12.
- **Classification:** `documented-exception-confirm-in-ws4`
- **Note:** Populated by `FormValueObserver`; rebuilt on every form value save. Volatile pivot data.
---
### Category A — summary
- **Total non-ULID tables:** 11 business tables + 14 framework/Spatie/Laravel tables (all expected, not listed).
- **Undocumented violations:** none — every non-ULID PK either has an inline migration comment justifying it, or is a pure pivot without a model.
- **WS-4 decision needed:** all 11 tables. ARCH §3 besluit 5 says "ULID consistent overal, ook pivots" without exceptions — so the migration comments stating "int AI for join performance" are now in conflict with the charter. WS-4 must explicitly confirm (retain exceptions) or reverse (migrate all).
---
## 3. Category B — JSON columns with queryable data
Rule 2 whitelist (SCHEMA §3.5.11): opaque config, free-text arrays, rider data, submission diff snapshots, events_during_shift. No JSON path query (`whereJsonContains`, `JSON_EXTRACT`, `->>`, `whereJsonLength`, etc.) was found anywhere in the backend. Every JSON column is read as a whole blob via the cast array. Classification therefore leans heavily on charter decisions and column intent, not on observed query patterns.
**Note on legacy tables:** migration `2026_04_20_100000_drop_remaining_legacy_registration_tables.php` already dropped `registration_form_fields`, `registration_field_templates`, and `person_field_values`. Their JSON columns are not part of the current schema and are not listed here.
### B-01 — `form_fields.binding`
- **Current use:** Conditional logic binding config (computed fields, dynamic visibility). Read as blob, validated/applied during submission in `FormValueService`.
- **Sample usage:** `api/app/Services/FormBuilder/FormValueService.php` (accesses `$field->binding`)
- **Classification:** `scheduled-ws5` (ARCH §3 besluit 6, WS-5a)
- **Proposed target:** new `form_field_bindings` table per ARCH §6.1.
### B-02 — `form_fields.validation_rules`
- **Current use:** Field validation schema (min/max, regex, required, tag_categories). Read as blob, passed to `FormFieldRuleBuilder` for runtime validation.
- **Sample usage:** `api/app/Services/FormBuilder/FormValueService.php` (`is_array($field->validation_rules)…`)
- **Classification:** `scheduled-ws5` (WS-5b)
- **Proposed target:** `form_field_validation_rules` table per ARCH §6.6.
### B-03 — `form_fields.conditional_logic`
- **Current use:** Skip-logic and visibility rules (if X=Y, show this field). Read as blob to extract dependent field slugs.
- **Sample usage:** `api/app/Services/FormBuilder/FormFieldService.php` (`extractConditionSlugs()`)
- **Classification:** `scheduled-ws5` (WS-5c)
- **Proposed target:** `form_field_conditional_logic` table. Charter flags AND/OR tree shape as sub-decision during WS-5c.
### B-04 — `form_fields.options`
- **Current use:** Field-specific UI metadata (select choices, radio buttons). Read as whole blob in resources.
- **Sample usage:** `api/app/Http/Resources/FormBuilder/FormFieldResource.php`
- **Classification:** `scheduled-ws5` (WS-5d)
- **Proposed target:** `form_field_options` table per ARCH §6.6.
### B-05 — `form_field_library.options`
- **Current use:** Library-level default UI options copied to new fields at instantiation.
- **Sample usage:** `api/app/Services/FormBuilder/FormFieldService.php`
- **Classification:** `needs-architect-decision`
- **Proposed target:** if WS-5d splits `form_fields.options` into its own table, should the library-level defaults follow the same split or remain inline? Two consistent outcomes: (a) split both (library becomes a template row in the new options table), (b) keep library JSON as opaque "seed-data" and hydrate on instantiation. Needs a call before WS-5d starts.
### B-06 — `form_field_library.validation_rules`
- **Classification:** `needs-architect-decision` — same shape of decision as B-05, but for validation rules.
### B-07 — `form_field_library.default_binding`
- **Classification:** `needs-architect-decision` — same shape as B-05, but for bindings.
- **Note:** The migration column name (`default_binding`) suggests it was designed to pre-fill a binding on new fields. If WS-5a's `form_field_bindings` table is the target, the natural mapping is "library rows may define default bindings which are cloned". Architect to confirm.
### B-08 — `form_fields.role_restrictions`
- **Current use:** Role-based visibility (portal-only, admin-only) read in `FieldAccessService`.
- **Sample usage:** `api/app/Services/FormBuilder/FieldAccessService.php` (`$field->role_restrictions…`)
- **Classification:** `needs-architect-decision`
- **Reason:** Not in ARCH's committed WS-5 split list, but semantically akin to a queryable enum set. If WS-5 is expanded to "everything structured on form_fields becomes relational", this one belongs with them; if WS-5 is kept to the four committed splits, it stays JSON.
### B-09 / B-10 — `form_fields.translations`, `form_field_library.translations`
- **Current use:** I18n label/help text overrides per field. Read-only, per-field UI.
- **Classification:** `opaque-keep` — translations are flat key-value bags by locale, not queried anywhere. Splitting them into a relational translations table would be speculative.
### B-11 — `form_schemas.settings`
- **Current use:** Form-level configuration blob. Charter explicitly (§3 besluit 6) says `form_schemas.settings` should *shrink* to "echt opaque config (rendering hints)" after WS-5 completes. Today the blob is small and read as-is.
- **Classification:** `opaque-keep` — provided WS-5 a/b/c/d land. Re-confirm at end of WS-5 that nothing queryable has crept back in.
### B-12 / B-13 / B-15 — `form_submissions.schema_snapshot`, `form_templates.schema_snapshot`, `form_webhook_deliveries.payload_snapshot`
- **Current use:** Immutable snapshots for audit/replay.
- **Classification:** `opaque-keep` — SCHEMA.md Rule 2 explicitly allows submission diff snapshots.
### B-14 — `form_values.value`
- **Current use:** EAV storage of actual answers. Filtering is done via denormalised `value_indexed`, `value_number`, `value_date` typed columns; the `value` JSON itself is read as a blob.
- **Classification:** `opaque-keep`
- **Note:** This is an intentional EAV+denormalised-filter-column pattern. Valid as long as the typed columns cover all filter cases; WS-5/WS-6 should verify that when bindings read answers, they read from the typed columns or from the cast array, not via JSON path.
### B-16 — `shifts.events_during_shift`
- **Classification:** `opaque-keep` — whitelisted in Rule 2.
### B-17 — `persons.custom_fields`
- **Current use:** Legacy registration-time extras (section preferences and similar). Read as blob.
- **Sample usage:** `api/app/Http/Controllers/Api/V1/ShiftAssignmentController.php` (accesses `custom_fields['section_preferences']`)
- **Classification:** `opaque-keep` — but flag for WS-6 FormBindingApplicator: once bindings land, `custom_fields` becomes redundant for bindable attributes. Track shrinkage after WS-6 lands so this column can be reassessed (deletion → BACKLOG, not sprint scope).
### B-18 — `person_identity_matches.match_details`
- **Classification:** `opaque-keep` — scoring metadata, never queried on nested keys.
### B-19 — `user_profiles.settings`
- **Classification:** `opaque-keep`.
### B-20 — `organisations.settings`
- **Classification:** `opaque-keep`.
### B-21 — `events.recurrence_exceptions`
- **Current use:** Array of excluded dates (RFC 5545 EXDATE pattern).
- **Classification:** `opaque-keep` — flat date list, never queried on positions.
### B-22 — `activity_log.properties` / `attribute_changes`
- **Classification:** `opaque-keep` (Spatie-managed).
---
### Category B — summary
- **Queryable-must-split found in practice:** 0. No `whereJsonContains`, `JSON_EXTRACT`, or `->>` usage exists anywhere in the backend.
- **Scheduled WS-5 splits confirmed:** 4 (`form_fields.binding`, `.validation_rules`, `.conditional_logic`, `.options`).
- **Open architect decisions:** 4 (`form_field_library.options`, `.validation_rules`, `.default_binding`, `form_fields.role_restrictions`).
- **Genuinely opaque (keep):** 14.
---
## 4. Category C — Polymorphic relations
### C-01 — `form_schemas.owner_type / owner_id`
- **Defined in:** `api/database/migrations/2026_04_19_100002_create_form_schemas_table.php:1819`. Model: `api/app/Models/FormBuilder/FormSchema.php:92` (`owner()`).
- **Allowed types (observed):** morph map registers `event`, `user`, `user_profile`, `person`, `company`, `organisation`. Only `event` is actively used in queries (`FilterRegistryController`, `PublicFormController`). The other five are placeholders for future purposes (USER_PROFILE etc.).
- **Morph map status:** fully registered in `api/app/Providers/AppServiceProvider.php:73122` via `Relation::enforceMorphMap(...)`.
- **Index on (type, id):** yes — `$table->index(['owner_type', 'owner_id'], 'fs_owner_idx')`.
- **Classification:** `domain-justified-keep` (ARCH §1 P4).
### C-02 — `form_submissions.subject_type / subject_id`
- **Defined in:** `api/database/migrations/2026_04_19_100006_create_form_submissions_table.php:2021`. Model: `FormSubmission.php:80` (`subject()`). Inverse example: `Person.php:108` (`formSubmissions()`).
- **Allowed types (observed):** `person`, `user`, `user_profile`, `company`, `organisation`, `event` — same morph map keys as C-01. Active uses in `FieldAccessService`, `FormSubmissionService` (via `morphKeyFor()`).
- **Morph map status:** registered (same provider).
- **Index on (type, id):** yes — `fs_subject_idx`.
- **Classification:** `domain-justified-keep` (ARCH §1 P4).
### C-03 — `activity_log.subject_type/subject_id` and `causer_type/causer_id`
- **Defined in:** `api/database/migrations/2026_04_07_120000_create_activity_log_table.php:17, 19` via `nullableMorphs()`.
- **Allowed types (observed):** ~23 domain models + 8 form-builder models, all registered in the single `enforceMorphMap` call.
- **Morph map status:** fully registered; `Activity::saving` hook in `AppServiceProvider.php:145161` adds impersonation context.
- **Index on (type, id):** **no**`nullableMorphs()` does NOT auto-create a `(type, id)` composite index, unlike `morphs()` and `ulidMorphs()`. The pair is unindexed on both `subject_*` and `causer_*`.
- **Classification:** `domain-justified-keep` (Spatie-framework); **WS-7 should add indexes** if/when the activity log is queried by subject.
### C-04 — `personal_access_tokens.tokenable_type / tokenable_id`
- **Defined in:** `api/database/migrations/2026_04_07_100000_create_personal_access_tokens_table.php:15` via `ulidMorphs('tokenable')`.
- **Allowed types (observed):** Sanctum default (`User` in practice); no morph-map entry.
- **Morph map status:** NOT in `enforceMorphMap` — Sanctum uses FQCN in the DB.
- **Index on (type, id):** yes — `ulidMorphs()` auto-indexes.
- **Classification:** `needs-architect-decision`
- **Note:** Low urgency. If the team wants strict morph-map consistency ("no FQCN in DB anywhere"), add a `user` alias and document in ARCH. Otherwise leave Sanctum alone. Decide in WS-8 when docs are rewritten.
---
### Category C — summary
- **Polymorphs found:** 4 relation pairs (2 domain, 2 framework).
- **Domain polymorphs both justified and correctly indexed:** C-01, C-02.
- **Framework polymorphs:** C-03 (Spatie), C-04 (Sanctum).
- **Only real action item:** C-03 missing `(type, id)` index — candidate scope-addition for WS-7.
- **No inconsistency:** no logical relation is modelled polymorphically in one place and typed-FK in another. No unused single-target polymorphs (C-01 has only `event` in active use today, but the other five owner types are committed purpose targets per ARCH §3 besluit 4, so keeping it polymorphic is justified).
---
## 5. Category D — Other architecture smells
### D-01 — Many tables documented in SCHEMA.md have no migration
- **Location:** SCHEMA.md §3.5.4, §3.5.6, §3.5.7, §3.5.8, §3.5.9 vs `api/database/migrations/`.
- **Smell:** The following tables are fully documented in SCHEMA.md but no `Schema::create(...)` migration exists for them:
- §3.5.4: `volunteer_festival_history`, `post_festival_evaluations`, `festival_retrospectives`
- §3.5.6: `accreditation_categories`, `accreditation_items`, `event_accreditation_items`, `accreditation_assignments`, `access_zones`, `access_zone_days`, `person_access_zones`
- §3.5.7: `performances`, `stages`, `stage_days`, `advance_submissions`, `artist_contacts`, `artist_riders`, `itinerary_items`
- §3.5.8: `briefing_templates`, `briefings`, `briefing_sends`, `communication_campaigns`, `messages`, `message_replies`, `broadcast_messages`, `broadcast_message_targets`
- §3.5.9: `check_ins`, `show_day_absence_alerts`, `scanners`, `inventory_items`, `event_info_blocks`, `event_info_block_crowd_types`, `production_requests`, `material_requests`
- **Why it matters this sprint:** SCHEMA.md is the charter's stated "truth for 'as built'" (§3). Today it documents roughly 30 tables that don't exist. Rule 4 of §3.5.11 even prescribes required indexes on `check_ins`, `briefing_sends`, `performances`, `advance_sections`, `person_section_preferences` — two of those (`check_ins`, `briefing_sends`) have no table. WS-8's SCHEMA rewrite needs to either (a) remove these and move them to a separate "Planned modules" document, or (b) mark each row as `STATUS: planned`. Either way, SCHEMA.md cannot be trusted as-is during WS-4 through WS-7.
- **Proposed workstream:** `ws-8` (primary); also informs `ws-4` scope bounds (we don't migrate PKs on tables that don't exist).
- **Classification:** `scope-addition-ws-8`
### D-02 — `OrganisationScope` strategies don't cover `form_schema_id` or any FK chain beyond one level
- **Location:** `api/app/Models/Scopes/OrganisationScope.php` (whole file). Acknowledged explicitly in the class comment of `api/app/Models/FormBuilder/FormSchemaWebhook.php:1322`: *"OrganisationScope's column strategies (organisation_id / event_id / festival_section_id) do not cover form_schema_id — extending the scope to a new strategy is out of scope for S1."*
- **Smell:** The scope supports only three strategies: direct `organisation_id`, via `event_id`, via `festival_section_id`. Form-builder child tables (`form_schema_sections`, `form_fields`, `form_submissions`, `form_values`, `form_value_options`, `form_submission_delegations`, `form_submission_section_statuses`, `form_schema_webhooks`, `form_webhook_deliveries`) all scope via `form_schema_id` or deeper — none of which the scope can resolve. The `FormSchemaWebhook` comment calls out the caller discipline required: *"NEVER query FormSchemaWebhook::query() without an eager constraint."*
- **Why it matters this sprint:** The architecture's tenancy guarantee (`CLAUDE.md` > Multi-tenancy: "Every query on event data MUST scope on organisation_id") relies on developer discipline for these models instead of on a global scope. One forgotten eager constraint in a future controller is a cross-org data leak. WS-6 adds a new service (`FormBindingApplicator`) that will traverse submission → schema → org — exactly the path the scope doesn't currently model.
- **Proposed workstream:** `ws-4` (extend scope with a new strategy: `form_schema_id` and possibly a generic `through_fk_chain` strategy). Alternative: `new-ws` if the architect decides to solve this more broadly (e.g., declarative scope chain in models).
- **Classification:** `scope-addition-ws-4` (or `needs-architect-decision` for the "extend strategy" vs "add-on new-ws" split).
### D-03 — Form-builder child models don't register `OrganisationScope`
- **Location:** `api/app/Models/FormBuilder/FormSchemaSection.php`, `FormField.php`, `FormSubmission.php`, `FormValue.php`, `FormValueOption.php`, `FormSubmissionDelegation.php`, `FormSubmissionSectionStatus.php`, `FormSchemaWebhook.php`, `FormWebhookDelivery.php`.
- **Smell:** None of the 9 form-builder child models call `static::addGlobalScope(new OrganisationScope())`. `FormSchema`, `FormFieldLibrary`, `FormTemplate` do register it (direct `organisation_id`). The child models rely on the caller-discipline workaround in D-02.
- **Why it matters this sprint:** Same as D-02 (tenancy leak risk via direct `Model::query()` calls), but here the fix does not require extending the scope class — it requires either adopting a new strategy once D-02 is resolved, or denormalising `organisation_id` onto the child tables (which ARCH §3 besluit 3 already commits to for `form_submissions`).
- **Proposed workstream:** `ws-4` (coupled to D-02). Note that charter §3 besluit 3 already commits to adding `organisation_id` to `form_submissions` — so `FormSubmission` is trivially fixable by the existing `organisation_id` strategy. The remaining 8 models still need D-02's extension.
- **Classification:** `scope-addition-ws-4`
### D-04 — Non-form-builder event-data models missing `OrganisationScope`
- **Location:** `ShiftAssignment.php`, `ShiftWaitlist.php`, `VolunteerAvailability.php`, `PersonSectionPreference.php`, `PersonIdentityMatch.php`, `UserInvitation.php`, `EmailChangeRequest.php`, `EmailLog.php`, `UserOrganisationTag.php`, `ImpersonationSession.php`, `MfaBackupCode.php`, `MfaEmailCode.php`, `TrustedDevice.php`, `UserProfile.php`, `OrganisationEmailSettings.php`, `OrganisationEmailTemplate.php`.
- **Smell:** Several of these are event-data (`ShiftAssignment`, `ShiftWaitlist`, `VolunteerAvailability`, `PersonSectionPreference`, `PersonIdentityMatch`) and should apply `OrganisationScope` via one of the existing strategies (`festival_section_id`, `event_id` on parents). Others (`MfaBackupCode`, `MfaEmailCode`, `TrustedDevice`, `UserProfile`, `OrganisationEmailSettings/Templates`, `EmailLog`, `EmailChangeRequest`, `UserInvitation`, `ImpersonationSession`) are user-scoped or organisation-scoped audit/admin data where the scope's suitability depends on intent (is this a per-user record, a per-org admin record, or a cross-org super-admin record?).
- **Why it matters this sprint:** At minimum the five event-data models (`ShiftAssignment`, `ShiftWaitlist`, `VolunteerAvailability`, `PersonSectionPreference`, `PersonIdentityMatch`) are tenancy-leak-prone and in the hot path for WS-5 and WS-6 work. Fixing them now with existing strategies (`festival_section_id` via parent Shift; `event_id` on the model; or denormalised `organisation_id` for the Person-identity models) is low-cost and unblocks safer refactor work downstream.
- **Proposed workstream:** `ws-4` for event-data models. User/admin-scoped items: `backlog` with a separate ticket per model.
- **Classification:** `scope-addition-ws-4`
### D-05 — Verify `Person` has `SoftDeletes` (SCHEMA Rule 3 YES list)
- **Location:** `api/app/Models/Person.php` — verify trait usage.
- **Smell:** Rule 3 lists `persons` in the soft-delete-YES list. Most of the YES-list models were spot-checked OK. `Person.php` was not directly verified line-by-line during the scan — before WS-2 start, verify `use SoftDeletes` is present on both the model and as `$table->softDeletes()` in the `create_persons_table.php` migration.
- **Why it matters this sprint:** If `Person` lacks soft delete but SCHEMA says it should have it, the ARTIST_ADVANCE / EVENT_REGISTRATION workflows (WS-6) cannot rely on documented recovery semantics.
- **Proposed workstream:** `ws-4` (trivial verification + fix if needed).
- **Classification:** `standalone-fix (verify first)`
### D-06 — `activity_log` morph columns lack `(type, id)` composite indexes
- **Location:** `api/database/migrations/2026_04_07_120000_create_activity_log_table.php:17, 19``nullableMorphs()` does not auto-index.
- **Smell:** If any admin feature needs "show all activity for this person" or "what did user X do", the query is a full-scan on `subject_type`/`causer_type`.
- **Why it matters this sprint:** Observability (WS-7) is likely to surface queries that need these indexes — better to add them while we're already migrating.
- **Proposed workstream:** `ws-7` (observability foundation likely drives the first such queries).
- **Classification:** `scope-addition-ws-7`
### D-07 — Morph map registers 6 owner types for form_schemas, only one (`event`) in practice
- **Location:** `api/app/Providers/AppServiceProvider.php:73122`; usage: `api/app/Http/Controllers/Api/V1/FilterRegistryController.php`, `PublicFormController.php`.
- **Smell:** `event`, `user`, `user_profile`, `person`, `company`, `organisation` are all registered as `form_schemas.owner_type` possibilities, but five of the six are never assigned anywhere. Charter §3 besluit 4 commits to seven purposes (EVENT_REGISTRATION, ARTIST_ADVANCE, SUPPLIER_INTAKE, POST_EVENT_EVALUATION, INCIDENT_REPORT, SIGNATURE_CONTRACT, USER_PROFILE) — so the registered types are forward-looking, not dead.
- **Why it matters this sprint:** Not an architectural issue today. Documentation item for WS-8: explicitly link the morph-map entries to the purpose registry so that reviewers can see *why* each owner type is registered even when unused. Avoids a future `"why is `organisation` in the morph map?"` half-day of archaeology.
- **Proposed workstream:** `ws-8` (docs).
- **Classification:** `standalone-fix`
### D-08 — `form_submissions.subject_type` allow-list in two places
- **Location:** `api/config/form_subjects.php` (used by `StoreFormSubmissionRequest` validation) vs `api/app/Providers/AppServiceProvider.php:73122` (morph map).
- **Smell:** Both files list the allowed subject types. They are expected to stay in sync, per a comment in `AppServiceProvider`, but the sync is enforced by developer discipline only — no test or compile-time guard.
- **Why it matters this sprint:** WS-2 (purpose registry) is the natural place to consolidate. If purposes define their `subject_type` via value object (per charter §6.4), then both `config/form_subjects.php` and the morph map entries can be derived from the same source.
- **Proposed workstream:** `ws-2` (consolidate into purpose registry), or `ws-8` (docs-only, if the de-dupe isn't done).
- **Classification:** `scope-addition-ws-2`
---
### Category D — summary
- **Tenancy-leak-prone models without scope:** 14 (9 form-builder + 5 other event-data). Fixable within WS-4 provided `OrganisationScope` gets a `form_schema_id` strategy.
- **Scope class itself needs extension:** 1 (D-02).
- **Indexes missing on framework polymorph:** 1 (`activity_log`).
- **SCHEMA.md drift:** ~30 tables documented without migrations — a WS-8 problem, not a WS-4 problem.
- **Duplication between config and morph map:** 1 (D-08).
- **Verification needed before fix:** 1 (`Person` soft-delete, D-05).
---
## 6. Scope implications
### WS-2 — Purpose registry
- **Scope:** confirmed + minor expansion.
- **Addition:** D-08 — merge `config/form_subjects.php` into the purpose registry so that `subject_type` per purpose comes from one source. Lightweight.
### WS-3 — Eén SPA consolidatie
- **Scope:** unchanged by WS-1. Frontend-only workstream; this scan did not cover it.
### WS-4 — ULID consistency + denormalized submission columns
- **Scope:** significantly expanded.
- **Confirmed in initial list:** pivot-ULID migration for A-01 through A-11 (if architect reaffirms besluit 5). `organisation_user`, `event_user_roles`, `crowd_list_persons`, `event_person_activations` were implied but not explicit; `mfa_backup_codes`, `mfa_email_codes` and `user_organisation_tags` are additions to the initial charter expectations. `form_values` and `form_value_options` carry the strongest inline-comment justification to keep — flag them as the first exceptions the architect must rule on.
- **Added:** D-02 (extend `OrganisationScope` with `form_schema_id` strategy), D-03 (register scope on 9 form-builder child models), D-04 (register scope on 5 event-data non-form models), D-05 (verify `Person` soft-delete). Denormalised `organisation_id` on `form_submissions` (besluit 3) makes D-03 easier for `FormSubmission` specifically.
- **Risk:** WS-4 as written was 2-3 days. With D-02/D-03/D-04 additions, expect 4-5 days.
### WS-5 — JSON-kolom-opsplitsing
- **Scope:** confirmed for WS-5a/b/c/d on `form_fields`. **Open questions:** B-05, B-06, B-07 (library-level JSON mirrors) and B-08 (`role_restrictions`) — architect to rule before WS-5 starts.
- **Minimum viable:** if architect rules "library stays JSON, `role_restrictions` stays JSON", WS-5 is exactly the 4 committed splits.
- **Maximum scope:** if architect rules "all three library columns + role_restrictions also split", that adds 4 more sub-workstreams to WS-5.
### WS-6 — FormBindingApplicator
- **Scope:** confirmed. No surprises.
- **Addition:** when the applicator writes back to `persons.custom_fields` (B-17), document the shrinkage plan so B-17 can move from `opaque-keep` to `deprecated-remove` in a later sprint.
### WS-7 — Observability foundation
- **Scope:** confirmed + small addition.
- **Addition:** D-06 — add `(subject_type, subject_id)` and `(causer_type, causer_id)` indexes on `activity_log` when the observability stack starts asking questions of the log.
### WS-8 — Documentation consolidation
- **Scope:** expanded significantly.
- **Addition D-01 is the big one.** SCHEMA.md has ~30 tables documented without migrations. WS-8 must decide: (a) extract them into `/dev-docs/ARCH-PLANNED-MODULES.md` and reduce SCHEMA.md to built-only tables, or (b) add a `status: planned` marker per row. Either way WS-8 is no longer "rewrite v2.0 of SCHEMA.md" but "reduce SCHEMA.md to reality + separate planned-module doc".
- **Additions D-07, D-08 (partial), C-04:** all docs/clarification work that lands naturally here.
- **Risk:** WS-8 as written was 2-3 days. With D-01 added, expect 4-5 days; the "planned modules" document is non-trivial because it has to preserve the indexing/soft-delete intent SCHEMA.md captured.
### Candidates for backlog or a new workstream
- **None require a new workstream** — all findings map cleanly onto WS-2/4/5/7/8. D-02 might warrant a mini-WS ("scope extension") if the architect wants to solve the FK-chain problem generically rather than adding a `form_schema_id` strategy.
---
## 7. Questions for the architect
Ordered by how much they block downstream scoping; answer before WS-2 starts.
1. **Re-confirm or reverse ARCH §3 besluit 5 ("ULID overal, ook pivots") for the 11 non-ULID tables in Category A.** Pivots with inline "int AI for join performance" comments (A-01 A-04) are straightforward pivot-exception candidates. `form_values` and `form_value_options` (A-10, A-11) cite `ARCH §4.4` in the migration itself as performance rationale — is that rationale still valid, or overridden by the consolidation charter? Either answer is fine; both must be explicit.
2. **Does `OrganisationScope` grow a new strategy, or do we denormalise `organisation_id` onto every form-builder child table?** D-02 + D-03 are the same problem from two angles. A new `form_schema_id` strategy is one migration to the scope class. Denormalising columns is ~8 migrations plus backfill. Pick one approach for WS-4.
3. **Scope of WS-5: four committed columns, or also the three library mirrors (B-05/06/07) and `role_restrictions` (B-08)?** Keeping WS-5 to just `form_fields` is the minimum charter commitment; expanding to library + role_restrictions makes the form-builder relational story complete in one sprint but adds 3-4 days.
4. **`personal_access_tokens.tokenable` (C-04): add a morph-map alias for consistency, or leave Sanctum's default FQCN-in-DB pattern untouched?** Cosmetic; pick a direction so WS-8 can document it.
5. **WS-8 SCHEMA.md rewrite: "built-tables-only + separate planned-modules doc", or "single file with `status: planned` markers"?** D-01 means WS-8 has to pick a shape before starting the rewrite.
6. **`form_submissions.subject_type` allow-list (D-08): consolidate under WS-2 purpose registry, or leave the two-file pattern and add a CI test that keeps them in sync?** Either works; WS-2 scope depends on the answer.