ADR-0032 — Spine-stamp as a construction invariant (foundation, not patrol)
- Status: Accepted — implemented + merged 2026-05-26 (PR-A #1366 → PR-H #1440; all 7 spine entities under the construction invariant).
- Date: 2026-05-23
- Deciders: Gera — author; sane / feed / gear personas
- Supersedes: none
- Extends: ADR-0018 (Person identity model), ADR-0020 (Person as universal human spine), ADR-0027 (Three-layer architecture)
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:
- Add optional
personId?: stringto the entity type. - Build a per-entity L2 adapter (
ensurePersonFor<Entity>). - Add a self-heal pre-pass inside
save<Entity>. - Register an
isOrphan<Entity>classifier on the Atlas spineOrphans surface. - Pin the writer-discipline via a drift-guard test.
- 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 +266696687 → normalizePhoneE164 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:
- Optionality is the silent invariant. "Required at write time but optional on the type" is a runtime contract. Runtime contracts decay unless every writer is reviewed against them — which doesn't scale.
- Writer-side opt-in. The pre-pass is a manual line of code per writer. New writers, new entity types, new save paths all have to remember to call it. The 7 drift-guard files prove the pattern is being enforced post-hoc, not prevented up-front.
- Downstream entities trust upstream. Tour, WorkOrder, and Conversation
inherit
personIdfrom a caller. When the caller's context is unknown, the row getsundefinedand the entity is born orphaned. There's no enforcement point that says "you can't persist this without a stamp." - "Historical / unknown" excuse keeps the field permanently optional. The
inline comments cite a legitimate need to model
personId: undefinedfor pre-spine rows. That excuse blocks the type-level fix — but it conflates historical migration shape (already-persisted rows whose stamp couldn't be resolved) with new-row shape (rows being created NOW, which always have a resolvable identity path).
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:
- Stampable (entity has phone or email AND a resolvable orgId on
the parent property) →
ensurePersonForEntitymints/reuses, the drain writes the row back stamped via the same construction-invariant writer that PR-A introduces. Idempotent; re-runnable. - Unstampable (no anchor signals, or no orgId resolvable) →
archivedAt = nowso the row falls out of the canonical read path. No row is deleted — append-only audit; archive is reversible.
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:
PersonStampOptsis the existing options type insrc/lib/domain/identity/person-stamp.ts:106. The HOF uses it verbatim; no new options surface. The factory is a thin wrapper that ties the existing L2 orchestrator output to the writer signature — it's not a new layer.- The spread
{ ...entity, personId }adds a required property to an object of typeU, so the result typeU & { personId: string }is sound without anascast. TypeScript's spread-and-add inference accepts this since 4.5. If lint flags it on the team's version, an explicitsatisfiesannotation does the same job without a runtime cast.
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:
- Conversation is the natural identity anchor for any thread. Its
personIdbecomes required at write time. The "unknown caller" case resolves viaensurePersonByPhoneSkeleton(the existing race-safe primitive) before the Conversation row is written. No more silent skipping. This is an explicit reclassification fromspine-stamp-pattern.md's current "derived / writer trusts caller" treatment — Conversation moves to "primary identity-resolution point for unknown-caller inbound." The decision tree in that doc (§"When to apply") and thetypes.ts:1919JSDoc ("Stays optional permanently") both update in PR-B, in the same diff as the type change. The reclassification does NOT defer to PR-H. - Tour, WorkOrder carry required
personIdderived from the Conversation or Prospect/Tenant they branch from. The writer signature enforces it. - Message carries no
personIddirectly; it inherits viaconversationId → Conversation.personId. This is fine — Message is a child of Conversation, not a peer entity.
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
IPersonRepository): conditional write ofmergedIntoPersonId: targetIddeprecatedAt: <now>, race-safe via the existing condition expression, preferences merged into the target row.
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:
- New claim type:
claim_type: 'session', valueanonymous-<callId>(oranonymous-<sessionId>for text-only ElevenLabs sessions). - No new
PersonSourcevalue — anonymous-session Persons usesource: 'inbound'on the Person row. The anonymity is expressed exclusively viaverifiedBy: 'anonymous_inbound'on the Claim (Tier-2, noverifiedAt). - Per-call sentinel Person row:
displayName: 'Anonymous Caller',source: 'inbound',expiresAt: now + 30d. - Prune sweeper (PR-H scope) retires session-claim Persons after expiry, unless a merge into a real Person happened first.
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:
- Named claim resolves an existing Person — return that ID.
- Phone present, no match — mint via
ensurePersonByPhoneSkeleton, return the new ID. - 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
- No new entity can fail open. When the next Owner / Landlord / external-
contact type is added, the type-system + factory force the spine-stamp
before the writer compiles. The 8-step checklist in
docs/architecture/spine-stamp-pattern.mdcollapses to: declare the L2 adapter, wire it throughwithSpineStamp, done. - Drift-guard surface area shrinks. The 7+ writer-discipline tests collapse
into one — does every entity writer's exported
save*symbol come fromwithSpineStamp? Yes/no, single regex. - Reads stop optional-chaining. Every
entity.personIdsite (~hundreds across the codebase) drops the?.and the post-load null guard. The compiler proves the field is set. - Atlas spineOrphans becomes a fixed-pop cleanup queue, not an inflow surface. New rows can't be born orphaned; the queue drains and stays drained.
Harder
- Migration is invasive. Every consumer of
Tenant,Prospect,Vendor,User,Tour,WorkOrder,Conversationtypes touches the type change. TypeScript catches all of them, but the diff is large. Concrete scale for the first cohort (Tenant): 99 files importTenant; ~933.personIdaccess sites across the codebase, the bulk of which are mechanical?.removals (tenant.personIdbecomes non-null at the type level).tsc --noEmitis the correctness gate. Mitigation: ship per-entity (Tenant first as the canonical proof, then Conversation as the highest- cohort win, then Tour, then the rest), one PR per entity, each passing the drift-test gate before the next starts. Per ONE SOURCE OF TRUTH: each PR completes the migration for that entity — no "Phase A new lives next to old" intermediate. - Type-union ripple beyond the entity types. PR-A widens two
exported unions to support §4c's session-claim primitive:
IdentityClaimTypeadds'session',IdentityClaimVerifiedByadds'anonymous_inbound'(slotted Tier-2 intrustTier).PersonSourceis not widened — anonymous-session Persons usesource: 'inbound'on the Person row; the anonymity distinction lives exclusively on the Claim'sverifiedBy: 'anonymous_inbound'. A separatePersonSourceenum value would require a newpersonSourceTiercase and serve no purpose. Every exhaustive switch on the two widened unions (claim selection, trust-tier comparator, claim-source filters in Atlas) gets a new case. TypeScript catches all sites at build time; no runtime drift surface. - Unknown-caller path has to mint earlier — and at scale. Today an
inbound SMS from an unknown number creates a Conversation with
personId: undefinedand resolves later. After this ADR, the inbound webhook callsensurePersonByPhoneSkeletonbefore writing the Conversation. The primitive is already race-safe; the wiring is the work. Scale cost — must be acknowledged, not hand-waved: every spam SMS, typo, robocall hit, and short-code probe mints a Person row. At the inbound volumes we see today (~hundreds/day across the fleet) this is cheap (one DDB write, idempotent) and the Person row is small, but it drives Person-row count growth that's structurally proportional to inbound noise, not to real-tenant population. Two mitigations are part of PR-B's scope:- Skeleton-promotion contract. When a later real Tenant /
Prospect / Vendor save resolves to a phone-claim that already
points at a skeleton Person,
ensurePersonForSignalsalready reuses-and-upgrades that Person rather than minting a second one (the existing race-loss winner-follow path). PR-B pins a test case for the SMS-skeleton → Prospect-save promotion specifically; the path exists but isn't covered today. - Skeleton-prune job. A nightly sweeper that retires Person rows whose only Claim is a phone with no inbound activity in 90 days and no derived entity (Tenant/Prospect/Vendor) ever attached. Out of scope for PR-B itself; tracked as a follow-up in PR-H. Without these two, the Atlas spineOrphans surface drains but Person- row count grows monotonically with spam volume. The ADR's success criterion isn't just "zero orphans" — it's "zero orphans AND Person population tracks real-human population."
- Skeleton-promotion contract. When a later real Tenant /
Prospect / Vendor save resolves to a phone-claim that already
points at a skeleton Person,
- Per-entity drain scripts (
scripts/drain-<entity>-orphans.ts) read raw DDB rows asRecord<string, unknown>and useensurePersonFor<Entity>to stamp or archive. They run as part of each entity's PR — drain clears history, then the type flips. The orphan cohort hits zero before the type flip ships; the construction invariant keeps it there.
Follow-up work
- One ADR-implementation PR per entity (Tenant → Conversation → Tour →
WorkOrder → Prospect → Vendor → User). Order is by identity-graph
dependency — Conversation (the identity anchor for unknown inbound)
lands before Tour / WorkOrder so the derived entities have a
guaranteed-stamped parent to read
personIdfrom. See Rollout Plan for the full rationale. - Retire the 7 drift-guard tests as the factory becomes the enforcement
point. Replace with one
every-spine-entity-writer-uses-factory.drift.test.ts. - Update
docs/architecture/spine-stamp-pattern.mdto document the factory + type-split as the canonical path. The 8-step "adding a new entity" checklist collapses to ~3 steps. - Sunset the per-cohort backfill scripts after each cohort drains to zero. The Atlas spineOrphans surface stays as a forensic tool, not an ongoing inflow queue.
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.
- PR-A — type split +
withSpineStampfactory + three spine primitives, Tenant-only. Lands the foundation:UnstampedX/Xtype pattern,withSpineStampHOF, 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. - 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.
- PR-C — Tour. 769 orphans drain. Tour writers derive
personIdfrom the Conversation or Prospect they branch from. - PR-D — WorkOrder. 29 orphans drain. Same pattern as Tour.
- PR-E — Prospect. 143 orphans drain.
- PR-F — Vendor. 46 orphans drain.
- PR-G — User. Already required + zero orphans. The PR's job is the
type-shape conformance — User's writer must come from
withSpineStamplike 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. - PR-H — close-out. Retire remaining per-entity drift guards, collapse
spine-stamp-pattern.mdto 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.ts →
databaseHooks.user.create.after) now stamps all three branches:
- active (admin/invited) — minted under the real org (or
PROPFLOW_STAFF_ORG_IDforplatform_admin), as before. - restricted — stamped inline with the
classification.personIdalready resolved byclassifySignup(the existing tenant/vendor Person; nothing to mint). - waitlisted — minted under the new
PROPFLOW_PROSPECTS_ORG_IDsentinel. Persons are org-scoped and a waitlisted user has no customer org yet, so the holding org is where the pre-approval Person lives.
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 throughPOST /api/admin/companiesand 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.