Manual 0.8.0

Updating an instance without a shell

Who this is for: whoever runs an instance somewhere they do not log into, and whoever is building the UI that drives it. You need: an instance whose revetly.toml declares [updater] — declaring the section is what turns the endpoint on, because what it turns on is a container holding a Docker socket. Not here: what an update does to the data (failure-and-staleness.md covers the corpus revision) and the keys themselves (engine-config-surface.md).

./revetly-update moves an instance to a later engine in one command. It assumes a person standing at a shell on the machine Docker runs on.

Some recipients are not that person. They run the stack somewhere they do not log into and drive it from a UI they built themselves. For them the script is not a smaller gesture than a button — it is unreachable, and the instance quietly stops receiving engine updates.

The update endpoint is the seam that UI needs. It runs on your own infrastructure, against your own instance, and it reports to nobody.

What it is

A third service in the same compose stack:

service what it is Docker socket
neo4j the graph no
revetly the engine — stdio MCP, ingest, everything no
revetly-updater the update endpoint yes

The engine service is untouched by any of this. The swap cannot be done by the process being swapped, and the engine must never hold a socket — both are satisfied by a service of its own, running the same engine image under its own command.

The same image rather than a second one: this distribution has no registry, so a second image would be a second tarball in every release, a second digest in the release statement and a second copy of the release reader drifting from the engine's. Under one image the endpoint calls the same plan() the host script calls. The cost is that Graphiti and the provider SDK sit on disk in the privileged container. They never execute there, it holds no key, and no artifact passes through it: everything it reads inside your instance is revetly.toml, the compose file, the corpus stamp under .revetly/, and its own job records.

Turning it on

Absent unless you ask for it — nobody gets a Docker socket in their compose file by default:

docker compose exec revetly revetly init --transport container --updater

A re-run of init is also how an instance that already exists gains it. That run writes three files and never overwrites a fourth:

Then docker compose up -d, and the endpoint answers.

Whether it is set up

Two surfaces answer it, and they answer different halves — because the engine holds no Docker access and never gains any, not even to run a check.

revetly doctor reports one update endpoint line on every container-transport instance, and it is a line whether or not you run one: an instance with no [updater] gets a skip naming the reason, so a reader scanning for the endpoint learns it is off rather than learning nothing.

What it can see, and does:

it warns when because
the token is absent or empty the endpoint refuses to start without one, so the service is declared and nothing is listening. It names the command that writes it and does not write it — a check that creates what it was asked to verify has verified nothing
[updater] is declared and the compose declares no revetly-updater service, or the reverse the two are written together, and a drifted pair points a UI at an endpoint that does not exist
the published port or its exposure disagrees with [updater] the compose file is what the daemon acts on, and a hand edit moves one without the other
the engine builds from source, or no update source is declared the instance is sound and the endpoint will answer — and every update it is asked for will be refused

None of that fails the command. A doctor failure means an ingest would fail, and none of these stop a pass, a capture or a review: the instance works and cannot be updated. The token's presence and file mode are reported and its value never is, so the whole line can be pasted into an issue. The restart policy on the updater service stays on doctor's restart line, which already reports it.

A healthy instance reads:

[PASS] update endpoint token present (mode 0600); published on 127.0.0.1:9713
       (this host only, declared and not confirmed) by docker-compose.yml; the
       engine is pinned to revetly:0.2.1 and updates come from
       https://example.com/handout. What the file asks for: docker-compose.yml
       publishes 127.0.0.1:9713:9713. Nothing here confirms what is published:
       this port is published by another container in this stack, and a process
       can ask the daemon only what is published for its own, and a compose
       override, a `-f`, a `COMPOSE_FILE` or a plain `docker run -p` moves the
       mapping without changing the file this read. what only the socket can
       answer — the daemon, both containers, whether it is listening where
       docker-compose.yml publishes it — is on `GET /status`

The reach in brackets is the compose file's, said as the compose file's. Doctor runs in the engine container, which holds no Docker socket, and this endpoint runs in one of its own — so the one read available to the engine is of its own container and answers nothing about this port. An override file here publishing 9713 on another address is a warning of its own, on a service that holds a Docker socket: docker compose merges it without being asked, so which of the two the stack came up on is not visible from in here.

GET /status answers that other half, from the process that holds the socket, under setup:

"setup": {
  "ok": true,
  "docker": true,
  "containers": {
    "engine":  { "name": "revetly-engine-acme",  "exists": true },
    "updater": { "name": "revetly-updater-acme", "exists": true }
  },
  "listening": {
    "port": 9713, "bind": "0.0.0.0",
    "published": [{ "container_port": 9713, "host_ip": "127.0.0.1", "host_port": 9713 }],
    "matches": true, "reachable_from": "this host only"
  },
  "engine_running": true,
  "findings": []
}

ok is false and findings says what, in sentences: the daemon unreachable, a named container that is not on this daemon, or an endpoint listening on a port the daemon forwards nothing to. listening.port is what this process actually bound, not what was declared — a port already spent by something else is a service that never came up. listening.published and reachable_from are read off the daemon's own bindings for this container, which is why they are here rather than on the doctor line above: this is the process that can ask. reachable_from is one of the same two phrases the doctor line prints, this host only or every interface on this host, so the line and the answer it sends you to name one fact once. What the line adds and this does not is the qualification: the line read a file, and this asked the daemon.

What it serves

Every call needs the token:

TOKEN=$(cat .revetly/updater-token)
curl -sH "Authorization: Bearer $TOKEN" http://127.0.0.1:9713/status

GET /status

What this instance is running, what is published, and what an update would mean. No provider call, and nothing is changed:

POST /update

Returns immediately with a job handle. The body may name a version, and nothing else:

curl -sX POST -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' -d '{"version": "0.2.0"}' \
  http://127.0.0.1:9713/update
key meaning
version install this release, newer or older. Absent: whichever release the source currently names, and only if it is newer.
migrate_ok consent to the corpus migration this release needs. Without it, an update that would need one is refused and nothing is installed.

A request may never name a source, a URL or an image, and a key this endpoint does not read is refused by name rather than ignored. That is the whole security argument: the release source is read from your configuration, and every tarball is verified against the digest in the release statement before it is loaded. With both, a stolen token buys a version change from a folder you already trust. Without either, it buys the host.

GET /jobs, GET /jobs/<id>

The record of an update, phase by phase. It lives on the bind mount at .revetly/updates/, so it survives the swap — including the last phase, which replaces the endpoint that would have been reporting it. GET /jobs is {"jobs": [...]}, newest first.

POST /config/apply, POST /config/restore, GET /config/status

The configuration page's restart, and the way back from it. They live here because this service holds the Docker socket and the engine does not, and because a rollback offered by the engine is no rollback when the engine is what failed to come back. Each takes the same token; a browser may read the answer only from the page's own origin, matched on the port [config] declares.

Both writes carry page, which says whether the configuration page comes back with the engine: supervised where the compose file runs revetly supervise, not needed where nothing was recreated.

What comes back

Two shapes, and a code per outcome. Both are JSON; both are what the engine actually emits rather than a summary of it.

The status object

{
  "instance": "acme",
  "updater": { "version": "0.2.1", "container": "revetly-updater-acme", "docker": true },
  "engine": {
    "version": "0.2.1",
    "container": "revetly-engine-acme",
    "running": true,
    "ingest_pass_running": false,
    "ingest_pass_owner": "",
    "note": ""
  },
  "corpus": { "revision": 1, "stamped_by": "0.2.1", "where": "manifest" },
  "setup": { "ok": true, "docker": true, "containers": {}, "listening": {}, "findings": [] },
  "source": "https://example.com/handout",
  "compose_file": "docker-compose.yml",
  "pinned_image": "revetly:0.2.1",
  "job": null,
  "available": {
    "version": "0.3.0",
    "image": "revetly:0.3.0",
    "direction": "newer",
    "uptodate": false,
    "filename": "revetly-engine-0.3.0-linux-arm64.tar.gz",
    "commit": "0123456789abcdef0123456789abcdef01234567",
    "released": "2026-08-26",
    "notes": "https://example.com/handout/0.3.0/CHANGELOG.md",
    "sha256": "e3b98a4d…"
  },
  "migration": {
    "needed": true,
    "verdict": "migration",
    "detail": "0.3.0 reads corpus revision 2 and up; your data is revision 1. …",
    "from": 1, "to": 2,
    "price": {
      "known": true,
      "artifacts": 298, "model": "gpt-4.1-mini",
      "upload": "64.0MB", "calls": 10430, "duration": "12 min-38 min",
      "note": "an upper bound: every artifact in this corpus. …"
    },
    "consent": "POST /update with {\"migrate_ok\": true} — …"
  },
  "can_update": true,
  "rollback": { "available": false, "why": "no update has been made from here, …" }
}
field what to read off it
updater.docker whether this service can reach the daemon at all. false means every verb below will fail.
engine.version asked of the engine container over an exec, not assumed from this image. engine.running: false is not an error — a stopped engine runs no pass.
engine.ingest_pass_running a POST /update right now would be refused. ingest_pass_owner names who holds it.
engine.note why the two fields above are unknown, when they are. Empty when the engine answered.
corpus.where manifest, record, or baseline when nothing has recorded one. baseline is revision 1, not "unknown".
setup whether this endpoint is set up, as only the socket can say it. See Whether it is set up.
available absent (null) when the stack cannot be updated from here at all — read detail, and see below.
available.direction newer, older, same, or unknown. Anything but newer needs version named in the request.
migration.verdict ok, migration, downgrade, unknown. downgrade has no override.
migration.price present only when needed is true, because computing it reads every source. known: false carries a why. The numbers are a ceiling, not the bill — see the note in the object.
can_update false when already current, or when the stack is one this path cannot move.
job the running job's whole record, or null.
rollback taken from the last completed job. See Rollback.

When the stack cannot be updated from here, available is null, can_update is false, and detail says which of the two it is — no source declared, or an engine built from source. Nothing else in the object changes shape.

The job record

{
  "job_id": "UPD-20260814125208-0e4a94",
  "created_at": "2026-08-14T12:52:08+00:00",
  "updated_at": "2026-08-14T12:52:50+00:00",
  "status": "done",
  "phase": "done",
  "requested": { "version": "", "migrate_ok": false },
  "plan": { "current": "0.2.1", "version": "0.2.2", "image": "revetly:086b", "url": "…", "sha256": "…", "corpus_verdict": "ok", "…": "…" },
  "trail": [
    { "phase": "planning", "at": "…", "note": "reading the release statement at …" },
    { "phase": "checking", "at": "…", "note": "asking whether this release can read your data" },
    { "phase": "downloading", "at": "…", "note": "https://…/revetly-engine-0.2.2-linux-arm64.tar.gz" },
    { "phase": "verifying", "at": "…", "note": "sha256 against the published digest" },
    { "phase": "loading", "at": "…", "note": "loading revetly:086b into the daemon" },
    { "phase": "pinning", "at": "…", "note": "repointing docker-compose.yml at revetly:086b" },
    { "phase": "recreating-engine", "at": "…", "note": "recreating revetly-engine-acme on revetly:086b" },
    { "phase": "replacing-updater", "at": "…", "note": "starting a one-shot swap container …" },
    { "phase": "done", "at": "…", "note": "Revetly 0.2.1 -> 0.2.2. …" }
  ],
  "detail": "Revetly 0.2.1 -> 0.2.2. The engine, and this update service, are both on revetly:086b …",
  "from_version": "0.2.1",
  "to_version": "0.2.2",
  "image": "revetly:086b",
  "migration": { "needed": false, "consented": false, "detail": "…", "ran": false },
  "rollback": { "available": true, "to": "0.2.1", "how": "POST /update with {\"version\": \"0.2.1\"}", "why": "…" },
  "updater": { "container": "revetly-updater-acme", "superseded_as": "revetly-updater-acme-superseded-UPD-…", "payload": {} }
}

status is one of five, and only the first is not final:

status means
running in flight. phase says where.
done finished. detail is the sentence to show.
refused stopped on purpose, and the instance is where it was. detail says why.
failed stopped on something unexpected. detail carries the exception.
stalled it has sat in one phase past the window this instance declares, and nothing is coming back to it. Derived when the job is read, never written — see When a swap does not finish.

phase is one of queued, planning, checking, downloading, verifying, loading, pinning, recreating-engine, migrating, replacing-updater, done, failed, refused — in the order above. trail is every phase this job has entered, appended, never rewritten.

migration.price on a job is the new engine's own dry run, verbatim — the exact figure, unlike the ceiling on /status. migration.output is what the applied migration printed. updater.payload is the create call that restores this container on the new image; it is in the record so a swap that goes wrong is recoverable by hand.

Polling: the endpoint is unreachable for a few seconds during replacing-updater, because that phase replaces the container serving you. Treat a connection error there as "still working" rather than a failure, and read the job again — the record is on the bind mount and the new endpoint finishes it. A few seconds; not indefinitely. See below for how the two are told apart.

When a swap does not finish

replacing-updater is the one phase where nothing else is standing. The swap container renames and stops the old endpoint, then creates and starts the new one. Between those two moves there is no update endpoint on this instance, and the connection error your UI gets is the same connection error a swap that is working normally produces.

Three differences separate them, and none of them needs a shell.

The swap waits for the endpoint it installed. Starting a container is not the same as it staying up, so the swap container does not record success at start. It waits for the replacement to close the job itself — which it can only do from inside its own boot, after the port is bound and the record has been read. A replacement that exits, or that never binds, is not a swap that worked.

Past the window, the endpoint that was superseded goes back. The old container is renamed rather than removed for exactly this. Where the replacement does not answer within swap_timeout, the swap puts it back and starts it, and the job ends failed saying so: this instance keeps an update endpoint one version behind the engine, rather than none at all. Nothing is re-downloaded and nothing is re-attempted — the only container ever put back is the one that was already there.

Every stop is read off the daemon's own answer. A 304 means the container was already stopped; any error the daemon answers to a stop stops the job as an error, whatever digits its text holds, and an error body that is not a JSON object is reported with its status and the body as it came.

A job nobody is coming back to reads as stalled. Where the swap container itself dies mid-move there is no process left to record anything, so the stall is derived when the job is read, from the record and the declared window:

"status": "stalled",
"recorded_status": "running",
"stalled": {
  "phase": "replacing-updater",
  "since": "2026-08-14T12:52:50+00:00",
  "seconds": 431,
  "window": 120.0,
  "why": "this job has been in replacing-updater for 431s, past the 120s window this instance declares. …",
  "recovery": {
    "record": ".revetly/updates/UPD-20260814125208-0e4a94.json",
    "create_call": ".revetly/updates/UPD-20260814125208-0e4a94.json -> updater.payload",
    "container": "revetly-updater-acme",
    "superseded_as": "revetly-updater-acme-superseded-UPD-20260814125208-0e4a94",
    "steps": ["docker start revetly-updater-acme-superseded-UPD-…", "…"]
  }
}

recovery.steps are the commands, in the order to try them: start the container this swap superseded, or bring the stack up on the pin the job already moved, or replay the exact create call the record has been carrying since before the swap. They are a description. Nothing in this engine runs them.

The window is yours:

[updater]
swap_timeout = 120

It governs the phases that are daemon calls and a container boot — queued, planning, checking, pinning, recreating-engine, replacing-updater. The phases that wait on bytes or on a provider — downloading, verifying, loading, migrating — are deliberately outside it, because a window over those would call a slow download a stall. A job that dies inside one of those is caught another way: the updater that comes back finds a job still running that no process is running, and records it rather than leaving it reading as work in flight.

The engine's side of it

When the endpoint is what died, the endpoint cannot be what says so. The records are on the bind mount, and the engine reads them — with no Docker access, which it does not have and does not gain here:

docker compose exec revetly revetly update jobs        # the last one, --all for every one
docker compose exec revetly revetly doctor             # an `update job` line, every run

revetly update jobs prints the recovery under a stalled job. revetly doctor carries one update job line: skipped where no update has been driven from here, a pass naming the last one, and a warning where the last one failed or where one is stalled — with the first recovery step and the path to the record. It warns and never fails, for the reason the endpoint check does: the instance works, the graph is fine, and what is broken is the ability to update it.

Codes

code when
200 a read answered
202 POST /update accepted; the body carries job_id and job
400 the body is not JSON, or names a key this endpoint does not read
401 no token, or the wrong one
404 no such route, or no such job
409 refused, and nothing was changed: a pass is running, a migration needs consent, the release would downgrade the corpus, the release is older and was not named, the compose file is not one Revetly wrote, no source is declared, the stack builds from source, or an update is already running
500 the migration failed, or a container could not be recreated. The detail says what state that leaves
502 the download failed, or the digest did not match. Nothing was installed

A 409 and a refused job say the same at two moments: the endpoint refuses what it can see synchronously, and the job refuses what only turns up once it has read the release. Both leave the instance untouched.

What a job does, in order

Everything that can refuse happens before anything is downloaded; everything reversible happens before the pin moves.

  1. planning — read the release statement from your declared source.
  2. checking — can this release read your corpus? A release that would need a migration is refused unless the request consented to one. A release older than the engine that wrote your data is refused outright: installing it would not read what is there, it would rewrite it in the older shape. Then: is a read pass running? The endpoint asks the engine, which answers from the lock it holds — a lock in the engine's own container that this service cannot see and does not assume. A swap mid-pass would kill it partway through a corpus. Then: can the pin move at all? A compose file Revetly did not write is never rewritten; the job fails with the line to change and nothing is downloaded.
  3. downloading, verifying — sha256 against the published digest, before docker load and not after.
  4. loading — the image into the daemon.
  5. pinning — the image: line of the engine service, and of the updater service. Nothing else in the file.
  6. recreating-engine — the engine container, recreated on the new image with its own binds, environment, labels, ports and networks.
  7. migrating — only when consented to. Priced first and applied second, both inside the engine that was just installed, because that is the engine that declares the steps. The apply takes the pass lock, so a pass the new engine's watch started is waited for, up to 900 s; one still running after that fails this phase with nothing changed. What it cost is in the record.
  8. replacing-updater — a one-shot swap container, started from the image that was just loaded, stops the old endpoint and starts the new one. An updater cannot start its own replacement: the daemon holds the published port for as long as the old container runs. The new endpoint reads the job record on boot, removes the superseded container and closes the job with the outcome the old one could not report — and the swap container waits for exactly that, inside the declared window, before it treats the swap as having worked. See When a swap does not finish.

Because the component that ships with version N installs N+1, an instance several versions behind is carried forward one release at a time with nobody touching the host.

Rollback

Offered where no migration ran: every release stays where it was published, so installing the earlier one by name is the rollback.

curl -sX POST -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' -d '{"version": "0.2.0"}' \
  http://127.0.0.1:9713/update

Stated as unavailable where one did. A migration moves your data into the shape the newer engine writes; installing the older one again would not undo that, it would leave an engine that refuses to read its own corpus. GET /status says which of the two you are in.

What it will not do

Who can reach it

[updater] expose decides, and its default is loopback: the compose file publishes the port on this host's loopback interface only. Reaching it from another host is a tunnel you make, or expose = "all" with the compose port line changed to match — a deliberate choice about a service holding a Docker socket.

[updater]
port = 9713
expose = "loopback"
swap_timeout = 120

The token is the only credential. It is minted once at init and kept, so a restart never invalidates the one your UI is holding. Rotate it by deleting .revetly/updater-token and re-running init --updater.

This is not the hosted control plane

Revetly's own hosted shell — auth, billing, licence issuance, MCP-over-HTTP routing — owns the managed tier, and this is not it. This is the seam a self-hosted recipient's own UI needs against their own instance, on their own infrastructure, with Revetly seeing nothing. It is also, later, what a control plane would drive rather than reinventing.