docs(ws-6): RFC-WS-6 v1.1 addenda + ARCH-BINDINGS §6.4 alignment

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>
This commit is contained in:
2026-04-28 09:09:41 +02:00
parent 6399bacdb6
commit 85f4777e0c
5 changed files with 117 additions and 10 deletions

View File

@@ -244,7 +244,7 @@ Merge strategy × null winner matrix (Q7 + V1):
catch this at config time, but the runtime check protects against
live-table edits between publish and apply.
### 6.4 Person provisioning (RFC Q8)
### 6.4 Person provisioning (RFC Q8 + Q9, v1.1)
`PersonProvisioner::provisionFromSubmission()` (called from
`EventRegistrationSubjectResolver`):
@@ -258,8 +258,10 @@ live-table edits between publish and apply.
4. `Person::query()->where('email', $value)->where('event_id', $eventId)
->lockForUpdate()->first()` → returns existing if found.
5. Otherwise builds attributes from the OTHER (non-identity-key)
bindings filtered to `Person::$fillable`, sets a default
`crowd_type_id` (first active CrowdType in the org), and calls
bindings filtered to `Person::$fillable`, resolves
`crowd_type_id` from `$submission->schema->default_crowd_type_id`
(RFC Q9 v1.1 addendum — replaces the silent `CrowdType::oldest()`
heuristic), and calls
`Person::firstOrCreate(['email' => ..., 'event_id' => ...], $attrs)`.
`firstOrCreate` semantics resolve the
@@ -271,7 +273,25 @@ deferred to BACKLOG `LOAD-TEST-FOUNDATION`).
Multi-tenancy: Person's `organisationScopeColumn` is `event_id`
(not `organisation_id` directly). The provisioner scopes by
`event_id` only — cross-event submissions never collide.
`event_id` only — cross-event submissions never collide. Same email
registering across two events in the same org → two distinct Person
rows; identity reconciliation is `PersonIdentityService`'s job
(out of scope for WS-6, RFC §6 / RFC Q8 v1.1 addendum).
Default crowd type:
- `form_schemas.default_crowd_type_id` (nullable ULID) is the single
source of truth for the freshly-provisioned Person's `crowd_type_id`.
- `RequiresDefaultCrowdType` publish guard blocks publish when null on
an `event_registration` schema.
- `PersonProvisioner::resolveCrowdTypeId()` throws
`PersonProvisioningException('no_default_crowd_type', ...)` when
null at apply time (failsafe for live-table edits between publish
and apply).
- No DB-level FK — application-level integrity only (SQLite cascade
problem, see RFC Q9 v1.1 addendum). The Eloquent
`FormSchema::defaultCrowdType()` `belongsTo` relation handles
read-side correctness.
### 6.5 Per-purpose subject resolution (RFC Q9)