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

monolith

Monolith Architecture

Factory ownership is consolidated under factory.module within this application. The factory owns private interactions/MCP, its published public viewer, and the orchestration/session execution lifecycle. Its public descriptor is composed separately from private mutation and execution hooks. Execution and orchestration implementations live in factory.execution and factory.orchestration. Legacy swarm and agent_sessions modules only retain persisted exception names and the existing Discord thread adapter. SQL schemas and DBOS workflow names keep their deployed identities. The workflow source fingerprint normalizes only the relocated import module names, preserving active workflow recovery across this package move. The public reader ships factory.public_view and excludes the entire private execution and orchestration packages. It reads only published public API views and snapshots with the existing restricted database role. The private board and snapshot publisher live in factory.private_view and factory.publication. Factory bearer controls and MCP status tools share the standing human operator rule in factory.access; browser decisions retain the verified gateway email gate in that same module. None of these private modules ship in the public image. Factory MCP interactions, including the voice companion, require the operator group. The earlier anonymous companion exception is retired; anonymous catalogue discovery remains available to the gateway, while anonymous tool calls are denied.

Routine-job reconciliation and knowledge interventions use the narrow existing contracts in factory.api. The domain boundary test checks factory internals and requires its dependencies on other domains to pass through their APIs. Discord integration is outside this consolidation. (see: /projects/monolith/factory/module.py) (see: /projects/monolith/factory/public_module.py)

The monolith is the FastAPI and SvelteKit application suite for the knowledge graph, conversational agents, isolated agent sessions, and small public data products. It is deployed as separate private, public, and agent compositions over a shared Postgres data plane, with a separately gated friends surface. Production serves from the GKE hub since the September 2026 cutover; the home cluster deployment is dormant. (see: /projects/monolith/app/main.py) (see: /projects/monolith/app/main_public.py) (see: /projects/monolith/app/agents_main.py) (see: /projects/gke-apps/monolith/application.yaml)

Current as of 7b90da040 (2026-09-18)

1. What it is and request paths

The backend is a FastAPI application assembled from domain modules, while the browser application is SvelteKit. Together they host the knowledge graph, Discord agent, agent console, Grimoire, and public applications. (see: /projects/monolith/framework/core.py) (see: /projects/monolith/frontend/src/routes)

The deployed service has three audience tiers, plus the agents tier of sections 2 and 7: the private monolith carries the full route and MCP surface, the public deployment is a pruned composition on a read-only database role with a separately scoped writer for the two public chat domains, and the friends tier exposes only the moving planner, its browser API, and the SvelteKit bundle. The friends hostname has no Cloudflare Access application in front of it, only an authentik SecurityPolicy that is a separate object from its route, so verify the deny path against the live URL with the checklist kept beside the cfIngress.friends values rather than trusting the manifest. (see: /projects/monolith/app/modules_private.py) (see: /projects/monolith/app/modules_public.py) (see: /projects/monolith/deploy/values.yaml) (see: /projects/monolith/chart/templates/httproute-friends.yaml)

flowchart LR
    External[External callers] --> Edge[Cloudflare edge]
    Edge --> PublicIngress[Public ingress]
    Edge --> PrivateIngress[Private ingress]
    Edge --> FriendsIngress[Friends ingress]
    PublicIngress --> PublicWeb[SvelteKit public frontend]
    PublicWeb --> PublicApi[monolith-public]
    PrivateIngress --> PrivateWeb[SvelteKit private frontend]
    PrivateWeb --> PrivateApi[monolith]
    FriendsIngress --> FriendsPolicy[Friends authentication policy]
    FriendsPolicy --> PrivateApi
    MCP[MCP callers] --> ContextForge[Context Forge]
    ContextForge --> MCPMount[monolith /mcp]
    Guests[EmberVM guests] --> Sidecar[Guest egress sidecar]
    Sidecar --> AgentsApi[monolith-agents /mcp]
    Internal[Internal agents] --> ClusterIP[Kubernetes ClusterIP]
    ClusterIP --> PrivateApi
    Webhooks[GitHub / Semgrep webhooks] --> PrivateIngress
    PrivateIngress --> WebhookRoute[HMAC-verified webhook route]
    WebhookRoute --> PrivateApi

The public and private ingress split, the friends policy, and the internal service ports are rendered by the Helm chart. Two routes on the private hostname carry no SecurityPolicy on purpose: the GitHub and Semgrep webhook paths reach the backend through a Cloudflare Access IP bypass and are authenticated by the handler's HMAC verification alone, which is why they live on their own HTTPRoute rather than as rules on the private one. (see: /projects/monolith/chart/templates/httproute-private.yaml) (see: /projects/monolith/chart/templates/service.yaml) (see: /projects/mcp/ARCHITECTURE.md)

Where the Cilium CRDs exist, the application pod is default-deny for ingress. A CiliumNetworkPolicy allow-lists each caller by namespace and port: the gateway on 3000 and 8000, Context Forge on 8000, the workflows namespace on 8000, and EmberVM on 8091 and 3000. A caller that is not listed fails as a silent dial timeout rather than a readable deny, so a new in-cluster consumer needs an entry in the same change. The app endpoint also has an exact-FQDN egress arm there. Its required CoreDNS matchPattern: "*" lets Cilium learn addresses for toFQDNs, but accepts DNS-channel exfiltration as a residual.

The GKE hub has no Cilium policy CRDs, so the chart carries a native Kubernetes NetworkPolicy for the same app endpoint. Its overlay leaves that template disabled because Kubernetes NetworkPolicy has no additive audit mode. The API and resolver address lists also stay empty until GKE network-policy logs cover a representative live window, including periodic leader jobs and the secret-backed ICAL feed. An enabling change must validate both addresses from the live hub. Until then, the hub has no private-monolith egress enforcement.

When enabled, exact pod selectors and ports admit internal dependencies. The resolver pod selector is paired with its validated service address so policy does not depend on the dataplane's service-translation order. A public TCP 443 rule excludes cluster-addressable, loopback, link-local, shared-address, and multicast ranges. Native policy cannot select FQDNs, so arbitrary public HTTPS remains an accepted residual.

Both egress arms intentionally select only the private app endpoint, not every pod rendered into the namespace. Searxng, WhatsApp, CNPG, Atlas migration jobs, and batch pods in monolith-workflows keep their existing egress behavior. They have distinct service accounts, credentials, and execution roles, and folding them into one policy would replace destination-specific review with a namespace-wide union of every workload's privileges. Add a separate policy when one of those boundaries needs default-deny. (see: /projects/monolith/chart/templates/cilium-ingress-policy.yaml) (see: /projects/monolith/chart/templates/cilium-egress-policy.yaml) (see: /projects/monolith/chart/templates/networkpolicy-egress.yaml) (see: /projects/monolith-public/chart/templates/cilium-policy.yaml) (see: /projects/monolith/deploy/values-gke.yaml)

Why. The anonymous SSR surface once shared a pod, backend, database role, and full secret set with the private application, so an ingress mistake or public-process compromise crossed every boundary at once (ADR security/004). A feature-flagged full application and a replica without scoped grants were rejected because both retain that escalation path. Separate compositions, database identities, and fixed audience routes make private exposure the default, accepting another deployment and several policy bindings to operate (ADR networking/002, ADR services/010).

2. Modular framework

framework/core.py defines the registry contract through Tier, Profile, Module, and build_app. A profile selects public or private behavior and controls MCP, telemetry, static frontend, deep health, and leader singleton wiring. Each domain exports a Module and contributes private routes, public routes, MCP registration, startup work, leader hooks, or health checks. Registration uses module hooks, while MCP tools themselves use FastMCP decorators. (see: /projects/monolith/framework/core.py) (see: /projects/monolith/app/modules_private.py) (see: /projects/monolith/core/mcp_app.py)

Cross-domain imports must enter another domain through its api module. The boundary test parses every non-test Python file in its explicit domain set and reports internal imports, with no standing exceptions. Its Bazel target carries the selected sources as runfiles and an independently generated manifest, then requires exact, nonempty coverage before checking imports. The set is explicit because not every top-level package is currently classified as an architecture domain; adding one is an architecture decision rather than a test-side guess.

The retired app/architecture_test.py encoded two older conventions that are not current contracts. Domain composition now uses Module descriptors rather than requiring every package __init__.py to expose register(app). Route ownership is expressed by each descriptor's registration hooks, and routes may intentionally use /api, /internal, or /webhooks namespaces, so there is no blanket /api/{domain} prefix rule. Router ownership and any narrower prefix rules remain separate, domain-specific review concerns. (see: /projects/monolith/import_boundaries_test.py) (see: /projects/monolith/BUILD)

The public profile registers only register_public hooks and has no private lifespan, MCP mount, telemetry setup, or static frontend mount. The public binary also prunes private domains and private write paths from its file set, and a subprocess test checks that forbidden modules never enter its import closure. (see: /projects/monolith/BUILD) (see: /projects/monolith/app/main_public_imports_test.py)

The agents tier is a third composition with no registry at all. Its entrypoint builds a Starlette app that serves exactly four knowledge tools over stateless MCP plus one health route, behind identity middleware that rejects anonymous callers. The binary is pruned by source glob, and an import test proves the private domains never enter its closure. (see: /projects/monolith/app/agents_main.py) (see: /projects/monolith/app/main_agents_imports_test.py)

Why. Domain boundaries originally existed only by convention, allowing internal imports and composition glue to spread while public and private entrypoints risked drifting (ADR platform/008, ADR services/010). Independent services and databases per domain were rejected as disproportionate, and a base-class or dependency-injection framework was rejected because it would couple every domain to more machinery. Plain module descriptors, one build_app, endpoint-shaped api.py seams, and an AST boundary test preserve one deployable system while making future extraction and review explicit. The agents tier applies the same rule to guests: a pruned binary with its own database identity, rather than an MCP sidecar inside the private pod, bounds what an agent can write by a grant set and a source glob instead of by trust in the guest.

3. Data

One CloudNativePG cluster holds the monolith data. Production sets two instances, which gives one primary and one streaming hot standby for availability and read traffic. The hub's cluster was bootstrapped by recovery from the home cluster's Barman archive in Google Cloud Storage and keeps archiving there under its own server name, so the two archives never share a prefix. Daily base backups plus continuous WAL archiving keep fourteen days. (see: /projects/monolith/deploy/values-gke.yaml) (see: /projects/monolith/chart/templates/cnpg-cluster.yaml) (see: /projects/monolith/deploy/cnpg-gcs-backup-secret.md)

Atlas owns schema migration from the SQL files in chart/migrations. The database is divided into domain schemas including knowledge, chat, agent_sessions, claude_agent, grimoire, ships, stars, trips, campsites, and swarm. The migration bundle is rendered into a ConfigMap for Atlas, and bulk seed data is kept out of it because client-side apply records the manifest in an annotation with a 256 KiB ceiling. (see: /projects/monolith/chart/templates/atlas-migration.yaml) (see: /projects/monolith/chart/templates/migrations-configmap.yaml) (see: /bazel/tools/hooks/check-large-migration-sql.sh)

Application sessions use a cached SQLModel engine configured for psycopg, and domain code opens bounded session contexts around database work. Knowledge chunks store 1,024-dimensional pgvector embeddings under a cosine HNSW index, and the knowledge store ranks semantic matches before hydrating note and graph context. (see: /projects/monolith/core/db.py) (see: /projects/monolith/chart/migrations/20260408000000_knowledge_schema.sql) (see: /projects/monolith/knowledge/store.py)

The public read role receives explicit schema and object grants, including definer's-rights views (deliberately not security_invoker) that expose only public knowledge rows. The public write role has DML only on the bounded public chat schemas and specific demo latch tables. The agents tier connects as agents_writer, whose grant set is read across knowledge plus insert on four tables and nothing at all on the private application schemas. Each of these roles takes its password from an out-of-band basic-auth Secret because the 1Password operator cannot emit the Secret type CNPG requires. (see: /projects/monolith/chart/migrations/20260617000000_public_reader_role.sql) (see: /projects/monolith/chart/migrations/20260904180000_agents_writer_role.sql) (see: /projects/monolith/chat_public_grants_test.py) (see: /projects/monolith/deploy/agents-writer-secret.md)

The home overlay's nightly logical refresh into a development database (dumped from the primary, because a standby cancels any query that blocks WAL replay) is off on the hub, which has no development database. (see: /projects/monolith/chart/templates/cnpg-dev-refresh-cronworkflow.yaml)

Why. Keeping Obsidian and Postgres as writable peers created synchronization, conflict, and recovery questions, while a filesystem mount inside every replica expanded the serving path and security surface (ADR platform/006). Postgres was chosen as the body of record and as Grimoire's hot tier so transactions, pgvector, grants, and backups share one operational substrate rather than adding a service or datastore per domain (ADR services/011, ADR services/012). Schemas, roles, and definer's-rights views provide the isolation; the physical standby is accepted for availability and load isolation, not confidentiality (ADR security/004).

4. Agents

agent_sessions persists sessions, turns, pending messages, progress, model selection, workflow ownership, and EmberVM lineage in Postgres. Pending turns are claimed in sequence by one replica, refreshed by heartbeat, and reclaimed after a stale lease so a crashed worker does not strand work. A turn whose brick is preempted mid-flight is resumed or re-issued rather than lost, and the model the guest actually ran is persisted on the turn. (see: /projects/monolith/factory/execution/models.py) (see: /projects/monolith/factory/execution/store.py) (see: /projects/monolith/chart/migrations/20260903030000_agent_session_recovery.sql)

Model names map to three runtime families. spark selects the Pi family and its dedicated workload (qwen survives only as a legacy alias for persisted sessions), luna, terra, and sol select Codex dispatch, and opus, sonnet, and fable select Claude dispatch. The production list is explicit in chart values, so a newly supported model never appears in the console or the Discord command without a values change. Turns cross the EmberVM boundary through a session API: the Claude, Codex and Pi runtimes are session class guests, while run_code selects disposable language-specific task workloads, and both kinds are vsock-only with no NIC. (see: /projects/monolith/factory/execution/init.py) (see: /projects/monolith/factory/execution/transport.py) (see: /projects/monolith/sandbox/mcp.py) (see: /projects/embervm/ARCHITECTURE.md)

Swarm is the DBOS layer that composes sessions into durable workflows. The one engine today is implement_then_review: a Luna implementer session, an Opus review session, bounded attempts and review cycles, and a plan pinned into a step record at start so replay can never see a budget move. The pinned budget_usd is enforced at node boundaries. Every structured output an agent makes, review verdict and rationale record alike, arrives through one typed artifact channel validated at ingestion; nothing structured is parsed out of prose. For a legacy swarm run the workflow's Python control flow is still the graph, so the orchestration-level mutable DAG that ADR agents/062 decided reaches those runs only when this engine is retired (#5419, #4781). (see: /projects/monolith/factory/orchestration/workflows.py) (see: /projects/monolith/factory/orchestration/budget.py) (see: /projects/monolith/factory/orchestration/turn_artifact.py)

The factory lane is where that mutable DAG does exist. swarm/factory_conductor.py reconciles a versioned graph outside durable replay, and swarm/graph.py owns the versions, cycle and dependency checks, protected acted nodes and immutable dispatch pins. The planner builds the whole DAG at plan time through one plan decision whose edits apply atomically under a single expected revision, the reconciler dispatches each ready node as its own pinned unit, and a review that returns changes_requested opens a correction and re-review pair the engine appends itself, bounded by the policy's max_review_rounds (default 2). A round that fails is reopened the same way, against the same reviewed head and findings, with the failed round counted against the bound. The planner is called back only for a named deviation: no plan applied yet, a node that failed or escalated with no runnable retry, review rounds spent, a fan-in the engine could not open, or a settled graph with no verified delivery.

An attempt stop crosses the factory/session boundary through public APIs. swarm.factory_attempt_stop owns task authority, run/start checks, idempotency and stop audits. agent_sessions.factory_stop owns session/permit identity, the UNKNOWN transition and the executor heartbeat check. The factory calls agent_sessions.api with immutable values and its current transaction; the session domain calls swarm.api to read committed stop authority and exact cessation evidence. Neither side imports the other's models or private helpers for this path. The lock order stays factory control, capacity pool, session, and the UNKNOWN transition commits or rolls back with the factory's stop intent. Ember still owns physical execution cessation; a cancellation acknowledgment alone never releases the permit. (see: /projects/monolith/factory/execution/factory_stop.py) (see: /projects/monolith/factory/orchestration/factory_attempt_stop.py)

The accepted plan sizes the task. Its allowance is the sum over live unsucceeded nodes of max_attempts, plus the work turns history already spent, plus what the engine may still insert on its own. Review rounds are reserved one at a time: the next round only, at the two turns it costs, because the engine inserts its correction and its re-review at one attempt each. Each inserted round is then counted like any other live node while the round behind it is reserved, so the allowance grows by one round at a time. A fan-out wave reserves its one fan-in node at the policy's max_attempts, exactly as the inserted node will carry, so that insertion is turn-neutral. Every insertion is checked against the envelope as it happens, and an engine insertion is checked on the nodes it really adds, with no forward reserve counted on top: that reserve is headroom for the planner's next edit, not a charge against the round the engine is opening. Money is the same sum over node cost ceilings plus charged history. Review rounds are reserved only when the plan holds a review node. That allowance is persisted on the receipt with the graph revision it came from, re-derived and audited on every accepted plan, add, discard and engine round, and it is the bound authorize_start and the board actually read. Policy keeps only an envelope, max_task_turns_hard beside task_budget_usd and the deadline. An edit whose derived allowance would exceed either is refused whole with envelope_exceeded and the excess named as needed against allowed, beside the spare turns and dollars the graph still leaves under the reserve that edit implies, which reaches the planner as decision feedback so it can shrink the edit, split the work, or pause.

Nodes that can start together fan out, up to max_parallel_nodes. A wave is read from the live graph rather than from the plan as a whole: source-writing nodes that have never run, whose dependencies are all already on the task branch, and that no dependency path connects to each other. Each member works on factory/<task-id>-<node key>, a sibling of the task branch rather than a path below it, and an integrate node depending on all of them merges those branches into the task branch and reports the integrated head. The planner may name that node itself; otherwise the engine inserts integrate_<n> over the wave and repoints whatever depended on those branches at it. A node only takes a branch of its own once the fan-in that will merge it exists, so a refused fan-in asks the planner rather than stranding work, and the branch a node first ran on is pinned for every later attempt. While a wave is open it is the only source of source-writing work that may start, so a node outside it waits rather than becoming a second writer on the task branch. Fan-out is off entirely at a parallel limit of one, a planner-authored integrate node included: its members run serially on the task branch and it merges nothing. The lane is two lanes. A task's lane follows its class: work that ends in a pull request is delivery, work that ends in a comment is advisory. The policy's max_tasks bounds each separately, the chart's swarm.factoryMaxConcurrentTasks bounds their sum, and intake fills at most one candidate per lane per tick, so a full delivery lane never starves advisory work and the reverse. A quota_guard block watches the shared Claude 7-day window and, while it is nearly spent, routes review down the reviewer pool instead of spending it: nothing is held, admission is untouched, and Opus returns on its own below the resume threshold. The model is chosen when a review is dispatched rather than when it was planned, so the node keeps the planner's preference and the immutable pin records what really ran. Judgment work waits for Opus instead of falling back, and so does any review when no pool member has quota, because review is the gate. Transitions are recorded once each in the audit ledger, so a replica restart cannot forget a fallback mid-task, and an unknown or stale reading neither starts nor ends one. (see: /projects/monolith/factory/orchestration/factory_conductor.py) (see: /projects/monolith/factory/orchestration/factory_quota_guard.py) (see: /projects/monolith/factory/orchestration/graph.py) (see: /projects/monolith/factory/orchestration/deviations.py) (see: /projects/monolith/factory/orchestration/FACTORY.md)

Why. Autonomous intake lets the bounded lane discover delivery-ready work without making the operator continually copy issue numbers into policy, while keeping that authority inert by default and capped by labels, cooldown, and a daily limit. Issues that are not ready can take a separate refine-or-escalate path: one bounded node writes a structured brief, and the server trusts only the label and comment it re-reads from GitHub. This makes readiness evidence an observable issue state and keeps an unresolved human decision from entering the delivery DAG (#6002). The receipt stores the task class because intake decides it once and routing and audits must retain it across later policy changes. ADR agents/038 decision 5 gives judgment work an Opus-or-better implementer floor because no machine oracle can verify its correctness; quota pressure parks that work instead of demoting it.

Why. The chart ceiling and the per-lane maxima are set in two places and neither said which was binding, so a ceiling of 4 under lanes of 4 and 8 gave the advisory lane nothing: the limits handed delivery its whole maximum first and one sweep excluded 272 advisory candidates as lane_full. The ceiling is now 12, high enough to cover both lanes, and when it is not, the free part of it is dealt to whichever lane is furthest from its own maximum rather than reserved for delivery, with delivery keeping the first slot so it is never unable to start. The sweep names the mismatch as lane_ceiling_below_lanes. Twelve tasks could also hold every slot in the background session pool the drainers and the probes share, so factory dispatch now leaves a reserve there and every node is gated on it, the first node of a settled graph included: the factory is the only member of that pool whose work can simply wait for a later tick, so it is the one that yields. The same lanes also filled at one slot an hour, because a sweep takes one candidate per lane and the sweep clock only re-opened on the hour or on a settlement. An admission re-opens it too, which costs one extra sweep per admission and lets a lane fill over consecutive ticks (#6002).

Why. Landing exists because approval was not delivery: #3877 was shipped as PR #6007 whose body carried no closing keyword, so the issue stayed open with agent-ready intact and the next generation's sweep admitted it again as fresh work. The fix is three separate facts rather than one. Intake excludes any issue with a succeeded receipt, so the lane's own delivery record is what says work is done and the issue's labels are not. The delivery gate reads the pull request body back from GitHub and refuses a body that does not close its issue, naming the refusal so the planner can act on it. And landing, behind an auto_merge flag that defaults off, arms the merge and closes the issue against the merge it observes, one pull request at a time because the merge queue ejects everything behind a failed candidate. That holder is read from GitHub as well as from the lane's own audits, because an operator arming a factory pull request by hand puts it in the same queue, and landing selects on landing state rather than recency so a burst of newer settlements cannot evict the armed delivery from the batch that observes it. An ejection is a state the lane names and re-arms once rather than a wedge, and a head that moves under an armed pull request takes the arming back off. Landing stops at the merge: verifying the chart write-back and the live rollout is a node that does not exist yet (#6002).

Why. An escalation is a decision, not a message. The refine lane's needs-human verdict used to end at a warning and a question, so eleven of them in one hour on 2026-09-11 were eleven GitHub threads a person had to open, read and act on by hand, and the acting was the slow half: applying a label, writing the reason, opening the children, closing the parent. The verdict now carries two to four options, each naming the concrete act and the effect that performs it, with the recommendation first and matching the recommend: line so the brief on GitHub and the console offer one list rather than two. That turns the operator's work into picking, and picking is what a keyboard and a row of buttons are for, which is why /agents/escalations exists beside the board and answers to j, k, 1 to 4 and c. The effects run server-side under the monolith's own GitHub credential rather than as instructions to another agent, because a decision a person has already made should not wait on a guest to be scheduled. Every write is idempotent on the receipt and option pair, fenced by a hidden marker in the comment and an audit row per child issue, because the operator who clicks twice is the operator whose first click looked like it did nothing. And the private tier reaches the decision endpoint on the Access identity rather than the standing bearer the rest of /api/swarm/factory wants, because the browser behind Access carries no bearer at all. That identity is X-Auth-Email, the claim Envoy projects from a signature it verified and the gateway strips on ingress so it cannot be smuggled past the auth filter, never Cf-Access-Authenticated-User-Email, which nothing in the cluster validates or strips and which any caller reaching the backend could therefore write. Access itself is the gate, since private.jomcgi.dev is zero trust locked to one identity, so a verified address decides on its own and the FACTORY_OPERATOR_EMAILS secret is empty by default. Setting it narrows the click to a named list while leaving the page readable to everyone Access admits (#6002).

Why. A pause is a decision request, so it leaves the lane. A delivery planner that needed a person used to emit pause, and the receipt stayed admitted behind a task_paused flag, holding a delivery slot and its accounting until somebody resumed or cancelled by hand. Resuming replayed the same pause, because the planner's context is the issue body captured at admission plus graph evidence, so an answer posted as a comment never reached it: that happened twice in one day on #3824 and #3832. A pause now carries the question and the same two to four options a refine escalation carries, and settling it moves the receipt to escalated, which holds no slot, starts no node, and keeps the graph and the accounting for a decision to be read against. The answer comes back the other way rather than on the issue: an option that says carry on re-admits the receipt with the choice, its detail and the operator's note as direction, and the next task's first planner round reads that as a labelled untrusted section naming the previous branch and pull request. That option has to be the one the pause lists first, because resume_task applies option one without showing anyone the card and a pause recommending a close would make resuming the task close the issue. Every other answer settles the receipt cancelled rather than succeeded, because nothing was delivered and a succeeded delivery receipt is in the exclusion intake keeps for good. Re-admission mints a whole new task on a fresh budget, so the receipt keeps its previous task ids and the board shows what they spent beside the current task rather than inside its limits (#6041).

(see: /projects/monolith/factory/orchestration/factory_decisions.py) (see: /projects/monolith/frontend/src/routes/private/agents/escalations/+page.svelte)

Why. The factory has two lanes because Opus review is the only scarce input it has. Implementation capacity is three spot brick nodes behind the autoscaler, and the implementers on the other end of it bill someone else or almost nothing, so a single concurrency number was rationing the wrong thing: it held back advisory work, which passes no review gate at all, in order to protect a reviewer that advisory work never calls. Splitting the bound lets delivery stay as narrow as review can sustain while advisory work runs at whatever the platform holds. The quota guard applies the same reasoning to the window rather than to the count. Review is what spends the shared Claude subscription, and running it to zero would cost the operator their own sessions rather than only the factory's, so a nearly spent window buys a cheaper reviewer rather than stopping delivery: the work that does not need Opus was never the thing to hold. Independence is what a review gate is actually for, and that is a property of the session, so a cheaper reviewer is a weaker opinion rather than a weaker gate. The two things that do wait are the two where no substitute exists: judgment work, whose floor is a capability and not a price, and a pool with nothing left in it. An unknown reading neither starts nor ends a fallback, because reacting to an unreachable token broker in either direction is acting on something nobody has read (#6002).

Why. The bootstrap asked the planner for one graph edit at a time, so a single task (#5981) spent nine starts on five pieces of work: five were planner turns that added one node each, and a review that requested changes went back to the planner to restate findings the review had already written. Planning is the most expensive turn in the lane and the one least able to add information when the next step is mechanical, so both the shape and the loop moved into the server. A plan-time DAG lets one planner turn express the dependencies the graph machinery already enforced but never saw, and a bounded review loop runs the deterministic part with no model in it. The round bound is server policy, counted from the version ledger rather than from live nodes, so a planner cannot replenish it by renaming or discarding a correction node, and correct_<n> and review_<n> are refused as decision node keys for the same reason. Those same two refusals are why the engine, not the planner, reopens a round that failed: a correct_<n> whose guest ended its turn without pushing leaves a node the planner may not name and, because it has a run, may not discard either, so handing that back to the planner asked it for an edit no edit could express and the task only paused. Delivery stays with the planner because verify_delivery is the gate a completion claim has to pass, and an exhausted loop returns to the planner with the evidence instead of failing the task (#5419).

Escalation is a pause, not a return. At each escalate point the workflow writes a swarm.swarm_decision row and polls it on the cadence it already polls session turns; a human answers through the console, the run decision endpoint, or the agent_run_decide MCP tool, with the actor recorded best-effort the way cancel_run records one. An unanswered row expires after swarm.decisionTimeoutSeconds and the run ends the way an unwatched gate always did. Swarm deliberately stops short of merging: the role-separated review gate that ADR agents/027 describes does not exist in code, so nothing autonomous lands on main, and the only agent-merged path is docfix auto-merge, off by default and refusing agent instructions, skills, runbooks and ADRs even when on. (see: /projects/monolith/chart/migrations/20260822230000_swarm_decision.sql) (see: /projects/monolith/factory/orchestration/router.py) (see: /projects/monolith/deploy/values.yaml)

The work-queue drainer turns a standing backlog into continuous progress. An Argo tick every fifteen minutes asks the leader to start drain_cycle, a leader-owned DBOS workflow that claims claude_agent.routine_jobs rows under the existing skip-locked lease, runs each as one fresh Luna session, and completes it. Two job kinds are claimed: the general lane (qwen-drain, a legacy name kept so registered rows stay claimable) and knowledge extraction (kg-drain, capped per day with manual burst grants). Draining is serial by design, the tick's suspend and the agents.drainer.enabled value are the two kill switches, and a stall advisory rather than a Discord notify is the default failure surface. (see: /projects/monolith/factory/orchestration/drainer.py) (see: /projects/monolith/agent/routine_jobs.py) (see: /projects/monolith/chart/values.yaml)

The agent console is served at /agents on the private hostname as an inbox-first surface: rows state the ask, a run view draws the plan, decision records and walkthrough, a knowledge extraction queue lane shows the drainer's backlog, a VM map reads the control plane and is deliberately empty when it is unreachable, and the voice companion is a ledger-first screen the conversation drives. The route is grouped under the private tree behind the private ingress authentication policy. Session output reaches Discord only when a turn needs a human, unless the session is bound to a thread.

The factory board at /agents/factory is the operator's view of the factory: a state strip, then in flight, queue and recent panels, each task drawn as its plan (nodes ranked by dependency, one attempt row per node with the session it ran in) beside its starts and stop events. It reads one board endpoint, GET /api/agents/factory, a read-only join of the factory status, plan graph, node runs and session summaries that sits beside /api/agents/sessions rather than under the operator-gated /api/swarm/factory routes, because the browser on the private tier carries no bearer and the board is a view, not a control. A card's "Discuss with the conductor" link opens the console with an Astra session prefilled with that task's plan, so a sync with the conductor is an ordinary session rather than a second chat surface. The launcher leads with a factory strip above the knowledge extraction queue strip. (see: /projects/monolith/frontend/src/routes/private/agents/+page.svelte) (see: /projects/monolith/frontend/src/routes/private/agents/factory/+page.svelte) (see: /projects/monolith/factory/private_view.py) (see: /projects/monolith/factory/execution/voice.py) (see: /projects/monolith/chart/values.yaml)

A factory start reserves a ceiling and settles at the cost its node result reports. Codex-backed models report no provider cost, so a result falls back to the list price the turn store computed from token usage, and every result carries the basis it settled on: provider, list, or unknown. The graph surfaces that as an accounting basis of reported or list_priced beside the reserved and accounted figures. Only known completions settle at a priced amount. Unknown execution retains its whole reservation, and a completion with no price at all still consumes its ceiling. The receipt counts work starts as turns_used and conductor rounds separately as planner_turns_used. Work starts meet max_turns_per_task and planning rounds meet max_planner_turns, an optional policy field that inherits the work cap when a policy predates it. (see: /projects/monolith/factory/orchestration/factory_controls.py) (see: /projects/monolith/factory/orchestration/node_workflows.py) (see: /projects/monolith/shared/pricing.py)

Why. Booking an unpriced completion at its full ceiling is the conservative choice when nothing is known about the spend, but a Codex turn is not unknown: its token usage is recorded and priceable, so charging the ceiling overstated a task by more than an order of magnitude and retired it with most of its real budget unspent. A list price is an estimate, so it settles the ledger but never fails a delivery; only a provider-measured overrun does that. Counting planner rounds against the delivery cap let a task exhaust itself deciding, but the task budget alone is not the answer either: a planning round costs a few cents, so the budget would admit hundreds of them and a refused decision mints the next planner every tick. max_planner_turns bounds deliberation on its own count, leaving the work bound to the plan.

Why. A fixed max_turns_per_task is a guess made before anyone knows what the task is. It stranded a well-formed plan that needed one more node and it funded a trivial one at ten times its size, and neither failure told the planner anything. The plan is the only artifact that knows how much work there is, so it derives the allowance and policy keeps the envelope that plan has to fit inside. Refusing an over-envelope plan whole, with the excess named, is what makes the bound actionable: the planner can split the work into a follow-up task or pause for orchestration review instead of discovering the wall one turn at a time. max_turns_per_task is still accepted and read as the envelope, so a live policy needs no re-post. Reserving every remaining review round up front then reintroduced the same failure from the other side: at two nodes of max_attempts per round, two rounds cost eight turns of allowance on top of the plan, so a nine-turn envelope could not hold a three-node plan and a task with any history could not add a review node at all (t-5e48b6e1, #5981). The reserve is lazy instead, the next round only and at the two turns the engine really inserts, and the envelope is checked again at each insertion, which is the check that was doing the work all along. The same reasoning keeps the reserve out of the engine's own check: headroom the planner needs for sizing would otherwise refuse a round the envelope can afford because of a round that may never open. Naming the spare turns and dollars in the refusal is the other half, computed under the reserve the refused edit implies: a planner that only hears no, or that shrinks to a figure which ignored the reserve its own review node brings, re-proposes until max_planner_turns pauses the task (#5419).

Why. Nodes were serial because every node pushed to the one branch factory/<task-id> and the reconciler dispatched one active run per task, not because the plan said they depended on each other. Giving concurrent implementations their own branch and fanning them back in through one integrate node before review is the shape #5861 point 5 asks for: parallel contributors feed one integration branch per coherent slice, with the required checks at the integrated head. The branch is a sibling name rather than a path under the task branch because git cannot hold refs/heads/factory/<id> and a ref below it at the same time. Which nodes fan out is read from the dependencies the planner already wrote rather than from a separate marker, so the branch a node pushes to and the order the graph enforces can never disagree. Reading it as a wave rather than as plan-wide concurrency is what keeps a node added by a later replan off a branch of its own: its siblings have already run and been integrated, so it has nobody to run beside and works on the task branch serially. Fan-out is off until an operator raises max_parallel_nodes, and the extra concurrent guests are gated on the shared session pool first, so a node the pool cannot hold stays ready for the next tick rather than failing (#5419, #5861).

Why. An unconfirmed delivery error is one hold represented consistently across the turn, capacity reservation, routine job and health views. Capacity is released only by the explicit reconciliation owner after fresh authoritative terminal proof for the exact guest, generation, session and turn. Uncertain permit cessation settlement is behind agents.sessions.uncertainPermitSupervisionEnabled, which defaults off. It covers kg and project drainer permits, interactive permits, and factory-owned project permits. When probe supervision is also enabled, it extends probes to destroyed and no-guest proof. A bound guest requires exact control-plane eviction or timestamped destruction after the failed turn, and a guest that ceased without ever being stopped reports no stop precondition, so the factory path takes its identity from the committed stop intent or from the observed invocation. Both loops order that invocation against the failed turn rather than against the dispatch, because the dispatch stamp is written when the executor claims the turn and so lands before the guest is ever invoked. A no-guest permit requires durable claim evidence with no guest, binding or residual lineage evidence, and an operator destroy no longer clears a binding under an unresolved outcome, so a cleared binding cannot masquerade as one that never existed. A drainer permit whose routine job row is still parked on the attempt is left to the operator reconciliation path, and that path only handles kg jobs: it refuses any routine_kind other than kg-drain. swarm/drainer.py holds the job on every InvocationOutcomeUnknown, so the held row is the rule for drainer permits rather than the exception, and a held non-kg job currently has no owner at all (#5983). What this loop covers for drainers is the narrower shape where the workflow died before the hold was written, leaving the job row still armed. The two KG permits from the 2026-09-09 stall are held rows and still need the operator path. The shared settler preserves the session binding, while factory-owned sessions use the factory settlement path. Factory attempts with no bound guest remain held until that path can prove no delivery. Legacy swarm implement_then_review sessions stay a residual: they are project tier with no routine job and no factory pin, so nothing settles them, and covering them needs a check that their DBOS workflow is terminal, which this loop does not have. (see: /projects/monolith/factory/execution/permit_supervision.py)

Why. A lost invoke response is not a lost invocation. Every monolith rollout cancelled the executor watching an in-flight turn, and the guest went on working while the executor recorded invocation_outcome_unknown, failed the session and left supervision to destroy a guest that was mid-turn. Three factory attempts died that way in under an hour on 2026-09-11, which made the factory's own deploys its largest self-inflicted loss (#5938, #4322). The guest already publishes its complete native record to a result receipt before it writes the synchronous response, so the evidence survives the observer. What was missing was a state between "finished" and "unknown". A dispatch whose response is lost while the control plane still shows its guest running, with an invoke still in progress, is now held: an interrupted turn with stop reason response_lost, the pending row keeping its claim so nothing re-dispatches the prompt, and the permit keeping its state so nothing releases capacity the guest is still consuming. In progress is the order of two stamps, not the absence of one: invoke_started_at is stamped per invoke and strictly increases, while last_invoke_at is stamped on completion and is never cleared, so an invoke is running when its start is later than the last completion. Treating a missing completion as "still running" would have held a guest's first turn and no turn after it, and would have settled every held second turn unknown on the recovering owner's first look. Both stamps and the generation travel onto the hold, so a later owner can tell a guest still working this invocation from one that was banked and relit or handed another turn. The recovered attempt is finished from the committed receipt through the ordinary turn writer, so the recovered result passes the same parser, diff, artifact and permit validation the synchronous response would have, and the model runs once. The live thirty-second claim stamp used to be the only thing authorizing a receipt read, and an executor that lost its response stops refreshing it, so the durable hold takes its place: it is dispatch-exact, bounded, names one receipt and carries the digest of the request that receipt was minted against, and every other ownership condition is unchanged. A held adoption sets no guest reuse fence, because the fence exists so a follow-up waits for the original POST's own response and after a replica loss nobody is left to clear one. Holds are bounded by the invoke budget clamped to the twelve-hour workload backstop, and a guest that has ceased, that completed its invoke without publishing, or that has moved to another invocation ends the hold early, all into the same unknown outcome reconciliation already handles. A committed body that can never be adopted ends its hold at once, because a receipt body is immutable and waiting out the bound cannot change the answer. There is one hold per dispatch, ever: re-holding an expired marker would replace the twelve-hour bound with the receipt's own seven-day retention. The lifespan now cancels in-flight executors on shutdown and waits a bounded five seconds for each to record its own outcome, because uvicorn tears the loop down without cancelling them and DBOS.destroy() waits zero seconds for workflow completion, so the handler that writes the hold often never ran. The hold itself is still written synchronously from that handler, with a two-second lock timeout: inside a thirty-second termination grace, giving up on a row lock is better than giving up on the grace. A replica killed outright writes no marker at all, so the claim lease is the backstop: a stale claim that already has an unconsumed committed receipt is held, and one with no receipt settles unknown exactly as before. Behind agents.sessions.responseLostRecoveryEnabled, which defaults off and needs resultReceiptsEnabled, since without a receipt a hold would only delay the same unknown outcome. Every owner that writes, finishes or ends a hold reads that one flag, including the lease backstop and the node recovery, so off is byte-for-byte the behaviour that preceded this whatever the receipt flags say. (see: /projects/monolith/factory/execution/store.py)

Why. The guest reuse fence had a hold and no release. A receipt that beat its own POST completed the turn and fenced the session so a follow-up dispatch would wait for that POST's own validated response, and only that response ever cleared the fence. A POST that ends in an error, a timeout or a cancellation produces no such response, so the fence outlived every owner: turn completion skipped its destroy, the workflow reaper filed the row as pending, and admission, reconciliation and factory attempt stop all stood back. On an unpressured brick the control plane then left the guest running until idle_ttl, hours later. Eight guests leaked that way between 04:00 and 05:00 UTC on 2026-09-12, the sixteen-guest claude-runtime workload cap filled, every create was denied and the factory stalled on 429s while two attempts burned their whole turn window (#6050). The fence now has an owner with a deadline at both ends. The invoke coroutine's terminal path releases the exact receipt it fenced, bound to the same session, receipt, guest, claim owner and dispatch count identity the observed path checks, so a stale coroutine can neither release a newer fence nor authorize destroying a newer guest, and then runs the ordinary completion cleanup. That release is optional work on a completion path, so a contended pool that answers lock_not_available inside its one second lock timeout is retried a bounded number of times and then logged: it never replaces the exception the turn was already raising, and never stands between that turn and the destroy and unbind that follow it. The two cleanup owners are the backstop for a crash between those steps, and for every session that keeps its guest resident rather than destroying it at turn completion. Both release a fence whose receipt is gone, whose body has arrived, or whose acceptance window has closed. The drainer releases inside its own cleanup transaction, so a refused cleanup rolls the release back with everything else it was going to write. The workflow reaper cannot, because begin_guest_cleanup refuses while any row bound to the guest carries a fence, so it releases first and puts the fence back when no claim follows: a committed claim blocks dispatch, receipt minting and rebinding in the fence's place, but between the release and the claim nothing does, and a row left pending with neither would let a queued follow-up claim a guest whose POST may still be streaming. What none of them will touch is a receipt still inside its acceptance window with nothing committed, because until that body arrives the guest is the only path to the turn. The release deliberately does not stamp response_observed_at: nothing observed a response, and the lease backstop and factory reconciliation read that stamp as evidence about the response itself, so the count of received receipts with no observed response stays the honest health signal for how often the receipt is winning the race. (see: /projects/monolith/factory/execution/result_receipts.py)

Monolith batch work is rendered as Argo CronWorkflows in the workflows namespace, whose controller owns cadence, concurrency, deadlines, and history. Each entry runs the digest-pinned jobs image with one jobs_main.py subcommand. A job pod gets DATABASE_URL from a Kyverno-cloned Secret plus whatever the entry declares, never the deployment's environment. Entries marked internalApi only POST a private endpoint on the leader (the drain tick, the synthetic probes, the Semgrep harvest), so that work runs inside the API pod where the credentials already live. Suspended entries remain available for manual submission. (see: /projects/monolith/chart/templates/cronworkflows.yaml) (see: /projects/monolith/app/jobs_main.py)

Leader election scopes side-effecting singleton hooks to one API replica: the Discord bot, outbox drain and message-lock sweep, AIS ingest, the agent-session loops (pending-message sweep, title refresh, Ember-session export into the knowledge graph), the DBOS runtime, and the continuous-delivery probe writer. A failing singleton resigns and retries rather than ending leader election, and a failed acquire backs off while followers keep serving. (see: /projects/monolith/framework/core.py) (see: /projects/monolith/chat/leader.py) (see: /projects/monolith/factory/module.py)

Why. Terminal-lived runs and process-local queues could not survive a restart, support multiple frontends, or provide a queryable history (ADR agents/007). Putting every idle or short turn into an Argo Workflow was rejected because each run becomes a high-churn etcd object and snapshot-backed sessions wait on events rather than run to completion (ADR agents/019, ADR agents/022). Leased Postgres rows own ordered turn dispatch, EmberVM owns isolated execution, and short DBOS workflows own bounded multi-step recovery. This accepts polling and reconciliation latency in exchange for one durable record shared by MCP, the browser UI, and swarm orchestration (ADR agents/049, ADR agents/053). Escalation waits on a polled row rather than a DBOS message because the codebase's one rule about waiting is poll-shaped end to end and the audit fields have to live somewhere durable regardless (ADR agents/060). The drainer composes an Argo tick, a leader-owned workflow and the routine-job lease that already existed rather than a new queue table or a hosted routine, because ADR agents/038's work queue was never built and a hosted routine spends Claude quota to babysit work billed elsewhere (ADR agents/061). Graph mutation belongs at the orchestration level and never inside a durable workflow, because a workflow that reads mutable state recovers wrongly on replay (ADR agents/062).

5. Chat

The Discord bot handles direct messages, mentions, replies, ambient engagement, slash commands, thread sessions, and streamed response edits. The bot and its two supporting loops run only on the elected leader. Synchronous replies use a hosted route pinned to one OpenRouter provider for speed; clearing the model and base URL in values reverts to the Spark path without a code deploy, and on the hub that path is Meta's hosted API rather than an in-cluster server. (see: /projects/monolith/chat/bot.py) (see: /projects/monolith/deploy/values.yaml) (see: /projects/monolith/deploy/values-gke.yaml)

Proactive work is scheduled rather than reactive: hourly changelog summaries for three repositories, a reminders drain every two minutes, a weekly directive observation pass and a daily directive autopilot, daily channel summaries, and weekly safeguards training, all as CronWorkflows. The orchestrator brief compiler decides chat against task submission on channels that hold the orchestrator consent grant, with an ordered provider fallback chain that fails open to direct submit. (see: /projects/monolith/chart/values.yaml) (see: /projects/monolith/chat/orchestrator_client.py) (see: /projects/monolith/chat/acl.py)

Persisted message triggers match bounded regular expressions after optional channel and user filters, then dispatch a response, crosspost, or owner-gated agent run. A conditional database update claims each trigger's cooldown before the action runs, and trigger dispatch stays off the normal chat response path. (see: /projects/monolith/chat/triggers.py) (see: /projects/monolith/chart/migrations/20260914120000_chat_triggers.sql)

Why. Multiple bot replicas can observe the same eligible trigger, so the cooldown claim is a conditional database write committed before dispatch. The database selects one winner and prevents duplicate side effects after a restart, while accepting that a failed action still consumes its cooldown.

Trust and safety is a per-server, per-user ledger with three detection lanes: narrow regex heuristics on every observed message, an asynchronous LLM intent classifier on relevant messages, and an offline-trained random forest that is shadow-only until a model row is promoted to live. Scores start at a ceiling, decay back on a fixed per-day recovery, and soft-lock engagement below a threshold; the constants live in chat/safeguards.py and are environment-overridable. A pardon restores the score and relabels recent training events as benign, turning a false positive into supervised feedback. (see: /projects/monolith/chat/safeguards.py) (see: /projects/monolith/chart/migrations/20260711220000_chat_safeguards.sql)

Public chat v3 verifies a Turnstile challenge before minting a session and applies turn, token, prompt-size, concurrency, and shared GPU admission limits. The write path uses its restricted public database identity. On the hub its inference is the hosted Spark API at a global concurrency of one, set as a whole environment list in the GKE overlay because the chart carries that list as a literal. Grimoire chat is a parallel public chat surface grounded by pgvector retrieval over the Grimoire corpus; it streams model output, compacts long conversations, and shares the same admission and resource-control pattern, but its chat input is text rather than a multimodal message payload. (see: /projects/monolith/chat_public/limits.py) (see: /projects/monolith-public/deploy/values-gke.yaml) (see: /projects/monolith/grimoire_chat/router.py)

The WhatsApp gateway is a transport-only Go service in its own single-replica Deployment with a household agent path behind it. It holds an external session singleton, so it is parked during the cutover window and comes back on the hub only once the home deployment is off. (see: /projects/monolith/whatsapp) (see: /projects/monolith/deploy/values.yaml)

Why. LLM-only abuse detection was rejected because it charges a model call for every message and lets a flood turn the detector into the resource drain; hard bans and deletion were rejected because recoverable red-team play is part of the friends surface (ADR chat/003). One ledger and one enforcement choke point combine cheap deterministic signals, asynchronous semantic judgment, and a shadow-first learned model. The design accepts delayed LLM verdicts and tunable false positives, with score recovery and pardon providing the correction path. Anonymous chat adds Turnstile and bounded admission because its GPU and write paths cannot rely on an authenticated principal (ADR security/005).

6. Knowledge and Grimoire

The knowledge graph learns from evidence lanes. Every input becomes an immutable knowledge.raw_inputs row, body content-addressed in object storage, with a source naming its lane: finished Ember sessions (a leader loop with a per-session watermark and a pinned since-floor), local Claude and Codex sessions uploaded by a collector on the Mac over the tailnet, an hourly read-only repository diff scout, and the intentional reports report_knowledge, dispute_fact, and report_distress. Extraction is a kg-drain job on the drainer: the monolith builds the prompt with a source-specific lens, Luna returns candidate assertions, and the monolith parses them and writes atoms and provenance server-side through precision-first gates. Guests never write the graph. The account-hosted routines and the in-process gardener that preceded this are retired. No knowledge CronWorkflow remains today except the publication lane (15-minute visibility flip for verified/unverified agent facts). (see: /projects/monolith/knowledge/extraction.py) (see: /projects/monolith/factory/execution/kg_feed.py) (see: /projects/monolith/chart/migrations/20260903000000_knowledge_scoped_assertions.sql) (see: /projects/monolith/deploy/values.yaml)

Why. Knowledge stays in Postgres beside the evidence lanes rather than moving to a Loom deployment (#5991, closed 2026-09-14). Loom was removed from the cluster in 81c58b2ae, the entity spine (knowledge/entities.yaml, b3b2ea20b) and the recall floor (#5828) already anchor facts to projects and services in the same database the factory reads, and a second store would split provenance and scope enforcement (#5573) across two systems before either had a measured retrieval baseline. Expansion is gated on that baseline: #6128 measures a labelled set of real agent queries with expected evidence, including abstention cases, and each later slice (entity-aware drain, work-item linkage, runtime revalidation, component overviews) lands as its own issue only after the baseline names the failure it fixes.

Facts are anchored to entities seeded from a committed manifest (projects/monolith/knowledge/entities.yaml); note_entities edges link facts to entities with role constraints, and extraction uses the closed vocabulary of project, service, environment, and issue kinds, together with the scope grammar, to derive facts about the repository rather than hallucinations.

Why. Facts needed a subject so the public record and the extraction lens could speak about tangible entities (projects, organizations) rather than hypothetical ones, and a committed manifest keeps the extraction anchors derived from the repository rather than from model inference.

Why. Facts publish automatically so the public tier reflects what agents record without a human gate on every row, while the audit queue and human holds keep the human in the loop for review and override.

Derived facts carry scope, verification_state, confidence, and a validity window as columns. A dispute opens a knowledge.disputes row keyed by the stable note id, so it survives a reindex, and never deletes the disputed fact; a distress report notifies Discord and is retained but never extracted. Doc drift found by the repository diff feed opens human-reviewed documentation PRs on the routine lane, a demand-driven review turn verifies them against main, and the switch that would let it enqueue one stays off. Recall closes the loop: every new Ember session except a kg-drain one gets the top facts by embedding similarity written into its system prompt once, from its first prompt, each marked with its verification state, and the block does not refresh as the task evolves. (see: /projects/monolith/knowledge/mcp.py) (see: /projects/monolith/knowledge/docfix.py) (see: /projects/monolith/knowledge/recall.py)

Knowledge RAG embeds a query, performs cosine retrieval over HNSW-indexed chunks, ranks matching notes, and returns note, section, snippet, and edge context. Public retrieval reads only the public_api views, never the knowledge schema. The note visibility predicate is fail-closed: only an explicit public value is public, public routes strip links to anything outside the known public note set, and database views repeat the same visibility condition. (see: /projects/monolith/knowledge/store.py) (see: /projects/monolith/knowledge/visibility.py) (see: /projects/monolith/chart/migrations/20260617020000_public_api_knowledge_views.sql)

Grimoire is Postgres-first: a typed hot-tier schema (a CTI entity spine with typed detail tables, jsonb only for irregular display payloads) rather than a document store. The shared corpus, typed entity details, embeddings, mentions, relationships, campaign state, and knowledge grants all live in Postgres today. Campaign reads centralize the viewer predicate: global entities are visible to everyone in the campaign, while non-global entities require a matching grant, and projection then applies full, partial, or recognition-only scope. (see: /projects/monolith/grimoire/models.py) (see: /projects/monolith/chart/migrations/20260703070000_grimoire_schema.sql) (see: /projects/monolith/grimoire/visibility.py)

Why. Grimoire stays a corpus browser with a thin play layer rather than a live-play platform, and grows one table-driven slice at a time. A real table will use it (decided 2026-09-13, #3942), which is why authenticated campaign membership and private ACLs (#3959) were approved first: every later feature reads or writes campaign-private data, and the ?as= viewpoint override on the private routes had to become a real membership check before any of it. Character sheets as the mechanical source of truth (#3960) and a session transcript pipeline (#3961) remain proposals, each gated on membership landing and on Joe naming the concrete table workflow it serves with a bounded first deliverable; finishing membership does not authorise sheets, transcription, auto-reveals, combat automation or public replays. Voice capture with in-cluster transcription is deliberately last because the hub has no GPU pool for an ASR service (#5461), and derived character-knowledge automation (#3910) was dropped in favour of explicit DM grants until manual assignment is shown to be a burden.

The Grimoire ingest path converts extracted documents into ordered text and image-derived chunks, records section hierarchy and image references, embeds chunks, and extracts typed entities and relationships through a hosted model whose endpoint, model and concurrency are values. Chunk loading runs daily; extraction and hierarchy backfill are suspended, manual-only jobs. Post-extraction stat verification and alias merging remain accepted design work rather than current runtime passes (#3912, #3913). (see: /projects/monolith/grimoire/ingest.py) (see: /projects/monolith/grimoire/extract.py) (see: /projects/monolith/deploy/values.yaml)

Why. Postgres replaced the vault as the served-content authority because two writable stores made synchronization and conflict policy load-bearing (ADR platform/006). Grimoire reused that typed, pgvector-capable hot tier instead of adding a standalone service, and compiles audience rules into grants rather than growing every read predicate with contextual logic (ADR services/011, ADR services/012, ADR services/013). Extraction moved from account-hosted routines to a queue lane on the drainer because a hosted hourly routine spent Claude quota to schedule work Luna bills elsewhere, and a queue consumer can be paused, capped and reasoned about with the same levers as every other drainer job (ADR agents/063). Guests never write the graph because identity and provenance are server-stamped: the guest that produced a candidate fact must not be the party that asserts its provenance.

7. MCP surface

Two MCP surfaces exist. Context Forge remains the front door for people and hosted agents and stores the private monolith /mcp server as a registered streamable-HTTP upstream. That mount is one shared FastMCP instance populated by modules whose profile enables MCP: cluster, agent, agent sessions, chat directives and trust, knowledge and tasks, sandbox, Semgrep scanning, screenshotting, the swarm factory read tools, and the updates journal. Most register by a side-effect import of their decorated tool module during application composition; shotter calls an explicit register_mcp_tools() instead, so its BDD specs can attach the tools deterministically. (see: /projects/mcp/ARCHITECTURE.md) (see: /projects/monolith/core/mcp_app.py) (see: /projects/monolith/shotter/module.py)

The agents tier is the second surface, intended for guests. monolith-agents runs in its own namespace and serves four knowledge tools (search_knowledge, report_knowledge, dispute_fact, report_distress) plus two Kubernetes observation tools (kubernetes_read, kubernetes_pod_logs) over stateless MCP. Identity middleware rejects anonymous callers, the tokens are minted by authentik's agent provider, and an Ember guest reaches the tier only through its egress sidecar, since the guest itself has no network. The tier holds its own database and object-storage credentials, synced into its namespace, and a ServiceAccount with its own enumerated read RBAC. Namespaced workload reads are separate Roles in the recorded namespace allowlist; Argo CD Applications are limited to argocd, Kargo Freight to kargo-monolith and kargo-embervm, and the only cluster-scoped exceptions are nodes, namespaces, and node metrics. There is no watch, mutation, Secret, exec, attach, port-forward, or proxy grant. The read tools independently require the standing workload principal kg-agent-sa in kg-agents on every invocation. The knowledge tools retain the tier's existing non-anonymous gate.

These are repository manifests and application code, not a claim about live permissions. The hub consumes a hand-advanced chart pin, so the permissions do not exist there until this chart is published, pinned, and reconciled. The source change does not verify that rollout. (see: /projects/monolith/app/agents_main.py) (see: /projects/monolith-agents/chart/values.yaml) (see: /projects/gke-apps/monolith-agents/application.yaml) (see: /projects/embervm/ARCHITECTURE.md)

Context Forge filters tool visibility tags against caller team membership, which is tool-granular authorization. The monolith validates any bearer token on each stateless MCP message, but anonymous discovery remains allowed, and per-caller result scoping is not built (#4569), although caller tokens now reach the monolith on the Claude.ai path. The broader gateway architecture, identity limitations, and catalogue refresh behavior are documented in MCP architecture. (see: /projects/monolith/auth/middleware.py) (see: /projects/mcp/ARCHITECTURE.md)

Current authentication trust boundary

Status: Accepted for the current source boundary. This record describes mechanisms present at the current task head. The future delegation design in #4940 remains gated. The deployment values declare both standing issuers and the EmberVM token broker grant. Source and manifests establish code and configuration presence only. The dated runtime observation in projects/mcp/ARCHITECTURE.md remains deployment evidence from 2026-09-05; this source-only reconciliation adds no newer production observation.

Supported human callers authenticate to Context Forge with an authentik mcp-friends token. Context Forge forwards that bearer to the shared monolith /mcp mount, where PrincipalMiddleware calls TokenResolver.resolve. Supported EmberVM guests do not hold the authentik client secret: the token broker uses the mcp-agents client and the kg-agent-sa service account, then the egress sidecar presents the resulting bearer to monolith-agents. Both resolve as standing identities. Delegation is absent. The relevant declarations are mcp-auth.yaml (mcp-friends, mcp-agents, and kg-agent-sa), projects/embervm/deploy/values-gke.yaml (tokenBroker.authentik), and the two monolith deployment value files.

AuthentikStandingVerifier.verify first uses the unverified iss only to choose an owning verifier. For a token it owns, it requires an RS256 header and non-empty kid, finds that key in the configured JWKS, verifies the signature, strict issuer and configured audience, and requires exp, iss, aud, and sub. PyJWT also rejects expired and not-yet-valid tokens. The verifier then requires sub to be a non-empty string, groups to be a list of strings, and scope to be a string; a non-empty string email is retained. It does not check azp, infer a resource permission, or validate a dedicated human versus workload claim. Principal.kind currently infers human from email presence. That classification grants nothing. (see: /projects/monolith/auth/verifier.py) (see: /projects/monolith/auth/principal.py)

The request outcomes differ by surface:

Surface Missing or unusable authorization Valid standing token Resource and result authorization
Shared private /mcp No header, a blank header, or a non-Bearer scheme becomes the anonymous principal. Anonymous tools/list returns the catalogue for Context Forge refresh, but GroupPolicyMiddleware.on_call_tool denies every non-public tool. No current tool carries mcp:public. Bearer without a credential and any invalid or unrecognized bearer return 401; verifier or JWKS configuration faults return 503. The verified claims become a standing Principal. Listing is filtered and calls require the literal operators group unless a tool is explicitly tagged public. GroupPolicyMiddleware owns the coarse call gate. Domain checks can narrow it further. Factory orchestration uses factory.access.is_operator, knowledge and Grimoire HTTP mutations require a standing human operator, and submit_product_update requires updates:submit. Tool visibility, a populated Principal, and network reachability do not grant permission.
Agent /mcp /healthz is open. The MCP gate turns the anonymous principal into 401, while an invalid bearer is rejected by PrincipalMiddleware with 401 and an infrastructure fault with 503. The resolver is configured for both mcp-friends and mcp-agents; _AuthenticatedPrincipalGate accepts any non-anonymous principal for the four knowledge tools. Each of the two Kubernetes observation tools additionally requires a standing, undelegated workload principal whose subject is kg-agent-sa and whose groups include kg-agents. search_knowledge does not filter rows by principal. The three reporting tools record the resolved subject and authority for attribution. Kubernetes reads are confined again by the client allowlist, bounds, and the tier's separate Roles. Identity facts alone do not widen either surface.
Private HTTP API Only routes that opt into get_principal parse a bearer. On those routes, absence becomes anonymous and invalid bearer material returns 401 or 503; their owner may then return 403. Most private routes do not opt in and ignore bearer identity. Knowledge intervention and Grimoire alias owners require a standing human in operators. Browser factory decisions instead use the single X-Auth-Email claim projected from a verified Cloudflare Access JWT and optionally narrow it with FACTORY_OPERATOR_EMAILS. Cloudflare Access owns the external ingress gate. The application defines no blanket anonymous-principal policy. For example, GET /api/agents/sessions/{session_id} returns prompts and verbatim turn results without an application-level principal or per-session ownership check. Direct network reachability supplies no permission. The missing object-level result check remains unresolved and does not redefine the edge policy.
Result receipt callback /ingest/results/{receipt_id} Missing, malformed, unknown, or mismatched receipt bearer material returns 401. An expired acceptance window returns 410 and a different body for an already captured receipt returns 409. It never falls back to an anonymous principal. result_receipts.authenticate_receipt compares the presented capability hash before reading the body; capture_result rechecks it, the expiry, and exact-byte idempotency. This is a write-only callback for one receipt minted by prepare_receipt while the session, turn, dispatch owner, and guest are active. Capturing is evidence only; later consumption revalidates current executor ownership. The callback exposes no result-reading operation and does not use the authentik Principal.

Identity and attribution stay separate from permission to act. Middleware logs the resolved subject, kind, authority, and groups; report and decision records may persist subject and authority. Those facts explain who was observed. The surface or resource owner still has to check groups, scopes, an edge-verified claim, a receipt capability, target ownership, and state preconditions as appropriate. There is no central policy engine that turns every representable Principal field into authority.

Historical #4942 explains the origin of the auth package, but history is not deployment evidence. Authority.DELEGATED, Principal.actor, and Principal.scope can represent future authority; the default resolver constructs only Authority.STANDING, with an empty actor chain, or the local anonymous principal. No code here mints a delegation, verifies a monolith-issued delegation, brokers GitHub mutations, or performs a session-token swap.

Those proposals remain gated. #4943 requires a named caller whose existing authentication is inadequate. #4944 and #4946 require a concrete agent operation, exact issue and verb set, and demonstrated permission gap before delegation or a broker is selected. #4945 proceeds only if that selected design needs a new credential-delivery path. These closed gates may be reopened with their evidence; they are not accepted future architecture. The independent result-scoping work in #4569 and existing factory-control enforcement in #5789 do not wait for this record or for delegation. (see: /projects/monolith/core/mcp_policy.py) (see: /projects/monolith/app/agents_main.py) (see: /projects/monolith/factory/access.py) (see: /projects/monolith/factory/execution/result_receipts_router.py) (see: /projects/monolith/factory/execution/result_receipts.py) (tracks: #4940, #4941, #4943, #4944, #4945, #4946)

Why. A federating gateway originally replaced one authentication workaround and local proxy per backend, giving remote agents one catalogue and one place for tool-level entitlement (ADR agents/003). Per-domain MCP servers were rejected because they duplicate deployment and access plumbing, but gateway ACLs cannot decide whether a returned domain object belongs to the caller. The caller's verifiable identity therefore has to reach the monolith, and ADR agents/059 chooses a direct monolith endpoint once authentik federation is ready. The live gateway is an accepted transition cost, including catalogue refresh and a tool-wide failure domain (ADR agents/055, ADR agents/059). Guests got a pruned tier with its own identity instead of a sidecar in the private pod, so that what a guest can write is bounded by a grant set and a source glob rather than by trust in the guest.

8. Public apps

  • /app/hikes: walk catalogue and forecasts, served as cacheable public data. (see: /projects/monolith/hikes/router.py)
  • /app/trips: geotagged trip and photo feed with a private ingest path and public cached reads. (see: /projects/monolith/trips/read_router.py)
  • /app/stars: astronomy forecasts and historical climatology heatmaps. (see: /projects/monolith/stars/router.py)
  • /app/ships: current and historical maritime tracks and heat cells. (see: /projects/monolith/ships/router.py)
  • /app/wc2026: cached dynamic tournament summary and odds data. (see: /projects/monolith/worldcup/router.py)
  • /app/campsites: cached recreation-area search with weather-enriched snapshots. (see: /projects/monolith/campsites/router.py)
  • /app/grimoire: public knowledge graph, entity explorer, adventure index, and open-book reader with extracted illustrations. (see: /projects/monolith/grimoire/router_public.py)
  • /app/notes: Turnstile-gated, rate-limited public chat over the public knowledge graph, with a lazy graph view. (see: /projects/monolith/chat_public/router.py)
  • /app/grimoire/chat: Turnstile-gated Grimoire RAG chat. (see: /projects/monolith/grimoire_chat/router.py)
  • /app/dr-jobs: NHS job search over the scraped listings feed. (see: /projects/monolith/dr_jobs/router.py)
  • /app/llm-leaderboard: model-bench results scatter. (see: /projects/monolith/frontend/src/routes/public/app/llm-leaderboard/+page.svelte)
  • /ember/{bazel,semgrep,postgres,agents,firecracker}: the EmberVM demo pages the synthetic probes in section 9 exercise, which say when a brick was preempted and recovery is under way. (see: /projects/monolith/ember_public/bazel_router.py)
  • /artifact/{id}: agent-built HTML served from object storage in a sandboxed opaque origin. (see: /projects/monolith/artifact/router.py)
  • /blog, /docs, /engineering: the posts, the published repository documents (this file among them), and the engineering index. (see: /projects/monolith/frontend/src/routes/public/docs)
  • /agents (private hostname): the agent console of section 4. (see: /projects/monolith/frontend/src/routes/private/agents/+page.svelte)

Public application responses follow the anonymous cache pattern established by the platform CDN decisions, with route handlers declaring cache and ETag semantics where their data permits it. (see: /projects/platform/ARCHITECTURE.md) (see: /projects/monolith/hikes/router.py)

Why. These data products share the monolith's typed data, composition, frontend, and operational controls, so splitting each into a service would add deployment boundaries without removing their shared storage dependency (ADR services/010). Anonymous reads use explicit origin cache semantics and a hostname-scoped CDN rule; per-path rules were rejected once the public hostname became the stronger routing boundary (ADR platform/002, ADR platform/003). Authenticated or writable features stay on private routes or use narrowly scoped public writers, accepting the public route composition as a load-bearing review and test boundary (ADR security/004).

9. Observability and health

/healthz is process liveness. /api/health executes a database query and all registered module checks concurrently, returns 503 for fatal failures, and reports advisory degradation without changing the 200 response. The public /health route proxies it, hides internal detail strings, lists only failing component names, caches only healthy responses, and returns an uncached 503 when the backend is unhealthy or unreachable. (see: /projects/monolith/framework/core.py) (see: /projects/monolith/frontend/src/routes/public/health/+server.js)

Current fatal components are stars health plus the EmberVM synthetic latches for Bazel, Semgrep, pages, Postgres, and the Codex session. Continuous-delivery health and the drainer's stall signal are advisory latches computed by a private leader and read by both tiers. The combined demo probes run one hourly CronWorkflow and the Codex lane probe runs its own hourly CronWorkflow, each with a 2.5x staleness allowance. Codex is the one automatically scheduled agent probe; the Spark session probe is manual-only with no health component. The CD latch writer defaults to a 300-second interval, while its public reader uses an independent 750-second constant. That is currently the same 2.5x allowance, and changes to either value must keep the pair consistent. (see: /projects/monolith/ember_public/health.py) (see: /projects/monolith/core/platform_probe.py) (see: /projects/monolith/factory/orchestration/health.py) (see: /projects/monolith/chart/values.yaml)

The private profile instruments FastAPI and outbound HTTP calls and exports spans over HTTP/protobuf to the platform's OpenTelemetry collector, which forwards to Honeycomb. The endpoint value must spell out the collector's HTTP port and the full traces path, because the exporter posts to it verbatim, and the service name must stay on the collector's allow list or the spans are dropped after they arrive. The frontend exports nothing, and the demo trace waterfall returns no spans until a span store is connected (#5363). The public stats ticker scrapes the DCGM exporter directly for GPU utilization and frame buffer usage. (see: /projects/monolith/deploy/values.yaml) (see: /projects/monolith/home/observability/traces.py) (see: /projects/monolith/home/observability/stats.py) (see: /projects/platform/ARCHITECTURE.md)

Why. Process liveness alone stayed green through downstream failures, while alert-only maintenance failures could remain unread for days (ADR embervm/031). Composite checks therefore classify immediate serving failures as fatal and slower debt as advisory, rather than making every degraded dependency trigger a rollback. Healthy-only edge caching prevents an old green result from hiding an outage, and the public response strips internal details. The private profile exports its spans through the shared collector, while the public profile accepts less in-process detail to keep the anonymous runtime's dependency and credential surface smaller (ADR security/004).

10. Delivery

The monolith Helm chart is published as an OCI artifact. Every ArgoCD Application for it uses the OCI chart as one source and a Git source, named through a $values reference, as the second source for environment values. Pull requests do not change the chart version or production target revision: after merge, the publishing workflow calculates the next version, publishes the chart, and writes the version back on the main branch. (see: /projects/monolith/chart/Chart.yaml) (see: /bazel/helm/write-back-versions.sh) (see: /projects/platform/ARCHITECTURE.md)

Production is the GKE hub. Its Application layers the shared production values and then a GKE overlay that restores replicas, turns off the Cilium policies and the development refresh, points chat at hosted inference, and exposes the API port on the tailnet. The checked-in targetRevision there is a frozen bootstrap floor that the write-back does not maintain. Kargo owns the live revision for monolith and monolith-public: with no development stage on the hub, production subscribes to the chart warehouse directly, promotes automatically, and gates only on ArgoCD reaching synced and healthy at that moment. Git differing from the live value is correct here rather than a stuck deploy. Read the live one:

kubectl get application monolith -n argocd \
  -o jsonpath='{.spec.sources[0].targetRevision}'

(see: /projects/gke-apps/monolith/application.yaml) (see: /projects/monolith/deploy/values-gke.yaml) (see: /projects/platform/kargo/values.yaml)

The home Application is dormant (backend replicas zero by values commit, WhatsApp off) and keeps its write-back-maintained revision as the revert lever until the home cluster is wiped (#4964); the development overlays are inert until development Applications exist on the hub. (see: /projects/monolith/deploy/application.yaml)

Why. Branch-side version bumps made concurrent pull requests collide and could leave a merged change unpublished, while floating OCI revisions are not supported by ArgoCD's Helm range handling (ADR platform/009, ADR platform/011). Post-merge, commit-derived publishing and monotonic write-back remove shared version lines from feature branches. Kargo owns live promotion so a promotion that cannot come up stops rather than sitting half-rolled, accepting that runtime targetRevision is cluster state and, on the hub, that a point-in-time health wait stands in for a settling window until a development stage exists there (ADR platform/009).

11. Direction

Decided and not yet built, each with the issue that tracks it. A row leaves this table when the work ships or the issue closes without it.

Direction Decided in Tracks State
The orchestration-level graph becomes a mutable DAG dispatched per node, replacing the workflow's Python control flow section 4 #5419 in progress: the factory lane plans its DAG at plan time and runs engine-owned review rounds; legacy swarm runs are still implement_then_review
One factory conductor above every per-run conductor selects and coordinates work under a versioned charter, acting on Joe's behalf The factory conductor #5784 (children #5785, #5787, #5788, #5789, #5804; #5786 closed 2026-09-14) not started
The conductor decides reversible defaults, stages repository-only delivery when live checks are unavailable, and adopts unowned existing PRs; one human-needed notification per task section 11, reversible gates #6208 implemented, awaiting validation
The charter document and its loader govern what the conductor may read, coordinate, or act on The factory conductor #5785 not started
Product-goal records, the factory index, and acceptance evidence drive work selection The factory conductor #5786 not started
Conductor journal, memory assembly, and session lifecycle persist across restarts The factory conductor #5787 not started
One factory conversation spans web, Discord, and voice for the same conductor The factory conductor #5788 not started
Conductor mutations are server-gated by tier, ledgered, and stoppable, with health gates before autonomous action The factory conductor #5789 not started
Shared admission and reservations schedule product-goal work across lanes with downstream backpressure The factory conductor #5804 gated: no oversubscription observed as of 2026-09-18; the lane starvation seen twice (2026-09-13, 2026-09-16) was uncertain-attempt settlement, owned by #6091 and 49b617ef9, not admission. Opens when #5851's baseline or an incident shows concurrent lanes oversubscribing capacity
Execution profiles (implement, investigate, debug, research) become policy dimensions beside difficulty and risk The factory conductor #5784 not started; conductor and planner on Astra since generation 11
Autonomous intake selects bounded issue work and refines or escalates issues that are not delivery-ready section 4 #6002 in progress: policy, intake selection, and the refine path are implemented behind disabled defaults
Per-caller result scoping restricts what each MCP caller's tool calls can return section 7 #4569 not started
The knowledge graph gains project context, runtime evidence and known-work linkage in bounded slices behind a measured retrieval baseline section 6 #5829 (children #6128, #5849, #5571, #5573, #5913, #5926) not started: baseline #6128 first
Evidence-lane follow-ons: deployment observations (#5571), default retrieval scopes with personal opt-in (#5573), distress inbox (#5574), #5569, #5587 section 6 (agents/063) #5527 in progress: slice live 2026-09-03, children open
Grimoire private routes enforce campaign membership instead of the ?as= override section 6 #3959 in progress (PR #6125)
Approved character sheets with DM approval become Grimoire's mechanical source of truth section 6 #3960 proposal, gated on #3959 and a selected table workflow
A Discord-backed session transcript pipeline with ACL-filtered surfacing and reviewed replays section 6 #3961 proposal, gated on #3959, a selected table workflow and ASR capacity (#5461)
Discord chat automation gets persisted scheduled tasks, configurable message triggers, and per-channel memory notes Decision history (services/002) #3901 in progress: configurable message triggers are implemented; persisted scheduled tasks and per-channel memory notes remain
Grimoire post-extraction quality passes (evidence-grounded stat verification, review-approved alias merges) ship Decision history (services/014) #3912 not started
Public chat retention and takedown purge tooling ships Decision history (security/005) #3899 not started
A role-separated GitHub App review gate lets swarm merge autonomously Decision history (agents/027) #3835 not started

Reversible gates

Why. The escalation cards on 2026-09-18 repeatedly asked for reversible parameter choices, permission to separate repository work from live acceptance, and permission to reuse an existing delivery branch. These questions consumed operator attention without supplying authority the repository work needed. The conductor now chooses non-spending defaults with a reason on the issue, stages repository work and keeps operational checks on an open issue, and adopts the existing PR before any node starts. Spending, production deletion, external-account changes and a branch owned by a running task still require a person. Independent review and required Linux CI remain delivery gates. One task-level Discord summary replaces attempt and supervision-cycle noise; the audit retains every observation. This delegates the three reversible gates under #6208 without granting deployment or credential authority.

The factory conductor

One logical conductor per operator sits above every per-run conductor and drain lane. It runs in a replaceable fenced EmberVM session, on Astra since policy generation 11, and selects and coordinates work on Joe's behalf under a versioned charter. An escalation means Joe is needed. Its default view answers what advanced, what is running, what needs Joe, and why capacity is idle. Its objective is to keep all safely available subscription quota doing useful work toward agreed product goals while preserving platform health, interactive responsiveness, and the capacity to finish and verify what it started. The monolith owns the durable state and the deterministic enforcement; planning and execution stay in EmberVM guests. Tracked by #5784 and the six sub-issues in the table below; the full design text is on #5784.

Aspect Decision Owner
Entry point One factory conversation across web, Discord, and voice, integrating the existing launcher (#4781) on the private agents page; no choice of model, session, run, or conductor is required #5788
Responsiveness Input is persisted and acknowledged before model execution; one durable ordered queue feeds one executor, operator input ahead of coalesced background events; pause and stop are authenticated deterministic controls that bypass the model; status reads from records when the model is busy #5787
Charter A versioned document under projects/monolith, changed by reviewed PR, with a stable identifier per clause; goals in priority order are platform stability, useful product progress, efficient quota use; the loader, prompt, admission layer and ledger expose the same version hash; a retained prompt or a replaced session cannot preserve revoked authority; the ask-first set is charter or quota-policy changes, security-relevant changes, anything needing a new or amended decision record, and irreversible or production-impacting actions outside the allowed GitOps operations, and no clause authorises a cluster write the GitOps invariant forbids #5785
Work selection Durable records link product outcome to milestone, task, and acceptance evidence; a completion claim or a session count does not satisfy acceptance; every selected task advances an agreed goal or has a bounded maintenance allocation #5786
Factory index A materialised join of goal and task records, sessions, runs, drainer jobs, issues and PRs, decision rows, distress, platform health, provider quota, and reservations, served as MCP tools (factory_status, task_status, queue_next, overlaps), where queue_next only recommends; every row carries source time and freshness, and cloud sessions are an explicit coverage gap; index rows are untrusted evidence, so every mutation revalidates target state, ownership, health, and reservations at execution time #5786
Scheduling The conductor proposes work through #3840's existing dispatch boundary, with no second queue; the server admits it against every active provider window, observation freshness, in-flight reservations, VM and CI and review throughput, and work-in-progress limits; capacity for Joe's interactive work, the conductor, and the review and correction needed to finish admitted work is reserved first, and unknown capacity is not headroom; reservations are atomic and shared across lanes; a reset permits only a probe #5804
Continuity Journal plus KG recall (#5680); pending actions, task focus, and operator decisions are persisted before summarisation; one active executor per operator with ownership fencing, and replacement reconciles action IDs and reservations before retrying #5787
Authority Read, coordinate, act, and escalate are separately gated tiers; every mutation validates principal, target, tier, charter version, executor ownership, and control generation on the server; every tool call has a ledger row with intent persisted before the side effect; timestamped health inputs drive normal, reduced-concurrency, admissions-paused, and recovering behaviour #5789
Stop Pause admissions, pause task, and stop factory have distinct semantics; stop fences the conductor and its descendants, cancels queued starts, and survives any wakeup or restart; an unreachable worker stays explicitly unconfirmed #5789

Rollout priority is quota routing and observation recovery (#5753, #5803), the message board (#5704), goal records and the read-only conductor, coordinate with its controls, then act. Read-only index and recommendation work does not wait for the mutation gates. Act waits on the conductor's own budget line, shared reservations across every covered dispatch path, the health gates, and an STPA governance pass. Reserve values, estimation margins, freshness and probe intervals, health thresholds, work-in-progress limits, and receipt and control latency targets are chosen and validated by the implementing issues before the matching autonomous control is enabled. Fable is evaluated against recorded coordination correctness, unnecessary escalations, latency, and cost as an escalation model, not a lane.

Why. A per-run conductor owns one DAG and cannot pick priorities or reconcile overlap across local and cloud Claude sessions, Codex workers, drainer lanes, and swarm runs, so every added run adds coordination work for Joe. Quota observation and per-lane routing (#5752, #5753) see headroom but do not decide what should fill it, and independent dispatchers reading the same headroom oversubscribe it while starving review and CI. The node-boundary budget check that closed #4784 is a building block for this, since it bounds one run only. A second chat box beside the launcher was rejected because it makes the operator choose an execution abstraction before describing the work. A single unbounded conversation carrying work and controls was rejected because a long turn delays a correction; controls therefore bypass the model. Destroying the conductor session as the stop was rejected because it leaves delegated work and uncertain side effects unaccounted for. The design amends ADR agents/062 by adding coordination above individual runs and ADR agents/060 by resolving delegated decisions inside the charter before anything reaches Joe. Shared reservations were deferred because the only capacity failures recorded so far were permits held by attempts whose guest had died, which a reservation ledger would have held just as long; settling uncertain outcomes quickly buys more throughput than scheduling policy until an oversubscription is actually observed. The conductor and per-task planner run on Astra since policy generation 11 (2026-09-13), replacing the Opus default: Astra plans DAGs with fewer correction rounds at lower cost per turn, and the Opus weekly window is reserved for independent review, which is the scarce input. Fable stays an escalation model, not a lane. Implementer and reviewer pairs follow the difficulty profile on #5784 (hard engineering Astra/Fable, medium Sol/Opus, easy Luna/Terra) as trial configuration, judged on correction rounds and cost per merged PR.


12. Decision history

The ADR files were removed on 2026-09-06 (#4667); git log -- docs/decisions/ has the full text. ADR 064 never reached main; its text is a comment on #5784.

The status text below starts with each historical record. Accepted, shipped and Accepted, not shipped are reconciliation annotations based on the cited current code. A Draft, code exists row records a header and implementation mismatch without silently rewriting the decision record.

Services

ADR Title Status Disposition
services/001 Discord History Backfill Accepted, shipped (see: /projects/monolith/chat/backfill.py) deleted
services/002 Discord Chat Automation & Reactivity Draft, superseded in practice: reminders, ambient engagement, thread sessions and changelog posts shipped through chat/001, agents/035 and agents/043; #3901 to #3904 hold the remainder deleted
services/003 Knowledge Search Overlay Deprecated deleted
services/004 D&D Sourcebook Knowledge Graph Integration Deprecated deleted
services/005 Repo Markdown Knowledge Graph Sync via OCI Volume Implemented (see: /projects/monolith/knowledge/repo_docs.py) deleted
services/006 Stars grid ingest via a dedicated job writing to the monolith DB Accepted, shipped (see: /projects/monolith/stars/grid_gen) deleted
services/007 Stars quality model and heatmap Accepted, shipped (see: /projects/monolith/stars/models.py) deleted
services/008 Stars live and historical heatmaps via month-bucketed accumulate-at-drop Superseded in part by 009 deleted
services/009 Stars historical climatology backfill from ERA5 Accepted, shipped (see: /projects/monolith/stars/router.py) deleted
services/010 FastMonolith Modular Framework Accepted, shipped (see: /projects/monolith/framework/core.py) deleted
services/011 Grimoire Hot-Tier Schema on Postgres Accepted, shipped (see: /projects/monolith/grimoire/models.py) deleted
services/012 Grimoire Postgres-First, Loom-Shaped Accepted, shipped (see: /projects/monolith/grimoire/ingest.py) deleted
services/013 Grimoire Knowledge Audiences: Corpus-Derived Character Knowledge as Compiled Grants Accepted, shipped (see: /projects/monolith/grimoire/visibility.py) deleted
services/014 Grimoire post-extraction quality passes (stat verifier, alias merge) Accepted, not shipped (#3912, #3913) deleted

Chat

ADR Title Status Disposition
chat/001 Ambient Feedback Loop and Directive Autopilot Accepted, shipped (see: /projects/monolith/chat/ambient_analysis.py) deleted
chat/002 Structured, Scope-Locked Channel-History Query for the Chat Agent Accepted, shipped (see: /projects/monolith/chat/channel_data.py) deleted
chat/003 Trust & Safety Safeguards (Ledger, Lockout, Shadow Forest) Accepted, shipped (see: /projects/monolith/chat/safeguards.py) deleted

Security

ADR Title Status Disposition
security/004 Public Read-Only Service Isolation Accepted, shipped: separate pruned binary, public_reader on the CNPG standby, ingress and egress policy where the CRDs exist (see: /projects/monolith-public/chart/values.yaml). Private-tier default-deny egress still open (#5143) deleted
security/005 Public Chat Adversarial Hardening Implemented: Turnstile, per-session and global admission limits, single-host egress allow (see: /projects/monolith/chat_public/limits.py). Retention and takedown still open (#3899) deleted
006 Crossing (moving) on friends.jomcgi.dev as a second authentik lane Accepted, shipped (see: /projects/monolith/chart/templates/httproute-friends.yaml) deleted

Platform

ADR Title Status Disposition
platform/001 Migrate Obsidian Vault into Monolith with TigerFS Superseded by 006; Obsidian, TigerFS and Qdrant are gone deleted
platform/006 Decommission Obsidian, Postgres as the Body of Record Accepted, shipped: knowledge.notes.content in CNPG is the body of record (see: /projects/monolith/chart/migrations/20260408000000_knowledge_schema.sql) deleted
platform/008 Monolith Module Boundaries Accepted, shipped: <domain>/api.py is the only cross-domain import seam (see: /projects/monolith/import_boundaries_test.py) deleted

Agents

ADR Title Status Disposition
agents/001 Self-Hosted Autonomous Coding Agents via OpenHands Superseded by 004 deleted
002 Kubernetes-Native OpenHands Sandboxes via agent-sandbox Superseded by 004 deleted
003 MCP Context Forge as Agent Tool Gateway Superseded by 020, deployment remains live (see: /projects/mcp/ARCHITECTURE.md) deleted
agents/004 Autonomous Coding Agents Deprecated deleted
005 Role-Based MCP Access Deprecated deleted
006 OIDC Authentication for MCP Gateway Superseded by 011 deleted
agents/007 Agent Run Orchestration Service Implemented, later execution path evolved (see: /projects/monolith/agent_sessions) deleted
agents/008 Cluster Patrol Loop Resilience Accepted, not shipped in the current monolith deleted
agents/009 Automated Test Generation Bots Deprecated deleted
agents/010 Recipe-Driven Agent Registry Deprecated deleted
011 Cloudflare Managed OAuth for MCP Gateway Deprecated deleted
agents/012 Knowledge Gardener Two-Tier Model Pipeline Superseded by 013 deleted
agents/013 Knowledge Gardener Gemma4-Only Pipeline Deprecated; the routines it fed were retired 2026-09-04 deleted
014 AX + Substrate as the Agent Runtime Substrate Deprecated deleted
agents/015 Temporal as the Orchestration Substrate Deprecated deleted
agents/016 NATS as the Canonical Event Stream Accepted, built then retired (see: /projects/monolith/factory/execution/router.py) deleted
agents/017 Domain Event Schema and Tombstone Semantics Accepted, not shipped deleted
agents/018 Event-Driven Gardener Triggering via Remote-Trigger Runs Deprecated; the routines are retired deleted
019 Substrate Executor Interface and AgentWorkflow over Argo Accepted, not shipped as designed (see: /projects/monolith/factory/orchestration/workflows.py) deleted
020 Deprecate Context Forge, Serve MCP Directly from the Monolith Superseded by 059 deleted
021 Discord-Triggered AgentWorkflow with a Fast Hosted Model and Snapshot/Resume Draft, evolved into agent sessions (see: /projects/monolith/agent_sessions) deleted
022 Firecracker Snapshot/Restore Controller for AgentWorkflow (FC-Direct) Accepted, shipped through EmberVM (see: /projects/embervm/ARCHITECTURE.md) deleted
023 Egress Secret Proxy for Agent Sandboxes (Placeholder Substitution) Draft, generic egress shipped deleted
agents/024 Productive Discord Agent, Hosted-Model Tiers, and Isolated Live Artifacts Draft, shipped in evolved form: hosted tiers became the model families, the artifact store serves /artifact/{id} (see: /projects/monolith/artifact/router.py); the goosecracker agent it rode on is retired deleted
025 Three-Layer Agent Stack (firecracker-substrate, goosecracker, discord-agent) Draft, evolved into EmberVM deleted
026 Fast MicroVM Cold Starts and Stateful Artifact Iteration Accepted, shipped through EmberVM deleted
027 Agent GitHub App Roles: Implementer and Reviewer Draft, not shipped: no agent-review/gate exists, which is why swarm stops short of merging (#3835) deleted
028 Elastic Agent-MicroVM Capacity and State-Preserving Reclaim Draft header, shipped through EmberVM deleted
agents/029 Discord Bot Feature ACL (per-server command and repo grants) Accepted, shipped (see: /projects/monolith/chat/acl.py) deleted
030 fc-invoke, a Single Configurable Surface for Running Workloads in Firecracker Draft, evolved into EmberVM deleted
031 Control-Plane / Data-Plane Split for the Agent Substrate (cluster + node) Accepted, shipped through EmberVM deleted
032 Warm-Snapshot Bazel Worker as an MCP Tool Surface Draft, partially shipped as an EmberVM demo (see: /projects/monolith/ember_public/bazel_router.py) deleted
033 Golden-Template Distribution via Daemon-Pulled OCI Artifacts Accepted, shipped through EmberVM deleted
034 Per-Tier MCP Tool ACLs for Goosecracker Guests Draft, not shipped (#3838) deleted
agents/035 Discord Multiplayer Agent UX (Ambient Classifier, Thread Sessions, Live Task Checklist) Accepted, shipped (see: /projects/monolith/chat/bot.py) deleted
agents/036 Orchestrator Brief-Compiler Tier via OpenRouter Accepted, shipped (see: /projects/monolith/chat/orchestrator_client.py) deleted
037 Label-Driven Firecracker Node Enrollment Accepted, shipped through EmberVM deleted
agents/038 Autonomous Work Queue with Capability-Tier Routing and Reviewer-Verdict Feedback Accepted, shipped in bounded form (see: /projects/monolith/swarm); its work_queue table was never built, routine_jobs took the role (#3839) deleted
agents/039 WhatsApp Channel Gateway (whatsmeow) for the Household Agent Accepted, shipped, parked during the cutover window (see: /projects/monolith/whatsapp) deleted
040 Caller-Provided Context Injection for Agent Guests (/injected-context/) Draft, not shipped deleted
041 Hot Git Mirror for goosecracker Agent Workspaces Draft, not shipped deleted
042 Agent MCP v1 Follow-ons and Deferred Self-Improvement Loop Accepted, partially shipped (see: /projects/monolith/agent/mcp.py) (#3844) deleted
agents/043 Ambient Assistant Parity (Channel-Data Tools, Reminders, Directive Evolution) Accepted, shipped (see: /projects/monolith/chat/reminders.py) deleted
044 Code Executor Sandbox Workload and Self-Describing Guest Runtimes Accepted, shipped (see: /projects/monolith/sandbox/mcp.py) deleted
045 FaaS on the fc-invoke Sandbox Runtime Accepted, shipped through EmberVM (see: /projects/monolith/faas) deleted
046 MMDS for Dynamic Per-Workload Guest Env Accepted, shipped as the EmberVM metadata seam deleted
047 Per-Principal Egress Credentials and the Broker Identity Envelope Draft, not shipped deleted
048 Codex Subscription OAuth, a Single-Owner Token Broker for Guest Turns Accepted, shipped (see: /projects/embervm/tokenbroker) deleted
agents/049 Turn-Granular, Poll-Shaped Agent Session UI on Durable Postgres, Not a Live Event Stream Accepted, shipped (see: /projects/monolith/factory/execution/router.py) deleted
050 Workspace Hydration for Agent Sessions from the Hot Git Mirror Accepted, hydration shipped with direct HTTPS instead of the mirror deleted
051 Mid-Turn Session Progress Pushed by the Guest, Not Polled Through the Control Plane Accepted, shipped (see: /projects/monolith/factory/execution/progress_ingest.py) deleted
052 Cross-Family Agent Session Handoff via a Luna-Compiled Brief Accepted, not shipped: no handoff code exists (#4350) deleted
agents/053 Swarm Development, Bounded Conductor Orchestration for Feature-Scale Agent DAGs Draft, partially shipped (plan pin, recording schema, implement_then_review) and amended by 062, whose engine rewrite is pending (#4584, #5419) deleted
agents/054 The Run View: Pinned Plans, Epistemic Registers, and Recorded-Not-Inferred Data Draft header, shipped in the console redesign (see: /projects/monolith/factory/orchestration/view.py); #4625 closed deleted
055 Tool-Mediated GitHub Access for Agent Principals Superseded by 059 deleted
agents/056 Agent-Authored Walkthroughs: the Diff is Fact, the Points are Testimony Draft header, shipped (see: /projects/monolith/factory/orchestration/walkthrough_composer.py); #4600 and #4614 closed deleted
057 Per-Language Sandbox Guests and the Retirement of Sessioned Execution Draft header, shipped deleted
agents/058 The Voice Companion: a Ledger-First Screen the Conversation Drives, Never Load-Bearing Accepted, shipped (see: /projects/monolith/factory/execution/voice.py) deleted
059 Authentik Federates MCP Identity; the Monolith Serves MCP Directly Draft, not shipped (#3832, #3833) deleted
agents/060 Escalation as a Pause, Not a Return, With a Decision Row Accepted, shipped: decision row, endpoint, agent_run_decide, console buttons (see: /projects/monolith/factory/orchestration/router.py) deleted
agents/061 The Qwen Work-Queue Drainer Accepted, shipped; the drained sessions moved from qwen to Luna on 2026-09-01 and the qwen-drain kind is a legacy name (see: /projects/monolith/factory/orchestration/drainer.py) deleted
agents/062 A Mutable DAG Owned by an Opus Conductor, Executed Per-Node in VMs Accepted, partially shipped: typed artifact channel, rationale records, and the factory lane's plan-time graph with per-node dispatch exist; legacy swarm runs still execute implement_then_review (#5419, #4781) deleted
agents/063 The Factory Knowledge Graph Learns From Evidence Lanes Accepted, shipped with its 2026-09-03 amendment: schema, kg-drain lane, feeds, report tools, recall (see: /projects/monolith/knowledge/extraction.py); #5527 tracks the program deleted
064 A factory conductor coordinating conductors under a charter Proposed in PR #5792, never merged; rolled into the Direction subsection above on 2026-09-06 and the full text preserved on #5784 not merged