DUNIN7 · LOOMWORKS · RECORD
record.dunin7.com
Status Current
Path scoping-notes/loomworks-stele-integration-design-v0_1.md

Loomworks design note — Stele integration design (API contract and host integration) — v0.1

Version. 0.1 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. 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. 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.


Plain-language summary

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.


1. What Stele is, as an ecosystem

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.

2. The cross-cutting primitives (real shapes)

These types appear throughout the contract; pulled from the real code.

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.

3. Stele's API contract

Host-agnostic request/response. Multi-leg ceremonies (WebAuthn) are shown as their real two legs. Each operation notes (live) or (to build).

3.1 Authentication

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.

3.2 Session

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

3.3 Principal

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.

3.4 Credential lifecycle — registration

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

3.5 Credential lifecycle — revocation and rotation (the completeness the Operator chose)

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.

3.6 Principal creation and agent identity

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.

4. The host integration contract

4.1 The seam

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

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.

4.2 The mint↔onboarding seam (the one real refactor)

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?

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

4.3 Other consumers (same contract, later)

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.

5. Implementation path

5.1 In-process first, service-ready

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.

5.2 What the in-process module owns, references, and excludes

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

5.3 Build order

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.

6. What this settles and what it needs

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:

  1. Approve the contract surface (§3) — the operations and their shapes, including the to-build completeness operations.
  2. Pick the mint↔onboarding approach (§4.2: A / B / C; lean B given the standalone-ecosystem intent).
  3. Confirm the phase order (§5.3) — unchanged from the extraction scoping note unless the contract changes it.

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.


DUNIN7 — Done In Seven LLC — Miami, Florida Loomworks design note — Stele integration design (API contract and host integration) — v0.1 — 2026-06-14