DUNIN7 · LOOMWORKS · RECORD
record.dunin7.com
Status Current
Path change-requests/cr-2026-111-stele-extraction-contract-phase-host-teardown-v0_3.md

Loomworks change request — Stele extraction contract phase (host-side teardown) — CR-2026-111 — v0.3 (close-out)

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).


0. What this close-out records

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:

  1. §4.5 was placed on the wrong sidehost_account creation belonged HOST-side (signup), not in the Stele mint. Corrected at the C1.4 halt (Option B). The boundary held.
  2. A fifth code step, C1.5, did not exist in the plan — the §7 test-surface analysis missed the trigger-reliant fixture class that breaks at M1, not C2/M2. C1.5 is the pre-M1 fixture re-home.
  3. A C1 reader-inventory gap the taxonomy could not namenear_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.
  4. A follow-on migration, 0089 — the 3 value CHECKs cascaded off 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.


1. The executed commit map (the spine, as it actually landed)

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).


2. Correction 1 — §4.5 placement: HOST-side, not Stele (the boundary)

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/.


3. Correction 2 — C1.5, the trigger-reliant fixture class (the §7 gap)

§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.


4. Correction 3 — the near_exhaustion firing gap (a reader class the taxonomy could not name)

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 PersonRowdynamic 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.)


5. Correction 4 — 0089, the CHECK restoration (parity over minimalism)

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).


6. The 0087-only validity framing (shaped M2's down-path)

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:


7. Verification record (what was proven, where)

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.


8. The test surface, as it actually was (vs §7)

§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:

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.


9. Definition of done — met


10. Carry-forwards (named, boundaries fixed)

11. Methodology lessons (for canonical lift)

  1. The model strip is a tripwire that catches what grep cannot. Dynamic attribute access (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)
  2. Per-fixture explicit over a global test re-mirror when the phase's purpose is to remove a write path: the global hook masks the exact failure mode the phase exists to expose. (§3)
  3. Drive the test surface from the suite, not a pre-drafted count — a count cannot survive a mid-cutover step insertion (C1.5). (§8)
  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.
  5. Down-path honesty: when a code step retires the prior running system, the paired migration's downgrade is data-restoration, not live rollback — say so in the docstring. (§6)