Documents the IDOR-class threat model and the 404-vs-403
enforcement strategy implemented in WS-6 sessions 1-3a.
Two-axis policy enforcement:
- Role-class (super_admin platform endpoints): 403 for unauthorised
roles — endpoint exists; "you're not allowed in this room"
- Ownership-class (org-scoped endpoints): 404 for cross-tenant
access — resource indistinguishable from absence; "this room
doesn't exist for you"
Includes:
- Threat model: enumeration via ID sweeping
- Policy implementation (canAccess + viewAnyInOrganisation,
sessie 3a addition that closed the orgIndex gap)
- Test coverage map: 24 tests in
FormSubmissionActionFailureRouteSecurityTest
- Edge case enumeration: soft-deleted parent, invalid ULID,
non-existent ID, authenticated-without-role, unauthenticated
- Forward pointer to sessie 3b for the frontend authorisation model
Refs: RFC-WS-6.md §4 V3, sessie 3a Tasks 1-2 commits
6b22c8d (security tests) and 842cb01 (per-purpose pipeline)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
RFC §4 V3 compliance — cross-tenant access to FormSubmissionActionFailure
endpoints returns 404, not 403, to prevent resource-existence
enumeration. The FormSubmissionActionFailurePolicy is the single tenant
gate; these tests assert the route-level integration end-to-end.
Production-code finding (in scope per "security gaps zijn altijd urgent"):
the orgIndex endpoint had a real IDOR gap. Original implementation called
`Gate::authorize('viewAny', ...)` which permits any org_admin in any org,
then filtered the result set by the URL's `{organisation}` param. orgB's
admin hitting `/organisations/{orgA}/form-failures` would get back orgA's
failures — leakage.
Fix:
- New policy method `viewAnyInOrganisation(User, Organisation)` that
requires super_admin OR org_admin on THIS specific organisation.
- Controller `orgIndex` calls `authorizeViewAnyInOrgOrNotFound()` which
translates a denied policy → 404 (matches the show/retry/resolve/dismiss
pattern).
- viewAny on the class level stays as the platformIndex gate (super_admin
+ any-org_admin enumeration is acceptable on the platform endpoint
because the role middleware already restricts to super_admin).
Test coverage (24 tests, all passing):
- 5 org-scoped endpoints × cross-tenant scenarios (all return 404)
- 5 platform endpoints × role-class scenarios (org_admin gets 403, never 404)
- Edge cases: soft-deleted parent submission, invalid ULID format,
non-existent ID, unauthenticated, authenticated-without-role on org
The 403 vs 404 distinction matters: role-gated endpoints return 403
(auth-class — "not allowed in this room"); ownership-gated endpoints
return 404 (IDOR-class — "this room doesn't exist for you").
Refs: RFC-WS-6.md §4 V3, ARCH-BINDINGS.md §8.2 (Task 3 of this session)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
mysql-client is now installed on the dev host (brew install
mysql-client + PATH=/opt/homebrew/opt/mysql-client/bin), which
unblocks the fast path that session 2.7 prepared but couldn't
activate.
Changes:
- api/database/schema/mysql-schema.sql committed (current state, 122 KB,
1749 lines, all 155 migration records).
- api/database/schema/.gitignore removed: the dump is no longer opt-in,
it's the default code path (active in dev + CI).
- Makefile schema-dump target simplified: drops the docker exec mysqldump
workaround in favour of plain `php artisan schema:dump`. Now also runs
migrate to head first so the dump always reflects the latest migration
set without manual prep.
- CLAUDE.md "Schema dumps" rewritten: "opt-in fast path" → "CI fast
path", reflects that the dump is committed by default and contributors
regenerate via `make schema-dump` after adding migrations.
Backfill test wall-time, 4 classes combined:
- Session 2.7 baseline: 127.90s
- This session: 27.55s (78% reduction)
Per class (PHPUnit Duration):
- FormFieldBindingMigrationTest: 4.59s (2 tests)
- ConditionalLogicBackfillTest: 5.45s (4 tests)
- FormFieldOptionsBackfillTest: 12.25s (9 tests)
- FormFieldValidationRuleBackfillTest: 10.75s (6 tests)
Full suite knock-on: 209.95s → 89.16s (-57%) — every RefreshDatabase
test pays the up-front migrate cost, which `schema:dump` collapses
from ~6s × N to a single ~1s SQL load.
Refs: WS-6 session 2.7 deviation #3 cleanup, Q1 closure
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Session 2.6's per-test migrate:fresh in 4 backfill test classes
replays ~120 migrations. Laravel's schema:dump produces a single
SQL file that migrate:fresh loads atomically when present —
significantly faster on environments where the host has the
\`mysql\` CLI available for Laravel's load step.
Changes:
- New Makefile target \`schema-dump\` runs mysqldump INSIDE the
bm_mysql Docker container (no host mysqldump dependency to
GENERATE the dump). Outputs api/database/schema/mysql-schema.sql.
- api/database/schema/.gitignore added: mysql-schema.sql is NOT
committed by default. Laravel's auto-load on migrate:fresh
shells out to the host's \`mysql\` CLI; on hosts without it,
the presence of the dump file actively breaks migrate (exit 127).
Treat the dump as opt-in per dev environment.
- CLAUDE.md "Schema dumps (opt-in fast path)" subsection added with
the workflow: brew install mysql-client → migrate to head →
make schema-dump → optionally commit.
phpstan-baseline.neon: removed a stale "unused use \$actor" entry
in FormSubmissionService whose underlying closure pint cleaned up
in commit 060d6f3 (Task 1).
Wall-time on this dev machine (no host mysql CLI): NO speedup,
backfill tests still ~6s per setUp. The dump file is ready for
environments that have mysql CLI. Documented as a deviation.
JSON canonicalization (commit 060d6f3) is the load-bearing
correctness fix from this branch; the schema-dump perf path is a
nice-to-have that activates per-environment.
Refs: WS-6 session 2.6 deviation #5 cleanup
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
CLAUDE.md's existing "Database" subsection is expanded to spell out
that MySQL 8.0 is the exclusive database engine for dev, testing, and
production. SQLite is forbidden — its quirks (rebuild-on-FK-add
cascade behaviour, JSON key-order non-determinism on round-trip,
VARCHAR length enforcement gaps, concurrency model) mask bugs that
would surface in production MySQL.
Context: WS-6 session 2.5 had to omit a FK constraint due to an
SQLite-only quirk; session 2.6 (Task 1+2 of this branch) moved test
infrastructure to MySQL and restored the FK. This policy ensures the
issue cannot recur. Includes the cross-engine query-rewrite rules
the migration surfaced (information_schema.STATISTICS over
sqlite_master, assertEquals over assertSame on JSON-derived data).
Refs: RFC-WS-6.md v1.1, WS-6 session 2.5 deviation #1, session 2.6
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
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>
Trailing housekeeping after Task 1 (default_crowd_type_id) commit
d2059e3. The codebase pint config uses `new_with_parentheses = false`
(no `()` after class name when constructor has no args). Two new
tests slipped past with `new FormValue()` / `new RequiresDefaultCrowdType()`
patterns; pint converts them to `new FormValue` / `new RequiresDefaultCrowdType`.
No behavioural change.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Promote RFC-WS-6 to v1.1 with two §3 addenda capturing the post-session-2
cleanup decisions; align ARCH-BINDINGS.md §6.4 (Person provisioning)
with the v1.1 text. No architectural reversals — corrections + one
schema addition.
§3 Q8 v1.1 addendum — Person provisioning is scoped by `event_id`:
- Q8 v1.0 said `Person::firstOrCreate(['email', 'organisation_id'], ...)`.
That is incorrect against the actual model: `Person::$organisationScopeColumn`
is `event_id`. The provisioner looks up and creates by `(email, event_id)`.
- Same email registering across two events in the same org → two distinct
Person rows. Cross-event identity reconciliation remains the job of
`PersonIdentityService` (out of scope WS-6).
- Failsafe: `PersonProvisioningException('no_event', ...)` when
`submission.event_id` is null on event_registration; publish guard
`SchemaHasLinkedEvent` blocks at config time.
§3 Q9 v1.1 addendum — `form_schemas.default_crowd_type_id` replaces
`CrowdType::oldest()`:
- Session 2's PersonProvisioner used a silent oldest()-in-org heuristic
for the new Person's `crowd_type_id` (NOT NULL). Fragile, undocumented,
cross-org broken.
- v1.1 adds `form_schemas.default_crowd_type_id` (nullable ULID) as the
explicit, versioned schema attribute. `RequiresDefaultCrowdType` publish
guard wires into `EventRegistrationGuards`. Runtime failsafe in
`PersonProvisioner::resolveCrowdTypeId()` throws
`PersonProvisioningException('no_default_crowd_type', ...)` when null.
- Schema-level FK omitted intentionally (SQLite cascade-delete on
ALTER TABLE ADD FOREIGN KEY observed in WS-5b/c backfill tests).
Application-level integrity (publish guard + runtime failsafe +
Eloquent `belongsTo`) is sufficient because writes always go through
`FormSchemaService::publish()`.
- Snapshot impact: none. Provisioning reads from live FormSchema by
FK; audit replay uses whatever the schema's current
`default_crowd_type_id` is at retry time.
ARCH-BINDINGS.md §6.4:
- Now references "RFC Q8 + Q9, v1.1" in the heading.
- Default-crowd-type bullet replaces "first active CrowdType in the org"
(the session-2 oldest() heuristic) with the schema attribute lookup.
- Multi-tenancy paragraph clarified for cross-event scoping.
Cross-references touched up:
- `PersonProvisioner::resolveCrowdTypeId()` docblock: §3 Q8 → §3 Q9.
- `RequiresDefaultCrowdType` class docblock: §3 Q8 → §3 Q9.
- `SCHEMA.md` v2.7 changelog and `default_crowd_type_id` column note:
§3 Q8 → §3 Q9.
Document history entry added in §10 documenting v1.1 + the snapshot
dual-key cleanup and route-model-binding fix landed in earlier commits
on this branch.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replace the manual `$request->route('formSubmissionActionFailure')` workaround
with type-hinted parameters. Implicit route model binding now resolves
FormSubmissionActionFailure correctly on both the platform admin route
(/admin/form-failures/{id}) and the org-scoped route
(/organisations/{organisation}/form-failures/{id}).
Root cause:
On the nested org-scoped route, Laravel's implicit binding triggers its
scoped-binding code path: for the second URL segment, it tries to resolve
the failure as a relation of the route's parent ({organisation}) by calling
`$organisation->formSubmissionActionFailures()`. Organisation has no such
relation (failures live under FormSubmission, not Organisation directly),
so the lookup silently fell through and the controller received a raw
string. PHP then raised a TypeError on the type-hinted parameter.
A second issue compounded it: with the controller method declaring
`(FormSubmissionActionFailure $formSubmissionActionFailure, ?Organisation $organisation)`
the parameter order did NOT match the URL parameter order
(/{organisation}/.../{formSubmissionActionFailure}), so Laravel's
resolveMethodDependencies — which falls back to positional binding when
parameter counts diverge — bound them to the wrong slots.
Fix:
- Register an explicit `Route::bind('formSubmissionActionFailure', ...)`
in AppServiceProvider that loads the model `withoutGlobalScopes()` and
throws ModelNotFoundException on miss. This sidesteps the scoped-binding
parent-relation lookup entirely.
- Add `->withoutScopedBindings()` to all four org-scoped routes (show,
retry, resolve, dismiss) as a belt-and-braces guarantee that Laravel
never enters the scoped-binding path for these nested routes.
- Reorder controller method signatures to put `?Organisation $organisation`
FIRST, matching URL parameter order so positional binding lands the
ULID strings on the correct method parameters.
- Drop the now-unused private `resolveFailure()` helper.
- Tenant scoping continues to be enforced by FormSubmissionActionFailurePolicy
via the failure.submission.organisation_id FK chain (RFC V3); cross-
tenant access still translates denied → 404, never 403.
Tests: all 9 controller tests pass (cross-tenant 404 contract verified for
view, dismiss, and resolve).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
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>
Sections 6 (apply pipeline), 7 (failures and retry), 8 (multi-tenancy
and security tenant resolution), 9 (listener chain) populated from
session 2 implementation. Each subsection 200-400 words referencing
RFC-WS-6.md sections by number.
§8.2 (IDOR class tests) and frontend-specific sections in §3 admin UI
remain pending session 3.
Refs: RFC-WS-6.md
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
Two route groups: /api/v1/admin/form-failures (super_admin platform) and
/api/v1/organisations/{organisation}/form-failures (org_admin scoped).
Same controller, policy authorises via FK chain (RFC V3). Cross-tenant
access returns 404 not 403 to prevent enumeration.
Resolve takes optional note; Dismiss requires DismissalReasonType
enum with conditional note (mandatory for 'other'). Both via
FormRequest validation with explicit i18n message keys.
Implementation note: Laravel implicit model binding for nested-namespace
ULID models doesn't pick up reliably across nested route groups. Using
manual resolveFailure() helper that loads withoutGlobalScopes() (so
cross-tenant access still reaches the policy, which translates denied →
404 per V3). Policy explicitly checks soft-delete via deleted_at since
withoutGlobalScopes bypasses SoftDeletes too. Policy registered
explicitly in AppServiceProvider — auto-discovery doesn't reliably
resolve App\Models\FormBuilder\* → App\Policies\FormBuilder\*.
NOT: admin UI (session 3). Not: public form routes (no API contract
notification needed).
Refs: RFC-WS-6.md §3 (Q5), §4 (V2, V3)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three CLI commands for ops use, mirroring the API endpoints in Task 9:
- form-failures:retry (id|submission|org filter, --dry-run)
- form-failures:resolve (single id, optional note)
- form-failures:dismiss (single id, DismissalReasonType + note)
Cross-tenant isolation enforced via form_submissions.organisation_id
FK chain (RFC V3). retry_count incremented on retry; failure history
preserved (new row on repeat failure, not in-place mutation).
Refs: RFC-WS-6.md §3 (Q5), §4 (V2, V3)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Per RFC O2: pre-commit dispatch let queued listeners (tag sync, shifts,
webhooks, mailables) enqueue with state that might never persist on
rollback. Move dispatch to after DB::transaction returns.
This is semantically critical for the new ApplyBindings two-transaction
pattern (RFC Q4): the inner transaction must commit before sibling
listeners observe the submission.
Refs: RFC-WS-6.md §5 (O2)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Per RFC Q2: the 'no subject → pending' path becomes unreachable for
event_registration submissions because ApplyBindingsOnFormSubmit
provisions the Person before this listener fires. Path preserved as
failsafe with explicit warning log so misconfigurations and silent
ApplyBindings failures surface mechanically.
Existing test updated to spy on Log facade and assert the warning fires.
Refs: RFC-WS-6.md §3 (Q2)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ApplyBindingsOnFormSubmit (sync) wraps the applicator in DB::transaction
and writes apply_status post-commit. On exception: outer catch records
FormSubmissionActionFailure in a separate transaction (survives inner
rollback), marks apply_status=failed, swallows so siblings keep running
(RFC Q3, Q4). When ApplyBindings provisions a Person on a previously
no-subject submission, the listener also writes subject_type/subject_id
back so TriggerPersonIdentityMatchOnFormSubmit (next sync listener) can
find the freshly-provisioned subject.
ApplyBindingsOnFormSectionSubmitted (queued, feature-flagged) ready
for ARTIST_ADVANCE activation per RFC Q10.
Listener chain on FormSubmissionSubmitted explicitly registered in
AppServiceProvider::boot for deterministic ordering (RFC Q1):
ApplyBindings → IdentityMatch → queued siblings.
FormBindingApplicator dropped 'final readonly' to 'class' so listener
tests can subclass it for throw-path coverage; constructor properties
remain readonly individually.
Refs: RFC-WS-6.md §3 (Q1, Q3, Q4, Q10)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
Parallel interface to PurposeGuardProvider for runtime subject
resolution. Seven concrete resolvers, one per v1.0 purpose. Wired
through purposes.php via subject_resolver_class key.
EventRegistration uses PersonProvisioner (may create). Other purposes
resolve from existing context (portal token, production request, auth).
IncidentReport is the only purpose allowed to return null (anonymous-
allowed configurations); the others return concrete model types
(narrowed via PHP covariance) for caller convenience.
Refs: RFC-WS-6.md §3 (Q9)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Resolves bindings within a submission to one winner per (target_entity,
target_attribute) group. Candidate set = form_values rows present
(absence excludes; null value is explicit clear and IS a candidate).
Trust-precedence with sort_order tie-break. Section-filtering for
RFC Q10 stub future-readiness.
Pure-logic resolver — no DB writes, only reads form_values for the
candidate gate. Works against the 'bindings' (plural) snapshot key
introduced alongside PersonProvisioner.
Refs: RFC-WS-6.md §3 (Q7, Q10)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
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>
Tenant scope verified via failure.submission.organisation_id, NOT route
binding. Cross-tenant access returns false (controllers in sessions 2/3
will translate to 404 to prevent enumeration). Five abilities:
viewAny, view, retry, resolve, dismiss.
Laravel 12 auto-discovers App\Policies\FormBuilder\FormSubmissionActionFailurePolicy
for App\Models\FormBuilder\FormSubmissionActionFailure — no explicit
registration needed (pattern matches the existing FormSubmissionPolicy).
IDOR-class security tests included with explicit RFC V3 cross-reference
in the test class docblock.
Refs: RFC-WS-6.md §4 (V3), ARCH-FORM-BUILDER.md §22.9
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
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>
Per-purpose schema validation composes a PurposeGuardProvider returning
a list of guards. Errors collected (not first-fail) so the builder UI
surfaces every issue per save. ConditionalRequirement composes higher-
order without proliferating one-off classes.
RequiresIdentityKeyBinding checks the is_identity_key flag specifically;
the binding-existence check is handled additively by the existing
assertRequiredBindingsPresent in FormSchemaService.
SchemaHasLinkedEvent checks owner_type='event' + owner_id (FormSchema
uses polymorphic owner; there is no direct event_id column).
i18n messages live in lang/nl/form_builder_publish_guards.php.
Refs: RFC-WS-6.md §3 (Q13), §4 (V1, V3)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Config-driven mapping from (target_entity, target_attribute) to storage
shape (scalar/collection/relation), PHP type, and identity-key
eligibility. Replaces any name-suffix matching (e.g. _tags, _skills) —
those are convention-not-contract and reject by design.
Used by publish guards now and (in session 2) by FormBindingApplicator.
Refs: RFC-WS-6.md §4 (V1)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- FormFieldBindingMergeStrategy::nullWinnerBehaviour() and
isValidForScalarTargets() encode the per-strategy null-winner matrix
(RFC Q7) and the collection-only restriction (RFC V1).
- ResolvedBinding/BindingApplicationResult/BindingPassResult readonly
DTOs for the binding pipeline. Construction-time validation for
trust level. Apply-status derived from result aggregate.
Note: the existing enum is named FormFieldBindingMergeStrategy (not
MergeStrategy as the prompt sketched). Methods added to it directly.
Refs: RFC-WS-6.md §3 (Q4, Q7), §4 (V1)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
DismissalReasonType has six values; manually_resolved is intentionally
absent because Resolve and Dismiss are separate workflows (RFC V2).
Refs: RFC-WS-6.md §3 (Q4 partial-status separation), §4 (V2 dismiss enum)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- 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>
Captures the 13 design decisions, 4 refinements, and 3 observations
from the 2026-04-25 architectural session. Authoritative for sessions
1-3 of WS-6. Out-of-scope items explicitly listed in §6.
Refs: ARCH-CONSOLIDATION-2026-04.md §6.2, ARCH-FORM-BUILDER.md §31
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Reflects the FormFieldChildTableMorphScope extraction landed in the
previous commit:
- ARCH-FORM-BUILDER.md v1.9 — five locations updated:
§6.7 (Relational binding table) — added forward reference
sentence after the FormFieldBindingScope escape-hatch line
(WS-5a was the first scope; previously had no deferral note
because nothing existed yet to defer)
§17.4.2 (Relational table form_field_validation_rules) —
"deferred to WS-5d per addendum Q3" replaced with marker-
subclass forward reference
§17.5.3 (Service, scope, cascade — config) — same replacement
§17.6.1 (Field options rationale) — "unblocks the deliberate
follow-up" replaced with completion-confirmation
§17.6.3 (Service / scope / cascade — option) — "deferred to a
follow-up work package" replaced with marker-subclass forward
reference + Phase A diff verification result
Version metadata + changelog updated; v1.8 prose preserved in the
Previous-versions block.
- ARCH-CONSOLIDATION-ADDENDUM-2026-04-24.md — new
"Uitvoering — base scope-class extractie (2026-04-25)" section
inserted after the WS-5d Uitvoering, documenting the Phase A
diff-verification, marker-subclass approach, private→protected
YAGNI policy, the inline-FQN → use-statements stylistic refinement,
static-analysis impact (Larastan baseline clean, Rector
357 → 355), and net-diff figures.
- BACKLOG.md — FORM-BUILDER-MORPH-SCOPE-BASE-CLASS item closed
via strikethrough header + "Status: closed 2026-04-25" annotation
(matches the TECH-TS-PORTAL-TSC closure convention from earlier
this week).
- SCHEMA.md — three stale "deferred" claims updated to reflect the
completed extraction:
header v2.6 changelog mention rewritten to point at the now-
landed FormFieldChildTableMorphScope
form_field_validation_rules table-section global-scope note
replaced with marker-subclass forward reference
form_field_options table-section global-scope note same
replacement
Schema version NOT bumped — no actual schema change.
The two other scope mentions (form_field_bindings,
form_field_configs) made no deferral claims and remain accurate.
Note: the work package's prose listed "§6.7 / §17.4.3 / §17.5.3 /
§17.6.3" as deferral-note locations. The actual locations were
§17.4.2 (not §17.4.3), §17.5.3, §17.6.1 (not just §17.6.3), and
§17.6.3 — §6.7 had no deferral note (WS-5a was the first scope,
nothing to defer yet). All five spots updated in line with the work
package's intent.
WS-5 family fully complete: no open follow-up items remain under the
"delete > adapt" discipline of the WS-5 refactor.
Tests: 1208 passed (3260 assertions). No code changes in this commit.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes the WS-5 family follow-up tracked as
FORM-BUILDER-MORPH-SCOPE-BASE-CLASS in BACKLOG.md. Per addendum
§Q3 Uitvoering across WS-5a/b/c/d, base-class extraction was
deliberately deferred until all four concrete morph-scope siblings
existed and the "what actually varies" question could be answered
empirically.
The answer is: nothing. All four siblings —
FormFieldBindingScope (WS-5a), FormFieldValidationRuleScope (WS-5b),
FormFieldConfigScope (WS-5b commit 5), and FormFieldOptionScope
(WS-5d) — are byte-equal in their apply() and resolveOrganisationId()
methods (Phase A diff verification clean: zero lines diverging
across all three pairwise comparisons).
Approach:
- New abstract class FormFieldChildTableMorphScope holds the full
UNION-over-two-owner-chains scope logic with the morph alias
literals extracted as private constants
(OWNER_TYPE_FIELD, OWNER_TYPE_LIBRARY) for one-location-of-truth.
- The four concrete scopes become marker subclasses
(`final class X extends FormFieldChildTableMorphScope {}`) — class
identity preserved so every existing
`withoutGlobalScope(FormFieldXScope::class)` call site in cascade
observers, backfill migrations, and platform super_admin paths
continues to work unchanged. The 4 test call sites (in the four
*ScopeTest classes) work without modification.
- Helper visibility stays `private` per YAGNI. If a future sibling
needs to vary the morph aliases or owner-chain, the helpers
promote to `protected` at that point.
- Stylistic refinement vs. the four originals: `Organisation` and
`Event` in resolveOrganisationId() now use `use` statements at
the top of the file instead of inline `\App\Models\…` FQNs.
Net diff:
Pre: 4 concrete scope files at ~106 lines each (~424 lines total)
Post: 4 marker subclasses at 20 lines (80 total) +
1 abstract base at 125 lines = 205 lines total
Saving: ~219 lines of duplication removed.
Tests: 1208 passed (3260 assertions) → 1208 passed (3260 assertions).
Identical — public behaviour unchanged.
Larastan: clean (no new errors beyond baseline).
Rector: 357 → 355 dry-run suggestions (small reduction from the
deduplication; no apply in this commit).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Brings apps/portal from 729 vue-tsc errors (≈22 own-code, of which
18 were TS2339 downstream of tiptap; ≈707 in node_modules/@tiptap/)
to zero. Tiptap fix-route: Option A — patch upgrade @tiptap/* from
2.27.1 to 2.27.2 to fix tiptap's dist/index.d.ts re-export paths.
Sprint commits (this work package, 3 total):
- f7bb864 fix(portal-deps): upgrade @tiptap/* 2.27.1 → 2.27.2
to fix dist resolution (cleared 707 + 18 errors)
- a7ccd2b fix(portal-types): clear residual long-tail tsc errors
(cleared the 4 tiptap-independent stragglers:
vite.config.ts componentName param,
LayoutConfig.title Lowercase<string> over-constraint,
@iconify/types missing dev-dep, casl.ts meta string cast)
- this commit: close BACKLOG entry; correct the misleading "+4 in
tiptap" framing in the original entry (was the
ts-reset delta, not the absolute pre-existing count
of ~707); seed TECH-PORTAL-TSC-CI-GATE follow-up.
apps/portal `pnpm exec vue-tsc --noEmit` exits clean.
Vitest: 113/113 passing. Build: 8.52s, succeeded.
Pre-commit hook gate not added — the project has no husky/lefthook/
simple-git-hooks setup. Captured as TECH-PORTAL-TSC-CI-GATE follow-
up; without that gate the zero state has no enforcement and can
drift back. Should land before S3b organizer UI work to keep the
"new code introduces no new errors" discipline mechanically
enforceable.
S3b form-builder organizer UI can now land on top of a verified
TypeScript baseline.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Resolves the 4 tiptap-independent TypeScript errors that survived
the tiptap 2.27.2 upgrade. All fixes are type-narrowing or type-
annotation refinements; no runtime behavior changes.
Errors fixed:
- vite.config.ts:50 — TS7006: parameter 'componentName' implicitly
has an 'any' type.
Fix: annotate as `(componentName: string)`. The
unplugin-vue-components resolver always passes a component-name
string.
- src/@layouts/types.ts:7 — TS2322 source: Type 'string' is not
assignable to type 'Lowercase<string>'. Vuexy boilerplate
constrained `LayoutConfig.app.title` to all-lowercase, which
rejects "Crewli Portal" in themeConfig.ts. The lowercase
constraint serves no consumer in our code and was a Vuexy
template oversight.
Fix: relax type to `string` at the type definition (root cause).
No call-site changes needed.
- src/plugins/iconify/build-icons.ts:19 — TS2307: Cannot find
module '@iconify/types' or its corresponding type declarations.
The build:icons postinstall script uses `IconifyJSON` as a type
annotation. `@iconify/types@2.0.0` was already in the pnpm
store as a transitive dep of `@iconify/tools` but not hoisted
to portal's node_modules root.
Fix: add `@iconify/types` as an explicit dev-dependency.
- src/@layouts/plugins/casl.ts:51 — TS2345: Argument of type
'{}' is not assignable to parameter of type 'string'.
Vue-router types `RouteMeta` loosely; the if-guard on line 50
narrows truthiness but TS doesn't infer string from `{}`.
The same pattern on line 55 already uses `// @ts-expect-error`;
we prefer an explicit `as string` cast at the call site since
intent is clearer than a suppression comment.
Fix: cast `targetRoute.meta.action` and `targetRoute.meta.subject`
to `string` at the `ability.can(...)` call.
vue-tsc errors:
Pre: 4 own-code (post tiptap upgrade), 0 in node_modules.
Post: 0 own-code, 0 in node_modules.
apps/portal `pnpm exec vue-tsc --noEmit` now exits clean.
Vitest: 113/113 passing. Build: 8.68s, succeeded.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Tiptap 2.27.1 ships a packaging bug: dist/index.d.ts re-exports
from '../src/CommandManager.js' (and 22 similar lines), but those
.js files do not exist — only .ts source. With the project's
moduleResolution: "Bundler" config, vue-tsc falls through to
src/CommandManager.ts and pulls tiptap's entire uncompiled source
tree into the program. skipLibCheck is already true but does NOT
suppress the resulting errors: skipLibCheck only affects .d.ts,
not raw .ts reachable through the import graph.
Tiptap 2.27.2 fixes the dist exports to use sibling-relative paths
(./CommandManager.js), which resolve correctly to the existing
dist/CommandManager.d.ts files. No walk into src/.
The existing ^2.27.1 caret already accepted 2.27.2; pnpm-lock just
froze 2.27.1 from when it was the latest. `pnpm update '@tiptap/*'`
brings all 12 packages to 2.27.2:
- @tiptap/core 2.27.1 → 2.27.2 (transitive)
- @tiptap/extension-character-count 2.27.1 → 2.27.2
- @tiptap/extension-highlight 2.27.1 → 2.27.2
- @tiptap/extension-image 2.27.1 → 2.27.2
- @tiptap/extension-link 2.27.1 → 2.27.2
- @tiptap/extension-placeholder 2.27.1 → 2.27.2
- @tiptap/extension-subscript 2.27.1 → 2.27.2
- @tiptap/extension-superscript 2.27.1 → 2.27.2
- @tiptap/extension-text-align 2.27.1 → 2.27.2
- @tiptap/extension-underline 2.27.1 → 2.27.2
- @tiptap/pm 2.27.1 → 2.27.2
- @tiptap/starter-kit 2.27.1 → 2.27.2
- @tiptap/vue-3 2.27.1 → 2.27.2
Patch-level upgrade: no API surface change. Drop-in.
vue-tsc errors:
Pre: 729 total = 22 own-code (incl. 18 downstream tiptap
TS2339 'Property does not exist on type SingleCommands'
leaking from TiptapEditor.vue + ProductDescriptionEditor.vue)
+ 707 in node_modules/@tiptap/
Post: 4 total = 4 tiptap-independent own-code stragglers
(vite.config.ts, themeConfig.ts, casl.ts, build-icons.ts)
+ 0 in node_modules
Vitest: 113/113 passing. Build: 8.69s, succeeded.
The 4 remaining own-code errors are addressed in the next commit.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Both items currently sit at default priority. Foundation-tooling
commit 3 (ts-reset install) surfaced them but didn't constrain
when they need to close.
Adds explicit S3b-trigger rationale: launching the form-builder
organizer UI in apps/app on top of:
- 22 unverified pre-existing TypeScript errors in apps/portal,
AND
- zero Vitest setup in apps/app
is asymmetric quality, exactly the discipline gap that bites in
post-launch debugging. Both items now flagged "high before S3b
lands" with concrete close-criteria.
TECH-TS-PORTAL-TSC additionally clarifies tiptap node_modules
handling. Phase A check confirmed `skipLibCheck: true` is already
set in both SPAs' single tsconfig.json (no `tsconfig.app.json` /
`tsconfig.node.json` variants exist). Despite that, the 4 tiptap
errors persist because tiptap ships uncompiled `.ts` source files
in `node_modules/.../@tiptap/core/src/`, not `.d.ts` — and
`skipLibCheck` only suppresses checking of `.d.ts` files. Real
fix paths are upstream `@tiptap/*` upgrade (newer majors may ship
`.d.ts` only) or a focused `exclude` glob; flipping skipLibCheck
is a non-fix because it is already on.
TECH-APP-VITEST adds a 6-step setup outline scoped to "harness
exists + one test passes", explicitly excluding comprehensive
test-writing for existing apps/app code. `useImpersonationStore.ts`
called out as a natural early target — it has no runtime test
today and the pending TECH-TS-IMPERSONATION shape-validation work
benefits from coverage.
CLAUDE.md quality-gates list adds a Vitest entry that surfaces
the apps/portal / apps/app asymmetry, with a pointer to
TECH-APP-VITEST.
No code changes. Documentation only.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Installs laravel/telescope ^5.0 (v5.12.5) as a dev-dependency.
Three-layer production safety adapted to Laravel 11 layout (no
Kernel.php; routing/schedule in bootstrap/app.php +
routes/console.php):
1. composer.json `extra.laravel.dont-discover` lists
laravel/telescope. After editing, `php artisan package:discover`
regenerates bootstrap/cache/packages.php — without this step
the auto-discovery cache still registers the vendor provider.
2. AppServiceProvider::register() gates registration to local +
testing environments. Registers BOTH the vendor
Laravel\Telescope\TelescopeServiceProvider (routes, migrations,
publishing) AND the project's App\Providers\TelescopeService
Provider (gate + filter) — they're sibling classes that extend
ServiceProvider independently, not parent/child, so both must
register for the dashboard to work. bootstrap/providers.php
deliberately does NOT list either Telescope provider.
3. .env TELESCOPE_ENABLED flag (false in .env.example). Runtime
toggle that disables Telescope even when the providers are
registered.
Production safety verified via simulated APP_ENV=production check:
confirms no Telescope-* providers are loaded.
Authorization: viewTelescope gate restricts dashboard to users
with the super_admin Spatie Permission role. Even in local
environments, only super_admin can view. Default was an email
allow-list stub — replaced with `$user->hasRole('super_admin')`.
Pruning: Schedule::command('telescope:prune --hours=48') added in
routes/console.php (Laravel 11's schedule location), environment-
gated to local + testing only.
Documentation: /dev-docs/TELESCOPE.md added; CLAUDE.md gets a
Development-tooling section. The doc explicitly calls out the
dual-provider registration (vendor + app) which differs from the
single-provider pattern in older Laravel versions.
Migrations applied: telescope_entries, telescope_entries_tags,
telescope_monitoring tables. Route registration verified in local
(42 telescope.* routes).
Tests: 1208/1208 passing — Telescope loads in the testing
environment as well, so the suite exercised it without issues.
Deployment note (flag for separate docs): a production operator
who runs `php artisan migrate` manually will still apply the
Telescope migrations — but because the providers never register
in production, the tables stay empty.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Installs @total-typescript/ts-reset 0.6.1 as a dev-dependency in
apps/portal/ and apps/app/. Patches TypeScript's loosest default
types: Array.filter(Boolean) returns non-nullable, JSON.parse
returns unknown, fetch().json() returns unknown, Map.get() strict,
etc.
Configuration: src/reset.d.ts in each SPA imports the reset. Both
tsconfig.json files already include ./src/**/* so the .d.ts is
picked up automatically — no tsconfig edits needed.
Issues surfaced during install:
- apps/app — 0 pre-install tsc errors in own code; install
surfaced 2 errors in src/stores/useImpersonationStore.ts
(both from JSON.parse on sessionStorage content returning
unknown instead of any). Fixed inline at lines 19 + 123 via
`as ImpersonationState` casts that make the existing
trust-in-sessionStorage explicit. Backlog entry
TECH-TS-IMPERSONATION tracks proper runtime shape validation.
- apps/portal — 22 pre-existing tsc errors in own code (mostly
tiptap editor components — tracked as TECH-TS-PORTAL-TSC,
unrelated to ts-reset). Zero new errors in portal's own code.
4 additional errors surfaced in tiptap's uncompiled node_modules
.ts sources (third-party); left as-is.
Neither SPA achieves `tsc --noEmit` clean today — pre-existing
state unrelated to this work package. Build + vitest are the
actual working gates and both remain green:
- apps/portal: vitest 113/113 passing; production build succeeds
- apps/app: (no vitest setup — tracked as TECH-APP-VITEST);
production build succeeds
Documentation: /dev-docs/FRONTEND-TOOLING.md added; CLAUDE.md
quality-gates updated.
Backlog: TECH-TS-IMPERSONATION (runtime validation of stored
impersonation state), TECH-TS-PORTAL-TSC (pre-existing portal tsc
errors), TECH-APP-VITEST (Vitest coverage for apps/app).
No production behavior change.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pre-existing breakage on main since WS-5b's validation_rules
canonicalisation renamed max_priorities → max_selected. Component
was migrated; the spec fixtures were not.
Four occurrences in
apps/portal/tests/components/public-form/FieldSectionPriority.spec.ts:
- line 182, 253, 260: max_priorities used in fixture, the
component's max_selected read returned undefined → test
assertions on rendered max-cap behaviour failed (2 tests red)
- line 220: also used max_priorities; test was accidentally
passing because the value (99) was ignored and the component
fell back to HARD_CAP = 5 which happened to match the
"5 / 5" assertion. Now passes via the correct path (99 clamped
to HARD_CAP via Math.min).
No component-side changes. No new test helpers. Pure fixture
key-rename matching ARCH-CONSOLIDATION-ADDENDUM-2026-04-24
WS-5b Uitvoering: "max_priorities → rule_type = max_selected:
semantically equivalent; two enum cases for one semantic = rot."
Pre: 111/113 passing, 2 failing.
Post: 113/113 passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>