DUNIN7 · LOOMWORKS · RECORD
record.dunin7.com
Status Current
Path change-requests/cr-2026-115-stele-phase-7-p7-2-mountable-router-sdk-v0_2.md

CR-2026-115 — Stele Phase 7 P7-2 — Mountable Router + SDK Surface — v0.2

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.


Amendment summary (v0.1 → v0.2)

Five as-built resolutions. Each names the v0.1 position and the as-built outcome.

  1. Route count: nine → eight. v0.1's candidate set carried /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.
  2. 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.
  3. Session delivery: bearer default, cookie override. v0.1 §1.2 committed to host-pluggable extraction with no cookie-only inheritance. As built: 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.
  4. 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.
  5. Engine-side artifact: a committed mount-verify test. v0.1's step 6 left the engine deliverable as "SDK consumable + engine works," with the mount-form surfaced if verification needed it. As built: a committed 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).


Plain-language summary

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).

  1. Session delivery + current-principal resolution resolves to dependency-interface at resolution, mint-only-and-already-neutral at delivery. 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.
  2. LOOMWORKS_SECRET_KEYSTELE_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.


Seed alignment (verified)

The seed (v0.12) governs two things this CR touches. Both are honored:

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.


The state P7-2 starts from (settled — do not re-establish)


CR Step 0 — grounding pass (CC runs this BEFORE any build)

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:

  1. Confirm 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.
  2. Confirm the CR number against the live ledger (highest cr-2026-NNN). Correct this document's number if 115 is taken.

The route set (lifted-and-generalized — confirm against live host routers):

  1. From 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):

  1. Produce the deduplicated host-dependency set across 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):

  1. Confirm 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).
  2. Confirm the env-var rename blast radius: that every engine→Stele KEK call injects 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.


§1 — The mountable primitive router (built in 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):

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.


§2 — The env-var rename (Stele-internal — ships with the contract)

Rename Stele's no-arg KEK fallback env-var key: LOOMWORKS_SECRET_KEYSTELE_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.


§3 — The SDK surface (built in 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:

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.)


§4 — The 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.


Build sequencing (slice-by-slice, inspect-first, halt-before-push)

Two-repo add-and-verify (P7-1's discipline). Per-step commits, suite green at each, halt-before-push confirmed by CC.

  1. Step 0 — grounding pass (above). Output the grounding note. Halt — Operator confirms the route set and the injection contract before build.
  2. 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.
  3. stele — the SDK surface. Define the public API + mount contract (§3). Verify the import surface. Commit.
  4. 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.
  5. Push stele (Operator authorizes).
  6. Engine — consume the SDK. Re-point/extend the engine to mount or consume the new surface as far as P7-2 reaches (the engine may keep its own routers and simply gain the option to consume 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.
  7. Push engine (Operator authorizes).
  8. Record — completion record + manifest bump in 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.


Carry-forwards held out of P7-2 scope (named so they are not pulled in)


Standing disciplines carried in


Open items riding alongside (not P7-2 blockers)


Version history


DUNIN7 — Done In Seven LLC — Miami, Florida CR-2026-115 — Stele Phase 7 P7-2 — Mountable Router + SDK Surface — v0.2 — 2026-06-19