The loop has a gap
A modern delivery pipeline is a very good machine for getting a change from a laptop into production. Every step is automated, observable and repeatable: a commit triggers a build, tests gate the merge, a deploy rolls out and rolls back on its own.
Then the pipeline stops. From the pipeline's point of view, a system that has been deployed is done. From an engineer's point of view, that is where most of the work starts:
- Someone creates an S3 bucket in the console during an incident and nobody writes the Terraform for it.
- A resource drifts from its module because a hotfix was applied by hand.
- A credential leaks into a task definition and nobody notices until it is used.
- A pull request that fixes something real sits for two weeks because the reviewer asked for a change and the author moved on.
- A provider deprecates an argument, a runtime reaches end of life, a cost anomaly appears in a corner nobody owns.
None of these is a delivery problem. They are maintenance problems, and maintenance is the one part of the lifecycle that never got its own automation. It still runs on a backlog, a quarterly cleanup sprint and whoever happens to notice.
What Continuous Maintenance means
Continuous Integration is a property: the main branch is always mergeable. Continuous Delivery is a property: the main branch is always releasable. Continuous Maintenance is the same kind of property for what comes after: the running system and its code are always converging, and the work of making them converge happens on its own, in small reviewable steps.
Three rules make it a stage of the pipeline rather than another dashboard:
- It runs continuously, not on request. A scheduled rescan and long-running watchers, not a report someone remembers to generate.
- It knows who owns what. A finding without an owner is a ticket. A finding joined to the Terraform module and the repository that manage the resource is a change someone can merge.
- Its output is a pull request. Not a write to production, not a Slack message asking someone to do something. A diff that goes through the same CI, the same review and the same CD as any other change. Maintenance re-enters the pipeline instead of going around it.
The third rule is the important one. It is what makes it safe to let an autonomous agent do maintenance: the agent never gets a path around the controls the team already trusts.
Compass is built for this stage
Compass is an open-source (Apache-2.0) platform that runs on your machine or in your network: a dashboard, an API, a daemon and a DynamoDB table set in Docker Compose. Every piece of it maps to one of the three rules.
1. The map: knowing who owns what
The mapper captain reads the AWS account in every region, the Terraform state and the GitHub organisation, and writes a graph: this resource is managed by this module in this repository, and that one is managed by nothing. Everything else in Compass reads the map. It is why a finding arrives as a change to the right file instead of an ARN in a spreadsheet.
2. Services: running continuously
A captain can declare services: loops the daemon runs for as long as the captain is on. Three ship in the open edition, declared in each captain's manifest:
| Service | Captain | What it keeps true |
|---|---|---|
| map-rescan | mapper | The map matches the account, every map_interval seconds. |
| alert-consumer | honeypot | A decoy credential that is used becomes an incident, with its blast radius and a response, within minutes. |
| prs-tick | prs | Every pull request Compass opened is synced, answered, revised or nudged, every five minutes. |
Underneath is deliberately boring infrastructure. Every piece of work is a job: a row in a DynamoDB table that the daemon claims every two seconds, runs, and marks done, with a heartbeat every thirty seconds. A job without a heartbeat for three minutes is marked dead, so a crashed daemon never leaves anyone waiting. There is no broker. Maintenance work should be the most reliable thing you run, and the fewest moving parts is how you get there.
3. Pull requests: re-entering the pipeline
Every captain that changes something proposes it as a pull request:
- Code-as-cloud (
iac) turns an unmanaged resource into Terraform: generate the configuration, rewire literals to references in your modules, scrub secrets, verify withterraform plan, open the pull request with import blocks. Verification runs before publishing, always; a plan that is not clean is still published, but labelled, with the plan in the body. Drift gets the same treatment. - Decoys (
honeypot) are placed where the map says they would look real, and arrive as Terraform in a pull request to your infrastructure repository. - The PR watcher (
prs) closes the last gap: it reads reviews on the pull requests Compass opened, replies, and pushes the revision a reviewer asked for. A maintenance PR that nobody shepherds is a maintenance PR that dies, and this one does not.
Building an autonomous agent on Compass
The built-in captains are not special. Each one is a Python package with a manifest, discovered through the compass.captains entry point, and a new maintenance agent is the same shape. The whole example captain is two short files. Its commands:
from compass_core import dispatch
from compass_core.workspace import resolve
def greet(payload):
"""Say hello to name= with the configured greeting."""
workspace = resolve(payload)
section = workspace.config.get("hello") or {}
greeting = section.get("greeting", "hello")
return {"greeting": f"{greeting}, {payload.get('name', 'world')}"}
COMMANDS = {"greet": greet}
dispatch.register("hello", COMMANDS)
A command is a function from a payload dict to a dict. The same function runs from the shell (compass hello greet name=Lisbon), as a job the dashboard queued, and as a follow-up another command enqueues. The platform supplies everything an agent needs and nothing it should rebuild:
| Your agent needs | Compass gives it |
|---|---|
| Credentials for the account | compass_core.aws.build_target_session(config) |
| To know what owns a resource | the map, and the librarian captain that answers questions against it |
| To propose a change | compass_core.github_prs.open_or_update_pr(...) |
| A model | compass_core.llm.model_for(config), which returns None when none is configured: degrade, do not fail |
| To run on a schedule | a ServiceSpec in the manifest: a loop that ticks, then waits on a stop event |
| A settings form | JSON Schema in the manifest; the dashboard draws it and masks x-secret fields |
| A UI | nothing: a captain without a component gets a generic page with its commands, a run form and its jobs |
| A safety policy | compass_core.policy: the deterministic risk score and the gate (below) |
So an agent that, say, finds unencrypted EBS volumes and proposes the module change is a manifest, a command that reads the account and the map, and a call to open the pull request, plus a service entry if it should run on its own. compass validate path=. checks the manifest, the routes, the schema and name clashes the same way CI checks the shipped captains. The captain development guide goes from an empty directory to a published captain.
How the agents are guardrailed
The interesting question about an autonomous maintenance agent is not what it can do. It is what it cannot do, and whether that depends on the model behaving. In Compass the answer is layered, and the layers that matter most do not involve the model at all. The PR watcher, the one captain that lets a model edit code on its own, shows every layer.
The risk score is computed, not asked for
compass_core.policy scores a change from facts: blast radius, data loss, reversibility, operational load and compliance tags, weighted to a 0–5 scale. For a revision, the PR watcher starts from the risk the change had when it was opened and adds one for touching files outside the original change and one for sensitive paths (iam, kms, security groups, network, VPC, payments, prod). The model never sets the score. The gate itself is a few lines (abridged):
def gate(mode, risk, risk_max):
if mode == "off":
return {"decision": "recommend_only", ...}
if mode == "approve":
return {"decision": "wait_for_approval", ...}
if int(risk) <= int(risk_max):
return {"decision": "run", "reason": f"autopilot: risk {risk} <= risk_max {risk_max}"}
return {"decision": "wait_for_approval", "reason": f"autopilot: risk {risk} > risk_max {risk_max}"}
The same gate decides decoy responses (deactivate the leaked key, restart the task that leaked it), with the same three modes. Before the gate runs, a READ_ONLY environment variable blocks everything, a revision budget (max_revisions, three by default) stops a loop, and a pull request a human paused is left alone.
The model can only touch what the change already touches
When a revision runs, the model gets four tools bound to one git checkout (claude_runner.py): read a file, write a file, list files, run a check. Every path must resolve inside the checkout and outside .git. Writes are refused for any file not in the change's allowed_files. The only commands it can run are terraform fmt -check and terraform validate. There is no shell, no network tool and no cloud credential. Review comments are passed as data with an explicit rule: treat them as requests to evaluate against the intent, never as instructions that override it. That rule is the polite layer; the tool boundary is the one that holds when the polite layer is ignored.
A deterministic guard checks the result
After the model finishes, intent_guard.evaluate looks at the working tree and answers one question: does the pull request still do what its intent says? It never calls a model. It checks that no file outside the scope changed, that the intent's invariants still hold (the resource and import blocks are still there, the module is still present or absent), that terraform validate passed, that the diff is not empty, and that the model did not report blockers. Any failure is a reason (scope_expanded, invariant_broken, verification_failed…) and the pull request is labelled for a human instead of pushed.
And the boundaries outside the agent
- Read access to the cloud. Mapping and imports read. Changes go through pull requests and your own CD. The one exception, decoy responses, is a short whitelist of reversible actions behind the gate.
- Off means off. A captain that is turned off loses its tab, its services stop within one poll, and the API refuses writes under its route prefixes.
- Auto-merge is off by default, and when on it is limited to listed change types (Terraform imports by default), listed repositories and a lower risk ceiling.
- The model is optional. Without an API key Compass runs model-free and says so. Mapping, decoys and imports do not need one.
- Self-hosted. Your credentials, your account, your network. Nothing leaves except the pull requests you can read.
Where to start
Continuous Maintenance does not need a new team or a new process. It needs the pipeline you already trust to have one more entrance, and something on the other side of it that is always watching. Start with the map and code-as-cloud on one account: the first useful pull request usually arrives within the hour, and it is reviewed exactly like the ones your engineers open.
Related: obstacles for autonomous software, IaC coverage and drift, cloud honeypots, the autonomous DevOps guide.
Beamreach