Skip to content

Data and scope

Steelmoth uses tenant, workspace, agent, auth-subject, conversation ownership, and RuntimeScope concepts to keep operational state scoped. The domain glossary defines RuntimeScope as a server-derived frozen scope object passed through handlers, repositories, Redis keys, Memlink boundaries, sidecars, notification outbox, and Agent Vault lookups.

The context map assigns scope authority to registry and auth/deployment evidence. Request bodies, browser fields, user text, and model output are not authority for runtime scope.

The current private/operator web-chat path enforces that rule in code: require_authed() rejects non-canonical Authentik email claims when a primary email is configured, scope.py derives auth_subject_id through owner_identity.py, /v1/me reports the server-derived owner/auth-subject pair, and living-document routes inject the canonical runtime owner rather than trusting dashboard-provided owner ids.

As of PR1 (RFC 0084 / ADR 0135) the Memlink service enforces this boundary itself, not only at the dashboard proxy. The runtime, dashboard, and worker callers attach a trusted internal bearer credential plus signed x-memlink-* scope headers (steelmoth_runtime/memlink_scope_auth.py); Memlink builds an authenticated ScopeContext from those signed headers before any handler runs (memlink_hybrid/adapters/inbound/auth.py). A request body or query owner_id is an assertion only — a mismatch with the authenticated owner returns 403, and missing/invalid auth returns 401. The route’s required capability is chosen server-side and each call carries only that one capability, so no caller holds a standing memory.graph.admin grant. When per-tenant oracle signing is on the signature is produced by the prontera broker (the key never leaves prontera): /sign (HMAC, ADR 0143) or, behind the default-off STEELMOTH_RUNTIME_MEMLINK_SIGN_ED25519_ENABLED, /sign-ed25519 (Ed25519, ADR 0146 — headers_for emits X-Memlink-Signature: ed25519=… plus X-Memlink-Kid, both fail-closed when the broker returns nothing). On the verify side memlink verifies Ed25519 (resolving owner→vault from the control-plane DB and the kid→public-key from the prontera JWKS) or HMAC. As of the ADR 0146 cutover (2026-06-17) MEMLINK_HYBRID_SCOPE_AUTH_MODE=enforce + MEMLINK_HYBRID_SCOPE_AUTH_PER_TENANT_REQUIRED=1: unsigned/invalid and shared-key signatures are rejected, so a caller can sign only as its own vault (the INV-024 topological guarantee). The two live callers — the operator brain (stm-runtime-api) and stm-control-plane — both sign per-tenant Ed25519 as vault client-mark (STEELMOTH_RUNTIME_MEMLINK_PER_TENANT_SIGNING_ENABLED

  • STEELMOTH_RUNTIME_MEMLINK_SIGN_ED25519_ENABLED) and were proven to verify end-to-end before the flip. Future client gateways sign as their own client-pool-NN vault (compose.owlet.yml). Emergency revert is a single env override (MEMLINK_HYBRID_SCOPE_AUTH_MODE=compat_static_scope) + recreate. headers_for only attaches the Authorization bearer when the resolved credential is non-empty: if a gateway’s shared memlink-scope broker alias fails to resolve (returns ""), emitting Authorization: "Bearer " would be an illegal httpx header value (LocalProtocolError) that crashes the request client-side before it reaches Memlink and trips the web-chat proxy circuit (the 2026-06-17 garcia chat-history 503). Omitting it lets Memlink decline cleanly with a 4xx; the gateway still needs a provisioned memlink-scope credential to be authorized. Scope auth defaults to enforce; PR2’s world.* RLS backstops owner isolation at the database. Client-editable living documents, including the Learning Profile alias backed by user_model_summary, still flow through that canonical-owner injection; non-admin callers use the learning-profile alias and cannot directly request raw user_model_summary.

Private compose now defaults runtime-facing services to STEELMOTH_OWNER_ID=owner_mark_canarybuilds_com and STEELMOTH_PRIMARY_AUTHENTIK_EMAIL=mark@canarybuilds.com; config.py also defaults the primary Authentik email to the canonical operator and the admin group to dashboard-admins. The transitional admin-email config remains until RFC 0074 PR-D but no longer grants role after PR-B. Treat these as deployment/source-of-truth defaults, not as values accepted from browser request bodies.

Recent scheduling and settings schema changes added two owner-scoped tables. world.calendar_subscriptions stores per-owner Google calendar layer choices (PRIMARY KEY (owner_id, calendar_id), selected default false), and routes_calendar.py fans out events only across selected rows for the current owner. Cross-owner toggles return no row from the repository and are surfaced as NOT_FOUND.

world.background_runs (migration 0134, PRD 0058 / RFC 0116 / ADR 0203) is the durable ledger for off-chat background jobs. One owner-locked row per job records its lifecycle (queued → running → paused|awaiting_approval → succeeded|failed|cancelled), a JSONB steps array of the job’s events, and the delivery + reaper bookkeeping (attempt_count, claim_deadline, final_message_id, approval_request_id). It uses strict owner-only RLS (steelmoth.world_scope_matches(owner_id) as both USING and WITH CHECK, ENABLE + FORCE) with no worker-escape policy: the background-run store’s fetch/claim/finish/append methods all set the owner GUC and operate on the owner’s own rows, so no cross-owner scan exists (the strict-vs-worker rationale is stated in the migration comment). Grants are SELECT/INSERT/UPDATE to steelmoth_app, no DELETE (rows go terminal via status). owner_id (chat-key form) and conversation_id (the originating chat for delivery) are always server-derived, never from a model output (INV-007 / INV-022). The restart-orphan reaper reclaims running/awaiting_approval rows whose claim_deadline has lapsed, caps attempts at three, and clears a reclaimed row’s stale approval ask.

world.assistant_triggers + world.assistant_trigger_fires (migration 0136, PRD 0058 / RFC 0116 / ADR 0205) are the standing-rule tables behind the trigger evaluator. A rule row carries a closed condition_kind (gmail_arrival|calendar_lead, DB CHECK), the match fields (sender_match in the mail-allowlist grammar, subject_contains, event_match, lead_minutes), the operator’s instruction, an app-layer status (draft|armed|paused|cancelled), and checkpoint_at — the per-rule watermark on the steelmoth.mail_messages.created_at (mirror-insert) axis, never received_at. Only the evaluator writes a non-NULL checkpoint (advanced to run_start − 120 s after a clean cycle); arming initializes it to now() so a new rule never fires on backlog — the 0122 checkpoint contract. The fires table is the exactly-once ledger: UNIQUE (trigger_id, dedupe_key) claimed with INSERT … ON CONFLICT DO NOTHING, where dedupe_key is the Gmail provider message id or the per-instance "<event_id>:<start>", each row linking to the background_runs row it started and carrying a fired|skipped_injection status (the latter reserved for a future enforcement flip). Both tables use the same strict owner-only RLS and SELECT/INSERT/UPDATE-no-DELETE grants as world.background_runs, for the same no-cross-owner-scan reason.

steelmoth.anytype_direct_action_audit (migration 0156, PRD 0064 / RFC 0124 / ADR 0233) is the durable idempotency, audit, fuse, recovery, and reply-outbox row for the Anytype channel’s no-chip create/rename exception. The unique runtime-scoped provider-message key permits at most one mutation per inbound message. A partial unique owner/channel index permits at most one unresolved reserved/unknown mutation, so an ambiguous provider outcome remains a restart-proof direct-write fuse rather than becoming an automatic retry.

The row binds full runtime scope and owner to the exact channel, space, chat, creator, provider message, payload hash, binding fingerprint, action/mode, target, content hash, and minimum recovery/provider evidence. It does not store the raw human prompt or Anytype object body. Reply delivery is a separate state machine (not_ready|pending|claimed|delivered|unknown) in the same protected row; its reply text/hash and reconciliation evidence exist only to retry or prove delivery and never enter logs or metric labels. claimed/unknown delivery is not automatically reclaimed.

The table uses ENABLE + FORCE ROW LEVEL SECURITY: both steelmoth.runtime_scope_matches(tenant_id, workspace_id, agent_id) and app.owner_id must match for USING and WITH CHECK. The application receives SELECT/INSERT/UPDATE and no DELETE; evidence survives feature/code rollback. PostgresAnytypeDirectActionStore sets all scope GUCs transaction-locally before each operation. The migration’s down file exists for isolated CI reversibility, not normal production rollback.

steelmoth.user_settings_profile is now the canonical per-user profile row for display_name, timezone, and location, keyed by (tenant_id, workspace_id, agent_id, email) with lowercase-email checks. When steelmoth.runtime_scope_matches(...) exists, migration 0040 enables/forces RLS against runtime scope. routes_settings.py and runtime timezone resolution both use the same UserSettingsProfileRepository contract so dashboard PATCHes and chat-side timezone updates target one scoped store.

Migration 0042 re-homes that table to steelmoth_migrator and explicitly grants runtime-table access (SELECT, INSERT, UPDATE, DELETE to steelmoth_app; SELECT to steelmoth_maintenance). Without that grant repair, /v1/settings and chat-side _effective_timezone() lookups degrade to the route/runtime fail-open paths (settings.user_profile_*_failed, timezone.repo_lookup_failed) and persistence silently no-ops.

The notification-channel projection on /v1/settings.user.channels is also strictly server-derived (PRD 0029 / RFC 0064 / ADR 0084). The runtime config owns the authority for which channels are configured and supported; dashboard request bodies cannot set those flags. A scripted PATCH that tries to enable a channel reporting configured=false is rejected with INVALID_ARGUMENT before any preference row is written. This keeps the notification-channel surface aligned with the broader scope rule: auth/deployment evidence is the source of truth, request bodies are not.

The admin workspace-boundary projection in /v1/settings is also server-derived. Its protected_paths field now comes from self_code_safety.py:PROTECTED_RELATIVE_PATHS, the same denylist used by runtime self-code read/search path resolution. Browser request bodies cannot relax those path boundaries. The folder pick itself (plan docs/plans/2026-08-15-selfcode-home-workspace.md) is equally confined: the picker lists only the deployed base mount’s immediate subfolders, and the persisted pointer (<base>/.selfcode-root) is validated to resolve strictly inside the base root — a request body can never move the Code lane outside the operator-provisioned mount. The server workspace session that executes Code-lane ops inside that mount is equally scope-bound: it is constructed per dispatch for the turn’s owner and the pointer-resolved root, and fail-closed refuses any ServerContext naming a different owner or root (scope_mismatch).

The target column on steelmoth.user_notification_preferences is populated two ways and the daemon resolves both consistently (PRD 0032 / RFC 0067 / ADR 0087):

  • For inbox_only, the dashboard PATCH path writes target = owner_id (the inbox writer keys on owner_id, not chat_id).
  • For telegram, the runtime’s Telegram polling handler captures the inbound chat_platform_id via NotificationPreferenceRepository.upsert_target whenever an inbound message arrives. The upsert is idempotent and never flips enabled, so the operator’s panel toggle remains the only opt-in path. Without this capture, the reminder daemon’s Telegram leg would call sendMessage(chat_id="owner:owner_mark_…:main") — the synthetic owner-key string stored in world.reminders.chat_id — and the broker would return “chat not found”.
  • For other channels (email, slack/whatsapp/instagram/webhook placeholders), the column is unused this branch and the daemon emits an “adapter not wired in daemon” outcome (ADR 0084 Open Items).
  • Update this page when scope authority, tenant/workspace/agent registries, active durable truth, or Memlink tenant boundaries change.
  • For schema changes, follow the migration-before-code invariant.
  • This page summarizes current scope vocabulary, not a complete data model.