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

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

Version. 0.1 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 — planned trajectory. This v0.1 is the plan; the as-built v0.2 with a §0 As-Built Corrections section lands after execution, per the standing CR discipline (the shape CR-2026-112 v0.2 followed). Grounded against engine main at d0f302e, alembic head 0090. 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.

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.


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