ADR-0032 — Spine-stamp as a construction invariant (foundation, not patrol)


Visual summary

Today — patrol model (writer-time policy, optional FK):

flowchart LR
    A[Inbound webhook / admin / seed] --> B["entity { personId?: string }"]
    B --> C{Writer remembers
pre-pass?} C -->|yes| D[ensurePersonForSignals] C -->|"NO — silent skip"| E[(DDB row
personId: undefined)] D --> F[(DDB row
personId: id)] E -.->|"discovered later"| G[Atlas patrol
spineOrphans tile] G -.->|"manual backfill"| F style E fill:#fee,stroke:#c00 style G fill:#ffd,stroke:#a80

After — construction model (compile-enforced, required FK):

flowchart LR
    A[Inbound / admin / seed] --> B["UnstampedEntity
(no personId field)"] B --> H[withSpineStamp HOF] H --> I[ensurePersonForSignals
+ skeleton / merge / promote] I --> J["Entity { personId: string }"] J --> K[(DDB row
personId: id — always)] L[_unsafe_saveEntity
file-private] -.->|"cannot import
from outside"| H style L fill:#eef,stroke:#66c,stroke-dasharray:4 4 style K fill:#efe,stroke:#080

Downstream entities (Conversation / Tour / WorkOrder) carry the FK as required, derived from upstream:

flowchart TD
    P[Person spine] --> T[Tenant.personId]
    P --> R[Prospect.personId]
    P --> V[Vendor.personId]
    P --> U[User.personId]
    T --> C[Conversation.personId
required] R --> C V --> C C --> M[Message
inherits via conversationId] C --> W[WorkOrder.personId
required] R --> TR[Tour.personId
required] T --> TR style P fill:#efe,stroke:#080,stroke-width:3px

Context

The spine-stamp pattern (docs/architecture/spine-stamp-pattern.md) closed the human-identity gap for Tenant (PR #1139), Prospect (PR #1134), Vendor (PR #1145), and User (Better Auth hook). User is the proof the strict shape works: User.personId: string is non-optional on the type today (types.ts:3894), the Better Auth databaseHooks.user.create.after hook stamps it before the row is persisted, and the User cohort on the Atlas spineOrphans surface is 0. No writer-side pre-pass, no Atlas patrol — the type carries the invariant and the create-hook fulfills it.

The other four entities (Tenant, Prospect, Vendor, plus the downstream Conversation/Tour/WorkOrder) follow a softer shape:

  1. Add optional personId?: string to the entity type.
  2. Build a per-entity L2 adapter (ensurePersonFor<Entity>).
  3. Add a self-heal pre-pass inside save<Entity>.
  4. Register an isOrphan<Entity> classifier on the Atlas spineOrphans surface.
  5. Pin the writer-discipline via a drift-guard test.
  6. Run a one-shot backfill script.

The softer shape works — for the entities that get the closure done. But the spineOrphans dashboard tells the story of what the softer shape fails to prevent:

Date Total orphans Delta Composition
2026-05-20 1,531 (baseline)
2026-05-21 1,444 -87 (cleanup)
2026-05-22 1,519 +75 (regression)
2026-05-23 1,606 +87 (regression) 143 Prospects · 115 Tenants · 46 Vendors · 0 Users · 769 Tours · 29 WorkOrders · 504 Conversations

The largest cohorts — Tour (769), Conversation (504), WorkOrder (29) — are "implicit" in the per-entity status matrix. Their writers don't run the L2 orchestrator; they trust the caller to pass personId from upstream. User is the entity that isn't in the cohort — same pattern (required FK + create- time stamp), zero orphans. The contrast is the load-bearing signal.

The pattern is being enforced via patrol — drift-guard tests, Atlas surfaces, backfill scripts — instead of via construction. Today's compile error is no compile error: personId?: string is optional on every spine-stamped entity except User, so a writer that forgets the stamp ships green. The next entity type (Owner / Landlord / external contact) inherits the same trap by default.

Live write paths that emit unstamped rows today

Before claiming the optionality is "historical only," enumerate the writers that actively produce personId: undefined rows in steady-state production (not migration backfill, not pre-spine residual):

Write path Entity Why it ships unstamped today Frequency
Twilio inbound webhook (unknown caller) Conversation resolveIdentity() returns no Tenant/Prospect/Vendor match → conversation row created with personId: undefined, resolved later if at all every unknown SMS — spam, typos, ex-tenants, prospects pre-save_prospect
SES inbound system messages Conversation, Message system / no-reply senders have no human identity per email batch
PMS-email ingestion (pms-email parser) Tenant, Prospect parser creates entity from email body before identity resolution runs per PMS bounce / forward
Admin-create flows (/admin/users invite pre-accept, vendor CSV import) Vendor, Tenant seed before any inbound contact per ops action
Test seed (scripts/seed-*.ts, sandbox builder) Conversation, Tour, WO fixtures don't run the L2 adapter per seed run
Tour scheduled before Prospect resolved Tour voice-leasing flow can schedule a tour and save_prospect in either order per "voice agent schedules unknown caller"
WO created from unknown-caller conversation WorkOrder inherits personId: undefined from its parent Conversation per "Clara creates WO for caller before identity resolved"
Twilio / ElevenLabs inbound (CallerID suppressed) Conversation body.caller_id = 'anonymous' / NANPA test number +266696687normalizePhoneE164 returns null → no phone to mint on; today the row writes personId: undefined per anonymous inbound call (low single-digit / day but non-zero)
Voice call-ended fallback creation Conversation call-ended/route.ts:488–538 creates a fresh Conversation when personalization webhook failed or session is text-only — separate saveConversation call from the personalization-time create; today emits unstamped if Tenant unresolved per failed-personalization or text-only ElevenLabs session

These are the live emitters. The personId: undefined comments on types.ts:1265-1280, 1881-1895, 2903-2916 are protecting these write paths, not just historical reads. The fix has to wire each writer to mint / resolve a Person before the row is written; the comments aren't an excuse to be deleted, they're an inventory of paths to be retrofitted.

The shape of the failure is structural:


Decision

Treat spine-stamping as a construction invariant, not a writer-time policy. Three coordinated changes; all must ship together to count as the foundation fix (per the ONE SOURCE OF TRUTH rule — no parallel implementations during a migration).

1. Split the type into UnstampedX (input) and X (persisted)

For every identity-bearing entity, change the type model from:

// today — optional, runtime-enforced
export interface Tenant {
  id: string;
  personId?: string;     // optional → silent failure mode
  // ...
}

to:

// proposed — required on persisted shape; optional only on input shape
export interface UnstampedTenant {
  id: string;
  // no personId
  // ...
}

export interface Tenant extends UnstampedTenant {
  personId: string;       // required on every persisted row
  // ...
}

UnstampedX is the input shape callers pass; X is what comes back from the writer. The writer's signature changes from save<Entity>(X): Promise<X> to save<Entity>(UnstampedX): Promise<X> — the stamping happens inside, and the return type guarantees personId is set. Reads return X; the compiler guarantees every consumer can de-reference personId without optional chaining.

Historical rows. ONE SOURCE OF TRUTH: there is no parallel LegacyX shape, no "forensic view", no second name for an unstamped Tenant. The 1,606 existing orphans are drained — not modeled as a separate type — before the type flip ships. The drain is per-entity, runs as part of the entity's PR (scripts/drain-<entity>-orphans.ts), and handles exactly two outcomes per row:

After the drain, the entity's orphan cohort on the Atlas surface is zero. The Atlas surface's classifier (isOrphan<Entity>) becomes a runtime guard over raw DDB items (Record<string, unknown>), not a typed-shape consumer — there is no second Tenant type. New writes can't reproduce orphan rows because the construction invariant enforces stamping at the writer boundary. Together: the drain clears history, the invariant prevents recurrence, and source holds a single <Entity> type with personId: string required.

The drain script writes an audit-log row per action (stamped / archived / failed) so the operational trail survives in the data layer rather than in a parallel type.

2. Single save-gateway HOF per entity (compile-enforced)

Replace the per-writer pre-pass pattern with a higher-order factory that wraps the existing ensurePersonForSignals L2 orchestrator:

// src/lib/domain/identity/with-spine-stamp.ts
import type { PersonStampOpts } from '@/lib/domain/identity/person-stamp';

export function withSpineStamp<U>(
  saveFn: (entity: U & { personId: string }) => Promise<void>,
  stampFn: (entity: U, opts: PersonStampOpts) => Promise<string>,
): (entity: U, opts: PersonStampOpts) => Promise<U & { personId: string }> {
  return async (entity, opts) => {
    const personId = await stampFn(entity, opts);
    const stamped = { ...entity, personId };       // safe — adds required field
    await saveFn(stamped);
    return stamped;
  };
}

Two implementation notes the ADR pins here so PR-A doesn't have to re-resolve them:

Every entity writer is built ONLY through withSpineStamp. The factory takes the unstamped input, runs the L2 orchestrator to mint or reuse a Person, returns the stamped shape, and only then calls the inner saveFn. The inner saveFn accepts only the stamped shape — so a writer that bypasses the factory fails to compile.

Drift guard at the module-export level: src/lib/data/dynamo/<entity>.ts exports ONLY the factory-wrapped writer. The raw _unsafe_save<Entity> is not exported from the module — the non-export IS the enforcement, the _unsafe_ prefix is a reader-facing annotation that the symbol must never be re-exported. A drift test scans every dynamo/<entity>.ts to confirm (a) no export keyword precedes _unsafe_save<Entity> and (b) no other module imports _unsafe_* identifiers. If a test fixture genuinely needs the raw writer, it imports the wrapped save<Entity> and passes a deterministic PersonStampOpts — never bypasses the wrap.

3. Downstream entities carry personId as a required FK

Tour, WorkOrder, Conversation today carry personId?: string "by design" because the caller might not know the person. After this ADR:

4. Required spine primitives (PR-A scope)

The type split + HOF alone make the invariant compile-enforceable on the happy path. Three primitives must exist for the invariant to survive real-world inbound traffic — anonymous CallerID, skeleton-then-resolve promotion, and mid-call identity flip. All three ship in PR-A alongside the HOF; without them PR-B (Conversation) has no sound target to call.

No kill-switch. Per ONE SOURCE OF TRUTH the design has no env-var escape hatch. The stampFn is total — every row gets a real personId on every code path (phone-skeleton, session-claim, named-claim reuse). If stampFn fails (DDB outage), the writer fails. There is no "skip spine, write unstamped row" branch, no _unsafe_save re-entry, no sentinel value. The robustness comes from the primitives being correct, not from a flag that disables them.

4a. Person-merge — mergePersons(sourceId, targetId)

Today's spine has no merge primitive. addClaim dedups on (orgId, type, normalizedValue) and ensurePersonForSignals reuses-by-ID, but neither rewrites the Person row or migrates entities that already point at a soon-to-be-superseded Person. This is the root cause of the auto-review's "promotion is claim-reuse, not Person-row upgrade" finding: when a phone-only skeleton later resolves to a named Tenant, today's flow gives the Tenant personId: <skeleton-id> and the skeleton row stays displayName: 'Unknown Caller' forever. Any Conversation that was already pointed at a different skeleton (the mid-call identify- caller case) never reconciles.

The primitive:

// src/lib/domain/identity/merge-persons.ts
export async function mergePersons(
  sourceId: string,
  targetId: string,
  organizationId: string,
  opts: { reason: 'identify_caller' | 'skeleton_resolve' | 'manual_merge'; verifiedBy: VerifiedBy },
): Promise<MergePersonsResult>;

Migrates every active claim on sourceId to targetId (idempotent via addClaim dedup), then delegates the Person-row mechanics to the existing softRedirectPerson primitive (src/lib/data/dynamo/persons.ts

No entity-FK rewrite. Stale entity rows that still carry personId: sourceId (Tenant / Prospect / Vendor / Conversation / Tour / WorkOrder) resolve to the merged target at read time via getPerson's redirect-chain walk — already in production. No per-entity scan, no write-storm. There is no personId GSI on the entity partitions today (getConversationsByPersonId, getWorkOrdersByPersonId, getToursByPersonId all entity-type scan + client-filter), so an inline rewrite would be O(N) full-scan + O(K) writes per merge — unacceptable on a hot voice-tool path. FK-rewrite is deferred to the prune sweeper in PR-H (or graduates whenever personId GSIs land on the entity partitions).

Idempotent — re-running with the same args is a no-op after the first (once mergedIntoPersonId is set, the conditional write fails cleanly and the function returns the existing target). Race-safe via the condition expression on the source row's mergedIntoPersonId attribute; concurrent merges on the same source resolve to whichever target won.

Trigger boundary. mergePersons is NOT called from the HOF. The HOF calls stampFn on the entity's signals alone — one Person row in scope, no comparison possible. mergePersons fires from the identify_caller voice-agent tool handler, which is the only site that has both conversation.personId (the anonymous session-claim skeleton) and tenant.personId (the resolved identity) in context to compare. This separation keeps the HOF's invariant simple (entity → personId, one-way) and concentrates merge complexity at the one tool-call site that can detect the cross-row case.

4b. Trust-tier write-back on reuse

ensurePersonForSignals's phone-reuse path returns the existing Person without writing. PR-A adds a trust-tier comparator: when the incoming signals' source tier exceeds the stored Person's, the reuse branch writes back the higher-trust displayName, source, and verifiedBy. Last-writer-wins by tier, not by timestamp. Tier order (existing in ADR-0018 §4.2): pms_sync > manual_pm_verified > manual_pm > inbound named-claim > inbound phone-only skeleton.

This is what makes (4a) usable. Without it, every merge requires an explicit mergePersons call from the caller — the writer for skeleton- resolve doesn't know it's a merge until the trust-tier comparison runs. With it, the common case (Tenant save resolves a phone that points at a skeleton-tier Person on the same row) becomes a write-back, no merge needed; the merge primitive is reserved for cross-row cases (skeleton on Conversation, real Person on Tenant, both same phone — needs merge).

Pinned by a trust-tier matrix test: every (incoming source, stored source) pair has a deterministic outcome (write-back / no-op / reject).

4c. Anonymous as a first-class claim type

The auto-review's blocking finding #2 — stampFn returns null when normalizePhoneE164('anonymous') returns null — is real. Every withheld-CallerID voice call hits it. The design answer is NOT to put a null branch on the HOF (that re-opens the unstamped path). It's to make anonymous a legitimate identity:

expiresAt is an application-layer expiry field, NOT a DDB TTL attribute. PR-A adds expiresAt?: string to the Person interface (ISO-8601 string, same shape as Person.lastSeenAt). The prune sweeper in PR-H reads it via an entity-type scan filtered on expiresAt IS SET AND expiresAt < <now> (sufficient because expiresAt is written only by ensurePersonBySessionClaim and is undefined on every other Person). DDB TTL is explicitly NOT used here — DDB TTL would require an epoch-integer ttl attribute + table-level TTL configuration via CloudFormation, and DDB's TTL deletion is best-effort within 48h (not the precise 30-day boundary the spec wants). Application-layer sweep gives us deterministic behavior, audit-logged deletion, and a graceful merge-bypass (a session Person that got merged via mergePersons is tombstoned via mergedIntoPersonId; the sweep skips tombstoned rows). The scan cost is bounded — session-claim Person rows are short-lived (≤30d), and the sweeper runs daily via EventBridge.

The stampFn for the voice agent's personalization and call-ended fallback writers detects the anonymous CallerID upstream and routes through a ensurePersonBySessionClaim(callId) primitive that mints the sentinel — same race-safe shape as ensurePersonByPhoneSkeleton. The HOF sees a non-null string either way. Solves the null-return crash and the CallerID-withheld emitter (auto-review #5) in one shape.

4d. stampFn is total — no null branch, no escape hatch

stampFn: (entity, opts) => Promise<string> always resolves to a real personId. There are exactly three code paths:

  1. Named claim resolves an existing Person — return that ID.
  2. Phone present, no match — mint via ensurePersonByPhoneSkeleton, return the new ID.
  3. No phone / anonymous CallerID — mint via ensurePersonBySessionClaim(callId) (§4c), return the new ID.

If all three fail at the DDB layer (true outage), stampFn throws and the writer fails. The writer's caller decides retry/queue behavior — SQS for inbound webhooks, request-time error for admin paths. The spine invariant is never weakened to keep a writer succeeding; the calling layer absorbs the durability concern that's already there (SQS retry on the inbound webhook, Better Auth retries on user create-hooks, request-error surface on admin tooling).

This is the design substitute for a kill-switch: the primitives are robust enough — race-safe via DDB conditional updates, idempotent via the existing claim-dedup partition — that no env flag is needed to "turn off spine writes during an incident." If DDB is down, writes fail; that's the same failure mode the rest of the system already has, with no special case for spine.

Entity classification

This ADR does not propose new entities. It changes the type signature and writer-gateway discipline for existing canonical entities (Tenant, Prospect, Vendor, User, Tour, WorkOrder, Conversation), and adds the four spine primitives in §4a–4d.


Consequences

Easier

Harder

Follow-up work


Alternatives considered

A. Keep the writer-side pre-pass; add more drift guards.

The current direction. Bandage. Each new entity needs a new drift guard, a new pre-pass, a new backfill. The 1,606 orphan count is the evidence that this scales sub-linearly with entity count.

B. Runtime assertion (assertPersonId(entity)) at the API boundary.

Trades compile-time enforcement for runtime crashes. Better than nothing but still patrol — a route that forgets the assert ships green. Doesn't address downstream-entity inheritance.

C. DDB-side condition expression that rejects writes without personId.

Catches the writer but in the wrong layer — the entity is already shaped, the caller has already done work that has to be unwound. And it doesn't survive a test seed / admin import that bypasses the writer.

D. Make personId required on the existing type without splitting input.

The compiler can't distinguish "row I'm about to persist" from "row I just loaded" with one type — the writer would have to either re-derive a personId on every save call (wasteful) or accept a partial shape (back to the optional-FK problem). Splitting UnstampedX (input shape) from X (persisted shape) gives the writer a precise input contract and the reader a strict contract — same personId: string field, two roles. Per ONE SOURCE OF TRUTH, there is no parallel LegacyX view for orphan triage: the drain script (per entity) reads raw DDB rows as Record<string, unknown> and the orphan classifier is a runtime guard, not a typed parallel shape.

E. Move identity resolution into a global middleware layer.

Considered. Would centralize the logic but at the cost of every writer implicitly depending on async middleware context. The HOF approach achieves the same enforcement with explicit, traceable wiring per writer.


Rollout plan

Ordering rationale. Not cohort size — identity-graph dependency. Conversation is the identity anchor that Tour and WorkOrder branch from (an unknown-caller WO inherits its personId from the Conversation that spawned it, not the other way around). Migrating Tour before Conversation would force Tour writers to mint Persons that Conversation would then have to reconcile against. Conversation goes first, then the derived entities flip in any order.

  1. PR-A — type split + withSpineStamp factory + three spine primitives, Tenant-only. Lands the foundation: UnstampedX/X type pattern, withSpineStamp HOF, single drift guard, the file-private _unsafe_save<Entity> convention, AND the three primitives from §4 (Person-merge, trust-tier write-back, anonymous-session claim). Migrates Tenant as the proof entity. The drain script (scripts/drain-tenant-orphans.ts) clears the existing Tenant orphan cohort to zero before the type flip ships. All existing Tenant consumers update in the same PR. Tenant's per-entity drift guard retires in the same PR. The three primitives ship here (not deferred to PR-B) because PR-B has no sound target to call without them — particularly the merge primitive for mid-call identity resolution and the session-claim path for anonymous CallerID.
  2. PR-B — Conversation. Identity anchor — flipped first so Tour / WO downstream paths can derive from a guaranteed-stamped Conversation. See "PR-B blast radius" below for the pre-scope.
  3. PR-C — Tour. 769 orphans drain. Tour writers derive personId from the Conversation or Prospect they branch from.
  4. PR-D — WorkOrder. 29 orphans drain. Same pattern as Tour.
  5. PR-E — Prospect. 143 orphans drain.
  6. PR-F — Vendor. 46 orphans drain.
  7. PR-G — User. Already required + zero orphans. The PR's job is the type-shape conformance — User's writer must come from withSpineStamp like every other entity, so the drift guard at the end of the rollout has no special cases. No behavior change; pure refactor to fit the uniform shape.
  8. PR-H — close-out. Retire remaining per-entity drift guards, collapse spine-stamp-pattern.md to the new canonical doc, sunset per-cohort drain scripts now that their cohorts are empty, stand up the skeleton-prune sweeper (90-day no-activity, no-derived-entity), and update the "adding a new entity" §3 checklist to the collapsed ~3-step form. Listed here explicitly so it isn't confused with the per-entity PRs.

Each PR completes the migration for its entity — no parallel-implementation windows. The status-dashboard spineOrphans cohort for that entity drops to zero before the next PR starts.

PR-B blast radius — pre-scoped (load-bearing for the rollout)

PR-B is the highest-risk PR in the sequence because Conversation writes touch nearly every inbound surface. Pre-scope before the diff is written:

Write path What changes Test surface Rollback story
Twilio inbound webhook ensurePersonByPhoneSkeleton call pre-saveConversation inbound integration test + Pipeline Lab golden revert PR — webhook re-emits unstamped rows, drift guard surfaces them, no data loss
SES inbound (system + tenant) same pre-mint; system senders mint a claim_type: 'email' skeleton inbound-router unit test (tests/inbound-router.test.ts) same
PMS-email parser parser resolves Person before constructing Tenant/Prospect, then saves Conversation with that personId PMS-email parser unit test same
Voice agent (ElevenLabs → /api/voice/personalization) personalization caller already resolves Tenant; conversation save derives from it voice trace persistence test same
Voice agent — call-ended fallback Conversation create call-ended/route.ts:488–538 creates a Conversation when personalization webhook failed or session was text-only — same stampFn (phone-skeleton or session-claim per §4c) pre-saveConversation call-ended unit test same
Voice agent — CallerID suppressed (anonymous / +266696687) routes through §4c ensurePersonBySessionClaim(callId) instead of phone-skeleton; HOF sees non-null string personalization unit test for anonymous CallerID same
Voice agent — mid-call identify_caller resolves an anonymous skeleton identify_caller handler emits IdentityResolved → triggers §4a mergePersons(skeletonId, tenantPersonId) → Conversation.personId rewritten in place identify_caller integration test asserting Conversation.personId rewrite same
Voice agent — transfer_to_agent mid-call (specialist leg) ElevenLabs mints a fresh conversation_id for the specialist leg; resolveVoiceConversationId falls back to callSid (PSTN) or phone (text-only) and resolves the pre-stamped Conversation. No second saveConversation call; specialist-leg tools read personId from the existing row. Documented to prevent a future PR from minting a second Conversation per leg. resolveVoiceConversationId unit test (existing) + negative assertion that specialist tool-call does NOT trigger saveConversation n/a (no new write)
Admin-create paths (Pipeline Lab outbound, /admin/dev/tools send_sms) admin tooling passes a PersonStampOpts source admin_create; mints via the same primitive tools-eval fixture same
Test seed (sandbox + stage fixtures) seed scripts pass a deterministic personId they own, never undefined seed-fixture unit test seed scripts are idempotent — re-run after revert
Vendor-matched inbound SMS (clean exit) handleVendorMessage does NOT call saveConversation for vendor-resolved phones — no change required; documented to prevent re-introduction n/a (negative test in inbound-router) n/a

Single-PR feasibility. The ONE SOURCE OF TRUTH rule rules out a Phase-A / Phase-B split with saveConversation and a _saveConversationLegacy sitting next to each other. The six write paths above are independently testable but share one writer — that writer flips atomically. Mitigation for blast radius is therefore NOT staged code, it's staged validation: PR-B lands behind a single gh pr merge --squash but ships with an explicit pre-merge checklist (each row above verified on a Vercel preview before merge) and a fast rollback (revert + re-deploy, no DDB cleanup needed because the new path doesn't touch DDB schema, only adds writes).

No kill-switch, no escape hatch. A SPINE_STAMP_INBOUND=off flag was considered and rejected — it's a parallel implementation by another name, and the primitives in §4 (race-safe, idempotent, DDB-conditional) make it unnecessary. If stampFn fails at the DDB layer, the writer fails, the inbound webhook returns a Twilio retry, SQS retries the message. That's the same durability model the rest of the inbound funnel already has; spine isn't a special case.

No "TODO + follow-up card" fallback either. If pre-merge validation surfaces a problem on a write path, the PR doesn't ship until the path is fixed. The drift guard covers every write path in scope; narrowing it to ship around a broken path violates ONE SOURCE OF TRUTH. The PR-B scope is the six write paths above — all of them, atomically.


Addendum — 2026-06-15: User signup is stamped on every branch (no waitlist exception)

The earlier design left two User-signup branches intentionally unstamped: restricted (Google sign-in matched an existing tenant/vendor) and waitlisted (uninvited). Both produced steady-state User.personId: undefined rows that the spine-orphans status card correctly flagged. Jose's directive (2026-06-15): the spine must never carry an unstamped User — no status-based exceptions, including the waitlist flow.

The signup hook (src/lib/platform/auth/server.tsdatabaseHooks.user.create.after) now stamps all three branches:

ERRATA (2026-09-11). The route this section describes, POST /api/admin/waitlist/[email]/approve, was DELETED. Approving a waitlist signup is now an explicit staff action on Admin → Customers → Waitlist: it creates the customer through POST /api/admin/companies and invites the person into it as a separate press. Nothing auto-provisions an org or re-homes a Person on approval any more. The two paragraphs below record what that route did while it existed; the spine invariants they rest on (savePerson's conditional-put guard, same-org-only merges) are unchanged and still binding on every path that mints or moves a Person.

Approval re-homes, it does not duplicate. Because mergePersons / softRedirectPerson are SAME-ORG only, and savePerson's conditional-put guard refuses to flip an existing row's organizationId, the prospects-org Person cannot be folded or moved by org-attribute rewrite. Instead, waitlist approval (/api/admin/waitlist/[email]/approve) re-homes the same Person (rehomePersonToOrg) by migrating its active identity — claims + roles — into the freshly-provisioned operator org, deprecating the prospects-org claims and ending the prospects-org role. The Person keeps its id and its origin-org ATTRIBUTE (harmless, exactly like platform_admin Persons that live in the staff org but operate cross-org via Phase 6a reachability). User.personId never changes; no second Person is minted.

Operator-org provisioning. Approval is also where a waitlisted human becomes a real operator, so it mints the operator's org id (a dangling org_… string — no ORG#/META row, same convention as the staff + prospects sentinels) and stamps the pm PersonRole scoped to it. Re-approval / partial-failure recovery reuses an existing non-prospects operator role rather than minting a duplicate.

Guards: signup-no-orphan.drift.test.ts pins that the hook never re-introduces a status-gated early-return that skips the link; link-user-to-spine.test.ts covers the prospects mint, the re-home (claim/role migration, idempotency, and cross-org-collision refusal).

Known narrow window (fail-closed, not a leak). Between approval (which re-homes the Person into the operator org) and the user's next sign-in, a session minted before approval still resolves the old prospects-org home for its lifetime — so a just-approved user who is already signed in may see an empty dashboard until they re-authenticate. This is fail-closed (no cross-org data leak — they see less, never another org's data) and self-heals on the next sign-in. Documented here rather than fixed because forcing a session refresh on approval is out of scope for the no-orphan invariant.