DUNIN7 · LOOMWORKS · RECORD
record.dunin7.com
Status Current
Path change-requests/loomworks-me-security-recovery-codes-cr-v0_1.html
LoomworksChange Requestv0.12026-06-12Landed (engine)

Recovery-Code Regenerate + the me_security Consolidation

Making seed v0.12's first recovery mechanism — recovery codes — manageable: a person can now regenerate their codes and see how many remain, the old set genuinely revoked. Passkeys and recovery codes move under one /me/security roof.

Version   0.1
Date   2026-06-12
Status   Landed (engine) — built, tested, pushed at 2483093 (consolidation) and 158d4bc (regenerate + count + path change). This CR records the work and its scope decisions.
Render-type   Change request. Operator-facing — HTML primary, Markdown source alongside.
Source   Seed v0.12 (Account recovery — recovery codes); recovery-completeness handoff (2026-06-12); recovery-code surfacing inspection (2026-06-12).
Repos touched   loomworks-engine only (Operator Layer surface sequenced behind)
Supersedes (paths)   /me/passkeys Endpoints CR — passkey routes move /me/passkeys/* → /me/security/passkeys/* (trail below)

Plain-language summary

Recovery codes are the one account-recovery mechanism that already worked end to end — but only just barely. A person got ten codes, shown once at signup, and there was no way to get a fresh set or even to see how many were left. Use them up, or lose them, and that recovery route was simply gone.

This change makes recovery codes manageable. An authenticated person can now regenerate their codes — the old set is revoked and a fresh set is shown once — and see a count of how many unused codes remain. They cannot view their existing codes: codes are stored hashed and can never be shown again, by design. So the surface offers two things — a count, and a regenerate — never a view.

Alongside this, the passkeys routes from the previous change and these new recovery-code routes are consolidated under one /me/security/* route group, mirroring the single Operator Layer security screen that will call both. The passkeys routes move from /me/passkeys/* to /me/security/passkeys/*; their behaviour is unchanged.

As before, this is engine-only — real and proven through the test suite, not yet reachable by a person until the security screen lands.

What was built — the endpoints

Under the consolidated /me/security route group, for the signed-in person only:

All require an authenticated, second-factor-verified session; an unauthenticated request is rejected with 401.

Soft-invalidate, with a distinct column — honest data

Regenerate does not delete the old codes. It soft-invalidates them by stamping a new column, invalidated_at, and keeps the rows.

The column is deliberately distinct from the existing used_at, because "redeemed at sign-in" and "rotated out by a regenerate" are different facts, and the data should say which happened. Overloading used_at to mean both would lose that distinction. Keeping them separate preserves an honest, auditable history of a security-sensitive action — the same corrections-preserved discipline the rest of Loomworks holds to. A code is valid only when both used_at and invalidated_at are NULL.

Row accumulation (old invalidated rows stay) is acceptable; a cleanup pass is deferrable.

The redemption-filter edit — what makes rotation actually revoke

This is the load-bearing change, and it is easy to overlook. Soft-invalidating the old codes only means anything if redemption respects it. So the redemption function — verify_and_consume_recovery_code, the function the sign-in recovery path (POST /auth/login/recovery) calls to consume a code — was changed to filter invalidated_at IS NULL (alongside its existing used_at IS NULL).

Without this filter, regenerate would not revoke anything — the old codes would keep redeeming, and the whole feature would be defeated. With it, a rotated-out code can no longer be redeemed, because redemption happens only here. The test suite proves this directly: after a regenerate, an old code fails the real redemption function and a new code succeeds.

The same filter is applied to the count, so "unused" means genuinely redeemable (not used, not rotated out).

Atomic rotation

Regenerate runs in one transaction: it invalidates the person's currently-valid codes and inserts the fresh set together. A mid-rotation failure rolls back to the prior set — there is no window in which the person has zero valid codes, and no window in which both sets are live. Either the rotation completes (old revoked, new issued) or nothing changes.

No view, by design

Recovery codes are stored as bcrypt hashes; the plaintext is unrecoverable after the one-time display. There is therefore no view endpoint and no way to return stored codes — it is structurally impossible, not an omission. The surface offers a count (how many remain) and a regenerate (get a fresh set, shown once), and nothing else.

The me_security consolidation

The passkeys routes (from the prior change) and these recovery-code routes now live in one router, me_security, under the /me/security/* prefix. This mirrors the single Operator Layer security-settings screen that will call both — passkeys and recovery codes are two halves of "how I get into my own account," and they belong in one place. The consolidation was a pure move for the passkeys routes: same authentication, the same 403 person-binding guard on passkey completion, the same responses — only the URLs changed.

The path change — /me/passkeys/* → /me/security/passkeys/*

Was (passkeys CR)Now
POST /me/passkeys/beginPOST /me/security/passkeys/begin
POST /me/passkeys/completePOST /me/security/passkeys/complete
GET /me/passkeysGET /me/security/passkeys

The /me/passkeys Endpoints CR documented the old paths; this CR supersedes those path references. The change was made now precisely because there is no consumer yet — these endpoints are engine-only and not user-reachable until the OL security screen lands — so moving them was free, and would only have grown costlier once the screen was built against the URLs. Behaviour is unchanged; only the URLs moved.

Migration

0082 adds a nullable invalidated_at timestamp to recovery_codes. Forward-only, no backfill — existing codes have a NULL invalidated_at (never rotated). Reversible (the downgrade drops the column).

Verification

Proven through the test harness, not yet a UI:

me_security tests: 9 passed. The surrounding recovery-code + WebAuthn registration/authentication regression: 48 passed alongside them. The full suite collects 2,937 with no import breakage. The live engine serves all five /me/security/* routes.

What's next (sequenced behind this)

This is engine-only. The Operator Layer security-settings screen — one screen where a person adds a backup passkey, sees their registered passkeys, sees their recovery-code count, and regenerates their codes — is the next change, built once over both engine halves. Until it lands, these endpoints are real and provable but not reachable by a person.

Out of scope

The Operator Layer security-settings surface — the next change, over both engine halves.

Operator-mediated recovery (seed mechanism 3) — still deferred; its decision is pending at the documentation step.

Delete / revoke of passkeys and the last-credential guard — still deferred to where the recovery arc settles what "last credential" means.

Carried, unchanged from the passkeys CR: get_webauthn_config is defined twice in deps.py (lines 337 and 468); Depends(get_webauthn_config) resolves to the last (line 468). This work continues to use it knowingly; the duplicate remains a pre-existing oddity for its own cleanup decision.

DUNIN7 — Done In Seven LLC — Miami, Florida
Loomworks — Recovery-Code Regenerate + the me_security Consolidation — Change Request — v0.1 — 2026-06-12