Version. 0.2
Date. 2026-06-14
Status. Design note. Defines Stele — the principal layer — as a standalone ecosystem with a host-agnostic API contract, defines how a host integrates with it, and sets the in-process-first implementation path. Operator-facing. Markdown primary (technical consumer: Claude Code); HTML companion optional. Precedes the extraction change request; nothing here is built.
Change from v0.1. Adds §8 — the white-label and multi-language contract layer. Decision: Stele provides the hooks, the host provides the content. Stele stays brand-free and language-neutral; it takes a tenant_context and locale on user-facing operations, returns all outcomes as structured codes, and renders only the unavoidable Stele-originated artifacts (WebAuthn relying-party name, TOTP issuer label) brand-aware. Hosts hold translation tables and render the surface. tenant_context is passed explicitly per call; Stele stores no brand. All v0.1 content preserved.
Decisions recorded. (1) Stele's interface is shaped as a host-agnostic API contract (request/response), implemented in-process first, service-ready. (2) The contract defines the complete credential lifecycle — including operations not implemented today (passkey revocation, person-TOTP rotation) — so Stele is specified as a whole ecosystem, not as a wrapper over current code. (3) The first consumer is the Companion (Loomworks), which the Operator controls — so the contract and its first integration are designed together. (4) White-label and multi-language are first-class and host-rendered: Stele supplies hooks (tenant_context, locale) and structured codes; the host owns brand and copy (§8).
Source grounding. Built on the Claude Code operation-and-data-shape pull (engine c210715, 2026-06-14) — real request/response models, the WebAuthn begin/complete ceremonies, the partial-session two-factor flow, and the located mint↔onboarding seam — plus the coupling map (2026-06-13) and standing note v0.3 §8. Operations marked (live) exist in code today; (to build) are defined by this contract but not yet implemented. Verify against engine-database truth, not the test suite alone.
Stele is the principal layer — the part that says who you are and authenticates you. This note designs it as its own complete ecosystem with a clean API: a host (the Companion first, OVA/FORAY/others later) asks Stele to authenticate someone, resolve who they are, and manage their credentials, and Stele answers. The host never reaches inside Stele; it calls the contract. Everything host-specific — engagements, memory, credit, the sign-up onboarding tail — stays on the host's side of the line. Because the contract is shaped like an API (request/response) even though it runs in-process at first, Stele can be tested as a black box with no host present, and later lifted into its own service by putting a network under the same seam. The contract is deliberately complete — it defines the operations a real identity ecosystem needs, including two that today's code lacks (revoking a passkey, rotating the account's TOTP), so the design leads the build rather than inheriting its gaps. Decision needed: approve the contract surface (§3) and the host seam (§4); then it becomes the extraction change request.
Stele establishes, persists, and authenticates principals (human or agent), holds their credentials and roles, and produces the actor reference every recorded action is attributed to. Designed from its own boundary outward: Stele knows about principals and credentials and authentication. It does not know about engagements, memory, credit, the pipeline, or onboarding — those belong to hosts.
The design is isolation-first: Stele is defined by the contract it exposes, not by what it was carved out of. A consequence the Operator named: Stele is testable through its contract with no host in the room — the contract is the test surface.
These types appear throughout the contract; pulled from the real code.
{ id: UUID, display_name: str, email: str?, mobile: str?, companion_name: str = "Companion", created_at, updated_at }. (Today's Person view also carries two account-lifecycle fields, exhaustion_preference and spend_pause — those are host concerns and are excluded from Stele's Principal; see §5.2.){ kind: "person"|"companion"|"agent"|"contributor", id: UUID, instruction_version: int?, display_name: str?, capability_ref: UUID?, approval_mode: "explicit"|"pre_authorized"? }.{ person_id: UUID, totp_verified: bool, created_at, expires_at }.{ credential_id: bytes, public_key: bytes, sign_count: int, transports: [str]? }.
A standing rule visible in the shapes and preserved in the contract: the session token carries {person_id, totp_verified, created_at, expires_at} and nothing else — no roles, no email. Identity is the UUID; everything else is looked up. The seed's no-email-identity framework is already true in the data and the contract keeps it true.
Host-agnostic request/response. Multi-leg ceremonies (WebAuthn) are shown as their real two legs. Each operation notes (live) or (to build).
authn.begin (live) — start a passkey sign-in.
Request: {} (empty — discoverable/resident-key; never keyed by email).
Response: { authentication_id: str, options: object } (WebAuthn assertion options; browser offers every passkey on the relying party).
authn.complete (live) — finish the passkey leg.
Request: { authentication_id: str, credential: object } (raw navigator.credentials.get() JSON).
Response: { person_id: UUID, requires_totp: bool, partial_session: str }.
Note: completing the passkey does not issue a full session — it returns a partial session; the full session is issued only after the second factor.
Errors: pending_not_found (404), credential_not_registered (401), clone_signal_detected (401), assertion_verification_failed (401).
authn.second_factor.totp (live) — second factor by TOTP.
Request: { partial_session: str, code: str }.
Response: { person_id: UUID, session: str, expires_at, session_established: bool, reason: str?, first_login: bool }.
Contract subtlety (must be explicit): a suspended account returns a success-shaped response with session_established = false, reason = "suspended", session = "". Consumers must gate on session_established, not on the absence of an error. A deleted account is a hard error (account_deleted, 403).
Errors: partial_session_not_found (404), totp_invalid (401), totp_not_configured (409).
authn.second_factor.recovery (live) — second factor by recovery code. Same request/response shape as the TOTP second factor; consumes a single-use recovery code (bcrypt match over rows where used_at and invalidated_at are null; stamps used_at). Errors: recovery_invalid (401), same suspended-branch and account_deleted rules.
session.issue (live) — internal to the second-factor operations; issues the encrypted session token (default TTL 24h). Returns (Session, token).
session.resolve (live) — the operation a host calls on every request to learn who is acting.
Request: { token: str }.
Response: { principal: Principal } (resolves the token → person_id → Principal).
Errors: session_invalid (missing / bad signature / malformed / expired).
principal.get (live) — { id: UUID } → { principal: Principal | null }.
principal.find_by_email (live) — { email: str } → { principals: [Principal] }. Email is non-unique and non-identifying; this returns all matches and the caller must not treat email as a key. (Exists for host conveniences like duplicate-checks, never for identity.)
actor.for_principal (live) — produce the actor reference for a human turn. { principal } → { actor: ActorRef(kind="person", id=principal.id, display_name=principal.display_name) }.
actor.for_companion (live) — produce the actor reference for a companion turn. { principal } → { actor: ActorRef(kind="companion", id=principal.id, display_name=principal.companion_name) }. The companion actor's id is the principal's UUID, by construction.
actor.for_agent (to build) — produce the actor reference for an agent turn, given an authenticated agent principal (see §3.6). { agent_principal, instruction_version, capability_ref } → { actor: ActorRef(kind="agent", …) }. This is the constructor that does not exist today and is the point at which an agent gains a principal to act as.
credential.passkey.register.begin (live) — add a passkey to an existing principal. { principal_id } → { add_id: str, options: object }.
credential.passkey.register.complete (live) — { add_id: str, credential: object } → { credential_id: UUID }. Errors: pending_not_found (404), belongs_to_different_account (403), duplicate-credential integrity error.
credential.totp.enroll (to build for person scope) — today the account's TOTP secret is generated once during sign-up and never re-enrollable; this operation defines person-level TOTP enrollment as a first-class step. { principal_id } → { provisioning_uri: str, secret: str } (generate, not yet stored) then { principal_id, secret, code } → { verified: bool } (verify and store). (The shape mirrors the live contributor-TOTP enroll, which proves the pattern.)
credential.recovery.generate (live) — { principal_id } → { codes: [str] }. Plaintext returned once; only bcrypt hashes (cost 12) are stored. Status query: { principal_id } → { unused_count: int, total: int }.
credential.passkey.list (live) — { principal_id } → { passkeys: [{ id, display_name?, created_at, transports? }] }.
credential.passkey.revoke (to build — a real gap today) — revoke a single passkey. { principal_id, credential_id } → { revoked: bool }. There is no passkey deletion or revocation in the code today (credentials.py has add/list/get/update-sign-count, no delete, no DELETE route). An identity ecosystem must be able to revoke a compromised credential; the contract defines it, and the build implements it. Design note: prefer revoke (mark invalid, keep the row for audit — mirroring how recovery codes are invalidated, not deleted) over hard-delete, to preserve the corrections-not-smoothed discipline.
credential.totp.rotate (to build — a real gap today) — rotate or disable the account's TOTP. { principal_id } → { provisioning_uri, secret } (rotate) / { principal_id } → { disabled: bool } (disable). Person-TOTP has no rotation or disable today (only contributor-TOTP has reset). Required for recovery-completeness — you cannot fully recover an account if you cannot re-issue its second factor.
credential.recovery.rotate (live) — { principal_id } → { codes: [str] }. Invalidates all currently-valid recovery rows atomically (stamps invalidated_at, keeps for audit), issues a fresh set, returned once.
principal.mint (live, but currently fused — see §4.2) — create a principal with its first credential. This is the mint primitive the real code already isolates cleanly.
Request: { display_name: str, email?, mobile?, first_credential: VerifiedCredential, totp_secret_plaintext: str, secret_key }.
Response: { principal_id: UUID, recovery_codes: [str], credential_id: UUID }.
The mint creates the principal row (TOTP secret encrypted), registers the first passkey, and generates the recovery set. It returns principal_id — which is the only thing the host's onboarding consumes. The mint does not do onboarding (membership, personal engagement, session issuance) — those are the host's, on the host side of the seam (§4.2).
agent.authenticate (to build) — authenticate a non-human principal as itself, via RFC 7523 (JWT bearer) / RFC 8693 (token exchange, the act claim for "acting on behalf of"). This is the new construction that closes the "no principal for an agent to be" gap. Defined here as part of the complete ecosystem; sequenced as its own build after the human-side extraction. An agent that authenticates here can then be given an actor via actor.for_agent (§3.3), act and propose — and is still blocked from committing by the host's human-commit gate (R-B20), which is not part of Stele.
A host consumes Stele through the contract and nothing else. For the Companion (the first consumer), the seam is the one already documented (standing note v0.3 §8.3):
session.resolve to turn a session token into a Principal. The host keeps this dependency's name and signature, so the 119 sites that depend on it do not change; only its body now calls Stele.actor.for_principal / actor.for_companion (and later actor.for_agent) to turn a Principal into an ActorRef. The host hands the ActorRef to the memory substrate as it does today.authn. and session.; the host's security settings call credential.*.The host supplies Stele: a session token to resolve, a credential to verify, a principal id to act on. Stele returns: a Principal, an ActorRef, a grant/deny, a minted principal. Host-specific concerns never cross into Stele: engagements, memory events, credit/account lifecycle, and the onboarding tail all live on the host side.
Today, creating an account fuses the mint with onboarding in a single transaction (complete_totp_verify, ~lines 261–309): it mints the principal and creates the commons + engagement membership, creates the personal-engagement row, writes founding memory events, and issues the session — atomically.
Under the contract this splits at a clean line the real code already exposes: principal.mint returns principal_id; the host's onboarding step consumes only principal_id and does membership + personal-engagement + session issuance. The mint is Stele; onboarding is the host.
The decision this forces (carried from the scoping note, now precise): what happens if onboarding fails after the principal is minted?
principal.mint + onboarding in one outer transaction; onboarding failure rolls the mint back. Preserves today's all-or-nothing guarantee; couples Stele and host transactionally at sign-up only.Because the contract is API-shaped and service-ready, option B is the honest long-run form — an in-process transaction across the seam (A) will not survive the eventual service split, so if the service extraction is foreseeable, paying for B once now beats doing A then B. Lean: B, given the Operator's intent to design a complete standalone ecosystem — but A is acceptable as an explicitly interim choice if the service split is far off. This is the Operator's call (§6).
OVA verifies a credential a Stele principal holds (it consumes principal.get / role attributes, not the auth flow). FORAY records actions against a Stele principal id. An external host authenticates its own users through authn.* and resolves them through session.resolve — the same contract the Companion uses. Because the Companion (a consumer the Operator controls) is the first integration, the contract is proven against a cooperating host before any external consumer depends on it.
The contract is API-shaped; the first implementation is in-process function calls behind that shape (a Stele module inside the engine). The operations are the contract's operations; the request/response models are real types passed in-process. The service extraction, when a second consumer needs it, is "put a network transport under the existing seam" — the contract does not change, only its transport. This is why the contract is designed API-shaped now even while in-process: the seam is honest about Stele's independence from day one.
Owns (per the coupling map): the identity rows and credential tables, the registry/Principal view, the credential and auth modules, the session encode/decode, the actor constructors, and the mint primitive. References (does not own): the ActorRef type (memory substrate), the shared key-encryption key (stays in the credential store). Excludes from Stele's Principal view: the account-lifecycle fields (exhaustion_preference, spend_pause, and the rest) — those are host data; the host reads them from its own account record. (On day one Stele owns the whole persons table physically with lifecycle columns nullable-and-host-owned; the physical identity/account table split is deferred to the service extraction.)
Realize the contract in the phase order from the extraction scoping note (Phase 0 clean-internal pieces → Phase 1 rows/registry → Phase 2 actor constructors → Phase 3 session.resolve under the host dependency → Phase 4 the mint↔onboarding seam), each halt-before-push, the auth path tested against the database. The to-build operations (credential.passkey.revoke, credential.totp.rotate, person-credential.totp.enroll) land with recovery-completeness, which now builds into a defined contract shape rather than inventing these operations. agent.authenticate / actor.for_agent land as the agent effort after.
Settled: Stele is an isolation-first standalone ecosystem with a host-agnostic, service-ready API contract (§3); the contract is complete (defines revoke/rotate even where the code lacks them); the host seam is defined (§4.1); the mint↔onboarding line is precisely located (§4.2); in-process-first implementation realizes the same contract (§5).
Needs the Operator before the extraction change request:
On the Operator's go-ahead, this design plus the coupling map become the Stele extraction change request — phase by phase, halt-before-push — implementing the live operations and standing up the contract, with the to-build operations sequenced into recovery-completeness and the agent effort.
Stele is a user-facing component: a person signs in through it, and what they see and read can belong to any host's brand, in any language the host supports. But Stele is also a clean substrate consumed by OVA, FORAY, and external hosts that do not want Stele's opinions about brand or copy. These two facts are reconciled by one principle.
> Stele provides the hooks; the host provides the content. Stele never owns brand or copy. It takes a tenant_context (brand) and a locale on operations that produce user-facing artifacts, returns all outcomes as structured, language-neutral codes, and renders only the unavoidable Stele-originated artifacts (the WebAuthn relying-party name and the TOTP issuer label) brand-aware from tenant_context. Hosts hold the translation tables and render the surface. Adding a language or a brand is a host change, never a Stele change.
This is the architecture's recurring line again — source supplies, consumer governs: Stele supplies the mechanism and the hooks; the host governs the presentation.
The entire visible surface is the host's: the sign-in screen, the layout, the logo, the buttons, the copy of every message a user reads. Stele renders no UI. White-labeling the login experience is a host-frontend concern (for Loomworks, its existing brand system). Stele authenticates; it does not draw the page.
Only three user-facing artifacts originate inside Stele, and the contract handles each:
suspended, totp_invalid, clone_signal_detected, credential_not_registered, session_invalid. The host maps each code to its own copy, in its own languages. Stele holds no copy and no translations. (This formalizes the existing reason: codes in §3 as the host's translation surface.)tenant_context the host passes on authn.begin and the passkey-registration begin operations.tenant_context on TOTP enrollment.tenant_context — added to every operation that produces a user-facing artifact: authn.begin, the passkey-registration begin operation, and TOTP enrollment. It carries the host's brand identifier/display name for that call. Passed explicitly per call; Stele stores no brand — it uses the value for that one operation and retains nothing. A call without tenant_context falls back to a neutral default (a host-side omission, not a Stele concern).locale — added to the same operations, optional. Stele itself needs it only where it cannot avoid emitting text (it does not, in the codes-not-strings model, need it for outcomes). Carried for forward compatibility and for any future Stele-originated artifact that must be localized at source. Hosts translate outcome codes themselves and do not depend on Stele for outcome copy.The alternative — Stele holding translation tables and per-tenant brand insertions, returning finished strings — was rejected. It would make Stele a copy-management and localization platform with identity inside it: every copy change or new language would be a deploy of the security-critical identity layer; every consumer would inherit Stele's wording opinions; per-tenant overrides would re-introduce the brand ownership the substrate must not have. The codes-and-hooks model keeps Stele small, language-agnostic, and brand-free while fully enabling white-label and multi-language at the host.
A consumer (Aldous Coaching, say) presents Stele's identity flows entirely as its own: the login screen is Aldous's UI; the passkey prompt says "Aldous Coaching" (from tenant_context); the authenticator app shows "Aldous Coaching" (from tenant_context); every message the coach reads is Aldous's copy in the coach's language (host translates Stele's codes). The user never sees "Loomworks" or "Stele" unless the host chooses to show it. White-label is complete; Stele held no brand to leak.
DUNIN7 — Done In Seven LLC — Miami, Florida Loomworks design note — Stele integration design (API contract and host integration) — v0.2 — 2026-06-14