Version. 0.2
Date. 2026-06-19
Author. Marvin Percival (DUNIN7 Operator) with Claude.ai (CR drafting) on DUNIN7-M4. CC executed on DUNIN7-M4.
Status. Change request — built, verified, pushed. v0.2 amends v0.1 with the five as-built resolutions (corrections named, not smoothed; v0.1 positions preserved alongside). See the completion record loomworks-stele-phase-7-p7-2-completion-record-v0_1.md for the full as-built account. Markdown primary (technical consumer). Lands in loomworks-record at change-requests/.
CR number. CR-2026-115 — confirmed against the live ledger at Step 0 (P7-1 was 114; 115 free).
Governing slice. Stele Phase 7, slice 2 (of three): SDK surface + mountable primitive router. P7-1 (CR-2026-114) is the packaging floor below this; P7-3 (reference app + docs + WebAuthn-ceremony close) builds above it.
Five as-built resolutions. Each names the v0.1 position and the as-built outcome.
/auth/dev/issue-session as a ninth route (§1, with §1.5 surfacing the choice). Step 0 confirmed the route is dev/test-guarded host-side. As built: omitted from the mountable router (Operator decision) — session issuance stays the SDK-exposed stele.session.issue_session primitive; the dev session-mint route is a P7-3 reference affordance, not package-shipped. The mountable router is eight credential-backed routes.person_email: narrow injection, no host_account dependency. v0.1 §3.2 flagged keeping host_account an optional injection, with injecting person_email narrowly as a §1 build finding to evaluate. As built: the narrow person_email injection was taken (it threaded cleanly) — Stele depends on no host HostAccount view shape. The CurrentUser-shaped fallback was not needed.extract_token defaults to Authorization: Bearer (agent-neutral), host-overridable for cookie; issue_session stays mint-only. The default direction is the deliberate as-built choice.STELE_SECRET_KEYS_PREVIOUS rotation — a beyond-CR-text addition. v0.1 §2 scoped only the single-key fallback rename; §2.4 declined a transition fallback for the engine. As built: the §2 build also added a standalone key-rotation read-order affordance (STELE_SECRET_KEYS_PREVIOUS). Engine-irrelevant (the engine never fires Stele's fallback), standalone-only, sound. Named here as an as-built addition, not a silent extra.tests/test_stele_router_mount.py mounts stele.router with the engine's real providers and drives passkeys/begin end-to-end (production app + auth routers untouched). The real-provider proof is the engine's P7-2 artifact and a standing regression guard.
Two beyond-CR improvements also recorded in the completion record: the lazy router import (so import stele is FastAPI-free, keeping engine primitive imports framework-free) and the module-level mount shape (stele.router + dependency_overrides, the build-time choice v0.1 §3.1 delegated).
What this CR does. Gives the standalone stele package an HTTP surface of its own — a mountable FastAPI APIRouter carrying the direct-primitive auth endpoints (passkey enrollment, recovery-code regen/status, TOTP rotation, session issue), plus the SDK surface a host app imports to mount it. Today (P7-1 as-built) stele is a published library with no router; the engine wires Stele's primitives into its own host routers by hand. After P7-2 a downstream app can include_router(stele.router) and supply a small, named set of host dependencies to get the identity/credential/recovery/TOTP primitives without re-implementing the engine's hand-rolled wiring.
What changed since the handoff (two scope decisions now settled).
stele.session.resolve_session already takes a raw token and returns who — it never touches a cookie. So the router takes current-principal resolution as a host-supplied injected dependency, and session-delivery (cookie for UI / bearer for agent) is a host-side extraction concern over the already-neutral primitive. Stele does not default to cookie-only; Stele does not take a delivery position.LOOMWORKS_SECRET_KEY → STELE_SECRET_KEY rename is promoted into P7-2 (was P7-3 polish). CC determination (read-only, 2026-06-19) confirmed the rename is strictly Stele-internal: two separate KEK providers exist (engine's own loomworks.credentials.kek reads LOOMWORKS_SECRET_KEY; Stele's stele.kek reads it only as a no-arg fallback the engine never fires, because every engine→Stele call injects secret_key explicitly). Renaming Stele's fallback key changes nothing the engine deploys.
What P7-2 is NOT. Not the reference app, docs, or config generator (P7-3). Not the WebAuthn-ceremony-gap close (P7-3). Not the loomworks_session cookie rename (P7-3, with the reference app exercising delivery). Not the add_passkey ceremony lift (it stays an injection slot — see §4). Not a persons/signup.py split. Not the published-package path (the ../stele editable dep stays as P7-1 left it). Not a schema change (Stele's 3 tables are settled). Not a re-open of the §E fork (resolved B / primitives-only).
The decision this CR makes explicit (the authorization boundary). Stele's router ships authentication primitives and session issuance; it assumes the host owns authorization (who-may-reach-what — the OVA seam). The host's policy gate (totp_verified, host_account, membership) stays host-side. The router resolves who; the host gates what-here. This is stated plainly in the package so no adopting environment is surprised by the boundary.
The seed (v0.12) governs two things this CR touches. Both are honored:
Principal); authentication is by credentials the user controls. The router introduces no email-keyed lookup, no email-based recovery. Aligned.get_current_principal injection slot is the seam where authentication hands off to the host's authorization. The CR names what Stele provides (authenticated principal + session) vs what the host must provide (authorization over that principal). Aligned — and stated, not silent.No seed conflict. If CC's Step 0 surfaces a route that keys lookup or recovery by email, halt and surface — that would contradict the framework and is not in this CR's intent.
DUNIN7/stele (private) main = 0d5e265 — standalone library + manifest + 3-table baseline; clone-and-run verified. No router yet. (P7-1 completion record.)loomworks-engine main = ff0c4cf — consumes stele as an editable path dep (stele = ../stele); actor.py host-side; suite 2928/0/46. (Re-confirm at Step 0; the engine may have moved.)loomworks-record main = e1e7b8c — CR-2026-114 + P7-1 completion record + manifest v0.66.mint_principal (Stele) / onboard (host-pure). The architectural gift that lets B work with no Stele→host callback.resolve_session is delivery-agnostic (CC-confirmed) — takes a raw token string, returns identity; never reads a cookie. This is the fact the whole delivery resolution rests on.LOOMWORKS_SECRET_KEY; Stele's fallback is dead under engine usage. The rename is Stele-internal.The handoff's shorthand is not the ground. CC inspects the live tree — both repos now — and confirms the following before committing to the build. Read-only; no edits until §1 begins.
Ground state:
DUNIN7/stele main and loomworks-engine main are at or ahead of the start-state commits above. Record actual HEADs. If the engine moved, re-confirm suite green at the actual HEAD before proceeding.cr-2026-NNN). Correct this document's number if 115 is taken.The route set (lifted-and-generalized — confirm against live host routers):
loomworks-engine/src/loomworks/api/routers/me_security.py and auth_dev.py, confirm the candidate route set (§1 below) matches the live endpoints. Record any drift. The router is a construction generalizing these — not a relocation — so the result is new stele code, but the route shapes are grounded here.The injection contract (the SDK's core — enumerate verbatim):
me_security + auth_dev, grouped A/B/C (§3 below names the groups). For each item, capture the exact callable signature (params + return type) as written in source. This becomes §3's contract verbatim. (This is the enumeration requested 2026-06-19; if already produced, paste it into the CR record and skip re-running.)The two known asymmetries (confirm they hold):
add_passkey_begin / add_passkey_complete still live in persons/signup.py (host) and that me_security's passkey-enrollment routes reach into them. Capture their signatures (they become the injected-ceremony slot, §4).secret_key explicitly (CC's 2026-06-19 finding — re-confirm the call sites in deps.py, me_reactivate.py, auth_dev.py, me_security.py, persons/signup.py still pass secret_key=secret_key), so renaming Stele's no-arg fallback is engine-safe. If any site relies on the env-var default, halt and surface — the rename's blast radius changed.Step 0 output: a short grounding note recording actual HEADs, the confirmed route set, the verbatim injection contract, the two ceremony signatures, and the env-var confirmation. Then §1 begins.
stele)
A FastAPI APIRouter inside the stele package, carrying the direct-primitive endpoints — the me_security / auth_dev shape (Step 0 §B depth-1 of the inspection report). Candidate route set (confirm at Step 0 against live routers):
auth_dev shape — confirm whether dev-only or generalizes; if dev-only, ship it guarded or document it as a reference-only route)~~ [v0.2 as-built: OMITTED. Step 0 confirmed dev/test-guarded host-side; omitted from the mountable router. Session issuance stays the stele.session.issue_session SDK primitive. Mountable router = the eight credential-backed routes.]The router must:
1.1 — Take current-principal resolution as an injected dependency, not a hardcoded resolver.
Every me_security handler today depends on the host's get_current_principal (api/deps.py), which resolves the session cookie and carries the host's totp_verified gate and host_account load. A mountable router cannot assume the host's resolver. The router declares a dependency slot — a callable the host supplies that returns the authenticated Principal. Stele ships a default resolver (over its own resolve_session + a host-injected token-extraction) the reference app can use; the host overrides it to inject its own policy gate. The contract: Stele resolves who; the host's resolver carries what-here.
1.2 — Session delivery is host-pluggable extraction over resolve_session (no Stele primitive change).
resolve_session(token, secret_key, now, db) is already delivery-agnostic. The router does not read a cookie directly. Token extraction (cookie for UI, Authorization: Bearer for agent, or both) is supplied by the host as part of the resolver, or as a small extraction dependency the router calls before resolve_session. The router ships neither a cookie-only assumption nor a hardcoded extraction path. Name in the SDK docs (minimal here; full in P7-3) what a host writes to deliver cookie vs bearer over the same Stele-minted token.
1.3 — Session issuance stays mint-only.
issue_session mints the token and returns it. The router does not set cookies. Cookie set/clear mechanics stay host-side (the engine's set_cookie / _clear_session_cookie). A host that wants cookie delivery writes the cookie from the returned token; a host that wants bearer returns the token to the caller. Stele takes no delivery position.
1.4 — Name what the host injects.
The mountable-router contract is the injection surface (§3). At minimum: the DB session, the secret key, the WebAuthn config, the pending stores, the current-principal resolver, and the add_passkey ceremony (§4). The router declares these as dependencies the host provides; it hardcodes none of them.
1.5 — The auth_dev session-issue route.
Confirm at Step 0 whether /auth/dev/issue-session is dev-only (guarded) in the engine. If dev-only, the router either ships it behind the same guard or documents it as a reference/testing route — do not ship an unguarded session-mint route in a mountable package. Surface the choice if Step 0 shows it's reachable in non-dev.
Rename Stele's no-arg KEK fallback env-var key: LOOMWORKS_SECRET_KEY → STELE_SECRET_KEY.
2.1 — Change ONLY the no-arg fallback. stele.kek.EnvKeyEncryptionKeyProvider's no-arg/os.environ fallback reads STELE_SECRET_KEY after this CR. The injectable path is untouched: EnvKeyEncryptionKeyProvider(secret_key=...) stays exactly as now. Every explicit-injection call site continues to work unchanged.
2.2 — Engine deployment env stays LOOMWORKS_SECRET_KEY. The engine's own provider (loomworks.credentials.kek) reads LOOMWORKS_SECRET_KEY and hands that value to Stele via explicit injection. The engine never fires Stele's fallback. No engine deployment change. The engine sets LOOMWORKS_SECRET_KEY; it does not set STELE_SECRET_KEY.
2.3 — Verification gate. Engine suite green after the rename (it should be unaffected — every engine→Stele call injects). Stele standalone: a no-arg EnvKeyEncryptionKeyProvider() reads STELE_SECRET_KEY. Confirm an injected provider ignores the env entirely.
2.4 — Transition fallback NOT taken (decision preserved). A read-order fallback (STELE_SECRET_KEY then LOOMWORKS_SECRET_KEY) was considered and not adopted: the engine never reads Stele's fallback, so no transition window exists to bridge. Recorded as available-but-declined, not overlooked. If a future standalone deployment needs old-name compatibility, this is the place to add it.
stele)
The import surface a host app uses to mount the router and call the primitives — stele's public top-level API. The CR defines:
3.1 — The public API. What stele exposes at top level: the router (stele.router or a get_router(...) factory — Step 0 decides which mount shape fits FastAPI dependency-override cleanly), the primitive service functions (the §C binding surface — mint_principal, the WebAuthn ceremony functions, credential/recovery/TOTP operations), the Principal type, and the config/injection seam.
3.2 — The injection contract (the named host-dep set). From Step 0 §4's verbatim enumeration, grouped:
get_current_principal, get_current_user (the host supplies these; they carry the totp_verified gate and, for one route, the host_account load — get_current_user is the only route dep pulling host_account, per CC; keep host_account an optional injection used by the single route that needs it).add_passkey_begin / add_passkey_complete (§4).The CR states the contract as a count and a difficulty estimate, not only a list — so the "stranger mounts it" bar is honest about what a real adopter must satisfy. (Comfort-flag from scoping: the contract is wide; P7-3's reference app must prove it's satisfiable, not just listable.)
3.3 — The mount contract. What a host writes to mount Stele: include_router(...) (or include_router(get_router(...))) plus the FastAPI dependency overrides/providers for the A/B/C set. Demonstrated minimally here; fully in the P7-3 reference app.
3.4 — Keep the engine working (add-and-verify). The engine currently imports Stele primitives directly (stele.registry.mint_principal, etc.). The SDK surface must not break those import paths, or must re-point them in the same slice. Engine suite green throughout; the engine never has a window where it can't import what it needs. (P7-1's banked discipline.)
add_passkey ceremony: injection slot, not lift (named asymmetry)
me_security's passkey-enrollment routes reach into persons.signup.{add_passkey_begin, add_passkey_complete} — host-owned WebAuthn-registration code, not a Stele primitive. P7-2 treats this as one more injection slot: the host supplies the ceremony; the router calls the injected callable.
Why injection-slot, not lift. Lifting the ceremony into Stele would pull a host module across the cut line and reopen the persons/signup.py split the handoff explicitly parked (the module is doubly-bound — signup flow and me_security enrollment). Injection-slot keeps the router pure and B-first-consistent, and needs only the ceremony's signature, not its internals.
The named asymmetry (preserved, not smoothed). Stele ships passkey enrollment routes whose enrollment logic lives host-side — slightly odd for a package that already owns stele.webauthn (sibling registration logic). P7-2 accepts this asymmetry to avoid the split. It is recorded here as a correction-in-waiting: a later slice (P7-3 or beyond) may dissolve it by lifting add_passkey_* into stele.webauthn, at which point the persons/signup.py split gets scoped on its own terms. Not this CR.
Two-repo add-and-verify (P7-1's discipline). Per-step commits, suite green at each, halt-before-push confirmed by CC.
stele — the router. Build the APIRouter + the default current-principal resolver + the injection-dependency declarations (§1). Verify standalone (the router mounts in a bare FastAPI app with stub injections; routes resolve). Commit.stele — the SDK surface. Define the public API + mount contract (§3). Verify the import surface. Commit.stele — the env-var rename (§2). Change only the fallback key. Verify standalone no-arg reads STELE_SECRET_KEY; injected path unchanged. Commit. Halt before push — Operator confirms stele staged state.stele (Operator authorizes).stele.router; the CR's engine-side scope is keep-working + verify the SDK surface mounts, not replace the engine's auth routers — that's a later call). Engine suite green. Confirm no broken import paths. Halt before push.loomworks-record.
Note on step 6 scope. P7-2's engine-side deliverable is the SDK is consumable and the engine still works — not a migration of the engine's auth surface onto the Stele router. Whether the engine actually swaps its hand-rolled wiring for stele.router is a separate decision (likely P7-3 or a follow-on), because that swap touches the live signup/login flows. If CC's Step 0 finds the cleanest verification requires mounting stele.router in the engine, surface the scope question before doing it.
loomworks_session cookie rename — P7-3 (the reference app exercises delivery; the cookie name is functional mechanics the engine's set/clear depends on).add_passkey_* lift + persons/signup.py split** — §4's correction-in-waiting; a later slice.../stele editable dep stays; retiring it for a version pin is a parked thread.stele.router — the swap of the engine's hand-rolled signup/login wiring for the Stele router. Separate decision; not P7-2.me_security/auth_dev handlers into a mountable shape.stele, then re-point/extend the engine; engine suite green throughout.playground_dev where the build touches live-schema behavior.git add [explicit path], not git add -A).# breaks zsh).5472335) — four boundary findings, A3 privilege-model HALT, awaiting CC. Orthogonal.person_email injection; bearer-default delivery; STELE_SECRET_KEYS_PREVIOUS rotation addition; committed mount-verify test) plus two beyond-CR improvements (lazy router import; module-level mount shape). v0.1 positions preserved alongside. Full as-built account in the P7-2 completion record. End state: stele 1077803, engine 08e89c5, both pushed.LOOMWORKS_SECRET_KEY → STELE_SECRET_KEY rename into scope (CC-confirmed Stele-internal). Names the add_passkey injection-slot asymmetry as a correction-in-waiting. Injection contract sourced from the inspection report's named set; verbatim signatures deferred to the CR's Step 0 grounding pass.DUNIN7 — Done In Seven LLC — Miami, Florida CR-2026-115 — Stele Phase 7 P7-2 — Mountable Router + SDK Surface — v0.2 — 2026-06-19