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

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

Version. 0.4 Date. 2026-06-15 Status. Change request — the fifth executable phase of the Stele extraction. Steps 4.1–4.4 are complete on the branch cr-2026-107-stele-extraction-phase-4 (4.1 migration 0084 51563ab; 4.2 mint + strip fd8d626; 4.3a onboard d2d7994; 4.3b two-phase route rewrite b66e1d0; 4.3c new-semantics test re-points d2d54b4; 4.4 heal 258d056). The Step 4.3 orphan-half DB-truth check passed against playground_dev (real two-phase commit + forced-onboard-failure → clean orphaned principal). Suite at 4.4 close: 2921 passed / 46 skipped / 0 failed. Remaining: heal-half DB-truth (against the kept orphan), Step 4.5 (fixture pass — largely absorbed across 4.2/4.3/4.4 as the reds surfaced), Step 4.6 (final verification), completion record. Markdown primary (technical consumer: Claude Code). Halt-before-push; per-step commits; verify against engine-database truth, not the suite alone.

v0.4 amendment (this revision). The §5 heal placement was wrong and is corrected here. v0.1–v0.3 said the heal check sits "immediately before issue_session" at the two login.py verify functions. Build (Step 4.4) found that issue_session lives inside verify_*, while the account-status gate (suspended/deleted → reject) is a **route-level step after verify_* returns — so "immediately before issue_session" put heal before the gate. That is a real bug: a suspended-or-deleted orphan signing in would be fully onboarded by heal (durable committed memberships, personal engagement, org membership) and only then denied a session** — a gated account should be rejected without being onboarded. Root cause: the heal-placement instruction was written against the session-issue site without checking that the gate sat at a different layer between credential-verify and session-issue. issue_session running pre-gate is harmless (it is stateless — a throwaway token, no DB write); heal running pre-gate is not (it commits real onboarding state). Corrected design: heal moves **out of verify_ and into both routes, placed after the account-status gate and before* the session cookie** — flow verify → gate (reject suspended/deleted, no heal) → heal → cookie. Built and proven at 258d056 with a new regression test (test_suspended_orphan_is_gated_not_healed) that locks the ordering. Same correction class as the v0.3 mint-signature fix: an instruction written against a design site without checking the real surrounding code. No change to scope, the seam, the migration, the two-commit decision, or onboarding_complete as the orphan marker — only the heal call-site placement corrected.

v0.3 amendment (this revision). The v0.2 mint_principal signature was wrong and is corrected here (§2.1). v0.2 gave a signature (totp_secret_ciphertext caller-supplied, recovery_codes as input, -> PersonRow, no email/mobile/now) that contradicted the same section's "do exactly the :255–284 work" requirement — caught by CC at Step 4.2 before any mint_principal code was written. The live :255–284 encrypts the TOTP secret internally (so it needs plaintext + secret_key, not a pre-made ciphertext), generates recovery codes internally and returns the plaintext to the user (so the return can't be bare PersonRow), and sets email/mobile/first_login_at=now (which the v0.2 signature silently dropped — a behaviour change on a security-critical mint). The corrected signature is a verbatim relocation: mint_principal(, display_name, email, mobile, totp_secret_plaintext, passkey_credential, recovery_codes_count=<default>, secret_key, now, db) -> tuple[PersonRow, list[str]]. One intentional deviation from pure verbatim, locked by the Operator: recovery_codes_count is exposed as a parameter (defaulting to the live behaviour) to make the count an explicit knob on the host-agnostic mint contract. Root cause recorded, not smoothed: the v0.2 signature was sketched from the integration design's contract vocabulary* (totp_secret_ciphertext, -> PersonRow) rather than derived from the real :255–284 body — the same propagate-from-the-plan-not-the-code class the Phase 3 record names. The grounding discipline now extends to signatures: a primitive's signature must be derived from the real body it relocates, not from the design's noun for it. No change to scope, the seam, the migration, the heal design, or the locked decisions — only the mint signature corrected to match live behaviour.

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.4 — the §5 heal placement is corrected from "before issue_session" to "after the account-status gate, before the cookie," because the original placement would have onboarded a suspended/deleted orphan before denying it a session. Caught and fixed in build (Step 4.4, 258d056) with a regression test. Steps 4.1–4.4 are complete on the branch; the §9 known-limitations (failed-signup UX gap, orphaned claim) are logged. v0.1–v0.3 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 (corrected at v0.3 — see the v0.3 amendment; the v0.2 signature contradicted the "match :255–284 exactly" requirement and was wrong):


mint_principal(
    *, display_name, email, mobile, totp_secret_plaintext,
    passkey_credential, recovery_codes_count=<generate_recovery_codes default>,
    secret_key, now, db
) -> tuple[PersonRow, list[str]]   # (person, plaintext recovery codes)

This is a verbatim relocation of the live signup.py:255–284 — Stele owns the whole principal-creation primitive, encryption and recovery-generation included (both are identity work; pushing them out to the host caller would split credential creation across modules and move more surface, not less). It does exactly what the live block does:

It does not: seed saved filters, create any membership/org/personal-engagement/credit, issue a session, or commit. It writes into the session the host hands it; the host owns the mint commit (§2.3).

The one intentional deviation from pure verbatim (recorded as a choice, not a drift): recovery_codes_count is exposed as a parameter (defaulting to whatever generate_recovery_codes uses today, so live behaviour is unchanged when unspecified). The live code calls generate_recovery_codes() with no argument; surfacing the count makes it an explicit knob on a host-agnostic mint contract — a different host may want a different count. Locked by the Operator at v0.3.

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, recovery_plaintext = mint_principal(...)commit the mint transaction. (The mint is now durable on its own. The plaintext recovery codes are carried forward to the user in SignupCompleteResult, as today.)
  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 in verify_*, the account-status gate passes), the route checks person.onboarding_complete. If False:

  1. Run onboard(person, claim_token=None, verified_email=None, db) — the same onboard operation, atomic, in its own transaction. claim_token=None is forced by architecture, not merely chosen (resolved at Step 4.4 against the observed orphan state): the orphan has no stored claim token — the pending registration record that held it is discarded at mint-commit, before onboard ever fails. So a heal-time onboard cannot have a claim token to replay. Consequence (logged limitation): a user whose signup failed with a credit claim is healed but without the auto-claim — the grant still exists unclaimed in the credit system; only the automatic claim-at-signup is lost. Tracked alongside the failed-signup UX gap (§9) as a known limitation, not solved in Phase 4.
  2. On success, onboard commits; onboarding_complete is now True; the cookie is set.
  3. On failure, onboard rolls back; the principal stays orphaned; the sign-in surfaces the same retry-is-meaningful posture sign-up has today (no session issued for a still-orphaned principal).

Placement — two routes, AFTER the account-status gate (corrected at v0.4 — see the v0.4 amendment; v0.1–v0.3 said "before issue_session", which put heal before the gate and was wrong). There are two sign-in success paths. The heal check does not sit inside the verify_ functions (where issue_session is) — it sits in the routes, after the account-status gate passes and before* the session cookie is set, so a suspended/deleted account is rejected without being onboarded:

Flow per route: verify creds → gate (suspended/deleted → reject, no heal) → _heal_if_orphaned → set cookie. The person (a PersonRow) is threaded out of verify_* (added to SecondFactorResult) so the post-gate heal reads/mutates the same current ORM row. A new regression test (test_suspended_orphan_is_gated_not_healed) locks the ordering: a suspended orphan is gated and not onboarded.

(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, in the two login routes (post-gate).

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. COMPLETE (branch cr-2026-107-stele-extraction-phase-4, commit 51563ab). Column added, inverted backfill ran (playground_dev: 41 existing rows → onboarding_complete = true; fresh bare insert defaults false), model field added; alembic 0084 on playground_dev/playground_test; suite 2920/46/0. (Branch DBs at 0084, engine main at 0083 — see Status header caution.)

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. COMPLETE (258d056). Heal placed in both login routes, after the account-status gate, before the cookie (corrected from the v0.1–v0.3 "before issue_session" placement — §5/§v0.4 amendment). _heal_if_orphaned runs onboard(claim_token=None) when onboarding_complete=False; person threaded out of verify_* via SecondFactorResult. Not added at me_reactivate.py:148 (§5 boundary). New regression test test_suspended_orphan_is_gated_not_healed locks the verify→gate→heal ordering. The 8 login-test fixtures that built login-capable persons via create_person (flag defaulting false) were set to onboarding_complete=True (they represent already-complete accounts; the orphan flag stays authoritative). Suite 2921/46/0.

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. Known limitations (logged, not solved in Phase 4)

Two limitations on the failed-signup-with-claim path, both rooted in the same fact — the pending registration record (which holds the claim token and pre-mint state) is discarded at mint-commit, before onboard runs — so once a principal is orphaned there is no stored state to drive a richer recovery than heal-on-sign-in:

  1. Failed-signup UX gap. A user whose onboard fails is left at a failed-signup screen with no session (issue/cookie never runs) and no pending record (discarded at mint-commit). Recovery is heal-on-next-sign-in, which only fires when they sign in — but a brand-new user who just "failed signup" may not realise they already have an account to sign into. Not a correctness bug; a dead-end-feeling moment with no in-product guidance toward sign-in. Decided (Operator, Step 4.3b): discard at mint-commit and log this, rather than keep the pending record to enable retry-signup.
  1. Orphaned claim on failed signup. If that failed signup carried a credit-grant claim, heal completes onboarding with claim_token=None (forced — §5), so the auto-claim is lost. The grant itself is not destroyed (it remains an unclaimed grant in the credit system); only the automatic claim-at-signup does not happen. Decided (Operator, Step 4.4): heal does not replay claims; log this.

Both are candidates for a later heal/recovery-UX pass; neither is a Phase 4 obligation. Carried into the completion record and the queued-directions document.

10. 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.

11. 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.4 — 2026-06-15