Version. 0.1
Date. 2026-06-19
Author. Marvin Percival (DUNIN7 Operator) with Claude.ai (CR drafting) on DUNIN7-M4; CC to execute on DUNIN7-M4.
CR number. CR-2026-114 (confirmed against the live ledger: highest present cr-2026-113, contiguous 109–113; next free = 114).
Phase. Stele Extraction Phase 7 — slice P7-1 of the P7-1 → P7-2 → P7-3 sequence.
Status. CR drafting. Markdown primary (technical consumer — CC execution). Lands in loomworks-record at change-requests/.
Grounding line. engine main = fe7c3f1 (CR-B merge), alembic head 0093, suite 2928/0/46. If main is not at fe7c3f1, stop and report drift before grounding.
What this CR does. Lifts Stele out of the engine monorepo into its own standalone repository (stele), gives it its own dependency manifest, and gives it an independently-runnable database migration set. The done-bar: a stranger clones the stele repo and the tables build against a fresh database, with zero DUNIN7/Loomworks dependency.
What this CR does NOT do. No SDK surface (P7-2). No mountable router (P7-2). No reference app or docs (P7-3). It does not re-open the §E router fork (resolved B-first / primitives-only). It does not split persons/signup.py or decide the cookie helpers (P7-2). It packages the library and its migrations; it does not build the plugin's HTTP surface.
The three decisions this CR makes, named up front.
actor.py stays host-side. It is a Principal→ActorRef adapter (constructs the host memory substrate's contract object, reads no host data). Under B-first it is not part of Stele's standalone library; it is the host's adapter over Stele's Principal. Stele's standalone module set is therefore 9 modules, not 10. (Grounding §G1.)0029/0082/0085/0090/0092 with heavy host interleave (persons-seed INSERTs, mirror triggers, host FKs). A standalone repo has no migration history to preserve, so P7-1 authors one fresh revision that builds the settled Stele schema directly, carrying none of the in-engine expand→contract scaffolding. (Grounding §G2.)data_encryption_keys) at runtime for per-scope DEK isolation. Stele standalone has exactly one secret scope (TOTP). P7-1 switches the TOTP secret to the KEK-direct path that sessions already use, dropping the DEK table. Three tables: principals, webauthn_credentials, recovery_codes. (Grounding §G3 — this is the one runtime-behavior change in P7-1; §6 carries its verification.)Why P7-1 lands alone. The repo lift is mechanical (the import-graph blockers are few and resolved below). The dependency manifest is bounded. The migration extraction is the risk — and the TOTP re-wrap rides with it. Landing both alone, behind a clean clone-and-run done-bar, isolates that risk before P7-2 builds the router on top.
main = fe7c3f1 (CR-B merge), alembic head 0093, suite 2928/0/46.main = a8cad0b (Phase 7 scoping trail filed; manifest v0.65).APIRouter, no route decorators of its own (Step 0 §A). The mountable router is a P7-2 construction, not a P7-1 relocation.principals is the sole identity table — no persons shadow (CR-B dropped it). The standalone repo inherits a clean single-table identity model.SteleBase, zero outward FK on the table side (CR-A/C/B end-state). The cross-namespace coupling that remains is in Python imports, not in the schema — resolved in §1 below.
Step 0's brief named category 5 (import-graph coupling) the load-bearing category: every Stele→engine import is a BLOCKER — a mountable plugin cannot import upward. The Phase-7 Step 0 report focused on the router fork and under-delivered category 5; CC's CR-grounding inventory (stele-p7-1-grounding-inventories-v0_1.txt) completed it. Three Stele→engine imports exist. Each is resolved here, with the brief's triage (move-in / host-supplied / Operator-call).
loomworks.memory.base (ActorRef) in actor.py → host-supplied (actor.py stays host-side)
actor.py constructs ActorRef(kind=…, id=…, display_name=…) — it produces the host memory substrate's contract object. ActorRef is owned by loomworks.memory.base, "referenced, not moved" by design. actor.py reads no host data (Phase 6b made companion_name a passed-in parameter precisely so stele/ imports nothing from persons/). The coupling is contract-shaped, not data-coupled.
Resolution: actor.py is a host-integration adapter, not a core Stele primitive. It does not move into the standalone stele repo. It stays in the engine as the host's adapter over Stele's Principal. Stele's standalone library is the 9 remaining modules. The CR's grounding pass (§3 below) confirms no other module imports actor such that excluding it breaks the 9.
loomworks.credentials.envelope + loomworks.credentials.kek in person_totp / registry / session → move-in, KEK-direct (no DEK table)
The envelope path encrypts the TOTP secret via per-scope DEK (SCOPE_TOTP), and the encrypt/decrypt path reads data_encryption_keys at runtime. As-is, absorbing this crypto pulls a fourth table into Stele.
Resolution: Stele absorbs the KEK provider (EnvKeyEncryptionKeyProvider, environment-backed via LOOMWORKS_SECRET_KEY, no table) and switches the TOTP secret to the KEK-direct path that session.py already uses (KEK MultiFernet directly, Level A, no DEK). The data_encryption_keys table and the envelope/SCOPE_TOTP machinery are not absorbed — they stay engine-owned for the engine's other (multi-scope) secrets.
Consequence (named, not hidden): Stele gives up per-scope DEK isolation and cheap KEK rotation (rewrap_all_deks) for the TOTP secret. This is reclaimable if Stele later grows a second secret kind (re-introduce the envelope then). At one scope, the multi-scope mechanism is complexity without payoff. This is a behavior change to a Stele primitive — see §6 for its verification (existing TOTP secrets must decrypt under the new path or be re-wrapped).
The KEK-provider + LOOMWORKS_SECRET_KEY config dependency comes along either way — it is the injection seam P7-1 declares. It matches the loomworks_secret_key ExtractionSkill test pattern already on record.
The seed's authentication framework (v0.12, lines 105–137) commits to: identity is the UUID; credentials are a WebAuthn passkey and/or an authenticator code; recovery uses recovery codes / additional authenticators / operator-mediated verification; email is never identity. It is silent on encryption architecture — it never names envelope-with-DEK vs KEK-direct, and never specifies how the TOTP secret is held at rest beyond its being an authenticator credential.
P7-1 alters one Stele primitive's behavior (TOTP encryption path). It does not alter principals, the credential or recovery primitives, the WebAuthn ceremony, or any identity read/write. The seed-alignment discipline names the one change rather than smoothing it.
Each deliverable opens with a CR-Step-0 grounding pass (CC inspects the live tree) before the build, per the inspect-first cadence. Where this CR states a fact Phase-7 Step 0 did not enumerate, CC confirms it against the live tree first.
steleLift the 9 core modules into their own repo. Zero residual DUNIN7/Loomworks import.
The 9 (the 10 from Step 0 §A minus actor):
__init__ base credentials models person_totp recovery registry session webauthn
The CR-Step-0 grounding pass must:
actor.py leaves the 9 self-contained — no remaining module imports actor, and no shared util outside stele/ is pulled by the 9 (other than the envelope/KEK code being moved in per Blocker 2+3, and the host-supplied seam per Blocker 1).kek.py's EnvKeyEncryptionKeyProvider + whatever session.py already uses for KEK-direct) and confirm it carries no further upward import.
What stays behind in the engine: the host-side callers — api/routers/{signup,login,me_security,claim,auth_dev}.py, persons/{signup,login,onboard}.py, and actor.py (now the host's adapter). Post-lift, these import Stele as an installed package, not a local module.
The re-point decision (resolved here, blast radius named): P7-1 lifts Stele AND re-points the engine to consume it as an installed package. Rationale: a lift that breaks the engine's imports without re-pointing them is a half-step (the handoff's lean, adopted). The engine must keep building and its suite stay green (2928/0/46) at every step.
loomworks.stele.* becomes an import of the installed stele package. The CR-Step-0 pass inventories the exact engine import sites (Step 0 §B/§C name the consumers: the 5 auth routers, persons.signup/persons.login/persons.onboard, api/deps.py, and actor.py). CC produces the full import-site list at grounding.stele repo; copy the 9 modules + the moved-in KEK code into it; build its manifest and migration set (Deliverables 2, 3); verify the repo stands alone (clone-and-build, fresh DB).stele as a package into the engine's environment (editable/local path install at this stage is acceptable; the published-package path is a later concern).loomworks.stele. to stele., one cohesive step, suite green after.src/loomworks/stele/ copies (minus actor.py, which stays and is re-pointed to import stele).playground_dev, not suite alone.
Stele's own pyproject.toml declaring the third-party deps the 9 modules + moved-in KEK code actually import. Nothing inherited from the engine's manifest.
From CC's Inventory 1, the third-party set is:
sqlalchemy — base, credentials, models, person_totp, recovery, registry, session
pydantic — registry (the Principal view model)
webauthn — webauthn (py_webauthn) — the load-bearing ceremony dep
pyotp — person_totp (TOTP)
bcrypt — recovery (recovery-code hashing)
cryptography — session (Fernet/MultiFernet — and now the KEK-direct TOTP path)
The CR-Step-0 grounding pass must:
kek.py adds nothing beyond cryptography, already listed — confirm).webauthn (py_webauthn) is the load-bearing one — get its pin exactly right.Stele's own alembic root + one consolidated baseline revision that builds the 3-table settled schema against a fresh database, with no engine revisions present.
The schema the baseline builds (the final Stele state, per CR-B end-state + the KEK-direct resolution):
principals — 7 columns: id, display_name, totp_secret, first_login_at, last_presence_proof_at, created_at, updated_at.webauthn_credentials — person_id → principals.id (FK target is principals, not persons; persons does not exist in the standalone repo).recovery_codes — person_id → principals.id, including invalidated_at.data_encryption_keys — KEK-direct TOTP, no DEK store.The CR-Step-0 grounding pass must:
stele/models.py for the exact column definitions, types (the dialects.postgresql JSONB + UUID usage CC's Inventory 1 flagged), constraint names, and indexes — the baseline must reproduce them byte-faithfully, not from this CR's shorthand.ON DELETE / ON UPDATE behaviors for the credential and recovery FKs as they stand at 0093.totp_secret's column shape is unchanged by the KEK-direct switch (KEK-direct changes the bytes' encryption path, not the column — confirm the stored ciphertext column type/length still holds a KEK-direct Fernet token; if envelope and KEK-direct produce different ciphertext envelopes, the column must hold the KEK-direct form — see §6).
The extraction shape (decided): a fresh consolidated baseline. One revision, down_revision = None, building the 3-table schema as-of the settled state. It replays none of the engine's 0029→0093 incremental history — a standalone repo has no history to preserve. It carries none of the persons-seed INSERTs, the mirror triggers, or the host-FK interleave; those existed only to make the in-engine expand→contract reversible.
The engine's own chain is unaffected. The Stele migration set is additional and standalone, for the new repo's consumers. The engine keeps building at 0093 exactly as today. P7-1 does not alter the engine's migrations/versions/.
This is the deliverable that is not a pure relocation. Switching the TOTP secret from envelope (SCOPE_TOTP) to KEK-direct changes how the secret is encrypted at rest. The CR must carry this as an explicit, verified migration of existing encrypted secrets — not a silent swap.
The CR-Step-0 grounding pass establishes, against playground_dev:
principals.totp_secret values exist that were encrypted under the envelope path (per-scope DEK). On playground_dev (41 principals, live host data per CR-B's verification), determine how many carry an envelope-encrypted TOTP secret.Two cases, and the CR commits the handling for each:
playground_dev (and any deployed DB), post-re-point: existing totp_secret values are envelope-encrypted. After the engine consumes stele with KEK-direct, those secrets must still decrypt, or be re-wrapped. The CR commits a re-wrap step: a one-time data migration (engine-side, since it touches the engine's live data) that reads each envelope-encrypted totp_secret, decrypts it via the envelope path (still available engine-side), and re-encrypts it KEK-direct. This runs once, in the engine, as part of the re-point. It is not part of the standalone Stele migration set (the standalone repo never holds envelope-encrypted secrets).
Verification (on playground_dev, rolled back where mutating):
data_encryption_keys remain functional for the engine's other scopes (the re-wrap touches only SCOPE_TOTP secrets).If the CR-Step-0 pass finds the re-wrap is larger or more entangled than this (e.g. envelope and KEK-direct ciphertext are not column-compatible), halt and surface to the Operator before building — fold-vs-separate is the Operator's call at the moment of discovery (CR-B Lesson 5).
COOKIE_NAME set by signup/login, cleared by host _clear_session_cookie; api/deps.py resolves sessions via stele.session) — P7-2 (router + SDK surface). P7-1 does not decide whether Stele ships the cookie set/clear helpers.persons/signup.py doubly-bound (serves both signup and me_security passkey enrollment) — P7-2 splitting concern. P7-1 does not touch it.actor.py's eventual shape — it stays host-side in P7-1. Whether it becomes a formally host-supplied adapter interface is a P7-2 concern (the router work decides the host↔Stele contract surface).stele locally/editable into the engine. Publishing to an index is a later concern.stele/models.py directly — column types, constraint names, the JSONB/UUID dialect usage — not infer from this CR.stele/ copies come out only after the engine is re-pointed to the package. Per-step commit, suite green at each, halt-before-push.git add [explicit path], never git add -A) — a git add -A once swept a Cloudflare credential into a commit.playground_dev, not suite alone — especially the migration baseline (fresh-DB build) and the TOTP re-wrap (live envelope-encrypted secrets).loomworks-record at phases/stele-extraction-phase-7/ (or change-requests/), recording the as-built deltas, the re-wrap verification, and the clone-and-run done-bar met.Two-role division holds: CR-drafting (Claude.ai) separate from execution (CC). Fresh chat per role.
5472335) — four boundary findings, A3 privilege-model HALT, awaiting CC. Orthogonal.DUNIN7 — Done In Seven LLC — Miami, Florida CR-2026-114 — Stele Phase 7 P7-1 — Packaging Floor — v0.1 — 2026-06-19