DUNIN7 · LOOMWORKS · RECORD
record.dunin7.com
Status Current
Path phases/stele-extraction-phase-4/loomworks-stele-phase-4-completion-record-v0_1.md

Loomworks — Stele Extraction Phase 4 Completion Record — v0.1

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.


Plain-language summary

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.

1. The four-version CR trajectory (recorded, not smoothed)

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.

2. What Phase 4 added and changed

The seam (live code). The mint/onboard boundary was a clean conceptual line inside one functionpersons/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_principalexplicit db.commit() (mint, durable) → discard the pending record → onboarddb.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).

3. State after Phase 4 close (on the branch)

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 0084do 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.)

4. The test trajectory (three rounds of red, all resolved honestly)

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:

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.

5. Verification — proven in the real database, both halves

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):

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.

6. Two logged known-limitations (not solved in Phase 4)

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.

  1. Failed-signup UX gap. A user whose onboard fails is left at a failed-signup screen with no session and no pending record. 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. (Operator decision, Step 4.3b: discard at mint-commit and log this, rather than keep the pending record to enable retry-signup.)
  2. Orphaned claim on failed signup. If that failed signup carried a credit-grant claim, heal completes onboarding with 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.

7. Handoff — Phase 5 is next

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:

Doc-sync notes (not on the execution path):

Pending Operator authorizations (none on the execution path; all awaiting a separate go):

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