Version. 0.2
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, ready to build. The final Phase 7 slice — the clone-and-run-and-use bar. Both P7-3 forks are resolved going in (Fork 1 = LIFT; Fork 2 = ENV-SCAFFOLDING-ONLY); this CR does not re-open them. The Step 0 grounding pass is complete (CC, 74e16e6) and all six surfaced decisions are Operator-confirmed (2026-06-19) — folded into the deliverables below. Five deliverables. Markdown primary (technical consumer); the docs deliverable (§5) is operator/adopter-facing — 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).
v0.2 change. Folds in the six Operator-confirmed Step-0 resolutions: store-slot → Stele-internal (slot disappears; mandatory set → two); login-TOTP → host-composition; shared-symbol + env-floor → confirmed; reference-app home → hardcoded stele/examples/; cookie disposition → neutralize the stele.session.COOKIE_NAME constant (loomworks_session → stele_session), folded into the lift slice. §0 is rewritten as a settled-resolutions record, not an open-decision surface. Supersedes v0.1.
CR number. CR-2026-116, confirmed (CC: highest filed = 115; no renumber). The draft is staged in ~/Downloads/; it files to change-requests/ at build-go.
Against (re-confirm at build start). DUNIN7/stele 1077803, loomworks-engine 08e89c5, loomworks-record 74e16e6 (ahead of the cited 45ac2f1 by the two docs-only commits — the scoping note + handoff v0.2; no code-fact drift).
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):
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.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.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..env template: secret pre-generated, RP-ID / origin / RP-name / database URL to fill. No code generation; can't drift.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 §4 — not a 115 v0.3. CR-115 stays true to its own close; the dissolution belongs to P7-3's record, trajectory walkable.
One more brand-leak dissolved (Step 0 finding). The lift is the big engine→Stele decoupling, but Step 0 surfaced a small cousin: stele/session.py ships COOKIE_NAME = "loomworks_session" — an engine-branded constant lifted into Stele at P7-1 and exported. A standalone identity package whose whole pitch is "a stranger clones and runs it" should not ship a host's brand as a default. This CR neutralizes it (loomworks_session → stele_session), folded into the lift slice (§1.6). Two-site touch: the constant + the P7-2 mount test that imports it.
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 (confirmed; corrects v0.1's lean). v0.1 §0.9 read the cookie carry-forward as moot — "the cookie name is the host's; loomworks_session is not Stele's to rename." Step 0 found that half-right and half-wrong. Right: issue_session is mint-only and stele.router never calls set_cookie — delivery (including cookie name) is wholly the host's. Wrong: stele/session.py:37 ships COOKIE_NAME = "loomworks_session" — an engine-branded constant lifted into Stele at P7-1 and exported (the P7-2 mount test does from stele.session import COOKIE_NAME). The router doesn't consume it, but a standalone identity package is shipping a host's brand as a default cookie name. Resolution: neutralize — rename the Stele constant loomworks_session → stele_session. It fits this CR's spirit (P7-3 dissolves engine→Stele leakage; the ceremony lift is the big one, this constant the small cousin) and protects the clone-and-run bar. Folded into the lift slice (§1.6). The correction is named, not smoothed: v0.1 called the carry-forward moot; Step 0 found a real Stele-side residue; v0.2 dissolves it.
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.
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.
begin_registration / verify_registration (its siblings — the inspection confirmed the natural landing site).person_id is the principal id) — no host concepts. The stele.api AddPasskeyStore / AddPasskeyBeginResult Protocols become concrete in stele.webauthn.PasskeyEnrollmentNotFound in place of PendingRegistrationNotFound; bring/read a Stele-native TTL constant.stele first (P7-1/P7-2 two-repo discipline). The ceremony resolves standalone against Stele's own primitives.§1.2 — the injection slots disappear (all three).
stele.router's provide_add_passkey_begin / provide_add_passkey_complete slots are removed; the router calls the now-internal stele.webauthn ceremony directly.provide_add_passkey_store slot is also removed — the store becomes Stele-internal (§0.4 confirmed: plain in-process dict, person_id-only, no host binding, Protocol already in stele.api). The ceremony owns its pending store.provide_db_session + provide_webauthn_config. Update the router's slot inventory and the mount-contract docstring (stele/__init__.py) to match — and the SDK's public surface if it re-exported the now-removed slots.§1.3 — the engine re-points.
me_security.py (the only caller, §0.3-confirmed) imports the ceremony from stele.webauthn instead of persons.signup, and drops the now-removed injected callables. The engine does not mount stele.router in production (held-out scope), so me_security keeps calling the ceremony — now imported from Stele, not host-local.
§1.4 — persons/signup.py shrinks.
begin_signup / complete_passkey / complete_totp_verify + their store/types).playground_dev, not the suite alone.§1.6 — neutralize the brand-leaked cookie constant (rides with the lift).
stele/session.py:37 COOKIE_NAME = "loomworks_session" → COOKIE_NAME = "stele_session". A standalone identity package should not ship a host's brand as a default cookie name.from stele.session import COOKIE_NAME). Re-point the test's expectation to the new value. Grep stele/ and loomworks-engine/ for any other reader of stele.session.COOKIE_NAME before cutting — the engine has its own cookie mechanics and does not read Stele's constant, but confirm by grep, not assumption (rename-to-find: a missed reader should break loudly).issue_session stays mint-only; the router still sets no cookie). The reference app (§2.4) sets its own cookie name regardless.stele commits, with its own per-step commit so the rename is isolated in history.§1.7 — verification.
stele suite green (the lifted ceremony resolves standalone).tests/test_stele_router_mount.py) — confirm it still passes with the ceremony now Stele-internal; update it if it wired the now-removed ceremony slots (it did, per the P7-2 record §5 — it injected persons.signup.add_passkey_* into the ceremony slots). The updated test mounts the router and confirms the now-internal ceremony resolves end-to-end without the host supplying it. This is a real regression-guard update, not a deletion.playground_dev (a real passkey-begin round-trips through the Stele-internal ceremony).stele.session.COOKIE_NAME == "stele_session" and no stele/engine reader of the old value remains (the §1.6 grep came back clean and the mount test was re-pointed).Per-step commits; suite green at each; HALT before push; explicit-path staging.
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:
STELE_DATABASE_URL = postgresql+asyncpg://…).0001_baseline (the 3-table P7-1 migration) to stand up its DB.docker-compose (or equivalent) for the throwaway Postgres so the stranger's "clone and run" is one command.
§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:
provide_db_session — a request-scoped AsyncSession yielder over an async engine (a few lines).provide_webauthn_config — return WebauthnConfig(rp_id, rp_name, rp_origin) from env (one line).resolve_current_principal, extract_token, provide_secret_key, provide_person_email) except where a demonstration overrides them (§2.4, §2.5).
§2.4 — demonstrate both delivery shapes. Cookie (browser) and bearer (agent) over the same Stele-minted token, by overriding extract_token:
extract_token reads request.cookies[...]) for the browser front, and the bearer path for an agent caller.issue_session is mint-only — the app writes the cookie from the returned token (cookie shape) or returns the token (bearer shape). The cookie name is the app's choice (per §0.9).§2.5 — demonstrate enrollment AND login. Not just enrollment:
stele.webauthn.begin_authentication + verify_authentication, stele.credentials.get_credential_by_credential_id + update_sign_count;stele.recovery.verify_and_consume_recovery_code, and TOTP per §0.5 (host-composition default: kek_decrypt(totp_secret) → pyotp.TOTP(secret).verify(code));stele.session.issue_session, with the partial-session shape (assertion-then-2FA) a host concern.§2.6 — seed-alignment (mandatory). 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).
A minimal HTML/JS front that runs the real navigator.credentials ceremony against the mounted router.
§3.1 — what it does.
fetch /passkeys/begin → navigator.credentials.create(options) → fetch /passkeys/complete.fetch login-begin → navigator.credentials.get(options) → fetch login-verify (the reference app's composed login routes from §2.5).
§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.
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:
STELE_SECRET_KEY — freshly generated (a real Fernet key, so the stranger isn't tempted to ship a placeholder).STELE_RP_ID, STELE_RP_NAME, STELE_RP_ORIGIN — RP fields to fill (with inline comments naming what each is and the origin-match requirement from §3.2).STELE_DATABASE_URL — postgresql+asyncpg://… template to fill.STELE_SECRET_KEYS_PREVIOUS — optional, commented, for rotation.WebauthnConfig read.§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).
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.
resolve_current_principal. State it plainly; it's the conceptual spine.extract_token defaults to bearer; how a host writes the cookie override; issue_session is mint-only..env, start the app, open the browser front, register a passkey. The walkthrough is the reference app embodied; the docs narrate what the code does.
§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/.
../stele editable dep stays; retiring it for a version pin is a parked thread. (Note: the lift adds an engine→Stele import-path coupling, but the dep mechanism is unchanged — still the editable path dep.)stele.router — the engine keeps its hand-rolled routers; P7-3 does not mount stele.router in the engine's production surface. me_security keeps calling the ceremony (now imported from Stele).verify_totp_code primitive remains a walkable later slice the Operator may take — not P7-3.loomworks_session cookie rename as a Stele change — held out per §0.9's lean (the cookie name is a reference-app/host choice; issue_session is mint-only). Confirm at §0.9.74e16e6, all six decisions confirmed). Build re-confirms HEADs and the suite floor at start; it does not re-open the decisions.stele/examples/). The app wires the post-lift slot set; the front drives the real ceremony; the generator scaffolds the env.
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.
P7-3 is done when:
stele.webauthn; the ceremony injection slots are gone; the engine re-points and its suite is green; persons/signup.py carries only the intact signup flow..env, start the app, open it in a browser, and register a real passkey with a real authenticator that round-trips through stele.router — and log in with it.loomworks-record.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.
74e16e6); all six surfaced decisions Operator-confirmed and folded in. Store-slot → Stele-internal (slots collapse to two: db session + webauthn config). Login-TOTP → host-composition (Stele's surface does not grow). Reference-app home → hardcoded stele/examples/. Shared-symbol + env-floor → confirmed exact. Cookie disposition → neutralize (stele.session.COOKIE_NAME loomworks_session → stele_session, §1.6) — a v0.1 correction: v0.1 §0.9 called the cookie carry-forward moot; Step 0 found a real brand-leaked Stele constant; v0.2 dissolves it. §0 rewritten from open-decision surface to settled-resolutions record. Ready to build. Supersedes v0.1.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.2 — 2026-06-19