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

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

Version. 0.2 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.

v0.2 amendment (this revision). Step 0 pre-flight passed clean (HEAD d3acd84 == origin, suite 2920/46/0, alembic 0083 repo + playground_dev — all matched). Both execution-gating confirmations are now locked by the Operator: (a) keep create_person separate from mint_principal (do not converge — converging would force mint_principal's credential params optional to serve a test-only credential-less primitive with no production caller, weakening the production mint contract; they share only the PersonRow(...) line); (c) the single-session two-commit mechanism is viable and confirmed — the session factory uses expire_on_commit=False (db.py:25), so a mid-request session.commit() does not expire loaded ORM objects and the session stays usable. The pre-flight also surfaced three sharpenings folded into v0.2, none a redraw: (1) the route must own the mint commit explicitly, outside get_db_session's clean-return/rollback scope — because if onboard then fails, the wrapper's except: rollback unwinds only the in-flight onboard transaction and the already-committed PersonRow survives as the intended orphaned state (§2.3 sharpened); (2) there are two sign-in success paths, not one — TOTP second-factor login (persons/login.py:416) and recovery-code login (persons/login.py:461) — so the heal check lands at both sites (§5 corrected; recorded not smoothed: v0.1 §5 assumed a single heal site — the Phase-3 "two became five" pattern again, smaller); (3) the real behavioural blast radius of the split is four named signup-orchestration fixtures (test_signup_personal_engagement, test_webauthn_registration, test_automatic_membership, test_stepup_organization_structure), distinct from the ~37 inline-mint fixtures which mint PersonRow directly and do not traverse signup orchestration (§3.4 sharpened, §7 Step 4.5). No change to scope, the seam, the migration, the heal design, or the locked decisions — only added precision from the real tree.

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.2 — Step 0 pre-flight passed and the two execution-gating confirmations (keep create_person separate; route-owned single-session two-commit) are locked. Three sharpenings folded from the pre-flight: route-owned explicit mint commit, two heal sites not one, the four named signup-orchestration fixtures. No redraw — added precision only. v0.1 preserved beside it per discovery-trajectory discipline.

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 — placement confirmed stele/registry.py (Step 0: it already owns Person, create_person, get_person_by_id; mint_principal is a free name, grep-clean, no collision). 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 — keep separate (locked at Step 0). Strip seed_system_defined_filters out of create_person (:92–93); the saved-filter seed relocates to onboard (§2.2). After the strip, create_person is a bare credential-less mint (display_name/email/mobile only, no passkey/TOTP/recovery — it explicitly "does not create credentials") used only by ~test fixtures, while mint_principal is the full-credential production mint (verified WebAuthn credential + encrypted TOTP secret + recovery codes). They are kept separate, sharing only the PersonRow(...) line: converging create_person into a thin caller of mint_principal would force mint_principal's credential parameters optional to accommodate a primitive with no production caller — weakening the production mint contract to serve a test utility. create_person stays as the credential-less test/utility primitive.

2.2 onboard — host owns

A new host operation — placement confirmed persons/onboard.py (Step 0: free as a new file; persons/ already holds the operation modules signup.py, login.py, memberships.py, organizations.py, personal_engagement.py, and onboarding is host policy, so persons/ is the right package). 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).

The mechanism (confirmed at Step 0, not a guess). The session factory uses expire_on_commit=False (make_session_factoryasync_sessionmaker(engine, expire_on_commit=False), db.py:25). A mid-request session.commit() therefore does not expire loaded ORM objects — the identity map survives and the session stays fully usable. AsyncSession autobegins a fresh transaction after each commit. So the viable mechanism is a single request session committing twice: mint_principal(...)await session.commit() (principal durable) → onboard(...) → final commit. No separate session block is needed.

The route must own the mint commit explicitly — load-bearing. The explicit session.commit() after mint_principal is what makes approach B real. complete_totp_verify today never commits; it relies on get_db_session's clean-return commit / exception rollback (deps.py:37–55). In Phase 4 the route must own the mint commit explicitly and outside that wrapper's rollback scope: if onboard then fails, the wrapper's except: rollback unwinds only the in-flight onboard transaction — the already-committed PersonRow survives, which is the intended minted-not-onboarded state the heal path completes. This is the exact inversion of today's atomic-signup behaviour, by design. Do not let the mint commit sit inside the rollback scope — that would restore today's all-or-nothing and defeat the split.

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 real behavioural blast radius (confirmed at Step 0) is a separate, smaller set — four signup-orchestration fixtures, not the ~37 inline-mint fixtures. The ~37 mint PersonRow directly and seed credits/membership directly, bypassing signup orchestration (verified: 0 orchestration calls across the representative set; test_phase_47_credit_endpoints explicitly drives the seam directly rather than through the signup route, :285). The split does not change their mint path. The fixtures the mint↔onboard split lands on behaviourally are the four that exercise signup orchestration:

These four are where Step 4.5 does its real work; the ~37 inline-mint fixtures are checked for the new onboarding_complete=False default on a bare mint (Bucket 1) but are otherwise untouched by the split.

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 — two sites, confirmed at Step 0 (v0.1 assumed one; recorded not smoothed). There are two sign-in success paths that issue a full session after credential verification, and the onboarding_complete check sits immediately before issue_session in each:

Both are in the existing first-login neighbourhood, so the heal check sits naturally beside the first_login handling. (signup.py:356 is sign-up — excluded, it's the mint path itself; auth_dev.py:95 is the dev mint — excluded.)

Secondary site — a CR decision-point, not silently included. api/routers/me_reactivate.py:148 re-issues a session "mirroring the totp-verify success path" — a candidate heal site for reactivation. Reactivation is a different path than first-onboard, and whether an orphaned principal can even reach reactivation is its own question (a reactivated account was, by definition, previously a complete account — so it should already read onboarding_complete = true). Phase 4 does not add the heal check here; it is named as a boundary for a later decision. [If the Operator wants reactivation covered, it is a one-line addition mirroring the two login sites — but the default is to leave it, since a reactivation target is not an orphaned principal.]

No background sweep this phase — heal is sign-in-triggered only, at the two login.py sites.

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). COMPLETE — passed clean. Confirmed d3acd84 == origin/main, suite 2920/46/0, alembic 0083 repo + playground_dev. All six items reported; the two gating confirmations (keep create_person separate; route-owned single-session two-commit) are locked by the Operator. The three sharpenings are folded into this v0.2. Execution proceeds from Step 4.1. (The first executing step — archive this CR into the engine docs/phase-crs and cut the Phase 4 branch — happens at the start of Step 4.1, per the Phase 2/3 mechanic; Step 0 itself branched nothing.)

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 (both sites). Add the onboarding_complete check before issue_session at both sign-in-success sites (login.py:416 TOTP, login.py:461 recovery); run onboard if false. Do not add the check at me_reactivate.py:148 (§5 boundary). Suite green. Commit.

Step 4.5 — fixture migration (HALT: inspect-and-report first). Two sets. The four signup-orchestration fixtures (test_signup_personal_engagement, test_webauthn_registration, test_automatic_membership, test_stepup_organization_structure) are where the split lands behaviourally — inspect each against the two-phase flow and report what changes before editing. The ~37 inline-mint fixtures mint PersonRow directly and bypass orchestration — they need only the new onboarding_complete default checked (a bare Bucket-1 mint now defaults false, which is correct); report any that assert on a complete account without hand-rolling it (expected: none, Bucket 3 empty). Report the full per-fixture change set, then apply after Operator confirms. 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.2 — 2026-06-15