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).
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.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.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).
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).
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 idempotent — create_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."
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):
:255–284 — principal-creation (Stele): TOTP-secret encrypt, the inline PersonRow mint (:261), add_credential (passkey), recovery codes.:286 onward — onboarding (host): the comment at :286 ("Phase 15 §4.5: every signup atomically gets a Contributor…") marks the cut; then commons + E2E-test create_membership (:286–317), create_organization_membership DUNIN7 (:319–334), create_personal_engagement (:336–352), issue_session (:354–361), store.discard + return (:363–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.
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).
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:
seed_system_defined_filters(person) (relocated from create_person).create_membership commons + E2E-test engagement.create_organization_membership DUNIN7.create_personal_engagement (which sets personal_engagement_id).claim_token present: claim_grant(claim_token, person_id=person.id, verified_email, db) (relocated from the route :222–260).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.
complete_totp_verify (signup.py:226) becomes the orchestrator of the two phases:
:251–252) — unchanged.person = mint_principal(...) — commit the mint transaction. (The mint is now durable on its own.)onboard(person, claim_token, verified_email, db) — commit the onboard transaction.issue_session(person) — unchanged, after onboard.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.
persons/signup.py:261 (the real signup — repoints to mint_principal); persons/migration.py:146 (one-time Phase-14 operator migration — out of scope, a historical one-shot, not a live signup path; [CC confirms it is not on any live route]).stele/registry.py:create_person — onboarding tail stripped (§2.1).PersonRow directly (~37): bucketed below.
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.
claim_grant (credit/grants.py:531–~610) — SELECT … FOR UPDATE on the grant, validates, write_issuance_flow → credit.flows / credit.asset_balances, flips grant to claimed. Hard dependency on person_id existing → runs in onboard, after mint. Consistent with the move.
test_person_model.py (:26,39,53,73,99), test_ciphertext_migration.py:103, test_stele_isolation.py:183, test_me_security.py (:63,270), test_voice_person_settings.py:51, test_auth_logout_endpoint.py:37, test_phase_49_exhaustion_preference.py:38, test_phase_66_spend_pause.py:39, test_phase_49_admin_grants_list.py:43, test_require_founder.py:38, test_automatic_membership.py (:302,311). These hit /me-style endpoints resolving via get_current_person only.test_engagement_addressing_primitives.py:350, test_engagement_list_scoping.py:38, test_companion_completeness_check.py:163, test_phase13_ui_endpoints.py:37; hand-rolled credit state — the ~25 credit/phase-47/48/49/50/51/52/64/66 fixtures listed in read #2 §B.conftest.py full-onboarding fixture; nothing leans on a hidden helper. (This corrects the pre-read expectation that Bucket 3 would be the high-risk set — recorded, not smoothed.)
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.
0084) — first schema change of the extractionAdd 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")
spend_pause migration 0077 is the closest "add a flag to persons" precedent (read #2 §C.2); the Boolean-with-server_default style matches migrations 0070/0075/0005/0018 and the governance-row models.server_default=false governs rows minted after the migration (real new signups via mint_principal). The UPDATE … = true covers every pre-existing row (live accounts, already onboarded). downgrade() drops the column.onboarding_complete: Mapped[bool] = mapped_column(Boolean(), nullable=False, server_default=text("false")) to PersonRow (stele/models.py:43–153).
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:
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].)onboarding_complete is now True; issue the session as normal.
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.
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.
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.
playground_dev, not the suite alone)onboarding_complete = true, personal_engagement_id set, commons + org membership + personal engagement + (if claim) credits all present.create_personal_engagement); confirm the PersonRow survives with onboarding_complete = false, personal_engagement_id = NULL, and no membership/org/credit rows.onboard runs, completes, onboarding_complete flips true, and the session issues — and that the non-idempotent membership functions do not throw (because onboard runs fresh against a clean orphan, not partial state).onboarding_complete = true.onboarding_complete = true AND personal_engagement_id IS NULL.claim_token grants credits (now in onboard); a sign-up without one onboards cleanly.0084 on repo + playground_dev.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.
stele.registry.mint_principal(...) -> PersonRow, host-committed, with onboarding (incl. the credit claim and saved-filter seed) as a separate host onboard operation, and the onboarding_complete boolean + heal-on-sign-in as the orphaned-principal mechanism. Fold alongside the §3.3 contract-name-vs-code-symbol sync the Phase 2/3 records already named.0084) and the two-phase realization of approach B.DUNIN7 — Done In Seven LLC — Miami, Florida Loomworks — Stele Extraction Phase 4 Change Request — CR-2026-107 — v0.1 — 2026-06-15