Version. 0.3 (close-out / executed)
Date. 2026-06-18
Status. EXECUTED. CR-A (host-side teardown) complete. All eleven steps landed on branch cr-2026-111-stele-contract-host-teardown, local, not pushed. persons is principal-only (7 columns); host_account is authoritative for the 16 host columns and now carries the 3 value CHECKs. Suite green at every commit; final suite 2928 passed / 46 skipped / 0 failed at alembic head 0089. Per-step commits, playground_dev/playground_test verification, halt-before-push, explicit-path staging — all held.
v0.1 and v0.2 stay. This version does not supersede them; it closes the cycle. v0.1 is the original plan; v0.2 is the inventory-completeness correction (readers 23→28, total 45→50, me_spend.py added, evaluator coordinate :265→:264). v0.3 records what actually executed and the four corrections execution surfaced that the plan did not anticipate. Corrections preserved, not smoothed (the §3 discipline).
The plan (v0.2) was sound on the spine (C1 → M1 → C2 → M2) and on the inventory (50 sites, verified complete after the v0.2 comparison-read fix). Execution confirmed both. It also surfaced four things the plan did not have, each recorded below as a first-class correction:
host_account creation belonged HOST-side (signup), not in the Stele mint. Corrected at the C1.4 halt (Option B). The boundary held.near_exhaustion.py's firing path accessed host columns by getattr/setattr (dynamic attribute access), invisible to a grep-by-column-name audit and to the §4C raw-SQL class, but caught loudly by the C2 model strip.persons at M2 and had no home; the raw-SQL re-homes had made the host side's Pydantic-only posture insufficient. Restored at the schema level.Plus one framing insight that shaped M2's down-path: the cutover is 0087-valid-only after C2 — there is no running 0086 system to roll back to, so M2's downgrade is schema+DATA restoration, not a live rollback.
Branch cr-2026-111-stele-contract-host-teardown, grounded at engine main 129d124 (6b merged); all local, unpushed.
| Step | Commit | What landed |
|---|---|---|
| C1.1 | cbe6b07 | re-home the 7 pure-reader files' host-column reads to host_account |
| C1.2 | 6e9fa18 | re-home the 7 raw-SQL host-column readers |
| C1.3a | 684c79a | move the lifecycle FOR UPDATE lock + account_status RMW to host_account (coupled with the data) |
| C1.3b | a3823d0 | dual-write the 7 standalone UPDATE host-column writers to host_account |
| C1.3c | 8d0f466 | retire get_principal_by_email (prod-dead, boundary-bound) — §5.1; drops 3 tests (baseline 2933 → 2930) |
| C1.4 | 2a5d245 | explicit host_account creation on signup, HOST-side (the §4.5 correction) |
| C1.5 | 511ddeb | (new step) explicit per-fixture host_account creation — the trigger-reliant fixture class; lands green at 0086 |
| M1 | 82c69b2 | 0086 → 0087: drop the host mirror trigger + fn (host trigger only; principals trigger stays) |
| C2 | ecc79e8 | remove the persons dual-writes; strip the 16 host columns from the PersonRow ORM mapping; re-home the near_exhaustion firing gap; prune dead imports |
| M2 | 4978ee0 | 0087 → 0088: drop the 16 host columns from persons; add host_account.referred_by → principals.id FK |
| 0089 | 8ba5ae1 | 0088 → 0089: restore the 3 value CHECKs on host_account |
End state: persons = the 7 principal columns (id, display_name, totp_secret, first_login_at, last_presence_proof_at, created_at, updated_at). host_account = the 16 host columns + the 3 CHECKs (account_status, exhaustion_preference, spend_pause) + the new referred_by → principals FK. PersonRow NOT renamed (CR-C). The principals mirror trigger stays live (CR-C).
v0.1/v0.2 §4.5 placed the net-new host_account creation inside mint_principal (the Stele mint). That is wrong: mint_principal lives in stele/registry.py, and the standing boundary invariant is "a standalone Stele has no host_account; Stele touches no host data." Creating host_account from the mint violates it.
Corrected to Option B (host-side): signup.py, after mint_principal and before MINT COMMIT, does the explicit INSERT INTO host_account ... ON CONFLICT (id) DO UPDATE (upsert because at 0086 the live trigger races it; after M1 it is the sole creation). onboard.py writes onboarding_complete to host_account. At C2 the Stele mints (create_principal/mint_principal) drop their email/mobile/onboarding_complete kwargs entirely — signup sources those values from the signup record, not the minted row. The boundary held end to end: zero host writes originate in stele/.
§7 ("the test surface") scoped fixture breakage to two events: the C2 model strip (ORM access fails to compile) and the M2 column drop (raw SQL fails against the table). It missed a third event: M1 dropping the trigger.
A class of test fixtures inserted a persons row directly (raw INSERT INTO persons) and relied on the 0086 mirror trigger to materialize the matching host_account. Those fixtures are invisible to the model strip and survive the column drop — but the moment M1 drops the trigger, a direct persons insert no longer produces a host_account, and the fixture's host-reading endpoint/gate fails. The plan would have hit ~145 failures at M1 with no allocated step to absorb them.
C1.5 is the pre-M1 fixture re-home: a shared builder sync_host_account_from_persons (the callable equivalent of the dropped trigger) called per-fixture, plus per-site host writes. It lands green at 0086 (the explicit upserts coexist with the live trigger via ON CONFLICT) and stays green at 0087 (sole creation).
The listener-vs-explicit decision (recorded for the trajectory). An after_insert/after_update conftest listener — reproducing the mirror trigger inside the suite — was implemented first for leverage (one hook, all fixtures). It was then rejected and removed: it reproduces the production trigger inside the suite and would mask the exact missing-host-write failure mode this phase exists to remove — a production writer that forgets its host_account write stays green because the listener fabricates the row from the persons write. Worse, the listener and C2's "missing host write fails loud" safety are in direct tension; nothing would enforce tearing the listener out at exactly C2. Per-fixture explicit sync instead: a forgotten write gets no host_account row → read-back fails loud, at C1, not deferred to a C2 audit. The same discipline that caught the v0.2 inventory gap (do not trust prior audits) chose the louder mechanism.
The §4 inventory classified accessors into three classes: writers (§4A), ORM readers (§4B, caught by the model strip), raw-SQL readers (§4C, the silent class caught only by the grep checklist). The near_exhaustion firing path belonged to none of them cleanly:
detect_and_fire_near_exhaustion walked the threshold table and did getattr(person_row, column_name) / setattr(person_row, column_name, when) against a PersonRow — dynamic attribute access by column-name string. C1.3b had re-homed the near_exhaustion reset to host_account, but the firing read+write was never re-homed (the inventory's grep-by-literal-column-name could not see getattr(row, column_name); the §4C raw-SQL scan could not see it either — it is ORM, not raw SQL). It stayed green through C1/M1 (the column existed on persons; the firing wrote persons, the reset wrote host_account — an incoherence that "worked" because the tests read persons too).
The C2 model strip surfaced it loudly as AttributeError: 'PersonRow' object has no attribute 'near_exhaustion_20pct_seen_at'. Re-homed to host_account in C2. The lesson for the inventory taxonomy: dynamic attribute access (getattr/setattr by column name) is a fourth reader class — invisible to a grep-by-column audit, invisible to the raw-SQL scan, but caught by the model strip. This is precisely the value of the strip-as-tripwire: it catches what neither grep can.
The C2 grep gate ran clean afterward: zero raw-SQL host-column writes to persons in src/, zero PersonRow host-attribute access, zero host-kwarg ctors. (Remaining mentions are docstring prose.)
The 3 single-column value CHECKs (ck_persons_account_status/_exhaustion_preference/_spend_pause) depended solely on their columns and cascaded away with them at M2 — exactly as v0.2 §6/M2 said they would. host_account (landed at 6a) never carried them; the host side was Pydantic-at-the-boundary only.
That posture was sufficient at 6a, when every host write went through the request boundary. It is no longer sufficient: C1/C2's raw-SQL re-homes added non-boundary write paths straight to host_account (the lifecycle / converse / seed-conversation / personal-engagement writers, and the test sync helper). A boundary guard cannot cover a writer that does not pass the boundary; a schema CHECK can — raw writes cannot evade it.
0089 restores all three on host_account, matching the definitions that cascaded off persons (parity over minimalism — all three, the same value domains the fused table guaranteed). Pre-validated on playground_dev (0 violations across all three — the data came through Pydantic), so they validate on ADD; tested downgrade drops them. This is the new follow-on the user authorized after M2; it does not gate M2 (filed as a separate migration on a separate decision).
A structural fact that emerged in execution and reshaped the M2 downgrade contract: once C2 removed the persons dual-writes, the code is valid only at 0087+. At 0086 the live host trigger would clobber host_account from persons' now-default values (the 0087 migration's own downgrade docstring warns of exactly this). So:
host_account (the sole surviving source), and it requires the paired C2/M2 code revert. The docstring states this explicitly. This is the honest guarantee the down-path provides — data rescue for disaster recovery, not a one-command return to 0086.playground_dev at 0087: a principal-only persons write (UPDATE persons SET display_name) left host_account.email untouched (at 0086 the trigger would have clobbered it). Down-path md5-identical to the 0086 trigger/fn. Suite green at both 0086 and 0087: 2930 / 0.playground_test: persons → 7 columns, the 3 CHECKs + 2 outward FKs cascaded away (0 remaining, not dropped explicitly), host_account.referred_by → principals added + validated. Downgrade on playground_dev with a distinctive 16-column throwaway row: all 16 columns re-added with exact types/defaults, 3 CHECKs + 2 FKs recreated byte-identical to the originals, and the row's re-added persons columns were identical to its host_account values (diff clean across all 16) — losslessness proven. New FK dropped. No personal_engagement_id FK-isolation artifact (§5.5) surfaced — the FK dropped (upgrade) and recreated (downgrade) cleanly. Suite 2928 / 0 at 0088.persons ones; tested downgrade drops them, re-upgrade restores. Suite 2928 / 0 at 0089.
Suite count moved 2933 → 2930 (C1.3c retired get_principal_by_email + its 3 tests) → 2928 (M2 removed 2 obsolete persons-DB-CHECK tests; the invariant they asserted moved to host_account at 0089). No regressions; the deltas are the two deliberate test retirements.
§7 estimated the C2/M2 test-migration list. Execution drove it empirically instead — run the suite at each migration head, let the failures name the exact sites, fix, repeat — because §7's count was drafted before C1.5 existed and staled the moment C1.5 did the creation-side fixture work. Actual surfaces:
INSERT/UPDATE/SELECT persons <hostcol> failures, driven by the 0088 failure list; the C1.5 sync helper itself rewritten (it read the 16 columns FROM persons, now gone → builds the host_account shell from DB defaults + **host_overrides).The empirical-failure-driven approach is the recorded methodology lesson: a pre-drafted test-migration count cannot survive a mid-cutover step insertion; drive the surface from the suite, not the count.
host_account (C1); the 4th-class firing gap caught + re-homed (C2).host_account creation on signup, host-side (C1.4); Stele mints carry no host data.persons dual-writes removed (C2).PersonRow (C2) and dropped from persons (M2); persons principal-only.host_account.referred_by → principals.id added + validated (M2).host_account (0089) — value-domain parity with the fused table.conversation_turns.person_id, etc. → currently persons.id); carries the unresolved principals.id vs host_account.id target decision. Unblocked by this teardown (they reference the principal id, not a host column).PersonRow → PrincipalRow rename/relocation (+ the 3 _PersonRow stubs, the HostAccountRow file-inversion), and retiring the surviving stele_persons_mirror_to_principals trigger (persons is the principal write-source until then). Mechanical token sweep, no schema risk — deliberately not bundled into the dangerous migration.getattr/setattr by column name) is a 4th reader class invisible to both the by-column-name grep and the raw-SQL scan; the strip catches it loudly. (§4)ALEMBIC_URL, not PGDATABASE. Alembic targets settings.database_url (dev) unless ALEMBIC_URL is set; libpq PG* env vars are ignored by the explicit asyncpg URL. To migrate playground_test you MUST set ALEMBIC_URL. Carried into M2's down-path testing.