Continuous Maintenance: the stage after CI/CD

CI made merging boring. CD made deploying boring. Everything that happens to a system after it ships is still tickets, toil and good intentions. Continuous Maintenance is the missing stage: software that watches the running system against its code and sends the difference back as pull requests.

Ilia Dubovskii · October 2026 · 9 min read

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:

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.

The delivery loop with Continuous Maintenance Code flows to CI, CD and Run. Continuous Maintenance observes Run, compares it with the code and sends pull requests back into Code, closing the loop. Codecommit · review CIbuild · test · merge CDrelease · deploy Runthe live account Continuous Maintenance observe → compare with code → propose a pull request drift, unmanagedresources, decoy trips pull requests,review replies
CI and CD move a change forward. Continuous Maintenance watches what is running and feeds the difference back into the same review process, so the loop actually closes.

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:

  1. It runs continuously, not on request. A scheduled rescan and long-running watchers, not a report someone remembers to generate.
  2. 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.
  3. 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:

ServiceCaptainWhat it keeps true
map-rescanmapperThe map matches the account, every map_interval seconds.
alert-consumerhoneypotA decoy credential that is used becomes an incident, with its blast radius and a response, within minutes.
prs-tickprsEvery 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:

How Compass runs maintenance work Services and the dashboard enqueue jobs into the jobs table. The daemon claims a job every two seconds and dispatches it to a captain. Captains read AWS and the map and write pull requests to GitHub. Triggers map-rescan alert-consumer prs-tick dashboard · CLI jobs table DynamoDB rows daemon claims every 2 s dispatch(captain, command, payload) mapperhoneypotiacprsyour captain AWS account (read) · the map GitHub: pull requests
Everything is a job. Services, the dashboard and the CLI all enqueue; one daemon runs; captains read the account and write pull requests. A new captain slots in beside the built-in ones.

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 needsCompass gives it
Credentials for the accountcompass_core.aws.build_target_session(config)
To know what owns a resourcethe map, and the librarian captain that answers questions against it
To propose a changecompass_core.github_prs.open_or_update_pr(...)
A modelcompass_core.llm.model_for(config), which returns None when none is configured: degrade, do not fail
To run on a schedulea ServiceSpec in the manifest: a loop that ticks, then waits on a stop event
A settings formJSON Schema in the manifest; the dashboard draws it and masks x-secret fields
A UInothing: a captain without a component gets a generic page with its commands, a run form and its jobs
A safety policycompass_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.

Guardrails between a review comment and a push A review comment passes a deterministic risk gate, then the model edits inside a tool sandbox, then a deterministic intent guard checks the diff. Only then is one commit pushed. Any failure labels the pull request for a human. Reviewcomment 1 · Risk gate deterministic score mode · risk_max max_revisions · READ_ONLY 2 · Model in a sandbox 4 tools, one checkout writes: allowed files only runs: fmt -check, validate 3 · Intent guard scope · invariants verification · non-empty BLOCKERS line push one commit to the PR branch; CI runs wait for approval · label needs_human nothing is pushed; the reason is recorded on the pull request
Two deterministic checks surround the one probabilistic step. The model can be wrong; it cannot be wrong in a way that reaches the branch unchecked.

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

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.