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

Loomworks change request — Stele extraction Phase 4 (the sign-up mint↔onboard split, approach B — two-phase) — CR-2026-107 — v0.1

Version. 0.1 Date. 2026-06-15 Status. Change request — the fifth executable phase of the Stele extraction, drafted against the post-Phase-3 tree (engine origin/main d3acd84; suite baseline 2920 passed / 46 skipped / 0 failed; alembic head 0083, unchanged). Phase 4 splits the fused sign-up — which today mints the principal and onboards the new person in one body and one transaction — into two separable, separately-committed phases: mint_principal (Stele owns) and onboard (host owns), with an explicit onboarding_complete boolean on the principal and a heal-on-next-sign-in path that completes onboarding for a principal that was minted but not onboarded. This is the first schema change in the extraction (migration 0084): Phases 0–3 held alembic at 0083; you cannot persist a minted-not-onboarded principal without a field to record the state. Markdown primary (technical consumer: Claude Code). Halt-before-push; per-step commits; verify against engine-database truth, not the suite alone.

Decisions this CR rests on (settled — do not relitigate).

  1. Sign-up split = approach B (mint-then-onboard with a heal path) — locked at Phase 0.
  2. B is realized two-phase (locked this session): mint commits on its own; onboard commits separately; an orphaned principal (minted, onboard not committed) is a real persistable state the heal path repairs. The alternative (B-single-transaction, same atomicity as today, heal guarding only the dies-between-commit-and-cookie window) was considered and set aside in favour of the two-phase form, because the standalone Stele story (Phases 6–7) wants a host that can onboard out-of-band, and choosing two-phase now designs the orphaned-principal state deliberately rather than retrofitting it later.
  3. The credit-grant claim moves to the onboard phase (locked this session). Today it commits atomically with the mint (signup.py route :223–224); it has a hard ordering dependency on the PersonRow existing first (claim_grant takes person_id and credits that person — read #2 §A.4). It is logically onboarding (it grants credits to a new person; it does not establish identity), so it runs in onboard, after mint. A minted-not-onboarded principal holds no credits; the heal path grants them when it completes onboarding.
  4. mint_principal is the single mint primitive; create_person's onboarding tail is stripped (locked this session). The real signup mints inline at signup.py:261; the existing stele/registry.py:create_person (no production caller, ~test-only) diverges — it seeds the three system saved filters (seed_system_defined_filters, an onboarding tail at :92–93) which the real signup does not. Phase 4 makes mint_principal the one true mint, repoints the real signup to it, and moves the saved-filter seed out of create_person into the onboard phase where it belongs.
  5. State representation = explicit onboarding_complete boolean (locked this session). A no-migration alternative — reuse personal_engagement_id IS NULL as the orphaned-principal signal — was surfaced by read #2 and set aside: it overloads one field with two meanings and breaks if any future code sets the FK for another reason. The explicit boolean is a first-class field whose only job is this state. Cost: migration 0084 with an inverted backfill (existing rows → true; new-mint default → false).
  6. Move, not duplicate — one implementation throughout; safety from small reversible commits, not a parallel fallback. (Carried from Phase 0.)

Source grounding. Two read-only grounding inspections at d3acd84, recorded as stele-phase-4-grounding-read-1-v0_1 and stele-phase-4-grounding-read-2-v0_1 (the bodies and bucketing in §3 below are drawn from them). This CR is drafted against those reads, not from memory — the Phase 0–3 grounding discipline, made a standing pre-draft step by the Phase 3 record (its two scope misses both came from coordinate-only scoping). The Stele body-of-work brief v0.4 §5 (Phase 4 row = the sign-up split); the integration design v0.2 (§3.2 the mint operation); the Phase 3 completion record v0.1 §7 (Phase 4 handoff and its cautions).


Plain-language summary

What the document does. Gives Claude Code the exact, ordered steps to split sign-up into two parts that can succeed or fail independently: minting the person's identity (Stele's job) and setting up their Loomworks account — commons membership, org membership, personal engagement, credits (the host's job). Today these are one inseparable block; Phase 4 draws a seam between them.

What changed. This is v0.1 — first draft of the Phase 4 plan. No prior version.

What decision is needed. None blocking — the six decisions above are locked. One HALT is built into the steps (Step 4.5, the fixture-migration confirmation) where CC inspects-and-reports before editing the ~37 direct-minting fixtures, because the two-phase split changes what a complete-account fixture has to do and that blast radius is confirmed per-fixture at execution, not guessed here.

In scope. A new mint_principal operation in Stele; a new onboard host operation; the onboarding_complete column (migration 0084, inverted backfill); the heal-on-next-sign-in path; repointing the real signup; stripping create_person's saved-filter tail; the credit-claim move to onboard; fixture migration by bucket.

Out of scope. Recovery completion, passkey-revoke, TOTP-rotate (Phase 5); de-engine / packaging / the physical persons → principals + account split (Phase 6); docs + reference UI (Phase 7); any background orphan-sweep (heal is sign-in-triggered only this phase).

The one thing that makes Phase 4 different from 0–3. Every prior phase was schema-neutral. This one adds a column and runs a data backfill. The backfill is inverted — every existing account must read onboarding_complete = true (they are already onboarded), while the column default for new mints is false. Get this backwards and every live account looks un-onboarded on next sign-in, and heal tries to re-onboard real accounts (which then throw — see the next point).

The care-point the reads forced. The onboarding steps are not idempotentcreate_membership and create_organization_membership raise if an active row already exists (read #2 §A.2). So onboard cannot be re-run step-by-step against partial state. The resolution: onboard is its own atomic transaction. It either fully commits (flag flips true, never re-runs) or fully rolls back (flag stays false, heal runs the whole tail fresh next time). The orphaned principal is therefore always "minted, onboard never committed" — never "minted, onboard half-done." Heal is "run the onboard that never ran," not "resume a half-finished onboard."


1. The seam, in the live code

Read #1 found the mint/onboard boundary is a clean conceptual line inside one function, not a function boundary. In persons/signup.py:complete_totp_verify (:226–370):

Both halves share one function and one transaction (get_db_session, deps.py:37–55 — commit on clean return, rollback on exception). Phase 4 separates them at :285→286.

The principal-row mutation finding (read #2 §A.2). create_personal_engagement (persons/personal_engagement.py:327–~447) ends with a raw UPDATE persons.personal_engagement_id — onboarding writes back to the principal row. So in the two-phase form, mint writes the PersonRow and commits; onboard later re-writes that same row (sets the FK) in its own transaction. That is fine (a normal UPDATE in onboard's transaction), but it means a minted-not-onboarded principal has personal_engagement_id = NULL and onboarding_complete = false — two redundant signals of the same state. The CR asserts these must agree (§6 invariant); they are not allowed to drift.

2. The two operations Phase 4 introduces

2.1 mint_principal — Stele owns

A new operation in Stele (default placement stele/registry.py alongside the existing create_person, since both mint a PersonRow; [CC verifies at Step 0 — confirm no name collision; report if the Stele convention favours a dedicated module]). Signature shape:


mint_principal(
    *, display_name, totp_secret_ciphertext, passkey_credential, recovery_codes,
    secret_key, db
) -> PersonRow

It does exactly the :255–284 work: encrypt-already-supplied TOTP secret stored, PersonRow mint with onboarding_complete=False (the new column's default — explicit at construction for clarity), add_credential, recovery-code generate/store. It does not seed saved filters, does not create any membership, org, personal engagement, or credit, and does not issue a session. It commits nothing itself — the host owns the transaction boundary; mint_principal writes into the session the host hands it and the host commits the mint phase.

Reconcile create_person. Strip seed_system_defined_filters out of create_person (:92–93). After the strip, create_person and mint_principal should converge on "mint a bare PersonRow"; if create_person's only remaining divergence from mint_principal is the absence of credentials (it mints credential-less, mint_principal mints with passkey+recovery), keep both but [CC reports at Step 0 whether create_person can become a thin caller of mint_principal or whether the credential-less shape justifies keeping it separate — Operator picks if non-obvious]. The saved-filter seed relocates to onboard (§2.2).

2.2 onboard — host owns

A new host operation (placement persons/onboard.py, new file [CC verifies]). Signature shape:


onboard(
    *, person, claim_token, verified_email, db
) -> None   # raises on failure; caller's onboard-transaction rolls back

It does the :286–352 work plus the relocated saved-filter seed plus the relocated credit claim, in this order:

  1. seed_system_defined_filters(person) (relocated from create_person).
  2. create_membership commons + E2E-test engagement.
  3. create_organization_membership DUNIN7.
  4. create_personal_engagement (which sets personal_engagement_id).
  5. If claim_token present: claim_grant(claim_token, person_id=person.id, verified_email, db) (relocated from the route :222–260).
  6. Set person.onboarding_complete = True.

All six run in one transaction (onboard's own commit). On any failure the onboard transaction rolls back entirely — onboarding_complete stays false, personal_engagement_id stays NULL, no membership/credit survives — and the principal is left in the clean orphaned state for heal to complete. This atomic-onboard design is what removes the need for per-step idempotency in the non-idempotent membership functions: heal never re-enters a half-onboarded principal.

2.3 The new signup flow

complete_totp_verify (signup.py:226) becomes the orchestrator of the two phases:

  1. TOTP verify (:251–252) — unchanged.
  2. person = mint_principal(...)commit the mint transaction. (The mint is now durable on its own.)
  3. onboard(person, claim_token, verified_email, db)commit the onboard transaction.
  4. issue_session(person) — unchanged, after onboard.
  5. store.discard + return.

The route signup_totp_verify_route (api/routers/signup.py:163–275) stops owning the credit claim — the claim moves into onboard. The route's transaction handling changes from one-commit to two-commit (mint, then onboard); [CC confirms at Step 0 the exact mechanism — whether get_db_session can be made to commit twice within one request, or whether mint and onboard each take an explicit nested/separate session; report the mechanism and the Operator confirms before Step 4.3]. This is the load-bearing structural change and the most likely place for a surprise — it gets its own inspect-and-report.

3. Blast radius (from the two grounding reads)

3.1 Principal-creation sites

3.2 The onboard callees (read #2 §A.2 — re-runnability)

create_membership (persons/memberships.py:139–211) and create_organization_membership (persons/organizations.py:60–99) raise if an active row exists (not idempotent). create_personal_engagement (persons/personal_engagement.py:327–~447) is the heaviest — raw engagement INSERT, 3× memory events, operator membership, and the persons.personal_engagement_id UPDATE. seed_system_defined_filters (grouping/saved_filters.py:86–~128) is idempotent (skips existing by name). issue_session (stele/session.py:98–114) writes nothing. The atomic-onboard design (§2.2) means none of these is ever re-entered against partial state, so their non-idempotency is contained, not a problem.

3.3 The credit claim (read #2 §A.4)

claim_grant (credit/grants.py:531–~610) — SELECT … FOR UPDATE on the grant, validates, write_issuance_flowcredit.flows / credit.asset_balances, flips grant to claimed. Hard dependency on person_id existing → runs in onboard, after mint. Consistent with the move.

3.4 Fixture buckets (read #2 §B — Bucket 3 confirmed EMPTY)

The fixture decision (Step 4.5, HALT). Bucket-1 fixtures that mint a bare PersonRow now mint a principal with onboarding_complete=False — that is correct for a bare principal and most likely needs no change. Bucket-2 fixtures hand-roll their own onboarding and seed credits directly (not via the signup claim path — read #2 §B caveat), so the claim-moves-to-onboard change does not touch them; but if any Bucket-2 fixture mints via a path that the split changes, it gets repointed. CC inspects each bucket at Step 4.5 and reports before editing — the buckets are marker-based, not a line-by-line read of all 37 (read #2 §B caveat), so per-fixture confirmation happens at execution.

4. The migration (0084) — first schema change of the extraction

Add the column, then backfill inverted, in one migration.


# upgrade()
op.add_column(
    "persons",
    sa.Column("onboarding_complete", sa.Boolean(), nullable=False,
              server_default=sa.text("false")),
)
# inverted backfill: every existing row is an already-onboarded account
op.execute("UPDATE persons SET onboarding_complete = true")

5. The heal path

When a principal signs in (the credential proves them, resolve_session resolves them — Phase 3's machinery), the host checks person.onboarding_complete. If False:

  1. Run onboard(person, claim_token=None, verified_email=<the person's verified email if available>, db) — the same onboard operation, atomic, in its own transaction. (claim_token=None on heal — a credit grant claimed at signup-time that never committed is a separate concern; [CC confirms whether a claim token can survive to a heal attempt; default: heal does not replay claims — record as a Phase-4 boundary].)
  2. On success, onboarding_complete is now True; issue the session as normal.
  3. On failure, onboard rolls back; the principal stays orphaned; the sign-in surfaces the same retry-is-meaningful posture sign-up has today.

Placement. The heal check sits in the sign-in completion path [CC locates the exact sign-in-success site — likely the TOTP-verify or passkey sign-in route that issues the session — and reports at Step 0; the heal check goes immediately before issue_session]. No background sweep this phase — heal is sign-in-triggered only.

6. The invariant

A minted-not-onboarded principal has both onboarding_complete = false and personal_engagement_id = NULL. An onboarded principal has both onboarding_complete = true and personal_engagement_id set (non-null). These two signals must agree. The CR adds a database-truth check (§8) asserting no row exists with onboarding_complete = true AND personal_engagement_id IS NULL, nor the inverse-after-onboard. They are redundant by design — the boolean is authoritative (it is the one the heal check reads); the FK is set by create_personal_engagement inside the same atomic onboard, so they flip together.

7. Step plan (per-step commits; suite green at every commit; halt before push)

Step 0 — pre-flight (read-only, report, no commit). Confirm d3acd84 == origin/main, suite baseline 2920/46/0, alembic 0083 on repo + playground_dev. Verify/report: (a) mint_principal placement + whether create_person can become its thin caller; (b) onboard placement; (c) the two-commit mechanism for the route (§2.3) — the load-bearing item; (d) the heal-check site; (e) migration.py:146 is not on a live route; (f) the Bucket-2 fixtures that mint via a now-changed path, if any. Operator confirms (a) and (c) before execution proceeds.

Step 4.1 — migration 0084 + model column. Add the column, inverted backfill, model field. Run migration on playground_dev; confirm every existing row reads onboarding_complete = true and a fresh insert defaults false. Suite green. Commit.

Step 4.2 — mint_principal + strip create_person tail. Add mint_principal (Stele); strip seed_system_defined_filters from create_person. Do not repoint signup yet. Suite green (test-only callers of create_person may need the saved-filter seed re-supplied in-fixture — [CC reports; if the strip reddens create_person's test users, the seed moves into those fixtures or the onboard tail covers them]). Commit.

Step 4.3 — onboard + repoint signup to two-phase. Add onboard (host) with the six steps incl. relocated saved-filter seed and relocated claim; rewrite complete_totp_verify to mint→commit→onboard→commit→issue_session; remove the claim from the route. Inspect-first on the two-commit mechanism (§2.3) — report the seam, stop-and-report if mint and onboard cannot be made to commit separately cleanly. Suite green. Commit.

Step 4.4 — heal-on-sign-in. Add the onboarding_complete check before issue_session at the sign-in-success site; run onboard if false. Suite green. Commit.

Step 4.5 — fixture migration (HALT: inspect-and-report first). Inspect each bucket against the new mint signature; report which fixtures need a change and what change, before editing. After Operator confirms, apply. Suite green. Commit.

Step 4.6 — verification only (no commit). §8 below.

8. Verification (database-truth against playground_dev, not the suite alone)

9. Halt-before-push

Per Phase 0–3 discipline: per-step commits, suite green at every commit, halt before any push. Browser/database-truth review at the halt before fast-forward and push. If anything looks off at the halt, the offending step reverts as a single commit.

10. Doc-sync notes (not on the execution path)


DUNIN7 — Done In Seven LLC — Miami, Florida Loomworks — Stele Extraction Phase 4 Change Request — CR-2026-107 — v0.1 — 2026-06-15