STPA Control Analysis: monolith
logic @ 631c56c59 · security @ 5f9363262
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.
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)"]
factory-refill["Queue-driven backlog refill controller (default-off)"]
health-alerts["Leader-owned health transition notifier"]
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)"]
refill-producer["Trusted refill artifact and issue producer"]
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"]
backup-health["Private CNPG backup advisory evaluator"]
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"]
github-issues["GitHub repository issues"]
postgres["Postgres (per-domain schemas)"]
end
bot -- "acl.check" --> acl
leader-elector -- "agent.reclaim" --> agent-runner
mcp-caller -- "agent.reconcile-settled-retry" --> agent-runner
bot -- "agent.submit" --> agent-runner
console-caller -- "console.submit" --> agent-runner
docs-manifest-gen -- "docs.publish" --> public-frontend
mcp-caller -- "factory-refill.acknowledge-learning-cessation" --> factory-refill
factory-refill -- "factory-refill.dispatch" --> agent-runner
factory-refill -- "factory-refill.reserve" --> postgres
factory-refill -- "factory-refill.settle" --> postgres
health-alerts -- "health-alerts.notify" --> outbox
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
mcp-caller -- "mcp.restrict-receipt-models" --> postgres
outbox -- "outbox.post" --> bot
public-api -- "public.health" --> postgres
console-caller -- "raws.create" --> pg-primary
refill-producer -- "refill-producer.collect" --> postgres
refill-producer -- "refill-producer.publish" --> github-issues
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
agent-runner -. "Structured report-only inventory artifact" .-> refill-producer
backup-health -. "Healthy, known failure or unknown backup advisory result" .-> health-alerts
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
embervm -. "Fresh bounded terminal history and complete successor/restore exclusion for exact historical dispatch" .-> factory-refill
github-issues -. "Complete fresh eligible issue count from the existing intake sweep" .-> factory-refill
github-issues -. "Issue number and exact output marker discovery" .-> refill-producer
postgres -. "feature grant rows (30s cached)" .-> acl
postgres -. "claimed_by_replica + claimed_at lease row" .-> agent-runner
postgres -. "Live policy, receipt ownership, starts, settlements and breaker state" .-> factory-refill
postgres -. "Durable last-announced health state and reminder timestamp" .-> health-alerts
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
refill-producer -. "Confirmed new findings and inconclusive publication state" .-> factory-refill
shotter-tool -. "stored URL + stored flag (_meta) for the captured PNG" .-> mcp-caller
Physical view
flowchart TD
subgraph boundary["boundary"]
backup-checker["Shared collector CNPG checker CronJob"]
end
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"]
backup-status["Named backup-status ConfigMap"]
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
backup-checker -- "backup-checker.publish" --> backup-status
private-binary -- "backup-health.read" --> backup-status
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
backup-status -. "Per-target timestamp, successful-backup age and collector acceptance" .-> private-binary
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 | bot → acl |
built | projects/monolith/chat/acl.py:68 |
agent.reclaim |
logical | Reclaim stale turn claims left by a crashed replica (leader-owned 5s sweep) | leader-elector → agent-runner |
built | projects/monolith/factory/execution/store.py:772 |
agent.reconcile-settled-retry |
logical | Fence exact ceased lineage and fail one unknown factory attempt | mcp-caller → agent-runner |
built | projects/monolith/factory/settled_retry_reconciliation.py |
agent.submit |
logical | Submit a Discord-triggered agent turn to the runner | bot → agent-runner |
built | projects/monolith/chat/bot.py:1172 |
backup-checker.publish |
physical | Publish bounded per-target observation to the named ConfigMap | backup-checker → backup-status |
built | projects/platform/otel-collector/files/backup_check.py:216 |
backup-health.read |
physical | Read the configured backup-status ConfigMap | private-binary → backup-status |
built | projects/monolith/factory/ops_health.py:732 |
chatpublic.write |
physical | Write a bounded chat_public row to the primary as public_writer | public-binary → pg-primary |
built | projects/monolith-public/chart/values.yaml:82 |
console.submit |
logical | Start or continue an agent session from the private console | console-caller → agent-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-gen → public-frontend |
built | projects/monolith/knowledge/tools/gen_docs_manifest.py:128 |
factory-refill.acknowledge-learning-cessation |
logical | Acknowledge one positively ceased unknown attempt for cadence only | mcp-caller → factory-refill |
built | projects/monolith/factory/learning_reconciliation.py |
factory-refill.dispatch |
logical | Dispatch pinned read-only collection and independent review with fresh role quota | factory-refill → agent-runner |
built | projects/monolith/factory/orchestration/backlog_refill.py:584 |
factory-refill.reserve |
logical | Reserve one inventory receipt from fresh eligible backlog evidence | factory-refill → postgres |
built | projects/monolith/factory/orchestration/backlog_refill.py:180 |
factory-refill.settle |
logical | Settle yield once and update conclusive-run breaker streaks | factory-refill → postgres |
built | projects/monolith/factory/orchestration/backlog_refill.py:298 |
frontend.proxy |
physical | Proxy SSR/API calls from the frontend to the public backend, in-cluster only | frontend-ssr → public-binary |
built | projects/monolith-public/chart/templates/httproute-public.yaml:33 |
grant.public-reader |
physical | Grant public_reader SELECT on a schema/view | migrations → pg-replica |
built | projects/monolith/chart/migrations/20260617000000_public_reader_role.sql:24 |
health-alerts.notify |
logical | Enqueue a health transition or six-hour reminder | health-alerts → outbox |
built | projects/monolith/factory/health_alerts.py:137 |
k8s.mutate |
physical | Mutate cluster state via private MCP (ArgoCD sync) | private-binary → k8s-api |
built | projects/monolith/cluster/mcp.py:163 |
kg-drainer.dispatch |
logical | Claim a kg-drain job, run Luna extraction, apply the result | kg-drainer → agent-runner |
built | projects/monolith/factory/orchestration/drainer.py:538 |
kg-feed.export |
logical | Export a finished Ember session as an ember-session raw | kg-feed → pg-primary |
built | projects/monolith/factory/execution/kg_feed.py:328 |
leader.acquire |
logical | Acquire/renew/steal the singleton lease | leader-elector → postgres |
built | projects/monolith/core/leadership.py:56 |
lock.reclaim |
logical | Reclaim an expired message-processing lock | bot → postgres |
built | projects/monolith/chat/leader.py:109 |
mcp.agent-session |
logical | Start or drive an agent session via MCP | mcp-caller → agent-runner |
built | projects/monolith/factory/execution/mcp.py:660 |
mcp.authorize |
logical | Verify the bearer token on every MCP message (stateless streamable HTTP) | mcp-caller → principal-mw |
built | projects/monolith/framework/core.py:543 |
mcp.chat-pardon |
logical | Reset a Discord user trust score via MCP | mcp-caller → bot |
built | projects/monolith/agent/mcp.py:338 |
mcp.report-knowledge |
logical | Agent-report/dispute/distress raw capture (report_knowledge, dispute_fact, report_distress) | mcp-caller → pg-primary |
built | projects/monolith/knowledge/mcp.py:478 |
mcp.restrict-receipt-models |
logical | Remove unused model choices from one paused receipt after an exact operator preview | mcp-caller → postgres |
built | projects/monolith/factory/orchestration/factory_policy_amendment.py:277 |
outbox.post |
logical | Drain and post a queued Discord message/edit/reaction | outbox → bot |
built | projects/monolith/chat/outbox.py:146 |
public.health |
logical | Probe the public database as public_reader (SELECT 1) | public-api → postgres |
built | projects/monolith/framework/core.py:322 |
raws.create |
logical | Generic raw-input capture (POST /api/knowledge/raws) | console-caller → pg-primary |
built | projects/monolith/knowledge/router.py:365 |
refill-producer.collect |
logical | Authenticate collection and independent review before storing approved plans | refill-producer → postgres |
built | projects/monolith/factory/orchestration/backlog_refill_producer.py:343,410 |
refill-producer.publish |
logical | Commit an output intent and post one finding, summary or breaker issue | refill-producer → github-issues |
built | projects/monolith/factory/orchestration/backlog_refill_producer.py:150 |
route.public |
physical | Route the public hostname to the frontend SSR (no direct backend route) | cf-ingress → frontend-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-caller → sandbox-tool |
built | projects/monolith/sandbox/mcp.py:14 |
scheduler.trigger |
logical | Mark a job for immediate run | scheduler-api → postgres |
built | projects/monolith/scheduler/service.py:35 |
secret.deliver-public |
physical | Deliver only the public backend's constrained DB, Turnstile, and signing material | onepassword → public-binary |
built | projects/monolith-public/chart/templates/onepassworditem.yaml:1 |
shotter.capture |
logical | Request a rendered screenshot of a public/private page | mcp-caller → shotter-tool |
built | projects/monolith/shotter/mcp.py:123 |
shotter.dispatch |
logical | Dispatch a fresh task-class guest to render the URL | shotter-tool → embervm-shotter |
built | projects/monolith/shotter/client.py:91 |
shotter.store |
physical | Best-effort content-addressed upload of the captured PNG | private-binary → shotter-store |
built | projects/monolith/shotter/s3.py:83 |
shotter.validate |
logical | Validate scheme/host/dimensions before dispatch (2nd, defense-in-depth layer) | shotter-tool → shotter-validate |
built | projects/monolith/shotter/mcp.py:56 |
singletons.start |
logical | Start leader-only singletons (bot, ingest, drain, sweep) | leader-elector → bot |
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.
Maturity detail
- 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/raw_write.py persist_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 (factory/execution/kg_feed.py), a daily-capped DBOS drainer (factory/orchestration/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). The shared CNPG backup checker publishes bounded observations through one precreated ConfigMap, the private monolith evaluates their freshness and 36-hour backup age as advisory health, and its leader queues transitions through the existing durable Discord outbox. The default-off backlog refill controller reserves advisory inventory work from fresh intake counts, validates the artifact, publishes through durable per-output intents and records conclusive yield separately from interrupted or uncertain runs. Focused historical learning cessation repair against 434907e17 with this diff: authenticated retained journal proof, fresh successor/restore and local lineage checks, digest/CAS/idempotent audit; no live recovery or activation is claimed.
- 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.
- Note: Focused #7002 backup-advisory activation analysis at e7905ea1c; the hub overlays opt in, but live operational acceptance remains open on #7002. Earlier findings retain their prior review scope and are not revalidated by this focused update. Focused #7376 refill analysis against 15550c9 with the accompanying repair diff; this feature remains omitted from shipped policy and its live drills remain open on #7376. Focused #7014 knowledge-lane analysis at 970f6df81: raw redaction, missed-enqueue repair, dead-letter retry and replay, and the session-collector producers re-grounded; findings outside that lane retain their prior review scope. Focused receipt model-restriction analysis at 68c9ca11c with the accompanying repair diff: operator amendment, effective-policy continuation, same-receipt readmission and native model admission. Live operational acceptance is not claimed. Focused refill role review against 631c56c59 with this repair: new starts require independent Astra planning and exact-role fresh quota, while existing receipt restriction findings and legacy ledgers are retained. The same focused change adds bounded native-registry feedback and authenticated restriction-to-continuation regression coverage; it makes no live activation or downstream client-visibility claim.
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 |
|---|---|---|---|---|
backup-observability-blind |
logical | The backup checker stops publishing fresh observations while base backups fail, leaving operators without a backup-failure notification because unknown observations intentionally cause neither a transition nor reminder | L.silent-incorrectness | built |
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 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 |
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 | A credential in a shape outside the fixed patterns in knowledge/redact.py reaches the knowledge lane: persist_raw_with_status redacts known shapes for every lane-owned source, including the generic POST /api/knowledge/raws endpoint and the tools/session_collector claude-session/codex-session producer, then stores and extracts unmatched content verbatim into durable, searchable graph facts | 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 |
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 under any extractable source label except the MCP-owned agent-report and dispute; the shared boundary redacts only the fixed patterns in knowledge/redact.py, so a credential in another shape is enqueued for extraction and indexed as durable private-visibility knowledge notes | high | unredacted-transcript-raw | projects/monolith/knowledge/raw_write.py:110 |
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 |
|---|---|---|---|---|---|---|---|
backup-observations.missing |
logical | backup-health → health-alerts: Healthy, known failure or unknown backup advisory result |
missing | Absent, unreadable or older-than-two-hour snapshots produce status unknown, which preserves the last announced state and cannot falsely recover an alert, but also cannot send a checker-absence alert or repeat a previously failing backup alert while evidence remains unavailable; operators must inspect private health detail and CronJob failures | medium | backup-observability-blind | projects/monolith/factory/ops_health.py:688; projects/monolith/factory/health_alerts.py:110; docs/observability.md; #7002 |
claim-lease.stale |
logical | postgres → agent-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 |
grants-cache.stale |
logical | postgres → acl: 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 | postgres → leader-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 | postgres → public-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-sandbox → agent-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-sandbox → sandbox-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-tool → mcp-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-ingress → agent-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 |
- An uninvoked retry is mistaken for proof that an earlier missing dispatch never ran: Explicit operator recovery requires an empty exact first receipt, released observer, unchanged terminal workflow and native ownership. The CP must independently prove positive idled parking and complete journal/successor coverage, then durably fence that original lineage against restore. Digest/CAS and audited intent bind replay; the final lock rechecks local identity and preserves the native turn, settled permit, unknown result, reserved cost and unrelated holds. Partial remote effects remain explicit, never a success or a zero-cost refund. Evidence: projects/monolith/factory/settled_retry_reconciliation.py; factory/execution/reconciliation.py:read_unknown_retry_factory_attempt.
- 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)
- Retire a delivered Claude quota probe binding only after exact positive terminal evidence: The bounded leader retry binds the origin audit, sole completed turn and settled permit, receipt provenance, dispatch and original-generation destroyed guest. A fresh authenticated read is rechecked under control/capacity/session/receipt locks; changed identities, aliases, pending work, missing evidence and unknown outcomes refuse. Fence and binding retirement commit with one audit, preserve result/accounting/lineage and observer timestamps, and perform neither DELETE nor inference. Evidence: factory/completed_probe_cleanup.py; factory/execution/guest_cleanup.py.
- a KG-lane dead letter with no operator path back to extraction: GET /api/knowledge/dead-letter lists raws at MAX_GARDENER_RETRIES; replay deletes the failed provenance row, reopens resolution_failed disputes and re-enqueues extraction (projects/monolith/knowledge/router.py:541)
- a dead-lettered dispute leaving its note flagged disputed: Conservative: _record_failure moves an exhausted dispute to resolution_failed, which open_dispute_note_ids still counts as unresolved, so callers keep treating the fact as contested until replay reopens it (projects/monolith/knowledge/extraction.py:651, projects/monolith/knowledge/store.py:93)
- a historical session dispatches a removed receipt model after a separate resume: Native pre-delivery and physical admission pass the actual normalized PendingMessage model to factory_session_allowed. Restricted receipts refuse removed or unpinned models, including same-family overrides, while historical session models and results remain unchanged (projects/monolith/factory/execution/mcp.py:1164 and :1196; projects/monolith/factory/orchestration/api.py:74).
- a lost GitHub POST response causing either a duplicate POST or a permanently fenced refill run: An intent commits before POST and is never resent. Six read-only attempts quarantine only the output; the run can settle inconclusive while one due original output per maintenance tick continues daily marker reconciliation, including while disabled (projects/monolith/factory/orchestration/backlog_refill_producer.py:150, projects/monolith/factory/orchestration/backlog_refill_producer.py:112, projects/monolith/factory/orchestration/backlog_refill_producer.py:321).
- a malformed-output extraction failure dead-lettering after one attempt: Fixed: ExtractionOutputInvalid takes the same _retry_or_dead_letter_kg path as other failures, which defers the job while attempts stay below MAX_GARDENER_RETRIES and records the failure only after that (projects/monolith/factory/orchestration/drainer.py:2164)
- a missing historical guest row being treated as proof that an unknown learning invocation never ran: A typed missing projection selects only a bounded authenticated journal verifier; incomplete or stale proof and successor ownership refuse acknowledgement, while the failed unknown turn and accounting remain unchanged (projects/monolith/factory/learning_reconciliation.py).
- a model renaming a finding key to count the same title as fresh yield: The producer derives cross-run finding identity from the normalized title and counts only confirmed newly created issues. This conservatively matches titles; semantic duplicate detection is outside this check (projects/monolith/factory/orchestration/backlog_refill_producer.py:301, projects/monolith/factory/orchestration/backlog_refill_producer.py:321).
- a new cluster with no successful base backup being reported healthy because its creation age is small: A false has_successful_backup with a null successful-backup timestamp always produces a known failure, including age zero; successful backups become failures strictly after 129600 seconds, recomputed between checker runs (projects/monolith/factory/ops_health.py:703).
- a refill worker broadening its task into a second delivery workflow: New starts pin read-only collection and independent Astra review nodes with separate structured contracts. Legacy starts retain their original inventory contract. Transport checks bind receipt, generation, policy digest, exact role, attempt and model; only the trusted producer can publish evidence-approved plans (projects/monolith/factory/orchestration/backlog_refill_worker.py: reconcile; backlog_refill.py: _dispatch_allowed; backlog_refill_producer.py: collection and review).
- 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 rolling daily refill limit becoming a permanent human reset gate: The limit counts all starts in the rolling 24-hour window, including unresolved starts, and automatically permits another reservation when the window has room; it does not open a breaker (projects/monolith/factory/orchestration/backlog_refill.py:180).
- 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)
- a stale or repeated removal preview changes unrelated receipt authority: The preview binds control version, graph revision and state, receipt direction, accounting and funding/recovery identities. Apply rechecks under control/task locks and returns the recorded result for an identical request key, with a conflict refusing changed arguments (projects/monolith/factory/orchestration/factory_policy_amendment.py:35 and :277).
- accepting a saved finish grants another execution after the task deadline: The expired-lease saved-finish path verifies existing evidence before funding or dispatch. It rechecks the control, complete graph/run identity, quiescence, owned PR current head, CI and independent review under settlement locks. It neither changes deadlines nor admits work (factory/orchestration/factory_conductor.py: _adopt_saved_finish).
- an absent, malformed, future-dated or failed-export observation falsely clearing a backup alert: evaluate_cnpg_backups validates target identity and timestamps, rejects observations over 7200 seconds old, and requires successful export for every healthy target; decide skips unknown before changing the durable state (projects/monolith/factory/ops_health.py:664, projects/monolith/factory/health_alerts.py:110).
- an enqueue_extraction failure inside persist_raw_with_status leaving an extractable raw unqueued: The savepoint rolls back only the job insert; sweep_unqueued_raws re-registers any extractable raw with no job, no handled provenance and no exhausted failure at the start of every drain_cycle with the kg kind enabled (projects/monolith/knowledge/raw_write.py:139, projects/monolith/knowledge/extraction.py:399, projects/monolith/factory/orchestration/drainer.py:1792)
- an owner pause, cancellation or deadline change during discovery authorizing a new issue POST: The producer rechecks the owning admitted receipt, pause/cancellation state, generation, policy and deadline under the control lock immediately before committing the intent, after discovery I/O (projects/monolith/factory/orchestration/backlog_refill_producer.py:68, projects/monolith/factory/orchestration/backlog_refill_producer.py:150).
- 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)
- backup degradation restarting monolith or making public health fail: The check is registered only as private advisory health and liveness stays /healthz; composition tests assert private health HTTP 200, unchanged kubelet liveness and no public backup component (projects/monolith/factory/module.py:174, projects/monolith/factory/module_test.py:72).
- native registration feedback being mistaken for caller exposure or execution authority: Operator-only factory_status reports native source and explicitly unobserved caller visibility for two existing controls. It does not refresh catalogues or mutate authority. The authenticated flow retains separate restriction and continuation previews, exact identity bindings, fresh quota, holds and uncertain-execution guards (factory/orchestration/mcp.py and factory_operator_flow_test.py).
- normal leader handover resending the last backup transition on every new leader: The transition notifier reads platform_probe state and persists only after enqueue; normal handover deduplicates, while the existing enqueue-before-latch crash window can produce an at-least-once duplicate and is not claimed exactly-once (projects/monolith/factory/health_alerts.py:58, projects/monolith/factory/health_alerts.py:137).
- one unreadable target masking a known failed backup on another target: The checker records each target independently and the evaluator returns known failures before unknowns; the 120-second job deadline bounds at most four targets (projects/platform/otel-collector/files/backup_check.py:206, projects/monolith/factory/ops_health.py:722, projects/platform/otel-collector/templates/backup-check.yaml:147).
- operator chat or decision requeue silently restores a removed receipt model: The direction builder preserves model_restriction through same-receipt requeue. Fresh admission filters the effective policy and refuses conflicting scalar defaults or empty pools before creating a new task. Existing continuation compares the effective policy while retaining stale-revision and nonrenewable-recovery fences (projects/monolith/factory/orchestration/factory_decisions.py:1033; projects/monolith/factory/orchestration/factory_intake.py:860; projects/monolith/factory/orchestration/factory_continuations.py:81).
- 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
- quota becoming unavailable or authority changing before a refill role reaches transport: Each new role checks its exact offered model against fresh subscription windows without fallback. Durable ownership, policy, pause and deadline gates are checked again after broker I/O. The safe-control preview and apply each observe both generator and Astra quota (projects/monolith/factory/orchestration/backlog_refill.py: quota and _dispatch_allowed; refill_control.py: _observations and _plan).
- recording a draft-only hold renews a task lease or fabricates landing authority: An exact operator landing_hold-only amendment records the restrictive typed hold and audit without a funding grant, deadline, budget or graph change. Draft acceptance requires that existing hold; saved free text never creates one, and the hold survives settlement (factory/orchestration/factory_policy_amendment.py: _preview and apply; factory_conductor.py: _adopt_saved_finish).
- refill starting from a stale or incomplete low-backlog observation: Omission is inert. Enabled starts require complete evidence within the discovery refresh window, current policy, ordinary capacity, charter ownership and cooldown; an existing unsettled run prevents another reservation (projects/monolith/factory/orchestration/backlog_refill.py:180).
- removing an unused receipt model renews an expired task or grants execution: The standing-operator removal mode requires a paused, quiescent receipt, rejects mixed fields, current model choices and empty pools, and updates only the receipt restriction, control version and idempotent audit. Funding grants, expired deadlines, pause, holds and historical pins remain unchanged (projects/monolith/factory/orchestration/factory_policy_amendment.py:35 and :277).
- 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)
- routine cancellation or a paused task deadline poisoning the refill failure streak: Terminal interrupted runs settle inconclusive after unresolved execution drains. Inconclusive outcomes neither advance nor reset failure or low-yield streaks; conclusive settlements are recorded once (projects/monolith/factory/orchestration/backlog_refill_worker.py:88, projects/monolith/factory/orchestration/backlog_refill.py:298).
- scheduler.trigger submits the wrong execution path: Bounded by run-now looking up the CronWorkflow whose
replacesannotation names the requested job and creating a one-off Workflow from itsworkflowSpec;ARGO_JOBSonly 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 ember-session feed advancing its watermark past a turn range whose extraction job was never registered: Fixed: _feed_once_sync re-enqueues an existing unqueued raw and leaves kg_extracted_turn_seq unchanged unless the raw is queued or handled (projects/monolith/factory/execution/kg_feed.py:355)
- the explicitly authorized Codex worker and drainer reserve change implicitly enabling paid fallback or clearing execution holds: Joe approved both shared role floors at 8 percent on 2026-10-31. Only swarm.quotaFloors.codex.worker and .drainer change in deploy/values-gke.yaml, reaching model_pool.floor_for through SWARM_QUOTA_FLOORS. The drainer floor covers general and knowledge jobs, including hourly learning; implementation retains 10 percent and other providers retain their floors. Existing grant selection, subscription billing, capacity and daily caps, freshness checks, refill breaker, receipt holds and unknown-execution fences are unchanged (chart/templates/deployment.yaml; factory/orchestration/model_pool.py: availability; drainer.py: provider_walled and claim_drainer_job; backlog_refill.py: quota).
- 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)
- the rendered checker acquiring broad CNPG privileges or writing application status through a public route: Per-namespace Roles grant get only on configured Cluster resourceNames, and a separate Role grants get/patch only on one ConfigMap; no ClusterRole or application write route is introduced. Live additive bindings remain an explicit acceptance check (projects/platform/otel-collector/templates/backup-check.yaml:55, docs/observability.md, #7002).
- 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)
Open questions
- #7002: verify fresh successful-backup timestamps, scheduled checker completion, metric/advisory arrival, runtime ConfigMap preservation and effective additive RBAC on the hub; live Cluster/RBAC reads and Honeycomb queries were unavailable to this activation session. Checker-absence notifications remain outside the current advisory contract.
- Default-off refill live acceptance remains on #7376: verify ordinary intake, inventory outputs, a restart and ambiguous-write drill, breaker reset, rolling-limit recovery and disable reconciliation before enablement.
- 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?
- How should the native ingress successor cover Context Forge and direct-mount callers after #5816 removed the inert Cilium ingress template?
- 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?
- 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?
- 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 database grants and service-account permissions match the chart claims: public_writer must remain limited to chat_public, and the public service account must remain identity-only with no Kubernetes RBAC. The removed Cilium templates provide no replacement network enforcement; that remains separate work under #5276 and #5277.
- 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.
Maturity detail
- 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, GroupPolicyMiddleware denying every MCP tool call outside the operators group (no tool tagged mcp:public), 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 (ArgoCD sync, per-Kargo-project promote Roles) to the private pod.
- Designed-only: A narrower-than-operators scope for the production-moving MCP tools (#6475), principal attribution on MCP-driven cluster mutations (#6476), activation of the staged native default-deny egress NetworkPolicy for the private pod, per-domain database isolation and cross-domain contract.
- Note: Refreshed for the operators-group MCP gate (core/mcp_policy.py) and kargo_promote (#6458/#6459); other findings retain their earlier review stamp.
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 has no enforced default-deny egress policy on the hub; the native policy is staged off, so a compromised pod has open-ended cluster and internet reach | L.unauthorized-access, L.secret-exposure | designed | none | #5277 |
operator-grant-spans-prod-levers |
logical | The operators group is the only MCP authorization tier, so every operators principal (including an agent holding a forwarded operators token) reaches both production cluster levers, ArgoCD sync with prune and kargo_promote with rollback, and Kargo authorizes the Promotion against the monolith ServiceAccount rather than the caller | L.integrity-loss, L.unauthorized-access | built | none | #6475 |
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 has no enforced network isolation on the hub. Its inert Cilium templates were removed in #5816, so compromised public pods retain broad in-cluster reach until native-policy successor work lands | 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 | A monolith MCP tool is callable by a principal outside the operators group: GroupPolicyMiddleware denies such calls in prod and no tool carries mcp:public, but an absent bearer resolves to an anonymous principal rather than a 401, anonymous tools/list returns the full catalogue by design, and one env var (MCP_GROUP_POLICY_ENFORCED=false) reverts the gate to allow-all without a redeploy | L.unauthorized-access | built | enforced-prod | #4569 |
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 | A caller outside the operators group triggers ArgoCD sync/prune on any Application, or via kargo_promote creates a Promotion (rollback=true included) in any of the four production Kargo projects; denied in prod by the operators gate, but every operators principal holds both levers | high | unrestricted-tool-visibility, operator-grant-spans-prod-levers | enforced-prod | #6475 | projects/monolith/cluster/mcp.py:173, projects/monolith/cluster/mcp.py:258, projects/monolith/core/mcp_policy.py:75 |
mcp.agent-session.providing |
logical | mcp.agent-session |
providing | A caller outside the operators group 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 | enforced-prod | #4569 | projects/monolith/factory/execution/mcp.py:1580 |
mcp.chat-pardon.providing |
logical | mcp.chat-pardon |
providing | A caller outside the operators group 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 | enforced-prod | #4569 | projects/monolith/agent/mcp.py:380 |
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 | A caller outside the operators group 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 | enforced-prod | #4569 | projects/monolith/sandbox/mcp.py:15 |
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 | A caller outside the operators group (anonymous, or a token without the groups claim) 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 | enforced-prod | #4569 | projects/monolith/shotter/mcp.py:123 |
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-gen → public-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 |
k8s-mutation-attribution.unauthorized-source |
physical | private-binary → k8s-api: identity recorded on ArgoCD sync operations and Kargo Promotions |
unauthorized-source | MCP-driven syncs record the fixed username monolith-k8s-mcp and Promotions carry no requester, so the ArgoCD and Kargo audit trail names the monolith ServiceAccount, never the operator or agent session that moved production | medium | operator-grant-spans-prod-levers | none | #6476 | projects/monolith/cluster/kubernetes.py:580, projects/monolith/cluster/kargo.py:219 |
sandbox-dedupe.stale |
logical | embervm-sandbox → sandbox-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 |
- Anonymous tools/list returns the full MCP catalogue: Catalogue disclosure only: on_call_tool denies every untagged call, and filtering the anonymous list would blank Context Forge's cached catalogue (projects/monolith/core/mcp_policy.py:104)
- 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
Open questions
- Does Kargo's admission webhook itself refuse a Promotion for Freight not yet available to the Stage, so a holder of the monolith ServiceAccount token (bypassing cluster/kargo.py plan_promotion) cannot promote unverified Freight into production?
- 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?
- The monolith-agents MCP surface (projects/monolith/app/agents_main.py) rejects anonymous callers but applies no operators gate; should any authenticated token reach its board and knowledge write tools?
- When will the staged native default-deny egress NetworkPolicy (#5277 / #3897) be validated and activated 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)?
- structure gap: k8s.mutate is labelled ArgoCD sync only, and there is no control action for kargo_promote (private-binary to a Kargo Promotion) or node for the GroupPolicyMiddleware operators gate; this lens folds kargo_promote into k8s.mutate.providing until the logic lens models them.