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 ownclient-pool-NNvault (compose.owlet.yml). Emergency revert is a single env override (MEMLINK_HYBRID_SCOPE_AUTH_MODE=compat_static_scope) + recreate.headers_foronly attaches theAuthorizationbearer when the resolved credential is non-empty: if a gateway’s sharedmemlink-scopebroker alias fails to resolve (returns""), emittingAuthorization: "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 provisionedmemlink-scopecredential to be authorized. Scope auth defaults toenforce; PR2’sworld.*RLS backstops owner isolation at the database. Client-editable living documents, including the Learning Profile alias backed byuser_model_summary, still flow through that canonical-owner injection; non-admin callers use thelearning-profilealias and cannot directly request rawuser_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 writestarget = owner_id(the inbox writer keys on owner_id, not chat_id). - For
telegram, the runtime’s Telegram polling handler captures the inboundchat_platform_idviaNotificationPreferenceRepository.upsert_targetwhenever an inbound message arrives. The upsert is idempotent and never flipsenabled, so the operator’s panel toggle remains the only opt-in path. Without this capture, the reminder daemon’s Telegram leg would callsendMessage(chat_id="owner:owner_mark_…:main")— the synthetic owner-key string stored inworld.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).
Maintenance Notes
Section titled “Maintenance Notes”- 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.
Known Unknowns
Section titled “Known Unknowns”- This page summarizes current scope vocabulary, not a complete data model.

