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

CR-2026-113 — Stele Extraction CR-B — Host-FK Re-target, Mirror Retire, persons Drop — v0.2

Version. 0.2 Date. 2026-06-19 Author. Marvin Percival (DUNIN7 Operator) with Claude.ai (CR drafting) on DUNIN7-M4; CC executing on DUNIN7-M4. Status. Build change request for CR-B — amended as-built after execution. The v0.1 plan body (§1–§10 below) is preserved unchanged as the planned trajectory; this v0.2 adds §0 (As-Built Corrections) recording the five deltas between plan and build, each caught at a halt point or by the suite. Built on branch cr-2026-113-stele-cr-b, three commits, suite green at each (2928/0/46). Grounded against engine main at d0f302e, alembic head 0090; branch pushed to origin (not yet merged). Markdown primary (technical consumer — CC execution).

Governing handoff. phases/stele-extraction-cr-b/loomworks-stele-cr-b-cr-drafting-handoff-v0_1.md.

Grounding source. phases/stele-extraction-cr-b/loomworks-stele-cr-b-step-0-inspection-report-v0_1.md (CC, 2026-06-19, against d0f302e / 0090). Every per-FK fact below is drawn from that report's exact findings, not from inherited shorthand.

Supersedes. v0.1 (record ef23daf) 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."

Settled before drafting (not re-opened). FK target is principals.id — a UUID, the id of an authorized identity. Grounded against seed v0.12 §Identity (line 107): the UUID is the identity; it is what other Loomworks records reference. The Operator settled it; this CR drafts on it.


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

The v0.1 plan was three commits — A (re-target 11 FKs), B (retire trigger), C (drop persons + remove 4 stubs + fix tests). The destination held exactly: persons is gone, every host FK is on principals.id, the mirror is retired, the 4 stubs are removed, suite green at each commit. But the surface the build touched was wider than §3–§5 enumerated — three of the five deltas are grounding gaps where Step 0 inventoried one kind of persons coupling and missed another. Each was caught at a halt or by the suite, resolved on the merits, and is recorded here. Corrections preserved, not smoothed. The plan body (§1–§10) is left intact below.

Delta 1 — migration path is migrations/versions/, not src/loomworks/migrations/versions/ (all three commits). The §7 staging lines named src/loomworks/migrations/versions/<new>.py. The real alembic script_location is migrations/ at the repo root (alembic.ini:script_location = %(here)s/migrations). Migrations landed as migrations/versions/0091_…, 0092_…, 0093_…. A path error in the plan, not a design change; staged to the correct path.

Delta 2 — the reverse mirror had non-FK consumers Step 0 didn't catalog; the trigger-retire stranded 4 production reads (Commit 2). §4 reasoned "once no FK references persons, this mirror has no job." Step 0 inventoried FK references (§B) and ORM stubs (§C) but never swept raw-SQL reads of the persons table. Four production sites read persons directly and were silently fed by the mirror: credit/reconciliation_evaluator.py and credit/grant_decision_dispatcher.py (both SELECT id FROM persons ORDER BY created_at — approval-card recipient lists) and notifications/listener.py ×2 (JOIN persons p ON p.id = m.person_id — operator/member lookups). Retiring the mirror (Commit 2) broke 21 tests; unfixed in production it would have silently returned empty recipient lists for any post-CR-C identity — exactly the "live change done carelessly" danger §1 names. Resolution: all four repointed persons → principals (identical id population, id/created_at/display_name all present on principals). Folded into Commit 2 per Operator decision (causally inseparable from retiring the mirror). Falsifies §4's premise that the FKs were the mirror's only consumers.

Delta 3 — removing the 4 stubs strands 8 ORM ForeignKey("persons.id") references; they convert to DB-only, not just disappear (Commit 3). §5 enumerated the 4 _PersonRow stub classes to remove. But those stubs were the in-metadata FK target for 8 ORM ForeignKey("persons.id") column declarations across 5 modules (grouping×3, orchestration, files, notifications, persons×2). Removing the stubs without addressing the FK strings breaks configure_mappers (the target table vanishes from the MetaData). The plan said only "remove the 4 stubs." Resolution: all 8 converted to DB-only FKs → principals.id — the ForeignKey(...) wrapper dropped, the plain column kept — matching the established host_account.id → principals.id cross-base pattern (principals is on Stele's separate SteleBase MetaData; a string ForeignKey cannot resolve across bases). The DB constraints from Migration A are authoritative; verified no relationship() or host-side metadata.create_all depends on them (the only create_all is Stele's own self-contained MetaData). Two stale docstrings (identity_base.py, audit/models.py) referencing the old persons FK were corrected.

Delta 4 — Migration C's down-path restores schema AND data, not schema alone (Commit 3). §7 Commit-3 down said "recreate persons (7 columns)." That is insufficient: the chained downgrade runs C-down (recreate persons), then B-down (restore trigger), then A-down (re-point the 11 FKs back to persons(id)). With live host data, A-down's ADD CONSTRAINT … REFERENCES persons(id) fails — the recreated persons is empty, so host rows referencing principal ids violate the constraint (caught on playground_dev: companion_notifications_person_id_fkey). At the instant Migration C dropped persons, the table held a row for every principal (the mirror kept it 1:1 up to Migration B). So the faithful inverse is recreate and refill from principals. Resolution: C-down recreates the table (DDL read verbatim from playground_dev) then INSERT INTO persons SELECT … FROM principals — reconstructing the mirror state. This follows the CR-A/CR-C down-path discipline (schema + data, not schema alone). Caught only by the full-chain down test, not the suite.

Delta 5 — empirical test surface = 3 repoints, each WHY-checked (Commits 2–3). §7 directed: don't work from a count; let the suite name the breaks; verify the WHY before repointing. Three test-code repoints surfaced, all stale references to the dropped identity table (none an assertion against the mirror): test_automatic_membership.py migration-0036 back-fill query FROM persons → FROM principals (Commit 2; it seeds PrincipalRow, so principals is the correct source); conftest.py TRUNCATE list dropped persons (Commit 3, table gone); test_stepup_organization_structure.py org-less-identity invariant query FROM persons → FROM principals (Commit 3; organization_memberships.person_id now targets principals). Each preserves the test's intent.

As-built commit ledger (branch cr-2026-113-stele-cr-b)

| # | Commit | What | |---|---|---| | 1 | 47d544d | Migration A (0091) — re-target 11 host FKs persons(id)principals(id); per-FK delete behavior preserved (3 CASCADE / 8 NO ACTION); build-time subset assertion persons.id ⊆ principals.id | | 2 | bd1f18a | Migration B (0092) — retire reverse mirror trigger; + 4 production-read repoints (Delta 2) + 1 test repoint (Delta 5) | | 3 | f55386a | Migration C (0093) — drop persons (down recreates+refills, Delta 4); remove 4 _PersonRow stubs; 8 ORM FKs → DB-only (Delta 3); 2 test repoints (Delta 5) |

Suite green (2928/0/46) at every commit. Verified on playground_dev: per-FK CASCADE/NO-ACTION behavior, trigger stops/restarts mirroring, persons drop + host-FK resolution against principals, and the full chain down 0093→0090 (persons refilled 41=41, trigger restored, FKs re-pointed) then re-up to head. Branch pushed to origin; not yet merged.

What changed from v0.1's enumerated surface

The §3 FK table, the §4 trigger body, and the §5 stub list all held exactly (zero drift from Step 0). What the plan under-counted: the mirror's non-FK consumers (Delta 2), the ORM FK strings the stubs targeted (Delta 3), and the data half of the C down-path (Delta 4). §9 ("what CR-B does not do") and §10 ("done when") stand; the build-portion "done when" items are all met, with the record/manifest/branch-retire follow-ons in progress.


> The text below (§1–§10) 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

What CR-B is. The last Stele arc before Phase 7 (packaging). After CR-C, principals is the canonical identity table and persons stands only as a reversed mirror feeding 11 host foreign keys. CR-B moves those 11 FKs onto principals.id, then removes everything that existed only to keep persons alive — the reverse mirror trigger, the 4 _PersonRow registration stubs, and the persons table itself.

The danger is not data loss. There is no live data; playground_dev rebuilds at will. The danger is a live schema change done carelessly: an FK re-pointed with the wrong delete behavior, a column named by pattern instead of by hand, a trigger down-path that doesn't restore verbatim, or a \bPerson\b regex that corrupts string literals. Every step here is deliberate and named.

What Step 0 firmed up (this is the scope, not an estimate).

The build. A migration arc with a tested down-path (the CR-A safety model — live schema change, heavier than CR-C). Per-step commits, suite green at each, halt before push, explicit-path staging. The suite names the test breaks when persons drops; we don't work from a count.

The one open call this CR settles. Whether the trigger-retire and the persons-drop are one migration or two. Decided in §6 below.


1. Decision recap (settled before drafting)

FK target: principals.id. A UUID — the id of an authorized identity. Seed v0.12 §Identity is the ground: the UUID is the identity, held in the principal record, and it is what other Loomworks records reference. This CR does not re-litigate it.

The Phase-7 boundary worry the CR-A-era handoff flagged (would pointing host FKs at principals over-couple the host to identity?) was resolved in scoping: the host already FK-references principalshost_account.id → principals.id, plus the credential FKs. Pointing the 11 at principals.id makes them consistent with coupling that already exists structurally; it introduces no new coupling kind. Settled.

Corrections to the inherited record (named, not smoothed — Discovery discipline). The CR-B opening handoff and the CR-C completion record §6 both carried CR-C-era assumptions that Step 0 corrected. Recorded here so the trajectory stays walkable:

  1. "audit/credit schemas" → credit only. No audit-schema FK references persons. Exactly one schema-qualified FK: credit.credit_grant. The "audit/credit" phrasing over-counted.
  2. "11 × person_id" → 6 of 11 columns are differently named. The shorthand hid the claimed_by_ / added_by_ / created_by_ / owner_ (×2) / uploaded_by_ prefixes. 5 tables use the bare person_id; 6 columns across 5 other tables do not.
  3. "remove the 4 stubs (incl. the new persons/models.py one)" → all 4 are equal-status separate registrations. None is privileged. All 4 are id-only FK-target stubs on distinct MetaDatas; all 4 drop together at the table drop.

These corrections do not change the destination; they change which facts the migration is written against. The CR drafts on the corrected facts.

2. Why the re-target is a constraint swap, not a data migration (the safety floor)

persons is the reverse mirror of principals — every identity write since CR-C originates in principals and the 0090 trigger mirrors it into persons. Therefore persons.id ⊆ principals.id: every value any host FK currently resolves against in persons.id is the same UUID present in principals.id. Re-pointing a constraint from persons(id) to principals(id) validates against a superset of what it validated against before — so no existing row can violate the new constraint, and no backfill is implied.

9 of the 11 FK columns are NOT NULL (only credit_grant.claimed_by_person_id and engagements.created_by_person_id are nullable). For a constraint re-point the ordering concern is data validity, not column population — and validity holds today by the subset relation above. The migration still asserts the subset with a validation query at build time on playground_dev; it does not assume it.

Every FK column is already native uuid, matching principals.id. There is no projection or type mismatch at any of the 11. This is the floor the whole arc rests on.

3. The full re-target surface (against d0f302e, from the Step 0 report)

All 11 reference public.persons(id) today. Each re-target: drop the existing REFERENCES persons(id) constraint, add REFERENCES principals(id) with the same ON DELETE / ON UPDATE behavior and a clear constraint name. All 11 columns are uuid; none is a column-type change.

| # | Schema.table | FK column | Current constraint | NULL | ON DELETE | ON UPDATE | |---|---|---|---|---|---|---| | 1 | credit.credit_grant | claimed_by_person_id | fk_credit_grant_claimed_by_person | nullable | NO ACTION | NO ACTION | | 2 | public.companion_notifications | person_id | companion_notifications_person_id_fkey | NOT NULL | NO ACTION | NO ACTION | | 3 | public.conversation_turns | person_id | conversation_turns_person_id_fkey | NOT NULL | NO ACTION | NO ACTION | | 4 | public.engagement_tags | added_by_person_id | engagement_tags_added_by_person_id_fkey | NOT NULL | NO ACTION | NO ACTION | | 5 | public.engagements | created_by_person_id | engagements_created_by_person_id_fkey | nullable | NO ACTION | NO ACTION | | 6 | public.memberships | person_id | memberships_person_id_fkey | NOT NULL | NO ACTION | NO ACTION | | 7 | public.organization_memberships | person_id | organization_memberships_person_id_fkey | NOT NULL | NO ACTION | NO ACTION | | 8 | public.person_settings | person_id | person_settings_person_id_fkey | NOT NULL | CASCADE | NO ACTION | | 9 | public.saved_filters | owner_person_id | saved_filters_owner_person_id_fkey | NOT NULL | CASCADE | NO ACTION | | 10 | public.uploaded_files | uploaded_by_person_id | uploaded_files_uploaded_by_person_id_fkey | NOT NULL | NO ACTION | NO ACTION | | 11 | public.workspaces | owner_person_id | workspaces_owner_person_id_fkey | NOT NULL | CASCADE | NO ACTION |

Two facts the migration must honor per-FK (from Step 0 B):

Schema qualification (from Step 0 E.3). Exactly one of the 11 — credit.credit_grant — is outside public (schema credit). The migration handles that one schema-qualified. The other 10 are public.

4. The trigger to retire (from Step 0 D)

Migration 0090 installed the reverse mirror, live in the DB:


INSERT INTO persons (id, display_name, totp_secret, first_login_at,
                     last_presence_proof_at, created_at, updated_at)
VALUES (NEW.id, NEW.display_name, NEW.totp_secret, NEW.first_login_at,
        NEW.last_presence_proof_at, NEW.created_at, NEW.updated_at)
ON CONFLICT (id) DO UPDATE SET
    display_name = EXCLUDED.display_name,
    totp_secret = EXCLUDED.totp_secret,
    first_login_at = EXCLUDED.first_login_at,
    last_presence_proof_at = EXCLUDED.last_presence_proof_at,
    updated_at = EXCLUDED.updated_at;   -- created_at NOT updated on conflict (kept immutable)
RETURN NEW;

Once no FK references persons, this mirror has no job. The migration drops the trigger and the function. Its down-path restores both verbatim — trigger definition and the full function body above, including the created_at-immutable-on-conflict behavior.

5. The 4 _PersonRow stubs to remove (from Step 0 C)

Exactly 4, each an id-only FK-target stub (__tablename__ = "persons", single PK id, no app columns), each on its own module's Base/MetaData. All 4 are equal-status separate registrations — none privileged.

| # | File | Class | Maps | |---|---|---|---| | 1 | src/loomworks/files/models.py | _PersonRow(Base) | id only | | 2 | src/loomworks/notifications/models.py | _PersonRow(Base) | id only | | 3 | src/loomworks/orchestration/models.py | _PersonRow(Base) | id only | | 4 | src/loomworks/persons/models.py | _PersonRow(Base) (the CR-C shared-Base stub; default=uuid.uuid4) | id only |

grep -rl '^class _PersonRow' src/loomworks/ returns exactly these 4. All 4 are removed at the table drop, token-level — NAME-token sweep, never a blanket \bPerson\b regex (the standing discipline, proven 6b 3.7 → CR-A → CR-C: a blanket regex corrupts title="Person", "Test Person" literals, and docstrings).

6. The sequencing decision — two migrations (settled in this CR)

Question. Are the trigger-retire (§4) and the persons-drop (§5 + DROP TABLE) one migration or two?

Decision: two migrations. Reasoning:

So the arc is three migrations, in order: (A) re-target the 11 FKs; (B) retire the reverse trigger; (C) drop persons. The alternative — folding B and C into one — is defensible (the drop and the trigger-retire are conceptually "tear down the mirror"), but the forced ordering and the cleaner down-paths favor keeping them separate. Recorded as a drafting call; if execution finds B and C can't be cleanly separated (e.g. an alembic dependency knot), that delta goes in the as-built v0.2 §0.

7. The build — the commit arc

Discipline for every commit: suite green before the commit; per-step commit; halt before push; explicit-path staging (git add <paths>, never -A). Every migration verifies against playground_dev with a tested down-path, not the suite alone. Ground first: CC confirms git log -1 --oneline shows engine main at d0f302e and the working tree is clean before Commit 1. Branch: cr-2026-113-stele-cr-b.

Commit 1 — re-target the 11 host FKs to principals.id (migration A)

Write a migration (next after 0090 — CC names it) that, for each of the 11 FKs in §3:

The CR notes, per CASCADE FK, that delete semantics now anchor on the principals row (intended).

Verify: apply up on playground_dev; confirm all 11 FKs reference principals and the 3 CASCADE behaviors are intact (test a principals delete cascades person_settings/saved_filters/workspaces rows; a principals delete with a dependent NO ACTION row is blocked). Apply down; confirm all 11 restored to persons(id) with original behaviors. Re-apply up. Full suite green.

Staging: git add src/loomworks/migrations/versions/<new_A>.py

Message: CR-B Commit 1: re-target 11 host FKs persons(id)->principals(id) (migration A)

Commit 2 — retire the reverse mirror trigger (migration B)

With no FK referencing persons, the 0090 reverse trigger has no job. Write a migration that drops it.

Verify: apply up on playground_dev; confirm a principals write no longer touches persons. Apply down; confirm the trigger/function are restored and a principals write mirrors into persons again (display_name carried, created_at immutable on conflict). Re-apply up. Full suite green.

Staging: git add src/loomworks/migrations/versions/<new_B>.py

Message: CR-B Commit 2: retire reverse mirror trigger 0090 (migration B)

Commit 3 — remove the 4 _PersonRow stubs + drop persons + fix the test surface (migration C)

Two parts in one commit: the ORM stub removal and the table-drop migration, plus whatever test breaks the suite names. The stub removal and the DROP are one logical change — removing the registrations without dropping the table (or vice versa) leaves the codebase inconsistent.

Verify: apply up on playground_dev; confirm persons is gone and the full host-FK class still resolves against principals (a signup writes principals, and the host FKs — e.g. person_settings.person_id → principals.id — resolve). Apply down; confirm persons is recreated. Re-apply up. Full suite green after all test breaks are fixed.

Staging: git add src/loomworks/files/models.py src/loomworks/notifications/models.py src/loomworks/orchestration/models.py src/loomworks/persons/models.py src/loomworks/migrations/versions/<new_C>.py <each test file touched>

Message: CR-B Commit 3: remove 4 _PersonRow stubs; drop persons table (migration C); repoint test surface

8. Disciplines (carried in)

9. What CR-B does not do

10. Done when


DUNIN7 — Done In Seven LLC — Miami, Florida CR-2026-113 — Stele Extraction CR-B — v0.1 (planned trajectory) — 2026-06-19