Version. 0.1
Date. 2026-06-19
Status. Completion record. CR-2026-116 (Stele Phase 7 P7-3) is built, verified, and pushed across stele and loomworks-engine; the docs (§5) and this record are staged in loomworks-record. The ceremony is lifted into stele.webauthn; a runnable reference host at stele/examples/ mounts stele.router and composes signup + login; a browser front drives the real WebAuthn ceremony; a config generator scaffolds the env; the adopter docs are written. This closes Phase 7. Markdown primary (technical consumer); the §5 docs are adopter-facing (HTML primary + Markdown source).
P7-2 made stele.router mountable. P7-3 takes Stele from "a stranger can mount the router" to "a stranger can clone a reference host, run it, and use Stele end-to-end — registering a real passkey in a browser." Five deliverables, sequenced lift-first: the add-passkey enrollment ceremony moved out of the engine and into stele.webauthn (dissolving the P7-2 asymmetry and collapsing the mount to two required slots); a minimal standalone FastAPI reference host; a browser WebAuthn front; an env-scaffolding config generator; and the adopter's mount-contract guide. One CR step — §1.6, neutralizing a brand-leaked cookie constant — was deferred when the grounding grep exposed that the engine consumes that constant as its live cookie name (renaming it would log every engine user out). It is a named parked thread, not a loss.
add_passkey_begin / add_passkey_complete (+ their pending store, types, and enrollment exceptions) moved from loomworks-engine/persons/signup.py into stele.webauthn, beside the begin_registration / verify_registration primitives they already wrapped. The three injection slots (provide_add_passkey_store / provide_add_passkey_begin / provide_add_passkey_complete) disappeared; the post-lift mandatory slot set is exactly two (provide_db_session + provide_webauthn_config). The engine re-pointed its single consumer surface onto stele.webauthn; persons/signup.py shrank to the intact signup flow.stele/examples/reference_app/: a minimal standalone FastAPI app that mounts stele.router, wires the two required slots, and composes signup + login host-side over Stele primitives. Both delivery shapes (cookie + bearer) over one Stele-minted token; enrollment (the mounted routes) and login (passkey assertion → second factor → full session), with login-TOTP composed host-side.static/index.html + static/app.js: the smallest correct front running the real navigator.credentials.create() / .get() ceremony, with the base64url ↔ ArrayBuffer coercions that are the actual trick.examples/generate_env.py: emits a .env template with a freshly generated STELE_SECRET_KEY (a real Fernet key) plus the fields to fill. Env-scaffolding only; emits no code, so it cannot drift.standards/loomworks-stele-mount-contract-v0_1.{html,md}: the mount contract written down — the slot set, the authorization boundary, the cookie-vs-bearer choice, the copy-runnable walkthrough, the RP-origin-match gotcha, and the no-email-as-identity note. HTML primary + Markdown source.
Lift — stele (2 commits): ef69b88 §1.1 (ceremony + store + exceptions + Stele-native TTL into stele.webauthn); cfb779f §1.2 (router calls the internal ceremony; three slots + Protocols removed; SDK surface + mount docstring updated).
Lift — loomworks-engine (2 commits): 01d4aa6 §1.3+§1.7 (re-point me_security + deps + app.py onto stele.webauthn; the passkey_complete route catches Stele's PasskeyEnrollmentNotFound/PasskeyEnrollmentError; update the P7-2 mount test to the post-lift shape); 32c1a68 §1.4 (delete the now-dead ~109-line add-passkey block from persons/signup.py).
Reference app — stele (3 commits): df0e4c5 §2 (reference host + docker-compose + README); 402f8b0 §3 (browser front); 8246729 §4 (config generator).
Per-step discipline: suite green at each step; explicit-path staging throughout; HALT-before-push at each repo boundary, with the staged state reported for authorization before every push.
The post-lift injection contract — what an adopter wires:
| Slot | Required? | Supplied |
|---|---|---|
| provide_db_session | Required | A request-scoped AsyncSession (raises until supplied). |
| provide_webauthn_config | Required | A WebauthnConfig(rp_id, rp_name, rp_origin) (raises until supplied). |
| resolve_current_principal | Defaulted | Default gates session + 2FA; override for host authorization. |
| extract_token | Defaulted | Default bearer; override for cookie delivery. |
| provide_secret_key | Defaulted | Default reads STELE_SECRET_KEY. |
| provide_person_email | Defaulted | Default None — no email as identity. |
The passkey-enrollment ceremony is Stele's own; there is no ceremony slot. import stele stays FastAPI-free; stele.router loads FastAPI lazily.
stele.session.COOKIE_NAME = "loomworks_session" was an unread brand-leak ("two-site touch"). The §1.6 grep-first instruction — built to catch exactly this — found the engine sets and reads its live production session cookie through that constant at ~10 sites across 4 files (login.py, signup.py, me_reactivate.py, deps.py). Renaming it would change the engine's cookie name on deploy and invalidate every live session (mass logout). CC halted and surfaced; the Operator deferred §1.6 (option A). Parked thread (see §7): a properly-sequenced engine-decouple-then-neutralize sub-slice (the engine defines its own cookie-name constant first, then Stele neutralizes its own). The brand-leak is harmless to clone-and-run adopters — the reference host names its own cookie, so an adopter never inherits Stele's constant.PendingAddPasskeyStore carried no host binding (a plain in-process dict, person_id-only), so the store slot was removed with the two ceremony slots. The router reaches a module-level pending_add_passkey_store singleton directly; the ceremony functions accept an optional store kwarg (defaulting to the singleton) so the engine kept passing its own store across the re-point.kek_decrypt the secret, then pyotp verify), consistent with B-first. Stele's surface did not grow.add_passkey_ (only me_security); the store* (PendingAddPasskeyStore) was also imported by deps.py and app.py (app-state seeding). The pre-cut grep surfaced app.py before the lift broke it — the lens-defines-the-blind-spot lesson, caught.config.py sets stele_ref_session — §1.6 made concrete: delivery, including the cookie name, is the host's (issue_session is mint-only).stele (no pytest suite; standalone checks): plain import stele stays FastAPI-free; the three removed slots are gone and the ceremony surface is eager + FastAPI-free; stele.router still mounts 8 routes; the internal ceremony resolves standalone (begin parks an add_id in the singleton; the not-found path raises PasskeyEnrollmentNotFound).loomworks-engine suite: 2930 passed / 0 failed / 46 skipped — at the §1.3+§1.7 commit and at the §1.4 commit. The updated mount test (2) passes with the ceremony Stele-internal; 29 passkey/security tests green.playground_dev (§1.5): a live passkey-begin round-trip through the Stele-internal ceremony — a real principal with one passkey; excludeCredentials correctly = the existing count; parked in the internal store. Read-only, no writes.verify_registration/verify_authentication patched in lieu of a real authenticator): signup → enrollment route 200 → logout → login factor 1 (partial session, correctly gated 401) → factor 2 (login-TOTP host-composed) → enrollment 200 — in both cookie and bearer shapes.app.js passes node --check; index.html + /static/app.js serve 200 with the ceremony content. (The live real-authenticator step is the human-in-browser proof the README walks.).env carries the full field set; the fresh STELE_SECRET_KEY is a usable Stele KEK (session encode/decode round-trips under it); regenerated each run.begin_authentication defaults to no allowCredentials, /auth/login/begin takes no arguments, and the browser front has no email / username / "remember ID" field (its only inputs are a signup display name and the second-factor code/recovery fields, which key no lookup). Identity is held by the authenticator and resolved by the selected credential, never by an attribute; the engine's login.py is the same (discoverable-only, "never by email — seed v0_12"). The §5 docs name "remember ID" a non-feature accordingly.
| Repo | origin/main | State |
|---|---|---|
| DUNIN7/stele (private) | 8246729 | §1 lift + §2–§4 examples — pushed |
| loomworks-engine | 32c1a68 | re-pointed onto stele.webauthn; signup shrank; mount test updated — pushed |
| loomworks-record | (this record + §5 docs + manifest v0.68 + CR v0.3) | staged; HALT before push |
stele.webauthn; the ceremony injection slots are gone; the engine's suite is green; persons/signup.py carries only the intact signup flow..env, start the app, and register a real passkey in a browser that round-trips through stele.router — and log in with it.
The CR-2026-115 §4 asymmetry is dissolved. P7-2 §4 named a deliberate asymmetry — Stele shipped passkey-enrollment routes whose enrollment logic lived host-side — recorded as a correction-in-waiting against a feared persons/signup.py split. The P7-3 Step 0 inspection inverted the fear (a clean extraction, not a split: two trivial one-directional shared symbols, a thin wrapper over Stele's own primitives, only me_security calling it). This CR's §1 lift dissolved the asymmetry; Stele now owns the enrollment logic as well as the routes. The canonical record of this is the CR-2026-116 §9 forward-note (pointing back to CR-115 §4) — per the Operator's settled decision (2026-06-19), a forward-note in this CR, not a CR-115 v0.3.
Parked threads (found during P7-3, named so they are walkable, not dropped):
stele.session.COOKIE_NAME "loomworks_session" is consumed by the engine as its live production session cookie at ~10 sites across 4 files (login.py / signup.py / me_reactivate.py / deps.py). An in-place rename would change the engine's cookie on deploy and mass-logout every live session. Removing the leak needs a sequenced engine-decouple sub-slice (option B: the engine defines its own cookie-name constant + re-points its sites first, then Stele renames its constant). Out of P7-3 scope. Harmless to adopters — the reference host names its own cookie, so a clone-and-run adopter never inherits Stele's constant.login.py:5 stale comment. The module-level comment reads "POST /auth/login/begin — challenge + allowCredentials (email hint)", describing behavior the code does not implement: the login is discoverable-only and passes no email (begin_login receives none; the docstring at login.py:114–117 is the accurate account, citing seed v0_12). Engine doc-hygiene only; surfaced by the P7-3 seed-alignment inspection. Out of P7-3 scope.Other carry-forwards:
../stele editable path dep with a version pin against a published stele. (The lift added an engine→Stele import-path coupling; the dep mechanism is unchanged.)verify_totp_code primitive remains a walkable later slice if ever wanted.persons/signup.py was; the inspection found a clean extraction where the CR feared a split. Grounding the fork on real code (not the remembered shape) is what made the lift cheap.me_security); the store had two more importers (deps.py, app.py). Inspect-first on the actual symbols to be moved — not just the headline functions — caught app.py before the delete broke it.verify_registration/verify_authentication (the two library-boundary calls the reference app cannot fake) and running everything else real — against a real Postgres — proved the whole signup→login→enrollment composition, including the totp_verified gate, without a physical authenticator. The narrowest possible mock keeps the most of the system real.DUNIN7 — Done In Seven LLC — Miami, Florida Loomworks — Stele Phase 7 P7-3 (Ceremony Lift + Reference App + Browser Ceremony + Config Generator + Docs) Completion Record — v0.1 — 2026-06-19