Version. 0.1
Date. 2026-06-15
Author. Marvin Percival (DUNIN7 Operator) with Claude.ai (scoping/CR) and CC (executing agent) on DUNIN7-M4.
Status. Completion record. Phase 4 of the Stele extraction is functionally complete and fully verified on the branch cr-2026-107-stele-extraction-phase-4 at 258d056 — suite 2921 passed / 46 skipped / 0 failed, both playground_dev and playground_test at alembic 0084, heal proven end-to-end in the real database. Not yet pushed or merged — branch awaits Operator push/merge authorization; engine main is still at bcb59f5 (the v0.2 archive commit, alembic 0083). Markdown primary (technical consumer). Records what moved (the sign-up mint↔onboard split), the first schema change in the extraction, the four-amendment CR trajectory (v0.1→v0.4, two of the corrections caught in build), the grounding-discipline-extended-to-signatures lesson, the gate-ordering bug and its fix, the two-half database-truth proof (orphan-creation + heal), two logged known-limitations, and the Phase 5 handoff.
Governing CR. CR-2026-107 v0.4 (loomworks-record e930fbd carries v0.3; v0.4 filing pending; v0.1–v0.3 preserved beside it per discovery-trajectory discipline; engine branch archives v0.2/v0.3/v0.4 in docs/phase-crs/). The v0.1→v0.4 trail is the substance of this record.
Phase 4 split the sign-up flow in two. Until now, when a new person signed up, one inseparable block of code did two different jobs in one transaction: it minted the principal (created the identity — the person row, the encrypted TOTP secret, the passkey credential, the recovery codes) and it onboarded them (commons membership, organization membership, personal engagement, system saved filters, and any credit-grant claim). Phase 4 drew a seam between those two jobs. Minting is Stele's work — it is pure identity, and it now lives in a new operation mint_principal in stele/registry.py. Onboarding is the host's work — it is Loomworks policy about what a new arrival gets, and it now lives in a new operation onboard in persons/onboard.py.
The two halves now commit separately: mint commits first and is durable on its own; onboard commits second. Between them a new state becomes possible that the system had never been able to represent — a principal that was minted but not onboarded (an "orphan"), recorded by a new boolean column onboarding_complete on the person row. If onboard fails after the mint commits, the orphan survives, and the next time that person signs in, a heal path runs onboard to completion before letting them in. This is "approach B" — mint-then-onboard with a heal path — the split shape locked back at Phase 0.
Two things make Phase 4 categorically different from Phases 0–3. First, it is the first schema change in the whole extraction: Phases 0–3 held alembic at 0083; Phase 4 adds migration 0084 (the onboarding_complete column) with an inverted backfill — every pre-existing account is marked already-onboarded (true), while the column default for new mints is false. Second, it is the first real behaviour change, not a pure relocation: the old all-or-nothing signup guarantee is deliberately replaced by the two-phase mint-durable-then-onboard model, and the orphaned-principal state is created by design.
As in Phase 3, the most instructive part was not the code but the corrections. Phase 4's CR went through four versions; **two of the four corrections were caught in build, not in pre-flight** — the mint-operation signature (it contradicted the live code it claimed to relocate) and the heal placement (it would have onboarded a suspended account before denying it a session). Both are recorded below, because they are the lesson of this phase: the grounding discipline that Phase 3 applied to which functions exist has to extend to what a function's signature and surrounding code actually are.
Phase 4's CR went v0.1 → v0.4. Unlike Phase 3 (where all three corrections were caught in pre-flight before any code), two of Phase 4's corrections were caught during execution — which is itself the lesson: pre-flight grounding catches what you thought to look for; the rest surfaces only when real code meets the plan.
v0.1 → v0.2: pre-flight confirmations + three sharpenings. Step 0 pre-flight (read-only) passed clean and locked two gating decisions: keep create_person separate from mint_principal (converging would weaken the production mint contract to serve a test-only primitive); and the single-session two-commit mechanism is viable because the session factory uses expire_on_commit=False (db.py:25) — a mid-request commit does not expire loaded ORM objects. Three sharpenings folded in: the route must own the mint commit explicitly (outside the rollback scope); there are two sign-in success paths, not one; and the real behavioural blast radius is four named signup-orchestration fixtures, not the ~37 inline-mint fixtures.
v0.2 → v0.3: the mint signature was wrong (caught in build, Step 4.2). v0.2 gave mint_principal a clean-looking signature — totp_secret_ciphertext (caller-supplied, pre-encrypted), recovery_codes as input, -> PersonRow, no email/mobile/now — while the same section said "do exactly the live signup.py:255–284 work." Those contradict. The live block encrypts the TOTP secret internally (needs plaintext + secret_key, not a ready-made ciphertext), generates recovery codes internally and returns the plaintext to the user (so the return cannot 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). CC caught the contradiction before writing any mint_principal code. Corrected signature (a true 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 live behaviour) to make the count an explicit knob on a host-agnostic mint. Root cause: 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.
v0.3 → v0.4: the heal placement was wrong (caught in build, Step 4.4). v0.1–v0.3 said the heal check sits "immediately before issue_session" at the two login.py verify functions. Build found issue_session lives inside verify_*, while the account-status gate (suspended/deleted → reject) is a **route-level step after verify_* returns. So "before issue_session" put heal before the gate — meaning 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 must be rejected without being onboarded. The fix (locked: option iii): move _heal_if_orphaned out of verify_* and into both routes, after the gate, before the cookie — flow verify → gate (reject, no heal) → heal → cookie. Built with a new regression test that locks the ordering.
The standing lesson. Both build-caught corrections are the same class: an instruction written against a design site (the operation's contract noun; the session-issue line) without checking the real surrounding code (the live body being relocated; the gate sitting between verify and issue). Phase 3's lesson was "ground the design in a full read of the real code before drafting the steps." Phase 4 extends it: a primitive's signature must be derived from the real body it relocates, and a placement instruction must be checked against the real code at the placement site — the design's noun for a thing, and the design's mental model of where a thing sits, are both insufficient. The discipline now covers signatures and placements, not just function inventory.
The seam (live code). The mint/onboard boundary was a clean conceptual line inside one function — persons/signup.py:complete_totp_verify, with principal-creation at :255–284 and the onboarding tail from :286. Phase 4 separated them into two operations and a two-commit orchestrator.
mint_principal (Stele, stele/registry.py). A verbatim relocation of :255–284: encrypt the TOTP secret internally (encrypt_for_scope(SCOPE_TOTP, …, secret_key)), mint the PersonRow (display_name, email, mobile, encrypted totp_secret, first_login_at=now, onboarding_complete=False), add_credential (passkey), generate + store recovery codes, return (person, plaintext_codes). Commits nothing — the host owns the transaction. create_person was kept separate (a bare credential-less test/utility primitive) with its saved-filter tail stripped (relocated to onboard).
onboard (host, persons/onboard.py). A relocation of the onboarding tail plus the saved-filter seed plus the credit claim, in one atomic transaction: seed system filters → commons membership → E2E-test membership → org membership → personal engagement (sets personal_engagement_id) → if claim_token: claim_grant → set onboarding_complete=True. Raises on failure; the caller's onboard transaction rolls the whole thing back. The atomic-onboard design means the non-idempotent membership functions are never re-entered against partial state — an orphan is always "minted, onboard never committed," never "half-onboarded."
The two-phase route (complete_totp_verify + signup_totp_verify_route). New order: TOTP verify → mint_principal → explicit db.commit() (mint, durable) → discard the pending record → onboard → db.commit() (onboard) → issue_session → return. The route owns both commits; the mint commit sits outside get_db_session's rollback scope, so a failed onboard leaves the committed PersonRow as the orphan. The credit claim moved from the route into onboard (it has a hard ordering dependency on the person existing). The mechanism works because expire_on_commit=False keeps the session usable across the mid-request mint commit — proven, not just asserted (§5).
Migration 0084 (the first schema change). onboarding_complete BOOLEAN NOT NULL DEFAULT false, then UPDATE persons SET onboarding_complete = true — the inverted backfill: every existing row is an already-onboarded account (true), while the default governs new mints (false). Model field added to PersonRow. Verified on playground_dev: 41 existing rows → true, fresh insert → false.
Heal-on-sign-in (_heal_if_orphaned, both login routes). Post-gate, pre-cookie: if onboarding_complete=False, run onboard(claim_token=None), commit, then set the cookie. claim_token=None is forced — the orphan has no stored claim token (the pending record that held it was discarded at mint-commit). person is threaded out of verify_* via a new SecondFactorResult.person field so the post-gate heal reads/mutates the current ORM row. Not added at me_reactivate.py:148 (a reactivation target is a previously-complete account, not an orphan).
Engine branch cr-2026-107-stele-extraction-phase-4 at 258d056. Not pushed; engine main at bcb59f5 (0083). The branch's playground_dev/playground_test are at 0084 — do not run the suite from main until the branch merges (test DB would be ahead of the code).
Suite: 2921 passed / 46 skipped / 0 failed (from clean). Alembic: 0084 head on repo files, playground_dev, playground_test. No inline duplication: complete_totp_verify mints via mint_principal (no inline PersonRow/encrypt_for_scope/store_recovery_codes); the route has no claim_grant/get_credit_seam; the onboarding tail (create_organization_membership, create_personal_engagement) lives only in onboard.py. No stale Phase-4 skips: zero @pytest.mark.skip with a Phase-4 reason remain; all three temporary skips were un-skipped and re-pointed.
Commit series (d3acd84..258d056, 8 commits on the branch):
| Commit | Step | What |
|---|---|---|
| bcb59f5 | — | archive CR-2026-107 v0.2 into engine docs/phase-crs |
| 51563ab | 4.1 | migration 0084 + PersonRow.onboarding_complete (inverted backfill) |
| 171c05a | — | archive CR v0.3 (mint signature correction) onto branch |
| fd8d626 | 4.2 | add mint_principal (verbatim relocation); strip create_person saved-filter tail; split-resolve 3 substrate tests |
| d2d7994 | 4.3a | add onboard host operation (additive, not yet wired) |
| b66e1d0 | 4.3b | repoint signup to two-phase mint→commit→onboard→commit; move credit claim into onboard; discard pending at mint-commit; skip 2 atomicity tests |
| d2d54b4 | 4.3c | re-point 3 tests to two-phase semantics; un-skip all three |
| 258d056 | 4.4 | heal-on-sign-in at both login routes, post-gate; fix 8 login fixtures; add gate-ordering regression test |
(The v0.4 CR archive onto the branch and the v0.4 record-side filing are pending the Operator filing step; this record is drafted ahead of those two commits.)
Phase 4's test work was not a single fixture-migration step (the CR originally anticipated one at Step 4.5); the reds surfaced incrementally as each behavioural change landed, and were resolved as they appeared. Recorded because the pattern — never fixture-patch a test green when its premise is now false — recurred three times:
create_person's saved-filter seed reddened three tests in test_phase_two_substrate.py. Split-resolved: the two that needed a complete account as a precondition were fixture-seeded (the helper now does what onboard will); the one that asserted the stripped behaviour itself was skipped-with-reason (not fixture-patched green, which would have made it assert a behaviour that no longer existed) and re-pointed to onboard in 4.3c.complete_totp_verify in async with db.begin(): (illegal once the function commits internally) — a test-harness mismatch, not a regression (production's get_db_session yields a plain session); the db.begin() wrappers were removed. 2 atomicity tests asserted the removed all-or-nothing guarantee (persons == [] after a failed signup) — skipped-with-reason in 4.3b, re-pointed in 4.3c.create_membership raises a genuine FK violation after the mint commit).create_person (flag defaulting false), so the new heal branch classified them as orphans and onboard hit an FK violation. Per-test honesty check confirmed all 8 represent already-complete accounts (active, or suspended/deleted — both post-onboarding states); their builders were set to onboarding_complete=True, keeping the orphan flag authoritative rather than weakening the trigger or changing create_person's default. A new regression test (test_suspended_orphan_is_gated_not_healed) was added to cover the gate-ordering fix, which had had zero coverage — which is why the bug surfaced as a collateral FK violation rather than a direct test failure.
Latent fixtures (flagged, not bugs). Two test files (test_me_security.py, test_webauthn_authentication.py) build login-capable persons without onboarding_complete but never sign them in through the heal-firing route, so heal never runs. They are defined orphans, not misclassifications: if a future test signed one in through the real route, heal would correctly onboard it. Named here so a future test author understands why that would happen.
Everything was proven against playground_dev through the real production path (HTTP routes through real get_db_session, real transaction semantics; only the WebAuthn crypto verify stubbed as the suite does), not the test harness — because the entire Mode-1 argument (production uses a plain session, so the internal commits work) had been asserted-from-reading and needed observation.
Orphan-half (closes Step 4.3):
onboarding_complete=true, personal_engagement_id set, all memberships + org + personal engagement + 3 system filters present. (Mint commit + onboard commit both durable on the real path — validates the expire_on_commit=False two-commit mechanism against the database.)onboarding_complete=false and personal_engagement_id=NULL, zero memberships/org/personal-engagement/credit, pending record discarded. (The mint commit survived an onboard failure in the real database — approach B is real, not just structurally described.)onboarding_complete=true AND personal_engagement_id IS NULL.
Heal-half (closes Step 4.4), on the same kept orphan: the orphan from the orphan-half check was kept in playground_dev and signed in through the real TOTP login route (TOTP verification fully real — its stored secret decrypted to compute a valid code; no crypto stub). Active account → passed the gate → heal fired post-gate. Result: onboarding_complete flipped to true, personal_engagement_id now set, all memberships + org + personal engagement + 3 filters now present (everything the orphan-half confirmed absent), no auto-claim (heal ran claim_token=None — the logged limitation confirmed as real behaviour), session issued. The loop closed: the orphan one check created and kept is the orphan the next check repaired, on the real database via the real login path. Test rows cleaned up; invariant re-confirmed at 0.
Both sit on the failed-signup-with-claim path and share one root: the pending registration record (holding the claim token and pre-mint state) is discarded at mint-commit, so once a principal is orphaned there is no stored state to drive a richer recovery than heal-on-sign-in.
claim_token=None, so the auto-claim is lost. The grant itself is not destroyed (it remains unclaimed in the credit system); only the automatic claim-at-signup does not happen. (Operator decision, Step 4.4: heal does not replay claims; log this.)Both are candidates for a later heal/recovery-UX pass. Carried into the queued-directions document.
Phase 5 — recovery completion / credential lifecycle. The brief's Phase 5 row: second-passkey enrollment, passkey-revoke, TOTP-rotate, recovery-code surfacing — the credential-management operations that belong to Stele's identity surface but were deferred while the mint/resolve/split spine was built. (Note: an account-recovery amendment already landed separately on the Companion functional spec — Phase 5 is the Stele-side credential-lifecycle work, to be scoped against what that amendment did and did not cover.)
Carry-forward cautions for the Phase 5 CR:
playground_dev, not the suite alone. Phase 4's 4.3 was split into 4.3a/b/c precisely so the load-bearing two-commit cut was isolated in its own revertable commit.Doc-sync notes (not on the execution path):
stele.registry.mint_principal(...) -> tuple[PersonRow, list[str]] (person + plaintext recovery codes), host-committed, encrypting the TOTP secret internally; onboarding (incl. the credit claim and saved-filter seed) as a separate host onboard; onboarding_complete + heal-on-sign-in (post-gate) as the orphaned-principal mechanism. Note the v0.2→v0.3 signature correction and the v0.3→v0.4 placement correction.0084), the two-phase realization of approach B, and the two logged known-limitations.Pending Operator authorizations (none on the execution path; all awaiting a separate go):
cr-2026-107-stele-extraction-phase-4 (8 commits, d3acd84..258d056) to engine main. On merge, main advances to 0084 and the branch-DB-ahead-of-main caution resolves.loomworks-record) and archive onto the branch (engine docs/phase-crs).loomworks-record CR stack (v0.1–v0.4) — currently unpushed, ahead of origin.loomworks-record.
Branch cleanup. After push/merge and this record's filing, the branch can be deleted; main will carry the full series.
DUNIN7 — Done In Seven LLC — Miami, Florida Loomworks — Stele Extraction Phase 4 Completion Record — v0.1 — 2026-06-15