Version. 0.2
Date. 2026-06-17
Status. Change request — the contract phase of the Stele extraction, the physical teardown 6a/6b deliberately deferred. Drafted against engine main 129d124 (6b merged, --no-ff); suite baseline 2933 passed / 46 skipped / 0 failed; alembic head 0086. This phase has migrations — the safety model differs from 6b. Markdown primary (technical consumer: Claude Code). Per-step commits; suite green at every commit; verify against playground_dev, not the suite alone; halt before push; explicit-path staging.
v0.2 amendment (this revision). Step 0 pre-flight confirmed all v0.1 coordinates correct, but C1.1 inspect-first surfaced an inventory-completeness gap (not a coordinate drift): the v0.1 reader inventory used a write-filter (grep -vE '\.col *=') that also matched ==, silently dropping every host read written as an equality comparison. account_status is read almost exclusively that way (the == "deleted" / == "suspended" lifecycle gates). A comprehensive unfiltered, multi-line-aware re-audit of all 16 columns across all 3 access classes surfaced exactly 5 missing ORM reader sites (all account_status gate-reads) and confirmed nothing else of that category survives. ORM readers 23 → 28; total 45 → 50; files ~22 → ~23 (add me_spend.py). Writers (15) and raw (7 + 4) unchanged — the bug touched reads only (writes are =, not ==). The root cause is recorded in §3. Two slicing decisions resolved at the same halt are folded in: get_principal_by_email is retired, not re-homed (§5.1); the lifecycle.py / grants.py:634 reads defer to C1.3 with their locked writes (§5.3). The notifications/evaluator raw-reader coordinate is :264 (corrected from v0.1's :265). This CR is now grounded against the verified-complete inventory, not patched to the known gap.
Scope of this CR (host-side teardown only). Re-home all 50 host-column accessor sites to host_account; add explicit host_account creation to mint/onboard; retire the host_account mirror trigger; strip the 16 host columns from the PersonRow model; drop the 16 host columns from persons with their cascading constraints; add the one new host_account.referred_by → principals.id FK.
Explicitly OUT of this CR (named follow-ons, boundaries fixed):
conversation_turns.person_id, memberships.person_id, etc., currently → persons.id) → CR-B. They reference persons.id (the principal id), not any host column, so they neither block nor are blocked by this teardown. CR-B carries the unresolved identity-target decision (principals.id vs host_account.id) — that decision must not be made implicitly inside a column-drop migration.PersonRow rename/relocation (257 occurrences / 65 files + the 3 _PersonRow stubs + the HostAccountRow file-inversion) → CR-C. A mechanical token-level sweep with no schema risk; bundling a 257-site rename into the most dangerous migration in the extraction is rejected.principals mirror trigger (stele_persons_mirror_to_principals) — reads only principal columns, unaffected by host-column drops; persons remains the principal write-source until CR-C. Stays live.
Decisions this CR rests on (settled, carried from 6a/6b): (1) host_account.id = principals.id = persons.id, 1:1 shared id (6a). (2) host_account is already the read-source post-6b; get_host_account_by_id reads from it (chosen in 3.0 for exactly this durability). (3) The cutover is staged C1 → M1 → C2 → M2 — a migration cannot re-home application writers, so the code half and the schema half cannot be one atomic step (full derivation in §3). (4) email/mobile are host, not identity (the seed) — this teardown completes that physical separation.
Source grounding. The contract-phase opening handoff v0.2; the 6a completion record v0.1 (§1 column homes, §5 the trigger pair); CR-2026-110 v0.7 (the 6b close-out and contract handoff); and four Step-0 inspections at 129d124 recorded in §3–§5 below — the live persons schema, the 12-FK inventory, both trigger bodies, the transaction-shape analysis, the writer/reader classification, and the complete 50-site accessor inventory. This CR is drafted against that pull, not from memory — the grounding discipline.
6a split the one persons table into two physical tables (principals for identity, host_account for account state) and kept them in sync with mirror triggers. 6b moved every view-layer reader off the fused Person view. This phase — the contract phase — physically tears the transitional structure down: it stops persons from carrying host data at all.
The hard part is not the column drop. It is that the host mirror trigger is unconditional and fires on every persons write — so while it lives, any write to persons re-mirrors all 16 host columns into host_account, including stale defaults. Since 6b made host_account the read-source, that means an ordinary login (which writes first_login_at on persons) would silently revert a person's account_status or spend_pause to a stale value. The trigger has to die before the columns can drop — but you cannot drop the trigger until every host write goes directly to host_account instead, and re-homing those writes is application code, not migration DDL.
That circularity is why this is staged in four steps, not one: C1 re-homes all 50 accessor sites to host_account (keeping the persons writes too, as harmless idempotent dual-writes the live trigger mirrors equal values for) and adds explicit host_account creation to signup; M1 drops the host trigger alone; C2 removes the now-redundant persons dual-writes and strips the 16 host columns from the model; M2 drops the 16 columns from the table. Each step lands on a green suite; each migration has a tested down-path.
The dangerous, silent class throughout is raw SQL — 7 production reads and several test sites query persons host columns by hand, bypassing the ORM. The model strip cannot see them; they stay green through C2 and only fail at M2 against live traffic. The grep lists in §4 are the authoritative checklist; there is no type-checker backstop.
C1 (code): re-home all 50 host-column accessors to host_account
(writes ALSO keep writing persons — idempotent dual-write,
the live trigger mirrors equal values, harmless);
add explicit host_account creation to mint/onboard (upsert).
Deploy, suite green, verify on playground_dev WITH the trigger live.
M1 (migration): DROP the host_account mirror trigger + its function ONLY.
host_account now fed solely by the direct writes.
No clobber possible — there is no trigger.
Tiny downgrade (recreate trigger from 0086).
C2 (code): remove the persons-side dual-write; strip the 16 host columns
from the PersonRow model; migrate the test surface (§4).
persons host columns now dead: unmapped, unwritten, unread.
Suite green. The 7 raw-SQL readers are the must-grep checklist
(the suite will not surface them).
M2 (migration): DROP the 16 host columns (3 CHECKs + 2 outward FKs cascade);
ADD the new host_account.referred_by → principals.id FK.
Heavy but mechanical downgrade (re-add + re-backfill 16).
The one thing that cannot collapse: M1 must separate C1 from C2. Dual-write (C1) needs the columns mapped; the model strip (C2) removes them; the trigger (dropped at M1) cannot outlive the strip and cannot predate the dual-write. The trigger drop is wedged between the two code deploys. This placement is the result of four superseded approaches (§3).
C1 is sliced internally (per-step commits, suite green, halt after each — 50 sites is too large for one commit):
lifecycle_evaluator, conversion_observer, routing, me_credits, me_spend, api/login, persons/login. Safe to move first: host_account is already correct (6b + the live trigger), so a re-homed read just reads the right table. Excludes the lifecycle.py / grants.py:634 reads (they pair with locked writes → C1.3) and get_principal_by_email (retired, not re-homed → handled with the writers/retirement slice). Halt after C1.1 for review — confirm the reader pattern lands green before any writer or lock move.FROM persons reads remain in the re-homed files.persons write stays, the host_account write is added alongside). Carries the §5.3 lock move (the lifecycle.py/grants.py read+write+lock units move here as coupled wholes), the §5.2 query-shape reads, and the get_principal_by_email retirement.host_account creation in mint/onboard (upsert, §4.5).
The §4B "defers to C1.3" notes on the lifecycle.py / grants.py:634 reads are this slicing in effect: their reads do not move in C1.1 because tearing a read-modify-write apart from its lock (§5.3) is unsafe; they move as whole units in C1.3.
6b was application-layer: alembic stayed at 0086 throughout, and its safety net was "the suite breaks loudly, revert pure Python." This phase is the opposite — every schema step is a migration against live data. The safety model is:
downgrade(). The repo bar is real down-paths (0085/0086 both carry them); this phase matches it. M1's downgrade recreates the trigger; M2's re-adds and re-backfills the 16 columns.downgrade() docstring.playground_dev, not the suite alone — especially M1 (trigger retirement) and M2 (column drop against real rows), where a schema change can pass unit tests but fail against real data shape.The C1 → M1 → C2 → M2 sequence was earned across four superseded approaches. Each died on a real DB or code fact surfaced at inspection. Preserved so the negative paths are reconstructable:
WHEN, no column list — §C8/C10). While it lives, it actively re-mirrors stale persons host columns over host_account on any persons write — including unrelated principal writes (a login first_login_at UPDATE). The gap between "writers re-homed" and "trigger retired" is a live-clobber window, not a safe interval.AFTER … FOR EACH ROW trigger fires synchronously at statement-execution time, intra-transaction, the instant the persons row is written. Sharing a transaction does not avoid the clobber; within a transaction statement-order just decides which write wins. The only thing that closes the window is the trigger not existing when the persons statement runs.upgrade() and a code deploy land via different mechanisms and cannot be made atomic. The race does not vanish — it relocates to the migration↔deploy boundary, and there it is worse: PersonRow maps all 23 columns, so migration-first (columns dropped, old code live) makes every persons access emit SQL naming dropped columns — a full-table outage, reads included, until new code is live. Code-first leaves the trigger live to clobber the new direct writes.host_account, and a pure-principal UPDATE reverts live host_account data). Dual-write needs the columns mapped; the strip removes them. They cannot share a stage. The trigger drop must sit between dual-write-intact and model-strip — which forces the four-step C1 → M1 → C2 → M2.
The structural reason it cannot collapse: to stop the clobber you must remove the trigger; you cannot remove the trigger until writes go to host_account; but re-homing writes is a code deploy that, while the trigger still exists, gets clobbered. Circular — which is why expand→contract is inherently staged.
grep -vE '\.col =') intended to separate reads from writes. The filter also matched ==, so every host read written as an equality comparison was silently dropped from §4B. account_status is read almost exclusively as a gate (if person.account_status == "deleted" / "suspended"), so the gap concentrated entirely on that column: 5 sites missed (routing.py:148/150, lifecycle.py:225, me_spend.py:75, me_credits.py:89), including one whole file (me_spend.py). The pre-flight confirmed the listed coordinates were correct but did not test completeness* — a coordinate-confirm checks what is listed, not what is absent; the gap surfaced only when C1.1 inspect-first went to re-home routing.py and saw reads the inventory did not name. The correction was a comprehensive unfiltered, multi-line-aware re-audit of all 16 columns across all 3 access classes (not a patch to the one hole), which reconciled writers (15) and raw (7+4) as complete and bounded the reader gap to exactly these 5. **Lesson for future extraction inventories: a read/write grep filter that keys on = will swallow == and != comparison reads — audit reads by enumerating access forms (comparison, is None, ternary, arg, return), not by negating the write form.**
Replacement pattern (throughout): reads → await get_host_account_by_id(id=<pid>, db=db) then .col (or select(HostAccountRow.col).where(HostAccountRow.id==pid) / raw SELECT col FROM host_account WHERE id=:pid); writes → update(HostAccountRow).where(HostAccountRow.id==pid).values(col=…) or construct/upsert HostAccountRow. Per-site deviations are called out in §5. HostAccountRow is a complete target — it maps all 16 host columns (the drop list). persons/presence.py:68-70 writes last_presence_proof_at — that is principal, stays on persons, excluded.
persons write in C1 as dual-write; the persons write is removed in C2)
| file:line | column(s) | ORM/raw | host_account replacement |
|---|---|---|---|
| stele/registry.py:82 (create_principal) | email, mobile | ORM PersonRow(...) | explicit HostAccountRow create (§2 logic) |
| stele/registry.py:134 (mint_principal) | email, mobile, onboarding_complete | ORM PersonRow(...) | explicit HostAccountRow create/upsert (§4.5) |
| persons/migration.py:146 | full row | ORM | one-off backfill script — re-point or retire (also carries the dead Principal/_row_to_principal imports — remove here) |
| credit/grants.py:635 | referred_by | ORM attr | update(HostAccountRow)…values(referred_by=…) |
| credit/lifecycle.py:131-133 (suspend) | account_status, expires_at, previous_status_change_at | ORM attr | update host_account — moves the FOR UPDATE lock target, §5.3 |
| credit/lifecycle.py:171-173 (reactivate) | account_status, expires_at, previous_status_change_at | ORM attr | update host_account — lock, §5.3 |
| credit/lifecycle.py:240-248 (delete/anon) | account_status, expires_at, previous_status_change_at, email, mobile (host) + display_name (principal, stays) | ORM attr | update host_account for the 5 host cols; keep display_name on persons |
| persons/onboard.py:114 | onboarding_complete | ORM attr | update host_account (§4.5) |
| api/routers/memberships.py:421-423 | companion_name | ORM update(PersonRow).values | update(HostAccountRow) — live PATCH /me/settings, §5.4 |
| api/routers/memberships.py:467-469 | exhaustion_preference | ORM update(PersonRow).values | update(HostAccountRow) — PATCH /me/settings, §5.4 |
| api/routers/memberships.py:513-515 | spend_pause | ORM update(PersonRow).values | update(HostAccountRow) — PATCH /me/settings, §5.4 |
| credit/near_exhaustion.py:223 | near_exhaustion_20/10/5pct_seen_at (→NULL) | raw SQL | UPDATE host_account SET … |
| orchestration/routers/converse.py:1100 | previous_responder_model | raw SQL | UPDATE host_account … |
| persons/personal_engagement.py:465 | personal_engagement_id | raw SQL | UPDATE host_account … (§5.5 carries the FK-isolation artifact; note the existing multi-step commit-ordering comment at this site — read it before re-homing) |
| api/routers/seed_conversation.py:755 | previous_responder_model | raw SQL | UPDATE host_account … |
| file:line | column(s) | form | replacement |
|---|---|---|---|
| credit/lifecycle_evaluator.py:208-211 | account_status, expires_at ×3 | select(...).where(PersonRow.col…) | query-shape change → HostAccountRow, §5.2 |
| credit/lifecycle_evaluator.py:219,227,232 | email ×3 | attr read | host_account.email |
| credit/lifecycle_evaluator.py:237-238 | expires_at ×2 | attr read | host_account.expires_at |
| credit/lifecycle_evaluator.py:268-270 | account_status, expires_at ×2 | where(...) | query-shape change, §5.2 |
| credit/conversion_observer.py:134,148,161,169 | referred_by ×4 | attr read | host_account.referred_by |
| credit/routing.py:148 ★ | account_status | == "suspended" gate | host_account.account_status |
| credit/routing.py:149 | expires_at | attr read | host_account.expires_at |
| credit/routing.py:150 ★ | account_status | == "deleted" gate | host_account.account_status |
| credit/grants.py:634 | referred_by | attr read | host_account.referred_by — read+write under lock, defers to C1.3 (§5.3) |
| credit/lifecycle.py:124,126,160,162,165 | account_status ×2, expires_at | attr read (pre-write) | read host_account — read+write+lock unit, defers to C1.3 (§5.3) |
| credit/lifecycle.py:225 ★ | account_status | == "deleted" gate | read host_account — joins the lifecycle read+write+lock unit → C1.3 (§5.3) |
| credit/lifecycle.py:228 | email | attr read (pre-anonymize) | read host_account — C1.3 with the lifecycle unit |
| api/routers/me_spend.py:75 ★ | account_status | == "deleted" gate | read host_account — whole file added in v0.2 |
| api/routers/me_credits.py:89 ★ | account_status | == "deleted" gate | read host_account |
| api/routers/me_credits.py:147-149 | account_status, license_tier, expires_at | attr read | read host_account |
| api/routers/login.py:78 | account_status | select(PersonRow.account_status) | select(HostAccountRow.account_status) |
| api/routers/login.py:292,377 | expires_at ×2 | attr read | read host_account |
| persons/login.py:418 | onboarding_complete | attr read | read host_account |
| stele/registry.py:200 (get_principal_by_email) | email | where(PersonRow.email==…) | RETIRE, not re-home — §5.1 (production-dead; cannot cross the stele/→persons/ boundary) |
| file:line | column(s) | replacement |
|---|---|---|
| delegation/authorization.py:215-216 | personal_engagement_id | SELECT … FROM host_account |
| orchestration/routers/converse.py:706 | previous_responder_model | … FROM host_account |
| orchestration/exhaustion_choice.py:91 | exhaustion_preference | … FROM host_account |
| api/routers/seed_conversation.py:566 | previous_responder_model | … FROM host_account |
| persons/memberships.py:258 | personal_engagement_id | … FROM host_account |
| notifications/listener.py:122 | personal_engagement_id | … FROM host_account |
| notifications/evaluator.py:264 | personal_engagement_id (+ display_name principal) | split: host col → host_account, display_name → persons; §5.7 |
Totals: 15 writers · 28 ORM readers · 7 raw-SQL readers = 50 accessor sites across ~23 files.
Today onboard.py/signup.py contain zero host_account references — the host trigger materializes the row from the persons INSERT. After M1 the creation/update must be explicit, split across two points:
stele/registry.py:134 (mint_principal) writes email, mobile, onboarding_complete=False to the persons row → must additionally create the host_account row (email, mobile, and the column defaults for the other 14). Committed at signup.py:284 (MINT COMMIT).persons/onboard.py:114 sets onboarding_complete=True and, via create_personal_engagement → personal_engagement.py:465, sets personal_engagement_id → must update the host_account row for those two. Committed at signup.py (ONBOARD COMMIT).
id-ordering under the DEFERRABLE FK (host_account.id → principals.id): the persons INSERT in mint fires the principals trigger (creates principals); the explicit host_account create references principals.id. Both land in the mint transaction; the DEFERRABLE INITIALLY DEFERRED FK resolves at MINT COMMIT regardless of statement order. After M1, principals is still trigger-created (host-only slice); only host_account becomes explicit.
Upsert-during-C1 requirement: during C1 the host trigger is still live and races to create the same host_account row from the persons INSERT. The explicit creation must be INSERT … ON CONFLICT (id) DO UPDATE with equal values (idempotent), or it dies on duplicate-key. After M1 it can revert to a plain INSERT (C2 cleanup, optional).
get_principal_by_email is retired, not re-homed (registry.py:200 queries persons.email). The §5.1 caller-check (done at the C1.1 halt) found it production-dead — zero non-test callers; only test_person_creation.py exercises it. And it cannot re-home: reading host_account from inside Stele would be a stele/→persons/ import, the exact boundary the extraction protects. So it is a retirement, not a re-home: delete the function and its test. (This is a cleaner resolution than v0.1 anticipated — v0.1 floated "re-home or relocate host-side"; the caller-check found the function is dead and the answer is deletion.)credit/lifecycle_evaluator.py:208-211/268-270 builds bulk filters — select(...).where(PersonRow.account_status==…, PersonRow.expires_at…). Re-homing means querying HostAccountRow (or joining), not swapping an attribute. Same for notifications/evaluator.py:264 and notifications/listener.py:122. More work per site, and a join where there was none.credit/lifecycle.py suspend/reactivate/delete reads account_status then writes it under a SELECT … FOR UPDATE row lock on the persons row (docstring: "SELECT … FOR UPDATE on the person row") — a concurrency guard against racing status changes. Re-homing the host read+write to host_account moves the lock to host_account. If the data moves and the lock does not, they diverge and the guard silently stops guarding. This is the one place C1 touches concurrency semantics — the lock-and-data move is a single coupled change, not two.memberships.py settings writers are the live PATCH /me/settings paths (companion_name, exhaustion_preference, spend_pause). They were outside the original "~15 writers" framing — now in the list. The most user-visible host writes; do not let them slip.personal_engagement_id carries the FK-isolation artifact. Re-homing its writer (personal_engagement.py:465) and its raw readers, plus dropping the column's outward FK at M2, is where the 2-file isolated-run FK artifact surfaces (passes once any engagement-loading module is in the run; full suite green). Pre-existing; expect it during C2 test work, resolve it there.FROM persons / UPDATE persons and confirm zero host-column references remain.notifications/evaluator.py:264 mixes host + principal in one query (SELECT id, personal_engagement_id, display_name FROM persons). Re-home splits the columns: personal_engagement_id → host_account, display_name → persons (principal, stays) — or a join. The other mixed-table raw read is the same shape.129d124stele_persons_mirror_to_host_account → fn stele_mirror_persons_to_host_account (AFTER INSERT OR UPDATE, unconditional). Pattern (repo convention): op.execute("DROP TRIGGER IF EXISTS stele_persons_mirror_to_host_account ON persons") + op.execute("DROP FUNCTION IF EXISTS stele_mirror_persons_to_host_account()").stele_persons_mirror_to_principals (reads only principal columns — stays live).CREATE OR REPLACE FUNCTION … CREATE TRIGGER … blocks verbatim from migration 0086's upgrade(). Tiny, exact.host_account stays fed if the trigger returns). State in the downgrade() docstring.Columns (live types/defaults — drop all 16):
email text NULL · mobile text NULL · companion_name text NOT NULL DEFAULT 'Companion'
personal_engagement_id uuid NULL · referred_by uuid NULL
license_tier text NOT NULL DEFAULT 'trial' · account_status text NOT NULL DEFAULT 'active'
expires_at timestamptz NULL · previous_status_change_at timestamptz NULL
exhaustion_preference text NULL · near_exhaustion_20pct_seen_at timestamptz NULL
near_exhaustion_10pct_seen_at timestamptz NULL · near_exhaustion_5pct_seen_at timestamptz NULL
previous_responder_model text NULL · spend_pause text NOT NULL DEFAULT 'on'
onboarding_complete boolean NOT NULL DEFAULT false
Constraints — all 5 cascade automatically on DROP COLUMN (each depends on a single host column; no explicit drop needed, but name them in the migration comment for the downgrade):
ck_persons_account_status → CHECK (account_status IN ('active','exhausted','suspended','deleted'))ck_persons_exhaustion_preference → CHECK (exhaustion_preference IS NULL OR exhaustion_preference IN ('add_key','suspend','delete'))ck_persons_spend_pause → CHECK (spend_pause IN ('on','off'))fk_persons_personal_engagement_id → FK (personal_engagement_id) REFERENCES engagements(id)fk_persons_referred_by → FK (referred_by) REFERENCES persons(id) (the self-ref, #9 of the 12)
Additive DDL in M2 — exactly one: the new host_account.referred_by → principals.id FK. Pre-validation passed: host_account = 41 rows, 0 non-null referred_by, 0 orphans → the FK validates trivially today (all referral data currently NULL in dev). The corresponding writer (credit/grants.py:635) and the host-side referred_by column are already in C1's re-home; M2 is where the column-drop and the FK-create meet (the one coupling between this teardown and the FK task).
M2 downgrade must: re-add the 16 columns (types/defaults above), recreate the 3 CHECKs + the 2 outward FKs, re-backfill all 16 from host_account (the sole post-C2 source), and drop the new host_account.referred_by FK. Heavy but mechanical; safe only against C1-era code. State in the downgrade() docstring.
Target for all: the existing shared helper tests/helpers/identity.py (make_person_and_host / create_person_with_personal_engagement).
PersonRow with host-column kwargs — break at the model strip (C2): test_person_model (email, mobile), test_phase_47_credit_endpoints, test_phase_47_credit_substrate, test_phase_48_{me_credits, lifecycle_evaluator, reconciliation_evaluator, conversion_detection}, test_phase_49_{exhaustion_choice, tier_drop_voice}, test_phase_50_{conversion_credit_override, grant_decision_approval, grant_decision_dispatcher}, test_phase_51_{grant_email_composition, grant_email_wiring}, test_phase_52_routing_decision, test_phase_64_room_consumption, test_phase_66_{park_resume (spend_pause), me_spend}.PersonRow var — break at the model strip (C2): test_data_migration, test_me_security, test_personal_engagement, test_phase_48_{conversion_detection, lifecycle_evaluator, me_credits}, test_phase_50_conversion_credit_override, test_phase_66_me_spend, test_webauthn_registration.FROM persons / UPDATE persons touching host cols — ⚠ break at M2, NOT the strip; re-home like the src raw sites: test_phase_48_{login_gate_and_reactivate, seed_conversation_routing}, test_automatic_membership, test_signup_personal_engagement, test_phase_44_listener, test_engagement_creation_walk_guards, test_personal_engagement, test_phase_49_{near_exhaustion, tier_drop_voice}, test_auth_middleware, +.personal_engagement_id= kwarg to local builders; consolidate onto the shared helper while in this surface.persons/migration.py imports Principal, _row_to_principal unused (the 6b 3.7 sweep renamed pre-existing dead imports). That file is touched at C1 anyway; remove them.host_account (49 re-homed + get_principal_by_email retired); zero ORM or raw host-column access to persons remains (grep-confirmed for raw, including comparison-form reads).host_account creation in mint/onboard; signup creates principals + host_account correctly post-trigger.principals trigger still live.PersonRow model carries only the 7 principal columns; ~28 test files migrated to the shared helper.persons; 3 CHECKs + 2 outward FKs cascaded; new host_account.referred_by → principals.id FK added and validated.persons is principal-only (7 columns: id, display_name, totp_secret, first_login_at, last_presence_proof_at, created_at, updated_at).downgrade(); verified on playground_dev.PersonRow rename (CR-C), the principals trigger.git add <paths>, never -A).playground_dev, not the suite alone — for M1 and M2 especially.PersonRow rename is CR-C), but the model strip must touch only the column mappings, leaving the PersonRow name intact for CR-C.DUNIN7 — Done In Seven LLC — Miami, Florida CR-2026-111 — Stele extraction contract phase (host-side teardown) — v0.2 — 2026-06-17