Version. 0.1
Date. 2026-06-15
Author. Marvin Percival (DUNIN7 Operator) with Claude.ai (scoping/CR) on DUNIN7-M4; CC executing on DUNIN7-M4.
Status. Completion record. Phase 5 (CR-2026-108 v0.2) of the Stele extraction is built, verified against playground_dev, merged to engine main at 8f2665a, and pushed. This record is the full Phase 5 story — what moved, the decision trajectory, the two build-caught deviations, the real-database proof, and the lessons carried forward. Markdown primary (technical consumer).
Governing CR. cr-2026-108-stele-extraction-phase-5-v0_2.md (loomworks-record change-requests/; archived in engine at docs/phase-crs/ commit a877c90).
Governing scoping note. loomworks-stele-phase-5-scoping-note-v0_1.md (loomworks-record 53733db).
What Phase 5 did. Completed the credential-lifecycle surface of Stele. Two operations were genuinely missing and got built — passkey-revoke (with a last-passkey guard) and person-scoped TOTP-rotate (a new Stele twin). One small gap on the already-built additional-passkey path got closed — display_name now threads through enrollment. Second-passkey enrollment and recovery-code surfacing were already shipping (the latter via the §10.3 amendment) and were not rebuilt.
The build, in order. 5C (display_name) → 5A (passkey-revoke) → 5B (TOTP-rotate). Five commits, fast-forwarded onto main: archive → 5C → gitignore → 5A → 5B. No schema change — Phase 5 stayed at alembic 0084.
What this record preserves. Three decisions and the trajectory to each — including one correction the Step 0 grounding forced (the revoke guard predicate). Two build deviations CC caught. The real-database verification. And one grounding-gap lesson for future phases that introduce a delete.
Final state. main at 8f2665a, suite 2933/46/0, alembic 0084, origin in sync. Branch held (deletion is a separate step after this record files).
New Stele primitives:
stele/credentials.py remove_credential(*, person_id, credential_id, db) -> bool — the first delete idiom in stele/. Hard db.delete, flush-only, caller commits. Carries the last-passkey guard.stele/person_totp.py (new file) — the person-scoped TOTP-rotate twin: begin_totp_rotation (generate secret + provisioning URI, no write) and confirm_totp_rotation (verify the new secret, re-encrypt onto PersonRow.totp_secret, flush-only). Subject PersonRow; no import of contributors/; depends only on credentials.envelope.
New host routes (me_security.py), all get_current_person-gated (inherit the totp_verified second-factor gate):
DELETE /me/security/passkeys/{id} — passkey-revoke; route commits; last-passkey refusal surfaces as 409.POST /me/security/totp/rotate/begin — returns {secret, provisioning_uri}.POST /me/security/totp/rotate/confirm — verifies via the primitive; route commits; invalid code → 400.PasskeyCompleteRequest gained an optional display_name field, threaded into the existing add_passkey_complete → add_credential call (substrate already accepted it; the route had omitted it).
Repo hygiene: .wrangler/ added to .gitignore (its own commit e21e261).
| Commit | Step | What |
|---|---|---|
| a877c90 | 1.1 | Archive CR-2026-108 v0.2 into engine docs/phase-crs/ |
| 4d5091e | 1.2 (5C) | Thread display_name through the passkey-complete route (source + test only) |
| e21e261 | — | Ignore .wrangler/ (own commit, in response to the 5C credential-sweep catch) |
| 9477618 | 2 (5A) | passkey-revoke: remove_credential + last-passkey guard + DELETE route |
| 8f2665a | 3 (5B) | person-scoped TOTP-rotate twin (stele/person_totp.py + routes) |
Per-step commits, suite green at every commit, halt before every push. The gitignore landed between 5C and 5A — faithful to how the work ran (the .wrangler/ catch happened at the 5C commit, fixed before 5A began), not reordered for tidiness.
Phase 5's value is mostly in what Step 0's real-code grounding overturned. The scoping note locked decisions from a design-level model; the grounding read corrected one of them against the real login flow. The corrected position and the superseded position are both recorded.
Decision 1 — the revoke guard predicate. CORRECTED at Step 0.
passkey_count == 1 AND totp_secret IS NULL — TOTP treated as "another way in."passkey_count == 1), regardless of TOTP. The real authenticator floor is one passkey.Decision 2 — delete mechanism. Hard-delete, no migration.
revoked_at), mirroring recovery codes' invalidated_at. Set aside: a revoked passkey is a dead credential with no live query needing a tombstone (unlike recovery codes, whose redemption path filters on invalidated_at); the audit value is low and the cost (a migration past 0084, a filter on every credential read) is real. Hard-delete keeps Phase 5 at 0084. Credential-revocation audit, if ever wanted, is a Memory-layer provenance-thread concern, not a soft-delete column.Decision 3 — TOTP-rotate primitive. Person-scoped twin, not generalization. (The planned Step 0 decision.)
encrypt_for_scope/SCOPE_TOTP) both paths call identically — nothing to DRY; the contributor functions gate on row.kind != "human" and engagement-routing that persons lack; and generalizing would make stele/ depend on contributors/, inverting the extraction's layering. A twin matches existing locality (mint_principal already inlines the person-TOTP encrypt in registry.py).stele/person_totp.py, layering preserved (stele/ → credentials.envelope only).
Deviation 1 — the .wrangler/ credential sweep (at the 5C commit). The CR's commit instruction used git add -A, which swept .wrangler/cache/wrangler-account.json (the Operator's Cloudflare account id/name) into the staged 5C commit. CC caught it, amended it out (so 4d5091e is clean), and recommended a gitignore. Nothing was pushed, so the credential never left the machine. Two fixes followed: .wrangler/ was gitignored (e21e261), and from 5A onward the CR's commit instructions staged explicit paths, not git add -A — the class-fix for the footgun, not just the instance-fix.
Deviation 2 — the non-erasure architectural-guard collision (at 5A). The locked hard-delete decision (Decision 2) put the first .delete( in stele/, which tripped tests/test_no_delete_statements.py — the R-A28/R-B21 non-erasure guard that fails the build if .delete( appears in source. The CR did not anticipate this (see §5 — the grounding gap). CC resolved it the guard's designed way: added credentials.py to the file-level allowlist with a rationale matching the six existing precedents (hard_delete.py, workspaces.py, etc.) and the sibling OVA-credentials module's own documented discipline — credentials live outside the memory event log and are deletable without violating non-erasure. The allowlist entry argues the classification (a passkey is operational auth, not a MemoryObject), names the replacement access control (the last-passkey guard), cites the CR, and adds a scope note (unique basename — only stele/credentials.py) tighter than the precedents.
The Step 0 grounding read the placement site (the credential module) and the call-sites (the login flow) — which is what caught the Decision 1 guard correction. But it did not read the repo-wide architectural-guard suite for what polices the kind of change being made. Introducing the first delete into a non-erasure codebase is exactly the kind of change those invariants exist to catch, and the collision surfaced at build (5A) rather than at grounding.
The lesson, for future delete-introducing (or invariant-adjacent) phases: at Step 0, grep the architectural-guard suite for what polices the operation being added, not just the code being touched. This was applied immediately in 5B — CC's first action in Step 3 was a read-only scan of the guard suite for anything policing a new stele/ file or a PersonRow.totp_secret write (came back clean: rotate overwrites a column, no delete, so the non-erasure guard didn't apply). The discipline moved from "caught at build" toward "surfaced at grounding" within the same phase.
This extends the Stele-extraction grounding discipline: ground the function signatures and placements (Phase 4), and ground the architectural invariants that police the kind of change (Phase 5).
All six cases passed against playground_dev through the real production HTTP path (real get_db_session, real transaction semantics, real WebAuthn config wired from settings as _lifespan does), stubbing only the WebAuthn crypto verify exactly as the suite does. Run on the local branch before push, so any real-path divergence would have been fixed locally — none was.
DELETE → 409, row present. The exact case v0.1's OR-predicate would have wrongly allowed; the real path refuses it.200, the other present, person completes a real sign-in with the remaining passkey.200; stored ciphertext changed and decrypts to the new secret through the real crypto; on a real sign-in the old code → 401, new code → 200.400; stored secret unchanged (no partial write); old secret still signs in on the real path.display_name non-NULL; without a name → NULL.
Cleanup: five throwaway persons deleted, zero orphan credentials (FK integrity intact), playground_dev still 0084, branch untouched, no commit.
main at 8f2665a; origin/main == local main.9be25fd..8f2665a (7 files, 918 insertions; no merge commit, no force).main: 2933 passed / 46 skipped / 0 failed.0084 (no schema change in Phase 5), confirmed by direct DB query on both playground_test and playground_dev.cr-2026-108-stele-extraction-phase-5 held (deletion is a separate step after this record files).OneTimeSecretsDisplay — an Operator Layer / frontend component (the §10.3 recovery-code one-time display); not the engine repo. Engine recovery surfacing is server-side only.PendingAddPasskeyStore multi-worker hazard — pre-existing; a deployment-hardening item, not credential-lifecycle work.main at 8f2665a; credential-lifecycle surface complete (DELETE /me/security/passkeys/{id}, /me/security/totp/rotate/begin|confirm, the display_name field).DUNIN7 — Done In Seven LLC — Miami, Florida Loomworks — Stele Extraction Phase 5 Completion Record — v0.1 — 2026-06-15