DUNIN7 · LOOMWORKS · RECORD
record.dunin7.com
Status Current
Path change-requests/cr-2026-116-stele-phase-7-p7-3-reference-app-and-ceremony-lift-v0_3.md

CR-2026-116 — Stele Phase 7 P7-3 — Ceremony Lift + Reference App + Browser Ceremony + Config Generator + Docs — v0.3

Version. 0.3 Date. 2026-06-19 Author. Marvin Percival (DUNIN7 Operator) with Claude.ai (CR drafting) on DUNIN7-M4; CC executing on DUNIN7-M4. Status. Change request, as-built / landed. The final Phase 7 slice — the clone-and-run-and-use bar — is built, verified, and (pending the records push) closed. Phase 7 is closed. Five deliverables shipped; one sub-step (§1.6 cookie-constant neutralization) deferred to a named parked thread on a Step-1 finding. This version records what shipped and carries the §9 asymmetry-dissolution forward-note (the canonical home for it, per the settled decision). Markdown primary (technical consumer); the §5 docs deliverable shipped HTML primary + Markdown source. Governing scoping note. loomworks-stele-phase-7-p7-3-scoping-note-v0_1.md. Governing handoff. loomworks-stele-phase-7-p7-3-cr-drafting-handoff-v0_2.md. Grounding inspection. loomworks-stele-phase-7-p7-3-step-0-inspection-report-v0_1.md (record 45ac2f1). Step 0 grounding note. CC, read-only, loomworks-record at 74e16e6 (premise held, no drift, no HALT). Completion record. phases/stele-extraction-phase-7/loomworks-stele-phase-7-p7-3-completion-record-v0_1.md.

v0.3 change. Records the as-built outcome. §1.6 (cookie neutralization) deferred — a Step-1 grep-first finding inverted the CR's "two-site touch" premise: the engine consumes stele.session.COOKIE_NAME as its live production cookie (10 sites, 4 files), so an in-place rename mass-logs-out every session; removing the leak needs a sequenced engine-decouple sub-slice (out of P7-3 scope). The lift shipped without it. Adds the §9 asymmetry-dissolution forward-note as canonical (the completion record + manifest forward-reference this version for it). Records the seed-alignment inspection (discoverable-credential / "remember ID" non-feature) folded into the §5 docs. Names two parked threads (§1.6 cookie decouple; the login.py:5 stale comment). Supersedes v0.2.

CR number. CR-2026-116, confirmed. v0.2 filed at ed0b1d8; this v0.3 supersedes it.

End-state HEADs (landed). DUNIN7/stele 8246729; loomworks-engine 32c1a68; loomworks-record (this CR v0.3 + completion record + manifest v0.68 + §5 docs, pushed together).


Plain-language summary

What this CR does. Takes Stele from "a stranger can mount the router" (P7-2) to "a stranger can clone a reference host, run it, and use Stele's identity primitives end-to-end — including registering a real passkey in a browser" (P7-3). This closes Phase 7.

Five deliverables, sequenced so the lift lands first (everything downstream shrinks once it does):

  1. The ceremony lift — move the ~100-line add_passkey_begin / add_passkey_complete block out of loomworks-engine/persons/signup.py and into stele.webauthn, where it belongs (it already calls Stele's WebAuthn primitives). This dissolves the P7-2 asymmetry — Stele now owns the passkey-enrollment logic, not just the routes. The injection slots for the ceremony disappear; the engine re-points its import. The inspection proved this is a clean extraction, not a messy split — two trivial one-directional shared symbols, no entangled control flow, signup flow left fully intact.
  1. The reference app — a minimal standalone FastAPI app (not the engine, not Next.js) that mounts stele.router, runs Stele's 3-table migration baseline against a real Postgres, wires the post-lift slot set, and demonstrates both delivery shapes (cookie for browser, bearer for agent) plus enrollment and login.
  1. The browser WebAuthn ceremony — a minimal HTML/JS front that runs the real navigator.credentials.create() / .get() ceremony against the mounted router. This is the "clone, run, and use it with a real authenticator" proof the engine's synthetic-payload mount-verify could not show.
  1. The config generator (env-scaffolding-only) — a small helper that emits a .env template: secret pre-generated, RP-ID / origin / RP-name / database URL to fill. No code generation; can't drift.
  1. The docs — the mount contract written down: what each remaining slot is, what the host supplies, the authorization boundary (Stele authenticates, host authorizes), the cookie-vs-bearer delivery choice, the minimal-mount walkthrough the reference app embodies. Plain-English standard.

What changes for the Operator. After this CR, Phase 7 is closed. Stele is a self-contained, mountable identity plugin with a canonical reference host a stranger can clone and run. The published-package path and the CR-2026-102 SCOPE_TOTP thread remain parked, walkable — not Phase 7.

The asymmetry-dissolution recording. CR-2026-115 §4 named the add_passkey injection-slot asymmetry as a correction-in-waiting. This CR's lift dissolves it. Per the Operator's settled decision (2026-06-19), the dissolution is recorded as a forward-note in this CR (§9) pointing back to CR-2026-115 §4not a 115 v0.3. CR-115 stays true to its own close; the dissolution belongs to P7-3's record, trajectory walkable.

The cookie brand-leak: found deeper than scoped, deferred (correction named). v0.2 scoped a small §1.6 step — neutralize stele/session.py's COOKIE_NAME = "loomworks_session" (an engine-branded constant lifted into Stele at P7-1), estimated as a "two-site touch" (the constant + the P7-2 mount test). The Step-1 grep-first instruction — written to make a missed reader break loudly — did exactly that. It exposed that the engine consumes stele.session.COOKIE_NAME as its live production session-cookie name: 4 set-sites (login.py, signup.py, me_reactivate.py) + 6 read-sites (deps.py), across 4 files. Renaming in place would change the engine's production cookie on deploy → every live browser still sends loomworks_session, the engine reads stele_session, no token resolves → mass logout. Not a two-site touch — a ~10-site engine production-behavior change. So §1.6 is deferred (Operator decision, option A): the lift shipped without it. The brand-leak persists but is harmless to clone-and-run adopters — the reference app names its own cookie (stele_ref_session), so an adopter never inherits Stele's default. Removing the leak properly needs a sequenced engine-decouple sub-slice (engine defines its own COOKIE_NAME and re-points its ~10 sites first, keeping the value loomworks_session; then Stele renames) — a named parked thread (§6), not P7-3.


§0 — Step 0 grounding: complete, resolved (CC 74e16e6; Operator-confirmed 2026-06-19)

The Step 0 grounding pass ran read-only (no edits, commits, migrations, or build). The lift's clean-extraction premise held at live — no drift, no HALT. All six surfaced decisions are resolved and Operator-confirmed. This section records them as settled; the deliverables below are written against these resolutions. Build re-confirms HEADs and the suite floor at start, but does not re-open these.

§0.1 — HEADs. DUNIN7/stele 1077803 ✓; loomworks-engine 08e89c5 ✓; loomworks-record 74e16e6 (ahead of cited 45ac2f1 by two docs-only commits — the scoping note + handoff v0.2; confirmed ancestor, no code-fact drift).

§0.2 — CR number. Highest filed cr-2026-NNN = 115. This CR is 116 — no renumber, no rename. Draft staged in ~/Downloads/; files to change-requests/ at build-go.

§0.3 — Lift clean-extraction facts: HELD at live. Against persons/signup.py (450 lines): the add-passkey block is still co-located at the file bottom (_PendingAddPasskey L350, AddPasskeyBeginResult L359, PendingAddPasskeyStore L364, add_passkey_begin L388, add_passkey_complete L422); the two shared symbols are still one-directional (PENDING_TTL def L46, PendingRegistrationNotFound def L54 — both flow signup-flow → ceremony; the signup-flow half references zero add-passkey symbols, every add-passkey hit being at L≥350); the wrappers carry no host concepts (the file's host concepts — onboard import L35, host_account INSERT L284–319 — are all in the signup-flow half's complete_totp_verify, none in the ceremony); callers are still only me_security.py (import L49–50; calls L178, L230). The lift rests on a true premise.

§0.4 — Store-slot fate: STELE-INTERNAL (confirmed). PendingAddPasskeyStore is a plain in-process dict[str, _PendingAddPasskey] — no host config, no host lifecycle, no host-owned state. _PendingAddPasskey carries only person_id (= principal id) + challenge/user_handle/options_json/expires_at — identity-only, Stele-shaped. stele.api already defines the AddPasskeyStore Protocol (L65). No host binding → the store becomes Stele-internal; provide_add_passkey_store disappears with the two ceremony slots. Post-lift mandatory slot set = provide_db_session + provide_webauthn_config (two).

§0.5 — Login-TOTP path: HOST-COMPOSITION (confirmed). stele.person_totp exposes only begin_totp_rotation (L48) + confirm_totp_rotation (L73) — no standalone TOTP-verify primitive anywhere in stele/. The reference app composes login-TOTP itself (kek_decrypt(totp_secret)pyotp.TOTP(secret).verify(code)), consistent with B-first; Stele's primitive surface does not grow in this CR. (The alternative — a verify_totp_code Stele primitive — remains a walkable later slice if the Operator ever wants it; it is not in P7-3.)

§0.6 — Shared-symbol handling: confirmed. stele.api carries PasskeyEnrollmentNotFound (L84) + PasskeyEnrollmentError (L88) and the AddPasskeyPending / AddPasskeyStore / AddPasskeyBeginResult Protocols (L58/65/71). The lift raises Stele's PasskeyEnrollmentNotFound in place of PendingRegistrationNotFound and brings a Stele-native TTL constant. The signup flow keeps its own PendingRegistrationNotFound (a SignupError subclass) — unaffected.

§0.7 — WebauthnConfig field set + env floor: confirmed exact. WebauthnConfig (frozen) = exactly rp_id, rp_name, rp_origin (three). The generator's env floor: STELE_SECRET_KEY (Fernet, fresh), STELE_RP_ID, STELE_RP_NAME, STELE_RP_ORIGIN, STELE_DATABASE_URL (postgresql+asyncpg://…), optional STELE_SECRET_KEYS_PREVIOUS. Field names match the live EnvKeyEncryptionKeyProvider / WebauthnConfig reads.

§0.8 — Reference-app home: stele/examples/ (confirmed; Operator filing call made). stele top level is alembic.ini, migrations/, pyproject.toml, README.md, src/, tests/; examples/ does not yet exist (the build creates it). stele/examples/ is structurally clean (no collision, sits beside src/) and travels with the package a stranger clones. The reference app (§2), browser front (§3), and config generator (§4) all land under stele/examples/.

§0.9 — Cookie disposition: NEUTRALIZE scoped at Step 0, then DEFERRED at Step 1 on a grep finding (the full correction trajectory). This decision moved twice; both moves are preserved.

Suite floor (to confirm at build start, not re-decided): P7-2's documented floor was 2930/0/46. CC confirms the live floor at §1's start before cutting.


§1 — The ceremony lift (do first; both repos)

Goal. add_passkey_begin / add_passkey_complete (+ their private helpers + the _PendingAddPasskey / AddPasskeyBeginResult / PendingAddPasskeyStore types) move from loomworks-engine/persons/signup.py into stele.webauthn (or the natural stele home Step 0 confirms). The injection slots for the ceremony disappear; the engine re-points its import. The asymmetry dissolves.

§1.1 — extract into stele.webauthn.

§1.2 — the injection slots disappear (all three).

§1.3 — the engine re-points.

§1.4 — persons/signup.py shrinks.

§1.6 — brand-leaked cookie constant: DEFERRED on a Step-1 grep finding (not executed).

§1.7 — verification.

Per-step commits; suite green at each; HALT before push; explicit-path staging.


§2 — The reference app (standalone host)

A minimal standalone FastAPI app that mounts stele.router and demonstrates real end-to-end use. Lives at stele/examples/ (§0.8).

§2.1 — framework. A small FastAPI app — not the engine, not Next.js. stele.router is a plain APIRouter; the app include_routers it under a host-chosen prefix (e.g. /me/security).

§2.2 — DB: real Postgres, via the Stele baseline. SQLite is not viable — the Stele models use JSONB, LargeBinary, PG_UUID. The app:

§2.3 — wire the post-lift slot set. After §1, the mandatory slots are exactly two — provide_db_session + provide_webauthn_config (the store + both ceremony slots are gone, §1.2). The app supplies minimal real providers:

§2.4 — demonstrate both delivery shapes. Cookie (browser) and bearer (agent) over the same Stele-minted token, by overriding extract_token:

§2.5 — demonstrate enrollment AND login. Not just enrollment:

§2.6 — seed-alignment (mandatory; as-built confirmed by a dedicated inspection). The login composition honors the seed's authentication framework:

§2.7 — verification. The app runs; a request round-trips through the mounted router against the real Postgres; enrollment and login both work end-to-end (the browser front in §3 drives the real ceremony).


§3 — The browser WebAuthn ceremony (the gap-close)

A minimal HTML/JS front that runs the real navigator.credentials ceremony against the mounted router.

§3.1 — what it does.

§3.2 — the RP binding. rp_id / rp_origin (from the webauthn config) must match the serving origin or the browser refuses the ceremony. The reference app's config and the served origin must agree — document this in §5 (it's the first thing a stranger gets wrong).

§3.3 — the proof. A user opens the reference app in a browser, registers a real passkey with a real authenticator, and it round-trips through stele.router end-to-end. This is the proof the engine's synthetic-payload mount-verify (P7-2) could not show — "clone and run and use it."

§3.4 — minimum, not a product. Smallest static HTML/JS that demonstrates the ceremony. Not a styled UI; a working proof. No framework.


§4 — The config generator (env-scaffolding-only)

A small helper that emits a .env template. No code generation — env-scaffolding-only suffices because the post-lift mount is two short providers + ~5 env values (Fork 2's resolution).

§4.1 — what it emits. A .env template with:

§4.2 — can't drift. It emits no code, so it can't fall out of sync with the package surface. The adopter copies the file, fills the site-specific fields, runs.

§4.3 — form. A small script (generate_env.py) under stele/examples/. The fresh-secret generation is the one piece of real logic (call the same Fernet-key generation Stele uses internally — confirm the exact call against the live stele KEK module at build, so the generated key is valid for STELE_SECRET_KEY).


§5 — The docs (the mount contract written down)

The adopter-facing documentation — HTML primary + Markdown source (operator/adopter-facing, per convention). The reference-app code + its README stay Markdown/technical.

§5.1 — content.

§5.2 — plain-English standard. What each slot does, what the host writes, in plain terms. Examples where an example makes the wiring concrete (the provide_db_session yielder; the extract_token cookie override). The walkthrough is copy-runnable.

§5.3 — home. loomworks-record (the docs are canonical adopter documentation), with the reference-app code at stele/examples/.


§6 — Held out of P7-3 scope (named so they're not pulled in)


§7 — Build sequencing

  1. §0 — Step 0 grounding: DONE (CC 74e16e6, all six decisions confirmed). Build re-confirms HEADs and the suite floor at start; it does not re-open the decisions.
  2. §1 — the lift (both repos, add-and-verify, engine suite green throughout). First, because it shrinks the slot set everything downstream builds against. Per-step commits; HALT before push.
  3. §2–§4 — reference app + browser ceremony + config generator (built under stele/examples/). The app wires the post-lift slot set; the front drives the real ceremony; the generator scaffolds the env.
  4. §5 — docs (written against the as-built reference app — narrate what the code does, not what it should do).
  5. §8 — record — completion record + manifest bump; the §9 asymmetry-dissolution forward-note.

Each slice: inspect-first on every seam; per-step commits, suite green at each; HALT before push, CC confirms staged state; explicit-path staging (git add [explicit path], never git add -A); shell commands on separate lines, no inline comments.


§7.5 — As-built (what shipped)

Seven commits across two repos, plus the records.

DUNIN7/stele (10778038246729, 5 commits):

| Commit | Step | What | |---|---|---| | ef69b88 | §1.1 | Lift the add-passkey ceremony (+ store, types, enrollment exceptions, Stele-native TTL) into stele.webauthn | | cfb779f | §1.2 | Router calls the internal ceremony; the 3 ceremony/store slots + their Protocols removed; SDK surface + mount docstring updated | | df0e4c5 | §2 | Reference host under examples/ — mounts stele.router, composes signup + login host-side | | 402f8b0 | §3 | Browser WebAuthn ceremony front (index.html + app.js) | | 8246729 | §4 | Config generator (generate_env.py, env-scaffolding only) |

loomworks-engine (08e89c532c1a68, 2 commits):

| Commit | Step | What | |---|---|---| | 01d4aa6 | §1.3 + §1.7 | Re-point me_security + deps + app onto stele.webauthn; catch Stele's PasskeyEnrollment*; update the P7-2 mount test | | 32c1a68 | §1.4 | Delete the now-dead 109-line add-passkey block from persons/signup.py |

§1.6 (cookie neutralize): not executed — deferred (the §0.9 / §1.6 / §6 finding).

Verification (as-built).

As-built decisions embodied: 2-slot mount (provide_db_session + provide_webauthn_config); ceremony Stele-internal; no email as identity (signup collects a display name only; login keys on credentials); login-TOTP composed host-side (kek_decrypt + pyotp, Stele's surface unchanged); the reference app names its own cookie (stele_ref_session) — making the §1.6 deferral safe by construction.


§8 — Done-bar (met)

P7-3 is done. Confirmed:

Scoped-but-deferred: §1.6 cookie-constant neutralization (the engine-dependency finding) — a named parked thread (§6), not a done-bar item. The clone-and-run bar is unaffected: adopters never inherit Stele's cookie name.


§9 — Asymmetry-dissolution forward-note (records the CR-2026-115 §4 correction-in-waiting as dissolved)

This is the settled recording shape (Operator decision, 2026-06-19): a forward-note here, not a CR-2026-115 v0.3.

CR-2026-115 §4 ("The add_passkey ceremony: injection slot, not lift") named a deliberate asymmetry: P7-2's mountable router shipped passkey-enrollment routes whose enrollment logic lived host-side (in persons/signup.py), because lifting the ceremony was feared to reopen a parked persons/signup.py split. CR-115 §4 recorded this as a correction-in-waiting — to be dissolved by a later slice when cheap.

The P7-3 Step 0 inspection (45ac2f1) inverted the fear. The lift is a clean extraction, not a split: the add-passkey block shares exactly two trivial one-directional symbols with the signup flow, is a thin wrapper over Stele's own WebAuthn primitives, carries no host concepts, and has only me_security as a caller. Removing it leaves the signup flow fully intact.

This CR (§1) dissolves the asymmetry. The ceremony is lifted into stele.webauthn; Stele now owns the passkey-enrollment logic as well as the routes; the ceremony injection slots disappear. The asymmetry CR-115 §4 named is gone.

Trajectory preserved. CR-115 §4's position (injection-slot, asymmetry-accepted-to-avoid-a-split) was correct at 115's close, on the information then available (the split was assumed to exist). The inspection corrected the premise; this CR corrects the asymmetry. Both records stay true to their own moments — CR-115 records the asymmetry as it stood; this CR records its dissolution, with this note as the pointer back. The correction is named, not smoothed.


§10 — Version history


DUNIN7 — Done In Seven LLC — Miami, Florida CR-2026-116 — Stele Phase 7 P7-3 — Ceremony Lift + Reference App + Browser Ceremony + Config Generator + Docs — v0.3 — 2026-06-19