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.
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.
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.
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.
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).
uuid. Zero column-type migrations — every re-target is a pure constraint swap.person_id (across 5 tables). The migration names every column by hand.ON DELETE CASCADE; the other 8 are NO ACTION. Each re-target preserves its own behavior verbatim.public — credit.credit_grant (schema credit). There is no audit FK — the inherited "audit/credit" phrasing over-counted.
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.
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 principals — host_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:
credit only. No audit-schema FK references persons. Exactly one schema-qualified FK: credit.credit_grant. The "audit/credit" phrasing over-counted.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.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.
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.
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):
person_id columns by exact name: claimed_by_person_id, added_by_person_id, created_by_person_id, owner_person_id (×2 — saved_filters and workspaces), uploaded_by_person_id. The bare person_id covers only the 5 tables in rows 2,3,6,7,8. No pattern-match; the migration names all 11.person_settings, saved_filters, workspaces) carry ON DELETE CASCADE; preserve it verbatim on the re-targeted constraint. The other 8 are NO ACTION; preserve that. All 11 are ON UPDATE NO ACTION. A CASCADE silently dropped, or a NO ACTION silently turned CASCADE, is a real behavior change. The CR calls out, per CASCADE FK, that its semantics shift from "deleting a persons row cascades" to "deleting a principals row cascades" — this is the intended end-state (the row being deleted is now the identity row in principals), but it is stated, not assumed.
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.
Migration 0090 installed the reverse mirror, live in the DB:
stele_principals_mirror_to_persons — AFTER INSERT OR UPDATE ON public.principals FOR EACH ROW.stele_mirror_principals_to_persons() — plpgsql. Body (verbatim, for the down-path):
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.
_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).
Question. Are the trigger-retire (§4) and the persons-drop (§5 + DROP TABLE) one migration or two?
Decision: two migrations. Reasoning:
persons cannot drop until the trigger is gone anyway — the 0090 trigger writes to persons on every principals insert/update, so a live trigger over a dropped table fails. The ordering is forced: trigger retire before table drop. Two migrations make that ordering explicit in the migration graph rather than implicit inside one upgrade body.playground_dev. Migration A's down restores the trigger + function verbatim (one concern). Migration B's down recreates persons (7 columns) + re-adds the 4 stubs' table registration concern + re-points the 11 FKs back to persons(id) (the inverse of §3). Splitting keeps each down-path a single, independently testable unit. A combined down-path is compound and harder to prove.persons) before either the trigger or the table can go.
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.
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.
principals.id (migration A)
Write a migration (next after 0090 — CC names it) that, for each of the 11 FKs in §3:
REFERENCES persons(id) constraint by its exact name; add a REFERENCES principals(id) constraint preserving that FK's own ON DELETE behavior (CASCADE for rows 8/9/11, NO ACTION for the other 8) and ON UPDATE NO ACTION throughout. Schema-qualify credit.credit_grant. Name each column by hand per §3 — no pattern-match.principals constraint, restore the original persons(id) constraint with its original name and original delete/update behavior, for all 11.playground_dev confirming persons.id ⊆ principals.id (every existing non-null FK value resolves against principals.id). If it fails, HALT — the subset assumption is wrong and the orientation is stale.
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)
With no FK referencing persons, the 0090 reverse trigger has no job. Write a migration that drops it.
DROP TRIGGER stele_principals_mirror_to_persons ON public.principals; DROP FUNCTION stele_mirror_principals_to_persons();created_at-immutable-on-conflict clause.
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)
_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.
_PersonRow classes (§5), token-level (NAME-token sweep, not regex). Confirm with grep -rl '^class _PersonRow' src/loomworks/ returning nothing after.DROP TABLE persons; (no FK references it now; no trigger writes to it now — both cleared in Commits 1–2).persons (7 columns — id, display_name, totp_secret, first_login_at, last_presence_proof_at, created_at, updated_at, matching the CR-C persons.id shape with gen_random_uuid() default). The down-path restores the table so the alembic graph is reversible; note that the 4 ORM stubs are code, not migration state, and are restored by git, not by the down-migration — the down recreates the table, not the Python registrations.persons and removing the stubs will break tests. Per the empirical-test-surface discipline, do NOT work from a count — run the suite, let it name the breaks, fix each as it surfaces (repoint to principals/PrincipalRow or the appropriate fixture; verify the WHY before repointing — a test that was deliberately asserting against the mirror needs its premise re-examined, not fixture-patched green).
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
_PersonRow stubs.playground_dev, not the suite alone — all three migrations.persons.id ⊆ principals.id validated on playground_dev before the re-target constraints land.git add <paths>, never -A).persons coupling; Phase 7 (standalone repo, SDK surface, mountable router, reference app, the clone-and-run bar) is the next arc, gated on CR-B landing.principals since CR-C; CR-B only moves the FKs that still point at the mirror.principals.id, settled.cr-2026-113-stele-cr-b, suite green at each commit, branch pushed after halt-confirm.principals.id, each with its original delete/update behavior preserved._PersonRow stubs removed; persons dropped.main; playground_dev confirms the host-FK class resolves against principals with persons gone.DUNIN7 — Done In Seven LLC — Miami, Florida CR-2026-113 — Stele Extraction CR-B — v0.1 (planned trajectory) — 2026-06-19