DUNIN7 · LOOMWORKS · RECORD
record.dunin7.com
Status Current
Path change-requests/cr-2026-112-stele-extraction-cr-c-v0_2.md

CR-2026-112 — Stele Extraction CR-C — Identity-Table Collapse, Dead-Class Removal, HostAccountRow Relocation — v0.2

Version. 0.2 Date. 2026-06-18 Author. Marvin Percival (DUNIN7 Operator) with Claude.ai (CR drafting) on DUNIN7-M4; CC executing on DUNIN7-M4. Status. Build change request for CR-C — amended as-built after execution. The v0.1 plan body (§1–§8 below) is preserved unchanged as the planned trajectory; this v0.2 adds §0 (As-Built Corrections) recording the four deltas between plan and build, discovered at execution and resolved at halt points. Built on branch cr-2026-112-stele-cr-c, four commits, suite green at each. Grounded against engine main at bee2b4c. Markdown primary (technical consumer — CC execution).

Governing scoping note. change-requests/loomworks-stele-cr-c-scoping-note-v0_1.md (record 18ee5f0).

Supersedes. v0.1 (record 68f4cc2) for the as-built record. v0.1 is preserved as the planned trajectory; read §0 first, then the v0.1 body as "what was planned."


0. As-built corrections (v0.2 — the deltas between plan and build)

The v0.1 plan was five commits: reads, writes, retire-trigger, remove-class, relocate. Execution found four cracks, each caught at a halt point before damage, each resolved on the merits. The build is four commits. The plan body (§1–§8) is left intact below as the trajectory; this section records where reality diverged and why. Corrections preserved, not smoothed.

Delta 1 — the reads/writes split does not hold for load-then-write pairs (Commit 1). The v0.1 plan put all 7 reads in Commit 1 and all 6 writes in Commit 2. Two of the "reads" — #1 (lifecycle.py:124, the delete-scrub load) and #6 (person_totp.py:102, the TOTP-confirm load) — are the load half of a load-then-mutate on the same object. Repointing the load silently moves the write. The 2 TOTP-rotation tests went red because they read the secret back from persons while the rotate now wrote principals. Resolution: Commit 1 repoints only the 5 pure reads (#2, #3, #4, #5, #7); #1 and #6 move to Commit 2 with their writes. Also established: a test that reads a column from persons after the write moved to principals must be repointed in the same commit as the write, not deferred to the class-removal commit.

Delta 2 — the 11 host FKs force the trigger to reverse, not retire; Commits 2 and 3 fold (Commit 2). The v0.1 plan repointed the writes to principals (Commit 2), then retired the persons→principals trigger (Commit 3), treating persons as harmlessly idle afterward. It is not idle: 11 host FKs reference persons.id (memberships, conversation_turns, engagements, person_settings, saved_filters, workspaces, organization_memberships, companion_notifications, engagement_tags, credit.credit_grant, uploaded_files). If the mints write only principals, no persons row is created and onboarding's host-FK writes fail — every signup breaks. The write-source and the trigger direction are one decision. Resolution: principals becomes the write-source and the trigger is reversed (principals→persons, migration 0090) so persons stays mirror-populated for the 11 host FKs. The trigger is not retired in CR-C — it retires in CR-B after the 11 FKs retarget and persons drops. Old Commit 2 + Commit 3 fold into one commit.

Delta 3 — PersonRow cannot be fully deleted; it reduces to an FK-target stub (Commit 3). The v0.1 plan removed the PersonRow class entirely (Commit 4). PersonRow did two jobs: the identity model (moved to PrincipalRow, correct) and the ORM registration of the persons table on the shared identity_base.Base where the 11 host-FK tables live. Deleting it outright broke configure_mappers — ~414 tests red, because person_id → persons.id could not resolve. The 3 existing _PersonRow stubs are on independent per-module Bases and do not cover the shared Base. Since persons stays standing for the 11 FKs (Delta 2), its registration on the shared Base must stay. Resolution: PersonRow reduces to a minimal id-only _PersonRow(Base) stub — the FK target, no app columns, no reads/writes. The identity model is gone; the table registration remains. This is the 4th _PersonRow-style stub; all four are removed in CR-B with the table drop.

Delta 4 — "SteleBase-only" required moving two Base classes, not one (Commit 4). The v0.1 plan moved only HostAccountRow out of stele/models.py to reach the Phase-7 "SteleBase-only" end-state. But Commit 3 (Delta 3) added the id-only _PersonRow stub to stele/models.py, also on identity_base.Base. Moving only HostAccountRow would leave the stub behind and the module would stay mixed-Base. Resolution: both HostAccountRow and the _PersonRow stub move to persons/models.py (the existing host-model module on identity_base.Base). stele/models.py ends holding exactly 3 classes, all on SteleBase (PrincipalRow, WebauthnCredentialRow, RecoveryCodeRow), with the identity_base.Base import gone. The Phase-7 precondition is met, not approximated.

As-built commit ledger (branch cr-2026-112-stele-cr-c)

| # | Commit | What | |---|---|---| | 1 | 271dcf2 | 5 pure identity reads PersonRowPrincipalRow (#2,#3,#4,#5,#7) | | 2 | ee2c7c0 | principals = write-source; reverse mirror trigger (migration 0090); 6 writes + 2 coupled loads (#1,#6) + follow-on test reads | | 3 | a8fb53b | PersonRow → id-only persons-table FK-target stub; test reads repointed (WHY-checked — mirror-coverage reads stayed on persons) | | 4 | 73a1e94 | relocate HostAccountRow + _PersonRow stub → persons/models.py; stele/models.py now SteleBase-only |

Suite green (2928/0) at every commit. Branch four commits ahead of bee2b4c, not yet pushed at time of this amendment.

What CR-C delivered (as-built end-state)

What changed from v0.1's "what CR-C does not do" (§7)

§7 still holds with one correction: v0.1 said the trigger is retired in CR-C (§8 "the mirror trigger is gone"). As-built: the trigger is reversed in CR-C and retired in CR-B. Everything else in §7/§8 stands — persons not dropped, host FKs not retargeted, _PersonRow stubs (now 4) intact, CR-B's identity FK target unsettled.


> The text below (§1–§8) is the v0.1 plan, preserved unchanged as the planned trajectory. Read §0 above for the as-built corrections. Where the two differ, §0 is what was built.


Plain-language summary

Two tables hold the same people: persons and principals. The app writes to persons today; a database trigger photocopies each write into principals. Everything structural — login credentials, host account, the one display-name read — already points at principals. CR-C makes the app use principals directly, then turns off the photocopier.

The work is five commits, each one green on its own so we can stop at any point:

  1. Repoint the 7 reads to principals. Safe first move — the data is identical in both tables, so reading from principals returns the same rows.
  2. Repoint the 6 writes to principals. The real switch — after this, identity data is written to principals directly.
  3. Retire the trigger in a migration. The photocopier now has no job.
  4. Remove the dead PersonRow class and fix the test files that break.
  5. Move HostAccountRow out of the Stele module to the host side.

There is no live data. playground_dev can be rebuilt at any time. The danger is not data loss — it is corrupting the codebase with a careless rename. Every repoint is a deliberate token-level edit, never a blanket find-and-replace.

The persons table is NOT dropped in CR-C. It stays standing, empty of its identity job. Three _PersonRow stubs and twelve host foreign keys still point at it; clearing those and dropping the table is CR-B's job.


1. Decision recap (settled in the scoping note)

principals becomes the real identity table. persons is kept standing but loses its identity job. The trigger is retired. This is the seed-true resolution — the seed commits to the UUID as identity, held in the principal record, and every foreign key in the schema already anchors on principals. CR-C brings the application code into line with what the plumbing already believes.

Correction the grounding surfaced (recorded, not smoothed). The scoping note and the handoff before it described Task 2 as "rename PersonRowPrincipalRow." Grounding showed this is not a rename: PrincipalRow already exists (stele/models.py:172, on SteleBase, with 7 columns identical in name/type/nullability/default to PersonRow's current 7). The work is repoint the call sites from PersonRow to the existing PrincipalRow, then delete the now-dead PersonRow class — not rename one class into another. The 58-file figure was the token occurrence count; the actual edit is "swap the reference, then drop the dead class." The destination already exists and is column-clean, so no read or write has to be reshaped.

2. Why the columns line up (the safety floor)

PrincipalRow's 7 columns — id, display_name, totp_secret, first_login_at, last_presence_proof_at, created_at, updated_at — are identical to PersonRow's current 7 in name, type, nullability, and default. Every read site pulls a column that exists on principals; every write site sets a column that exists on principals. There is no projection mismatch. The trigger has kept the two tables identical, so during the transition (Commits 1–2) both tables hold the same data and any intermediate read/write state is consistent.

3. The full Task 1 surface (against bee2b4c)

7 reads:

| # | Site | What it reads | |---|---|---| | 1 | credit/lifecycle.py:124 | _load_person_for_dual_write — loads the row for the delete-scrub of display_name | | 2 | persons/login.py:385 | _person_or_raise(person_id) — existence/identity check | | 3 | persons/migration.py:109 | idempotency check by display_name (operator→person migration) | | 4 | persons/presence.py:91 | is_presence_freshselect(PersonRow.last_presence_proof_at) | | 5 | stele/person_totp.py:63 | begin_totp_rotation — reads display_name for the provisioning URI | | 6 | stele/person_totp.py:102 | confirm_totp_rotation — loads the row before writing totp_secret | | 7 | stele/registry.py:168 | get_principal_by_id — the canonical identity resolver |

3 constructions:

| Site | Writes | |---|---| | stele/registry.py:79 | create_principalPersonRow(id, display_name) | | stele/registry.py:127 | mint_principalPersonRow(display_name, totp_secret, first_login_at) | | persons/migration.py:146 | operator→person migration → PersonRow(id, display_name, totp_secret) |

3 mutations:

| Site | Writes | |---|---| | persons/presence.py:68 | update(PersonRow).values(last_presence_proof_at=now) | | stele/person_totp.py:110 | row.totp_secret = <encrypted> (the rotate) | | credit/lifecycle.py:283 | person.display_name = "" (delete scrub) |

Type annotations / passthrough refs the repoint also touches: persons/onboard.py:37,42,94 (person: PersonRow param + reads .display_name), persons/login.py:380 (annotation), credit/lifecycle.py:114 (return type).

5 dynamic getattr reads (the CR-A 4th-class lesson):


me_create_engagement.py:391    getattr(person, "display_name", None)
me_create_engagement.py:727    getattr(person, "display_name", None)
seed_commit_from_brief.py:274  getattr(person, "display_name", None)
seed_extraction.py:249         getattr(person, "display_name", None)
seed_extraction.py:286         getattr(person, "display_name", None)

Low-risk (display_name exists on both tables), but they are the class CR-A flags. Verified during Commit 2 — confirm the person object's source type at each is a PrincipalRow (or a Principal view derived from it) after the write repoint.

Out of scope — do NOT touch: the 3 _PersonRow(Base) stubs (files/models.py:38, notifications/models.py:41, orchestration/models.py:32) — distinct tokens, FK targets on the still-standing persons table, CR-B's job. The getattr/setattr pair at near_exhaustion.py:198,201 — on HostAccountRow, the C2 re-home, not identity. The 79 docs/ lines — preserved history.

4. Step 0 — grounding (already done)

Read-only inspection against bee2b4c is complete (CC, 2026-06-18) and is the basis for §3. CC re-confirms git log -1 --oneline shows engine main at bee2b4c and the working tree is clean before starting Commit 1. No new grounding needed.

5. The build — five commits

Discipline for every commit: suite green before the commit; per-step commit; halt before push; explicit-path staging (git add <paths>, never -A). Migration steps (Commit 3) verify against playground_dev, not the suite alone, with a tested down-path.

Commit 1 — repoint the 7 reads

Change the 7 read sites in §3 from PersonRow to PrincipalRow. Token-level edits, one site at a time — swap the class reference in the select(...) / load, nothing else. Reads are first because the trigger keeps both tables identical, so reading from principals returns the same rows; no behavior changes.

Files touched: credit/lifecycle.py, persons/login.py, persons/migration.py, persons/presence.py, stele/person_totp.py (2 sites), stele/registry.py.

Verify: full suite green. Then commit.

Staging: git add src/loomworks/credit/lifecycle.py src/loomworks/persons/login.py src/loomworks/persons/migration.py src/loomworks/persons/presence.py src/loomworks/stele/person_totp.py src/loomworks/stele/registry.py

Message: CR-C Commit 1: repoint 7 identity reads PersonRow→PrincipalRow

Commit 2 — repoint the 6 writes + verify the getattr five

Change the 3 constructions and 3 mutations in §3 to PrincipalRow. This is the real switch — after this, identity data is written to principals directly. The trigger is still live but now has nothing to mirror (the identity writes no longer hit persons), which is harmless.

Then verify the 5 getattr sites: at each, confirm the person object is sourced from a PrincipalRow (or a Principal view derived from it). If any is sourced from a stale PersonRow path, repoint its source. _row_to_principal (registry.py:55) is already typed PrincipalRow → Principal, so the Principal view path is already destination-shaped.

Also repoint the type annotations / passthrough refs (onboard.py, login.py:380, lifecycle.py:114).

Files touched: stele/registry.py (2 constructions), persons/migration.py (1 construction), persons/presence.py (1 mutation), stele/person_totp.py (1 mutation), credit/lifecycle.py (1 mutation), persons/onboard.py, persons/login.py. Plus any getattr-source file that needs its source repointed.

Verify: full suite green. Verify against playground_dev that a real signup writes identity columns to principals directly (the trigger no longer the path). Then commit.

Message: CR-C Commit 2: repoint 6 identity writes PersonRow→PrincipalRow; verify getattr sources

Commit 3 — retire the mirror trigger (migration)

With no identity write touching persons, the trigger stele_persons_mirror_to_principals (function stele_mirror_persons_to_principals, installed by 0085) has no job. Write a new migration that drops the trigger and the function.

After this commit, principals is fully canonical and there is no copying.

Verify: apply up on playground_dev; confirm a signup writes principals directly and persons no longer receives identity writes; apply down; confirm the trigger/function are restored and mirror again; re-apply up. Full suite green. Then commit.

Staging: git add src/loomworks/migrations/versions/<new>.py (CC names it — next after 0089).

Message: CR-C Commit 3: retire principals mirror trigger (migration)

Commit 4 — remove the dead PersonRow class + fix the test surface

With all call sites repointed (Commits 1–2), PersonRow is dead production code. Remove the class from stele/models.py. The 3 _PersonRow(Base) stubs are distinct tokens and STAY — they map the still-standing persons table for CR-B.

This is where test files break. Per the empirical-test-surface discipline, do NOT work from a scoped count — run the suite, let it name the breaks, fix them as they surface (repoint test references from PersonRow to PrincipalRow, or to the appropriate fixture). The 175 test-line figure is the upper bound, not a work list.

Verify: full suite green after all test breaks are fixed. Then commit.

Staging: git add src/loomworks/stele/models.py <each test file touched>

Message: CR-C Commit 4: remove dead PersonRow class; repoint test surface to PrincipalRow

Commit 5 — relocate HostAccountRow host-side

Independent of Commits 1–4. HostAccountRow (stele/models.py:90, on host Base) physically lives in the Stele module and is imported back by persons/host_account_view.py:28. Move the class to a host-side module (CC chooses the target — persons/host_account_model.py or the existing host model location; confirm against the real module layout), and repoint the import in host_account_view.py.

After this, stele/models.py holds only SteleBase rows (PrincipalRow + the two credential rows) — the Phase 7 precondition.

Verify: full suite green. Then commit.

Staging: git add src/loomworks/stele/models.py <new host module> src/loomworks/persons/host_account_view.py

Message: CR-C Commit 5: relocate HostAccountRow host-side; stele/models.py now SteleBase-only

6. Disciplines (carried in)

7. What CR-C does not do

8. Done when


DUNIN7 — Done In Seven LLC — Miami, Florida CR-2026-112 — Stele Extraction CR-C — v0.2 (as-built) — 2026-06-18