Infrastructure migration in progress: some features are temporarily unavailable.
jomcgi.dev

monolith

STPA Control Analysis: monolith

logic @ ac81a1554 · security @ 34d4f1816

Auto-generated STPA safety model: unsafe states this system can reach and control actions that get it there. Single or multiple lenses: logic (mission failure), security (deliberate attack), governance (data safety).

<details> <summary><b>How to read this</b>: STPA primer and diagram legend</summary>

STPA (System-Theoretic Process Analysis, Leveson) treats the system as controllers issuing control actions to controlled processes, with feedback flowing back. Instead of "what component fails," it asks "what control action, given or withheld at the wrong time, drives the system into an unsafe state?" Unsafe means a violation of this system's reason to exist. Multiple lenses (logic, security, governance) analyze the same control structure for different safety questions: mission failure, deliberate attack, data-processing violations. A finding appearing in multiple lenses is expected and correct.

Read top-down: Losses are outcomes we must prevent; Hazards are states leading to loss; diagrams show control structure (solid = control action, dashed = feedback); Unsafe Control Actions table is the core; Unsafe Feedback covers data channels (absent, stale, corrupted, spoofed). Every claim cites path:line; unbuilt elements are marked. Semantic stable IDs mean regenerating changes only findings that changed.

</details>

Control structure

Logical view

flowchart TD
  subgraph actor["actor"]
    console-caller["Private /agents console (human operator)"]
    discord-user["Discord user"]
    mcp-caller["MCP-authenticated caller (agent)"]
  end
  subgraph boundary["boundary"]
    embervm-sandbox["EmberVM per-language sandbox task guests (see projects/embervm/STPA.md)"]
    embervm-shotter["EmberVM shotter task guest (see projects/embervm/STPA.md)"]
  end
  subgraph control_plane["control_plane"]
    agent-runner["Agent session runner (claim-lease over all replicas)"]
    bot["Discord bot (leader singleton)"]
    kg-drainer["DBOS-scheduled routine drainer (qwen-drain, kg-drain)"]
    kg-feed["Leader-owned Ember session to knowledge raw exporter"]
    leader-elector["Leader elector"]
    outbox["Discord outbox drain"]
    scheduler-api["Scheduler read/trigger API"]
  end
  subgraph enforcement["enforcement"]
    acl["Feature ACL (grant cache)"]
    principal-mw["Principal middleware (bearer auth, cached JWKS)"]
    shotter-validate["Shotter URL/host allowlist (2nd layer)"]
  end
  subgraph publish["publish"]
    docs-manifest-gen["Docs/posts manifest generator (CI Format stage)"]
  end
  subgraph serving["serving"]
    public-api["Public backend (read + bounded writes)"]
    public-frontend["Public SSR frontend"]
    sandbox-tool["run_code MCP tool (per-language sandbox broker)"]
    shotter-tool["Shotter screenshot MCP tool"]
  end
  subgraph store["store"]
    postgres["Postgres (per-domain schemas)"]
  end
  bot -- "acl.check" --> acl
  leader-elector -- "agent.reclaim" --> agent-runner
  bot -- "agent.submit" --> agent-runner
  console-caller -- "console.submit" --> agent-runner
  docs-manifest-gen -- "docs.publish" --> public-frontend
  kg-drainer -- "kg-drainer.dispatch" --> agent-runner
  kg-feed -- "kg-feed.export" --> pg-primary
  leader-elector -- "leader.acquire" --> postgres
  bot -- "lock.reclaim" --> postgres
  mcp-caller -- "mcp.agent-session" --> agent-runner
  mcp-caller -- "mcp.authorize" --> principal-mw
  mcp-caller -- "mcp.chat-pardon" --> bot
  mcp-caller -- "mcp.report-knowledge" --> pg-primary
  outbox -- "outbox.post" --> bot
  public-api -- "public.health" --> postgres
  console-caller -- "raws.create" --> pg-primary
  mcp-caller -- "sandbox.run" --> sandbox-tool
  scheduler-api -- "scheduler.trigger" --> postgres
  mcp-caller -- "shotter.capture" --> shotter-tool
  shotter-tool -- "shotter.dispatch" --> embervm-shotter
  shotter-tool -- "shotter.validate" --> shotter-validate
  leader-elector -- "singletons.start" --> bot
  embervm-sandbox -. "stdout/stderr/exit_code/files for the run (Idempotency-Key from language+code only)" .-> sandbox-tool
  embervm-shotter -. "captured PNG + render metadata (random per-call Idempotency-Key)" .-> shotter-tool
  postgres -. "feature grant rows (30s cached)" .-> acl
  postgres -. "claimed_by_replica + claimed_at lease row" .-> agent-runner
  postgres -. "lease heartbeat freshness" .-> leader-elector
  postgres -. "disputed flag + verification_state on search/get_note results" .-> mcp-caller
  postgres -. "observability topology/stats snapshots" .-> public-api
  public-frontend -. "SSR/API proxy" .-> public-api
  shotter-tool -. "stored URL + stored flag (_meta) for the captured PNG" .-> mcp-caller

Physical view

flowchart TD
  subgraph compute["compute"]
    frontend-ssr["SvelteKit SSR sidecar"]
    private-binary["monolith (full secrets)"]
    public-binary["monolith-public backend (constrained DB + FaaS access)"]
  end
  subgraph control_plane["control_plane"]
    embervm["EmberVM / FaaS admission"]
  end
  subgraph data["data"]
    migrations["Grant migrations"]
    pg-primary["CNPG primary (-rw)"]
    pg-replica["CNPG hot standby (-ro)"]
    shotter-store["SeaweedFS shotter bucket (content-addressed PNGs)"]
  end
  subgraph ingress["ingress"]
    cf-ingress["Cloudflare ingress (Envoy Gateway)"]
  end
  subgraph secrets["secrets"]
    k8s-api["Kubernetes API"]
    onepassword["1Password operator"]
  end
  public-binary -- "chatpublic.write" --> pg-primary
  frontend-ssr -- "frontend.proxy" --> public-binary
  migrations -- "grant.public-reader" --> pg-replica
  private-binary -- "k8s.mutate" --> k8s-api
  cf-ingress -- "route.public" --> frontend-ssr
  onepassword -- "secret.deliver-public" --> public-binary
  private-binary -- "shotter.store" --> shotter-store
  cf-ingress -. "X-Auth-Email identity header (attribution only, allowlisted senders, not cryptographically bound)" .-> agent-runner
  pg-primary -. "streaming replication lag" .-> pg-replica

Control actions

ID View Control action Controller → Process Maturity Evidence
acl.check logical Authorize a Discord feature action botacl built projects/monolith/chat/acl.py:68
agent.reclaim logical Reclaim stale turn claims left by a crashed replica (leader-owned 5s sweep) leader-electoragent-runner built projects/monolith/factory/execution/store.py:772
agent.submit logical Submit a Discord-triggered agent turn to the runner botagent-runner built projects/monolith/chat/bot.py:1172
chatpublic.write physical Write a bounded chat_public row to the primary as public_writer public-binarypg-primary built projects/monolith-public/chart/values.yaml:82
console.submit logical Start or continue an agent session from the private console console-calleragent-runner built projects/monolith/factory/execution/router.py:544
docs.publish logical Copy an allowlisted repo doc's full body into the public docs/posts manifest docs-manifest-genpublic-frontend built projects/monolith/knowledge/tools/gen_docs_manifest.py:128
frontend.proxy physical Proxy SSR/API calls from the frontend to the public backend, in-cluster only frontend-ssrpublic-binary built projects/monolith-public/chart/templates/httproute-public.yaml:33
grant.public-reader physical Grant public_reader SELECT on a schema/view migrationspg-replica built projects/monolith/chart/migrations/20260617000000_public_reader_role.sql:24
k8s.mutate physical Mutate cluster state via private MCP (ArgoCD sync) private-binaryk8s-api built projects/monolith/cluster/mcp.py:163
kg-drainer.dispatch logical Claim a kg-drain job, run Luna extraction, apply the result kg-draineragent-runner built projects/monolith/factory/orchestration/drainer.py:404
kg-feed.export logical Export a finished Ember session as an ember-session raw kg-feedpg-primary built projects/monolith/factory/execution/kg_feed.py:231
leader.acquire logical Acquire/renew/steal the singleton lease leader-electorpostgres built projects/monolith/core/leadership.py:56
lock.reclaim logical Reclaim an expired message-processing lock botpostgres built projects/monolith/chat/leader.py:109
mcp.agent-session logical Start or drive an agent session via MCP mcp-calleragent-runner built projects/monolith/factory/execution/mcp.py:660
mcp.authorize logical Verify the bearer token on every MCP message (stateless streamable HTTP) mcp-callerprincipal-mw built projects/monolith/framework/core.py:543
mcp.chat-pardon logical Reset a Discord user trust score via MCP mcp-callerbot built projects/monolith/agent/mcp.py:338
mcp.report-knowledge logical Agent-report/dispute/distress raw capture (report_knowledge, dispute_fact, report_distress) mcp-callerpg-primary built projects/monolith/knowledge/mcp.py:222
outbox.post logical Drain and post a queued Discord message/edit/reaction outboxbot built projects/monolith/chat/outbox.py:146
public.health logical Probe the public database as public_reader (SELECT 1) public-apipostgres built projects/monolith/framework/core.py:322
raws.create logical Generic raw-input capture (POST /api/knowledge/raws) console-callerpg-primary built projects/monolith/knowledge/router.py:283
route.public physical Route the public hostname to the frontend SSR (no direct backend route) cf-ingressfrontend-ssr built projects/monolith-public/chart/templates/httproute-public.yaml:13
sandbox.run logical Run short code in a per-language zero-egress guest mcp-callersandbox-tool built projects/monolith/sandbox/mcp.py:14
scheduler.trigger logical Mark a job for immediate run scheduler-apipostgres built projects/monolith/scheduler/service.py:35
secret.deliver-public physical Deliver only the public backend's constrained DB, Turnstile, and signing material onepasswordpublic-binary built projects/monolith-public/chart/templates/onepassworditem.yaml:1
shotter.capture logical Request a rendered screenshot of a public/private page mcp-callershotter-tool built projects/monolith/shotter/mcp.py:123
shotter.dispatch logical Dispatch a fresh task-class guest to render the URL shotter-toolembervm-shotter built projects/monolith/shotter/client.py:91
shotter.store physical Best-effort content-addressed upload of the captured PNG private-binaryshotter-store built projects/monolith/shotter/s3.py:83
shotter.validate logical Validate scheme/host/dimensions before dispatch (2nd, defense-in-depth layer) shotter-toolshotter-validate built projects/monolith/shotter/mcp.py:56
singletons.start logical Start leader-only singletons (bot, ingest, drain, sweep) leader-electorbot built projects/monolith/framework/core.py:236

Lens: logic

Scope. A single Postgres-backed personal platform served as two composed binaries: a private read-write binary (full app capabilities, private.jomcgi.dev) and an isolated public binary (jomcgi.dev via the separate monolith-public chart). The public binary is read-only for public datasets with two constrained write/invocation paths, the private binary now also serves an agent-session console, a six-language code sandbox, a screenshot tool, and MCP over native streamable HTTP; safety is governed data access, bounded public capabilities, the secret boundary, and correct claim/lease ownership of queued agent work.

<details> <summary>Maturity detail</summary>
  • Built: framework/core.py composition and profiles, separate private/public module registries, Postgres leader-lease singletons (Discord bot, AIS ingest, outbox drain, message-lock sweep), the agent_sessions claim-lease turn engine (atomic per-message claiming across all replicas plus a leader-owned stale-claim sweep) backing both the Discord bot and the private /agents console HTTP API, goosecracker recipe/repo catalog, chat feature ACL with 30s grant cache, public_reader/public_writer roles with schema/view confinement, public/private HTTPRoutes, Turnstile secret isolation, public chat admission/concurrency limits, public FaaS identity gate, observability snapshot rollup, the shotter MCP domain (URL/host validation, EmberVM task dispatch, best-effort SeaweedFS PNG storage with a random per-call Idempotency-Key), the sandbox MCP domain (run_code, six per-language EmberVM task workloads, zero-egress except an optional scratch-Postgres credential), the native /mcp mount on stateless streamable HTTP with PrincipalMiddleware authenticating every message via cached JWKS, and the docs/posts manifest generators that publish an exact allowlist of committed repository documents to the public site. The factory knowledge-graph evidence lanes (#5527, ADR agents/063) add a shared raw-input write boundary (knowledge/ingest_queue.py ingest_raw_with_status) reached by the generic POST /api/knowledge/raws endpoint, three MCP tools (report_knowledge, dispute_fact, report_distress), and a leader-owned Ember-session exporter (agent_sessions/kg_feed.py), a daily-capped DBOS drainer (swarm/drainer.py) that runs Luna extraction in an EmberVM guest and writes atoms with server-side provenance (knowledge/extraction.py, knowledge/atoms.py), and a live disputed/verification_state signal on search results (knowledge/store.py).
  • Designed-only: Strict per-domain database isolation and the ADR 010 cross-domain contract remain architectural goals; the Module/build_app framework itself is built. ADR 059 (Draft) proposes removing Context Forge entirely as the MCP entry point and serving /mcp directly behind Cloudflare; only the first, independently-sequenced slice (the stateless-HTTP transport switch) has landed, Context Forge is still deployed and still in the request path. The Context Forge tool-visibility reconcile pass (#4569) that would scope which principal may call which tool is designed, not built, so per-tool authorization beyond bearer-token authentication does not exist for any MCP tool yet.</details>

Losses

ID Loss
L.integrity-loss Data is corrupted, or a side-effecting action (Discord post, agent run, chat_public write) is duplicated or forged
L.liveness-loss A queued turn or locked message never makes progress
L.provenance-loss An agent acts on incomplete or misattributed context, losing lineage of what it was told
L.secret-exposure A secret or token reaches a tier or surface that must not hold it
L.silent-incorrectness A controller serves stale or wrong data while believing it correct
L.unauthorized-access Anonymous or public-tier caller reads data it is not entitled to (private rows, ungoverned paths)

Hazards

ID View Hazard (unsafe state) → Losses Maturity
duplicate-agent-run logical An agent turn runs twice: a stale-claim sweep reclaims a lease still held by an actively-executing replica, or a manual resubmission overlaps a guest-side invoke the monolith timed out on without confirming it stopped L.integrity-loss built
header-authz-drift physical X-Auth-Email is forwarded by several allowlisted in-cluster senders (gateway, MCP gateway, Argo job pods, the WhatsApp gateway, the EmberVM progress-ingest sidecar) that do not all cryptographically bind the claim to a verified caller; today it is read only for attribution (agent_sessions triggered_by), but nothing stops a future authorization decision from keying on it without also verifying the caller's JWT L.unauthorized-access built
kg-dead-letter-unreachable logical A raw dead-lettered on its first malformed-output failure stops at retry_count=1 and its routine_jobs row is deleted immediately, but /api/knowledge/dead-letter and its replay action both require retry_count >= MAX_GARDENER_RETRIES (3), and even a successful replay only deletes the failed provenance row without re-creating the deleted job, so no operator path re-queues a KG-lane dead letter; a disputed note stuck in this state stays marked disputed indefinitely, since Dispute.state only ever leaves 'open' inside a successful apply_extraction L.silent-incorrectness, L.provenance-loss built
kg-enqueue-swallowed logical ingest_raw_with_status catches any exception from enqueue_extraction and continues, citing a repair sweep that does not exist anywhere in the codebase; for the ember-session feed this combines with an unconditional watermark advance, so the affected turn range is never re-offered for extraction and no health signal distinguishes the raw from one that was never meant to be extracted L.silent-incorrectness, L.provenance-loss built
over-broad-public-grant physical public_reader is granted on a schema or view that includes non-public rows L.unauthorized-access, L.secret-exposure built
phantom-stored-artifact logical A caller treats the returned content-addressed URL as a durable reference when the SeaweedFS write actually failed, because the URL is computed from the content hash before the upload is attempted and is returned either way L.silent-incorrectness built
private-capture-retained physical A captured screenshot, including of the private tier, is written to SeaweedFS with no expiry policy and persists indefinitely at a stable content-addressed URL after the request that produced it L.unauthorized-access built
public-route-exposes-private-path physical The public HTTPRoute forwards an internal or unfiltered path to a served handler L.unauthorized-access built
public-write-admission-bypass physical The internet-adjacent public tier can write outside the intended chat_public path, or bypasses Turnstile, per-session limits, and the cluster-wide inference cap L.integrity-loss built
sandbox-credential-egress logical The scratch-Postgres feature, when enabled, injects a database DSN into the executed code's own process environment, so a Python run in the advertised zero-network sandbox gains credentialed, in-cluster network reach to a shared datastore; the tool's own docstring still claims there is no network at all L.secret-exposure, L.unauthorized-access built
second-layer-validation-gap logical The monolith-side host/scheme allowlist is weakened, widened without an ADR amendment, or bypassed, letting an out-of-policy top-level URL reach the EmberVM dispatch call; the guest-side in-guest proxy allowlist (projects/embervm/STPA.md) is the actual control on what gets fetched, so this alone does not open egress L.unauthorized-access built
secret-in-wrong-tier physical A private secret or a K8s token is delivered to the public or frontend tier L.secret-exposure built
split-brain-singletons logical Two replicas both believe they are leader and run duplicate bot/ingest/drain L.integrity-loss built
stale-authz logical A revoked feature grant keeps authorizing an action from the 30s ACL cache L.unauthorized-access built
stale-public-snapshot logical The public stats endpoint serves an old observability snapshot after the rollup job stops L.silent-incorrectness built
stale-sandbox-result logical A caller re-invoking run_code with the same code but different input files receives a cached result computed against a prior submission's files instead of a fresh run L.silent-incorrectness built
unbounded-capture-queueing logical No rate limit gates shotter.capture or sandbox.run, so a caller can queue captures/runs faster than the workload's own cap admits, consuming brick memMib capacity shared with other task-class workloads L.liveness-loss built
unredacted-public-doc logical The docs/posts manifest generators copy an allowlisted document's full body into the public site verbatim, gating only on which path may be published, never on what the content contains, so an internal-only identifier (a cluster-internal hostname, a secret env var name) left in a published project's README/ARCHITECTURE/STPA reaches the public docs route L.secret-exposure built
unredacted-transcript-raw logical The only redaction pass (agent_sessions.redact.redact_text) runs inside the ember-session feed's own renderer; the shared ingest_raw_with_status boundary every EXTRACTABLE_SOURCES producer funnels through, including the generic POST /api/knowledge/raws endpoint and the not-yet-shipped claude-session/codex-session producers (#5568), applies no redaction of its own, so content posted under one of those source labels is extracted into durable, searchable graph facts verbatim L.secret-exposure built
unrestricted-tool-visibility logical PrincipalMiddleware authenticates every MCP message (a valid authentik bearer token is required), but no monolith tool authorizes on the resulting Principal: the Context Forge tool-visibility reconcile pass that would scope who may call a given tool is still designed, not built (#4569), so any authenticated caller, not only ones entitled to the private tier, can invoke shotter.capture, sandbox.run, or k8s_sync_argocd_app L.unauthorized-access built
wedged-turn logical A queued agent turn never progresses because no replica ever holds leadership to run the stale-claim sweep, and the replica that would have executed it directly crashed or restarted before claiming it L.liveness-loss built

Unsafe control actions

ID View Control action Guideword Unsafe condition Severity → Hazards Evidence
agent.reclaim.wrong-timing logical agent.reclaim wrong-timing The reclaim lease and the local heartbeat check are computed from two different clocks (the lease cutoff from the pod's own monotonic-adjacent wall clock, the claimed_at write from the database's); a pod running ahead of the database clock reclaims a lease that is still being actively refreshed elsewhere, and the reclaiming replica's own self-check ('claim was stolen') only protects the replica that LOST the claim, not the window before the next 10s heartbeat catches it, so both replicas can execute the same turn concurrently for up to that window medium duplicate-agent-run, split-brain-singletons projects/monolith/factory/execution/store.py:791
chatpublic.write.providing physical chatpublic.write providing The public tier accepts a write without the Turnstile/IP-hash admission gate or writes outside the constrained chat_public schema, poisoning or escaping the public boundary high public-write-admission-bypass projects/monolith-public/chart/values.yaml:146
docs.publish.providing logical docs.publish providing The generator's only gate is an exact repo-path allowlist; it checks documents for certain markers (in-cluster hostnames, 1Password refs, private IPs, node/brick names, S3 URIs, .internal domains, secret env assignments) but internal identifiers not matching these patterns pass through unreviewed medium unredacted-public-doc projects/monolith/knowledge/tools/gen_docs_manifest.py:128
grant.public-reader.providing physical grant.public-reader providing A grant on ALL TABLES or a view lacking the visibility filter exposes private rows to the anonymous tier high over-broad-public-grant projects/monolith/chart/migrations/20260617000000_public_reader_role.sql:24
k8s.mutate.providing physical k8s.mutate providing An MCP caller with a valid bearer token issues an ArgoCD sync or resource mutation with no additional per-tool authorization of which Principal may trigger it; the delegation seam (#4940) exists but this tool does not consume it medium unrestricted-tool-visibility, secret-in-wrong-tier projects/monolith/cluster/mcp.py:163
kg-drainer.dispatch.not-providing logical kg-drainer.dispatch not-providing ExtractionOutputInvalid (malformed LLM JSON, the same shape a truncated turn produces) is caught separately from generic exceptions and dead-letters the job after exactly one attempt, while every other failure gets up to MAX_GARDENER_RETRIES bounded retries first; a transient LLM formatting miss on a dispute or report never gets the retry a comparable infra hiccup would, and the resulting dead letter is unrecoverable per kg-dead-letter-unreachable medium kg-dead-letter-unreachable projects/monolith/factory/orchestration/drainer.py:483
kg-feed.export.not-providing logical kg-feed.export not-providing enqueue_extraction can silently fail inside ingest_raw_with_status (swallowed, citing a nonexistent repair sweep) for a raw that ingest still reports as created; feed_once then unconditionally advances kg_extracted_turn_seq past that turn range regardless of enqueue outcome, so the extraction job for those turns is never registered and never re-offered high kg-enqueue-swallowed projects/monolith/factory/execution/kg_feed.py:258
lock.reclaim.wrong-timing logical lock.reclaim wrong-timing A slow handler still processing past the 30s TTL is reclaimed and the message is reprocessed concurrently medium duplicate-agent-run projects/monolith/chat/leader.py:109
raws.create.providing logical raws.create providing The endpoint accepts up to 2 MiB of content under any EXTRACTABLE_SOURCES source label (e.g. ember-session, claude-session) with no redaction step of its own, so unredacted transcript content posted under one of those labels is enqueued for extraction and, once extracted, indexed as durable private-visibility knowledge notes indistinguishable from a properly-redacted one high unredacted-transcript-raw projects/monolith/knowledge/router.py:283
route.public.providing physical route.public providing A public HTTPRoute sends an unapproved backend path to the internet, or the frontend SSR proxy exposes a private-only route high public-route-exposes-private-path projects/monolith-public/chart/templates/httproute-public.yaml:20
sandbox.run.providing logical sandbox.run providing When the scratch-Postgres feature is enabled, run_code's own docstring still advertises 'there is no network at all' for every language while the Python path silently gains a credentialed connection string to an in-cluster datastore, so a caller relies on an isolation guarantee the tool does not actually provide for that one language medium sandbox-credential-egress projects/monolith/sandbox/client.py:40
secret.deliver-public.providing physical secret.deliver-public providing A private credential, Kubernetes API-capable token, or Turnstile secret is wired into the public frontend or public backend beyond its explicitly constrained use high secret-in-wrong-tier projects/monolith/public_turnstile_secret_isolation_test.py:76
shotter.capture.providing logical shotter.capture providing PrincipalMiddleware verifies the caller holds a valid authentik-issued token, but no per-tool authorization scopes who may invoke shotter.capture: any authenticated MCP caller, not only ones entitled to the private tier, can render private.jomcgi.dev pages high unrestricted-tool-visibility projects/monolith/shotter/mcp.py:120
shotter.store.wrong-duration physical shotter.store wrong-duration The stored PNG is retained indefinitely with no expiry policy, so a private-tier capture remains fetchable at its content-addressed URL by anything that can reach the SeaweedFS S3 endpoint and obtain the hash, long after the request that produced it medium private-capture-retained projects/monolith/shotter/s3.py:99
shotter.validate.not-providing logical shotter.validate not-providing HOST_SERVICE_MAP is widened past its documented exactly-two-entries invariant, or validate_screenshot_url is bypassed, without an accompanying ADR amendment; the two allowlists (monolith host map, guest-side hard allowlist) are not tied by any build-time invariant, so they can drift independently low second-layer-validation-gap projects/monolith/shotter/hosts.py:20

Unsafe feedback

Data channels (absent, stale, corrupted, spoofed) whose failure drives a controller into a hazard.

ID View Channel Guideword Unsafe condition Severity → Hazards Evidence
claim-lease.stale logical postgresagent-runner: claimed_by_replica + claimed_at lease row stale A pod's own clock running ahead of the database's is the dangerous skew direction named in the reclaim function's own docstring: it sees a live claim as older than it is and reclaims a turn that is still being actively executed by its true owner. NTP keeps this far inside the 30s lease in practice, but the failure is explicitly documented as silent medium duplicate-agent-run projects/monolith/factory/execution/store.py:794
disputed-flag.stale logical postgresmcp-caller: disputed flag + verification_state on search/get_note results stale disputed is derived live from open Dispute rows, so it correctly clears on a successful extraction, but a dead-lettered dispute raw (kg-dead-letter-unreachable) leaves its Dispute.state at 'open' forever with no other code path that ever updates it, so a caller relying on the flag to judge whether a fact is trustworthy sees it marked disputed indefinitely with no way to tell a stuck dispute from a live one medium kg-dead-letter-unreachable projects/monolith/knowledge/store.py:523
grants-cache.stale logical postgresacl: feature grant rows for guild+subject+scope stale A revoked or newly added grant is not seen for up to the 30s cache TTL, so the ACL authorizes on old policy medium stale-authz projects/monolith/chat/acl.py:59
lease-heartbeat.stale logical postgresleader-elector: leader_lease.heartbeat_at freshness stale A leader whose event loop stalls past the 5s TTL (e.g. a sync Session call in async) is stolen from while still holding live singletons, so two replicas run them high split-brain-singletons projects/monolith/core/leadership.py:31
obs-snapshot.stale logical postgrespublic-api: observability topology/stats snapshot rows stale If the Argo rollup stops, the public stats endpoint can serve an old snapshot without an age field or freshness error medium stale-public-snapshot projects/monolith/home/observability/router.py:24
run-ack.missing logical embervm-sandboxagent-runner: run-accepted / result acknowledgement from the EmberVM shim invoke missing A read timeout raised as EmberVMTimeout leaves the monolith unable to confirm whether the guest actually started or is still running the turn; the pending row is deleted and the turn recorded as a terminal error either way, so a caller who resubmits the same prompt can produce a second guest-side run for what they perceive as one request low duplicate-agent-run projects/monolith/factory/execution/transport.py:646
sandbox-dedupe.stale logical embervm-sandboxsandbox-tool: stdout/stderr/exit_code/files for a (language, code) key stale The Idempotency-Key hashes only language and code, not the optional input files; EmberVM's task store dedupes a resubmit against the existing task's stored result for its result TTL (CRD default 86400s, not overridden for the sandbox workloads), so a caller who resubmits identical code with different input files within that window silently receives the first submission's output computed against the first submission's files. Shotter had the identical bug (a URL-only key colliding across distinct captures) and was fixed by switching to a random per-call nonce; that fix's own comment explains a params-derived key is only correct when EVERY input that affects the result is part of the key, which this key does not satisfy medium stale-sandbox-result projects/monolith/sandbox/client.py:97
stored-metadata.corrupted logical shotter-toolmcp-caller: content-addressed screenshot URL + stored flag in tool metadata corrupted put_screenshot derives the URL from the content hash before attempting the upload and returns it unconditionally; when the upload fails, stored is false but the URL still looks like a valid, resolvable pointer. A caller that does not check _meta.stored persists or shares a link that 404s, indistinguishable at a glance from one that resolves medium phantom-stored-artifact projects/monolith/shotter/s3.py:126
triggered-by-header.unauthorized-source physical cf-ingressagent-runner: X-Auth-Email identity header unauthorized-source The header is forwarded by several allowlisted in-cluster senders behind a default-deny ingress NetworkPolicy, but the policy narrows WHO can reach the pod, not whether the value each of those senders sends is itself verified; the only current consumer records it as attribution with no authorization decision resting on it, so the gap is dormant rather than exploitable today, but nothing in the code enforces that boundary if a future authorization check starts keying on the header directly instead of verifying the underlying JWT low header-authz-drift projects/monolith/factory/execution/router.py:546
<details> <summary><b>Not UCAs</b>: 14 examined and rejected</summary>
  • Luna claiming verification_state=verified with no evidence array: apply_extraction force-downgrades to unverified server-side whenever assertion.evidence is empty, regardless of what the model claimed (projects/monolith/knowledge/extraction.py:364)
  • a rendered public doc linking out to a non-public repository document: buildPathIndex only maps allowlisted manifest paths to slugs; a link to anything else is stripped to plain text rather than left live (projects/monolith/frontend/src/lib/server/docs.js:9)
  • a repeat POST of the same shotter.capture request colliding across two unrelated captures: Fixed: the Idempotency-Key is now a random per-call nonce, so it only buys transport-retry safety within one call and cannot collapse two distinct captures of the same URL onto one cached result (projects/monolith/shotter/client.py:82)
  • a repo doc outside the seven PUBLIC_PROJECTS directories reaching the docs manifest: The generator indexes only exact path matches built from a fixed PUBLIC_PROJECTS x DOC_KINDS product, not a directory walk, so nothing else in the repo, including ADRs and nested READMEs, is reachable through this generator regardless of what git ls-files returns (projects/monolith/knowledge/tools/gen_docs_manifest.py:49)
  • a single missed leadership heartbeat: Bounded by 2s renew inside the 5s TTL, and any error resolves to follower fail-safe (projects/monolith/core/leadership.py:29)
  • an ungated docs/posts draft (no public key, or public: false) reaching the public site: Fails closed: _declared_public_value returns None for an ungated file and it is skipped, and a strict literal-match check on the public key raises rather than guessing (projects/monolith/knowledge/tools/gen_posts_manifest.py:64)
  • apply_extraction re-running on a raw that already extracted successfully: Guarded by an early replayed-check keyed on (raw_fk, gardener_version, non-failed provenance) that returns before writing any atom (projects/monolith/knowledge/extraction.py:326)
  • outbox at-least-once duplicate on crash between post and mark: Bounded and mostly idempotent: reactions/edits resolve missing targets, a duplicate notify is a rare nuisance (projects/monolith/chat/outbox.py:135)
  • public_reader denied on a private schema: Not a hazard but the enforced control: DB permission denies the read; asserted by projects/monolith/public_reader_grants_test.py:44
  • report_distress's Discord notification failing without the reporting agent knowing: notify() failure is caught and reflected honestly in the returned status (recorded vs notified), and the underlying enqueue reuses the durable at-least-once outbox row already covered by the existing outbox non_uca (projects/monolith/knowledge/mcp.py:446)
  • scheduler.trigger submits the wrong execution path: Bounded by run-now looking up the CronWorkflow whose replaces annotation names the requested job and creating a one-off Workflow from its workflowSpec; ARGO_JOBS only suppresses legacy registry metadata, including for suspended entries (projects/monolith/scheduler/service.py:53)
  • shotter.capture exceeding its render budget: Bounded by the nested timeout chain (Context Forge 60s > client read 55s > workload 50s > guest handler 45s > CDP navigate), converted into a ToolTimeout rather than an indefinite hold (projects/monolith/shotter/client.py:96)
  • the kg daily-cap deferral calling both finish_drainer_job and defer_drainer_job for the same one-shot job: complete_job's NULL next_run_at write for a one-shot row (interval_secs IS NULL) is immediately overwritten by defer_job's later write in the same call sequence, so the job still re-arms at +3600s despite the double write (projects/monolith/factory/orchestration/drainer.py:407)
  • two replicas racing to claim the same pending message: claim_pending_message_for_session_sync is a single atomic UPDATE ... WHERE claimed_by_replica IS NULL, so only one of two concurrent claimants can succeed (projects/monolith/factory/execution/store.py:634)</details>

Open questions

  • #5568's claude-session/codex-session producers are not yet shipped, but EXTRACTABLE_SOURCES already accepts those source labels via the generic POST /api/knowledge/raws endpoint today; will that producer route through the same unredacted shared boundary, or call agent_sessions.redact.redact_text itself the way kg_feed.py does?
  • ADR 059 is still Draft: does the full Context Forge removal change anything about the ingestion allowlist in cilium-ingress-policy.yaml (the mcp entry), or does the direct-mount path simply add a second ingress source alongside it?
  • Does any workload other than shotter and the six sandbox languages share the same EmberVM Idempotency-Key-from-request-parameters pattern, and if so does its key cover every parameter that affects the result?
  • In production, can a chat message handler (LLM summarizer) exceed the 30s message-lock TTL under load and trip lock.reclaim.wrong-timing, or is processing reliably shorter?
  • Is the X-Auth-Email header ever going to gate an authorization decision, or does #4940's delegation model deliberately avoid keying on inbound headers at all? If the latter, header-authz-drift can be downgraded once that is documented somewhere durable.
  • Is the shotter SeaweedFS bucket's read path gated the way artifact's is (proxied through a monolith-mediated read), or does anything with in-cluster network reach get an anonymous GET against the S3 endpoint once it has or guesses a content hash?
  • Is there an intended path to make a KG-lane dead letter recoverable (re-create the routine_jobs row on replay, or align the ExtractionOutputInvalid retry policy with MAX_GARDENER_RETRIES), or is a stuck-open dispute meant to be resolved by hand in Postgres today?
  • Now that PrincipalMiddleware authenticates every MCP message, when does #4569's tool-visibility reconcile pass land, and will it gate shotter.capture, sandbox.run, and k8s_sync_argocd_app by Principal scope, or only by the coarser tool-granular ACL ADR 059 compares Context Forge against?
  • Should an enqueue_extraction failure inside ingest_raw_with_status roll back the whole raw write instead of being swallowed, now that no sweep exists anywhere to repair a missed enqueue?
  • The framework is now built, but strict per-domain schema isolation and the cross-domain contract are still conventions. Re-run this analysis when those controls become enforceable rather than merely compositional.
  • The private chart enables HPA from 1 to 3 replicas; verify claim-lease behavior under real scale-out and termination, especially whether a terminating pod's in-flight claims are released promptly enough for the 30s lease to matter in practice.
  • The public backend has a deliberate primary write path and public FaaS invocation path. Confirm production Cilium/EmberVM policy matches the chart claims: public_writer must remain limited to chat_public, and the public service account must remain identity-only with no Kubernetes RBAC.
  • When the scratch-Postgres feature is re-enabled (embervm side), should run_code's docstring be corrected to stop claiming zero network for python, or should the DSN injection move behind a separate, explicitly-network-capable tool so the isolation claim stays true for the tool most callers reach for?

Lens: security

Scope. Adversarial abuse of the monolith's control structure: an attacker who deliberately forges or withholds identity, reaches a tool the entitlement model does not yet gate, or crosses a tier boundary in the control structure.

<details> <summary>Maturity detail</summary>
  • Built: Separate public and private binaries (ADR security/004), public_reader role with visibility filters on the replica, Turnstile-gated chat with three nested budgets, PrincipalMiddleware bearer-token verification on every MCP message, Discord trust ledger with per-guild per-user scoring and heuristics-fed instant enforcement, Cloudflare Access lane projecting verified email to agents console, Kubernetes RBAC scoping cluster mutation to the private pod.
  • Designed-only: Per-tool authorization on the monolith's MCP surface (ADR 059 to route through delegation-consuming broker, #4569 tool-visibility reconcile pass), default-deny egress CiliumNetworkPolicy for the private pod, per-domain database isolation and cross-domain contract.</details>

Losses

ID Loss
L.capacity-exhaustion Attacker exhausts shared compute (GPU, sandbox, shotter) or causes denial of service
L.integrity-loss Data is corrupted, or a side-effecting action (Discord post, agent run, chat_public write) is duplicated or forged
L.secret-exposure A secret or token reaches a tier or surface that must not hold it
L.silent-incorrectness A controller serves stale or wrong data while believing it correct
L.unauthorized-access Anonymous or public-tier caller reads data it is not entitled to (private rows, ungoverned paths)

Hazards

ID View Hazard (unsafe state) → Losses Maturity Status Issue
no-egress-policy physical The private monolith pod carries no default-deny egress CiliumNetworkPolicy, so a compromised pod has open-ended cluster and internet reach L.unauthorized-access, L.secret-exposure designed none #5277
over-broad-public-grant physical public_reader is granted on a schema or view that includes non-public rows L.unauthorized-access, L.secret-exposure built
private-capture-retained physical A captured screenshot, including of the private tier, is written to SeaweedFS with no expiry policy and persists indefinitely at a stable content-addressed URL after the request that produced it L.unauthorized-access built
public-route-exposes-private-path physical The public HTTPRoute forwards an internal or unfiltered path to a served handler L.unauthorized-access built
public-tier-broad-cluster-reach physical The public tier's CiliumNetworkPolicy scopes by 'in-cluster or not' (toEntities: cluster), allowing every in-cluster pod endpoint rather than only the intended four (Postgres, vLLM, embeddings, SeaweedFS) L.unauthorized-access built none #5276
public-write-admission-bypass physical The internet-adjacent public tier can write outside the intended chat_public path, or bypasses Turnstile, per-session limits, and the cluster-wide inference cap L.integrity-loss, L.capacity-exhaustion built
sandbox-credential-egress logical The scratch-Postgres feature, when enabled, injects a database DSN into the executed code's own process environment, so a Python run in the advertised zero-network sandbox gains credentialed, in-cluster network reach to a shared datastore; the tool's own docstring still claims there is no network at all L.secret-exposure, L.unauthorized-access built
secret-in-wrong-tier physical A private secret or a K8s token is delivered to the public or frontend tier L.secret-exposure built
stale-sandbox-result logical A caller re-invoking run_code with the same code but different input files receives a cached result computed against a prior submission's files instead of a fresh run L.silent-incorrectness built
unredacted-public-doc logical The docs/posts manifest generators copy an allowlisted document's full body into the public site verbatim, gating only on which path may be published, never on what the content contains, so an internal-only identifier (a cluster-internal hostname, a secret env var name) left in a published project's README/ARCHITECTURE/STPA reaches the public docs route L.secret-exposure built
unrestricted-tool-visibility logical PrincipalMiddleware authenticates every MCP message (a valid authentik bearer token is required), but no monolith tool authorizes on the resulting Principal: the Context Forge tool-visibility reconcile pass that would scope who may call a given tool is still designed, not built (#4569), so any authenticated caller, not only ones entitled to the private tier, can invoke shotter.capture, sandbox.run, or k8s_sync_argocd_app L.unauthorized-access built

Unsafe control actions

ID View Control action Guideword Unsafe condition Severity → Hazards Status Issue Evidence
chatpublic.write.providing physical chatpublic.write providing Anonymous attacker bypasses Turnstile, per-session limits, or the cluster-wide inference cap to write outside chat_public or exhaust shared GPU high public-write-admission-bypass enforced-prod projects/monolith-public/chart/values.yaml:146
docs.publish.providing logical docs.publish providing A document is added to an allowlisted path and the generator publishes it unreviewed for certain markers (in-cluster hostnames, 1Password refs, private IPs, node/brick names, S3 URIs, .internal domains, secret env assignments); internal identifiers not matching these markers still pass through medium unredacted-public-doc enforced-prod #5275 projects/monolith/knowledge/tools/gen_docs_manifest.py:128
grant.public-reader.providing physical grant.public-reader providing A schema-wide grant or a missing visibility filter serves a private row through a public route, disclosing private knowledge-graph content directly high over-broad-public-grant enforced-prod projects/monolith/chart/migrations/20260617000000_public_reader_role.sql:24
k8s.mutate.providing physical k8s.mutate providing Any authenticated MCP caller triggers ArgoCD sync/prune on any Application or performs cluster-wide pod/log/configmap/event read with an unentitled token high unrestricted-tool-visibility none #4569 projects/monolith/cluster/mcp.py:163
mcp.agent-session.providing logical mcp.agent-session providing Any authenticated MCP caller starts or drives a full agent session, inheriting every tool that session can reach and compounding every other unentitled-tool row high unrestricted-tool-visibility none #4569 projects/monolith/factory/execution/mcp.py:660
mcp.chat-pardon.providing logical mcp.chat-pardon providing An MCP caller with no Discord-side privilege calls monolith_chat_trust_pardon, resetting any locked-out user's score and flipping their labels, undoing the ledger's containment of an active red-team session from a different trust boundary high unrestricted-tool-visibility none #4569 projects/monolith/agent/mcp.py:338
route.public.providing physical route.public providing Attacker reaches a private or internal path via the public HTTPRoute or the SSR proxy, reading private data or invoking an internal handler high public-route-exposes-private-path enforced-prod projects/monolith-public/chart/templates/httproute-public.yaml:13
sandbox.run.providing logical sandbox.run providing Any authenticated MCP caller spends zero-egress compute or, when scratch feature is enabled, reaches the in-cluster scratch database with credentialed DSN medium unrestricted-tool-visibility, sandbox-credential-egress none #4569 projects/monolith/sandbox/mcp.py:14
secret.deliver-public.providing physical secret.deliver-public providing A private credential, Kubernetes API-capable token, or Turnstile secret is wired into the public frontend or public backend beyond its explicitly constrained use high secret-in-wrong-tier enforced-prod projects/monolith/public_turnstile_secret_isolation_test.py:76
shotter.capture.providing logical shotter.capture providing Any authenticated MCP caller (not entitled to private tier) calls the screenshot tool for private.jomcgi.dev pages, disclosing private-tier page content retained indefinitely at a guessable URL high unrestricted-tool-visibility, private-capture-retained none #4569 projects/monolith/shotter/mcp.py:120

Unsafe feedback

Data channels (absent, stale, corrupted, spoofed) whose failure drives a controller into a hazard.

ID View Channel Guideword Unsafe condition Severity → Hazards Status Issue Evidence
docs-publish.unauthorized-source logical docs-manifest-genpublic-frontend: allowlisted document content published to public site unauthorized-source A document is added to an allowlisted path and the generator publishes it unreviewed for certain markers (in-cluster hostnames, 1Password refs, private IPs, node/brick names, S3 URIs, .internal domains, secret env assignments); internal identifiers not matching these markers still pass through medium unredacted-public-doc enforced-prod #5275 projects/monolith/knowledge/tools/gen_docs_manifest.py:128
sandbox-dedupe.stale logical embervm-sandboxsandbox-tool: stdout/stderr/exit_code/files for a (language, code) key stale Idempotency-Key is (language, code) only, not input files; resubmit with same code but different files within result-cache TTL receives stale output from prior submission medium stale-sandbox-result none #5304 projects/monolith/sandbox/client.py:97
<details> <summary><b>Not UCAs</b>: 6 examined and rejected</summary>
  • Authentik and Kubernetes control-plane compromise: Assumed not to hold
  • Cluster layers below the monolith: Ingress tunnel, CNI policy, admission control, secret operator are owned by docs/security.md
  • Cluster-wide capacity exhaustion outside modeled admission and budget gates: Not analyzed here
  • Discord platform account compromise: Outside the trust ledger's design
  • EmberVM guest and hypervisor isolation: Covered in projects/embervm/STPA.md (logic and security lenses); this document covers only monolith-side broker
  • Supply chain of the monolith's own image build: Not analyzed here</details>

Open questions

  • Should run_code's docstring be corrected to stop claiming zero network for Python, or should the scratch-Postgres DSN injection move behind a separate explicitly-network-capable tool?
  • When #4569's tool-visibility reconcile pass lands, will it gate shotter.capture, sandbox.run, k8s_sync_argocd_app, and agent-session tools by Principal scope, or only by coarser tool-granular ACL?
  • When will the default-deny egress CiliumNetworkPolicy (#5277) land for the private pod's open-ended cluster and internet reach?
  • Will the public tier's cluster-reach policy (#5276) be scoped to only the four intended destinations (Postgres, vLLM, embeddings, SeaweedFS)?