Greentic · update-channel · runbook

A running environment updates itself from a signed plan

Start an environment. Start the plan server. Publish an update on the server. The running client discovers it, verifies it against a key it already trusts, and stages it — nobody pushed anything at it. One command then swaps traffic onto the new version, under a snapshot it can roll back to.

Every command on this page was run end-to-end and its real output captured. Random ids (plan ids, key ids) and bundle digests will differ on your machine — the shape will not. Nothing here touches your real ~/.greentic.

01 What the update channel is

A way to move a deployed environment from one version to the next without anyone logging into it.

The operator signs a description of the desired next state of an environment — a greentic.env-manifest.v1 — and publishes it. Environments that have been pointed at that publisher poll it, fetch the plan, check the signature against their own trust root, and stage it. Applying the staged plan is a deliberate, separate act.

Three properties fall out of that shape, and this demo shows all three:

PropertyHow it is enforced
The server cannot forge an update The plan is DSSE-signed by the operator key. The server stores bytes and answers GETs; it holds no signing key. A tampered plan fails verification at the client.
The client cannot be pushed a downgrade Each plan carries a monotonic sequence. The signed plan — not the server's advisory metadata — is what the anti-rollback check reads.
Discovery is not authority Discovering a plan stages it. Nothing changes what is serving traffic until op updates apply runs, under a snapshot with automatic rollback.

02 Where the trust comes from

One key, minted locally, seeded into the environment, never sent anywhere.

op env init mints an operator key at ~/.greentic/operator/key.pem and seeds its public half into the environment's trust root. That is the whole ceremony.

op updates plan-build signs with that private key. The runtime's poll loop verifies against that public key before it will stage anything. So the same key_id appears three times in the run below — in env init, in plan-build, and implicitly in every successful verification. If they ever diverge, staging fails closed.

No enrollment, no certificate authority. Earlier iterations of this demo enrolled the environment with a Cert-CA to get an mTLS identity. The pull path does not need it: plan reads are anonymous, and the plan's own signature — not the transport — is what makes it trustworthy. mTLS on the read leg is transport hardening, not the trust anchor.

03 Prerequisites

Two released binaries, one you build, and the two sample packs already in the workspace.

Why 1.1.9 specifically. op updates plan-build first shipped in 1.1.8, but it hard-required a --binary artifact — it could only mint plans for the binary self-update path. 1.1.9 lets it sign a content-only plan from a --target-file, which is what an ordinary environment update is. On 1.1.8 you get invalid argument: at least one --binary is required. 1.1.9 is on crates.io and has release binaries for Linux (x64, arm64), Apple Silicon and Windows (x64, arm64), so binstall resolves it without a source build.
No helper binaries. No signer, no CLI shim, no static-file stand-in for the plan server. Everything below is a released binary or the real server.

04 Run it in one command

The script below is the seven steps, verbatim, with the waits and the cleanup wired up.

bash
cd my_demos/plan-server-update-demo
./demo.sh
⤓ Download demo.sh self-contained; ./demo.sh build builds the bundles, ./demo.sh clean removes all demo-local state
./demo.sh — real output, trimmed
══ check the released binaries ══
   greentic-deployer 1.1.9
   greentic-start 1.1.8
   greentic-updates-server …/target/debug/greentic-updates-server

══ 1. start the environment (running v1) ══
▸ op env init — creates the env, mints the operator key, seeds the trust root
    outcome=created   operator_key_id=6ce4f50a04f814236b16ebbf9d0faae3
▸ op env apply — deploy v1
   v1 is live:
    sequence 1   sha256:7c70a41c63058e8b8c60ccb4fbf09ead6344f5a9b3fdcc5193a64f90144d7a6a   [app-webchat-bot]

══ 2. start the plan server ══
   listening on http://127.0.0.1:3140

══ 3. sign the update ══
▸ op updates plan-build — DSSE-sign the v2 env-manifest with the operator key
    plan_id=01KX041WERDPHJA2PQT1N14JQQ  sequence=2  key_id=6ce4f50a04f814236b16ebbf9d0faae3
   the signing key never leaves this machine

══ 4. publish the update on the server ══
    http 201
    {"sequence":2,"status":"stored"}

══ 5. point the environment at the plan server ══
    enabled=True  on_notify=stage  poll_interval_secs=60
    plan_endpoint=http://127.0.0.1:3140/v1/environments/local/plan

══ 6. the client updates itself ══
▸ waiting for the runtime to fetch → DSSE-verify → stage…
    poll: env `local` plan sequence 2 -> 200 (staged)
   the RUNTIME pulled the plan, verified it against the trust root, and staged it
  nobody pushed it — the server only answered a GET

══ 7. swap traffic onto v2 ══
    ensure-environment   no-op    exists (public_base_url unchanged)
    deploy-bundle        update   digest sha256:7c70a41c → sha256:e2d75f53 (blue-green re-stage)
    stage=applied  verify.failures=[]
   v2 is live:
    sequence 2   sha256:e2d75f538dbd0e01761e616f780f4daa0a43f67b90e3b2b28fbe0e8cc5ad9d17   [app-webchat-bot, messaging-telegram]
▸ the plan is terminal — re-applying it is refused, not repeated
    invalid argument: plan `01KX041WERDPHJA2PQT1N14JQQ` is `applied`, not `staged`; only a staged plan can be applied

✓ sequence 1 → 2, digest changed, messaging-telegram joined webchat.

Note the operator_key_id from step 1 and the key_id from step 3: the same key. The runtime verified against it in step 6 without ever being handed it.

demo.sh — full source
demo.sh
#!/usr/bin/env bash
# ===========================================================================
# plan-server-update-demo
#
#   Start an environment → start the plan server → publish an update on the
#   server → the running client picks it up, verifies it, and stages it by
#   itself → one command swaps traffic onto the new version.
#
# Everything runs on RELEASED binaries. No signer helper, no CLI shim, no
# enrollment step:
#
#   greentic-deployer >= 1.1.9   cargo binstall greentic-deployer@1.1.9
#   greentic-start    >= 1.1.8   cargo binstall greentic-start@1.1.8
#   greentic-updates-server      built from source (private repo, no release)
#
# The trust chain is real end to end: `op env init` mints the operator key and
# seeds it into the environment's trust root; `op updates plan-build` DSSE-signs
# the plan with that key; the runtime's poll loop verifies the signature against
# the trust root before it will stage anything. The plan server never holds a
# signing key — it only stores and serves what the operator signed.
#
# Usage:
#   ./demo.sh              build the bundles (if missing), then run
#   ./demo.sh build        build the two bundles only
#   ./demo.sh clean        remove demo-local state (home/, served/, build/)
#
# For the Phase-7d binary self-update (the runtime swapping its own binary),
# see ./demo-binary-selfupdate.sh — that one needs source builds.
# ===========================================================================
set -euo pipefail

HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WS="$(cd "$HERE/../.." && pwd)"                       # greenticai workspace root

# --- binaries -------------------------------------------------------------
DEP="${DEP:-greentic-deployer}"                       # >= 1.1.9 (the `op` verbs)
RUNTIME="${RUNTIME:-greentic-start}"                  # >= 1.1.8 (the poll loop)
# The update server is a private greentic-biz repo with no crates.io publish and
# no GitHub release, so it is the one thing you build yourself.
SRV_DIR="${SRV_DIR:-$WS/GREENTIC-BIZ/greentic-updates-server}"
SRV_BIN="${SRV_BIN:-$SRV_DIR/target/debug/greentic-updates-server}"
# Bundle authoring CLI, only used by `demo.sh build`.
GTC="${GTC:-gtc}"

# --- content: two bundles built from existing sample packs ----------------
APP_PACK="$WS/my_demos/webchat-gui-default/packs/app-webchat-bot.gtpack"
TG_PACK="$WS/my_demos/webchat-gui-default/packs/messaging-telegram.gtpack"

# --- demo-local, isolated state (never touches your real ~/.greentic) -----
HOME_DIR="$HERE/home"                                 # becomes $HOME for every command below
BUILD_DIR="$HERE/build"                               # v1/v2 bundles land here
SERVED_DIR="$HERE/served"                             # plan.json + plan.json.sig
ENV_ID="local"
BUNDLE_ID="updatedemo"
SRV_PORT="${SRV_PORT:-3140}"
UPLOAD_TOKEN="${UPLOAD_TOKEN:-demo-upload-token}"     # X-Api-Key for POST …/plan
SRV_URL="http://127.0.0.1:$SRV_PORT"
PLAN_ENDPOINT="$SRV_URL/v1/environments/$ENV_ID/plan" # the poll loop appends /meta and .sig

# --- presentation ---------------------------------------------------------
if [ -t 1 ]; then BOLD=$'\e[1m'; GRN=$'\e[32m'; CYN=$'\e[36m'; RED=$'\e[31m'; DIM=$'\e[2m'; Z=$'\e[0m'
else BOLD= GRN= CYN= RED= DIM= Z=; fi
say()   { printf '%s\n' "${CYN}${BOLD}▸ $*${Z}"; }
ok()    { printf '%s\n' "${GRN}  ✓ $*${Z}"; }
info()  { printf '%s\n' "  $*"; }
dim()   { printf '%s\n' "${DIM}  $*${Z}"; }
die()   { printf '%s\n' "${RED}✗ $*${Z}" >&2; exit 1; }
step()  { printf '\n%s\n' "${BOLD}══ $* ══${Z}"; }

# --- background processes: PID-based cleanup, never `pkill -f` ------------
PIDS=()
cleanup() { local p; for p in "${PIDS[@]:-}"; do kill "$p" 2>/dev/null || true; done; }
start_bg() { local log="$1"; shift; "$@" >"$log" 2>&1 & PIDS+=($!); }

# Every command runs against the isolated home. `op` is a subcommand of the
# deployer binary: `greentic-deployer op <noun> <verb>`.
op() { env HOME="$HOME_DIR" "$DEP" op "$@"; }

# Print the revision currently serving 100% of traffic.
show_live() {
  op env show "$ENV_ID" | python3 -c '
import json, sys
env = json.load(sys.stdin)["result"]["environment"]
live = {e["revision_id"] for s in env["traffic_splits"] for e in s["entries"] if e["weight_bps"] > 0}
for r in env["revisions"]:
    if r["revision_id"] in live:
        packs = ", ".join(p["pack_id"] for p in r["pack_list"])
        print("    sequence %s   %s   [%s]" % (r["sequence"], r["bundle_digest"], packs))
'
}

need() { command -v "$1" >/dev/null 2>&1 || die "$1 not on PATH${2:+ — $2}"; }

version_at_least() { # version_at_least <binary> <min>
  local have; have=$("$1" --version 2>/dev/null | awk '{print $NF}')
  python3 - "$have" "$2" <<'PY' || die "$1 is $have, need >= $2"
import sys
def parts(v): return [int(x) for x in v.split("-")[0].split(".")]
sys.exit(0 if parts(sys.argv[1]) >= parts(sys.argv[2]) else 1)
PY
  printf '%s\n' "${GRN}  ✓ $1 $have${Z}"
}

# ===========================================================================
cmd_clean() {
  rm -rf "$HOME_DIR" "$SERVED_DIR" "$BUILD_DIR"
  rm -f "$HERE"/.server.log "$HERE"/.runtime.log
  ok "removed home/, served/, build/ and logs"
}

# ===========================================================================
cmd_build() {
  step "build the content: two versions of one bundle"
  [ -f "$APP_PACK" ] || die "missing sample pack $APP_PACK"
  [ -f "$TG_PACK" ]  || die "missing sample pack $TG_PACK"
  need "$GTC" "set GTC=<path> to a gtc binary that can build bundles"

  mkdir -p "$BUILD_DIR"
  say "v1 — webchat only (the version we update FROM)"
  local r1="$BUILD_DIR/ws-v1"; rm -rf "$r1"; mkdir -p "$r1/packs"
  "$GTC" dev bundle init "$r1" --bundle-name "$BUNDLE_ID" --bundle-id "$BUNDLE_ID" --execute >/dev/null
  cp "$APP_PACK" "$r1/packs/"
  "$GTC" dev bundle add app-pack "$APP_PACK" --root "$r1" --execute >/dev/null
  "$GTC" dev bundle build --root "$r1" --output "$BUILD_DIR/v1.gtbundle" >/dev/null
  ok "v1.gtbundle  sha256:$(sha256sum "$BUILD_DIR/v1.gtbundle" | cut -c1-16)…"

  say "v2 — webchat + telegram (the version we update TO)"
  local r2="$BUILD_DIR/ws-v2"; rm -rf "$r2"; mkdir -p "$r2/packs"
  "$GTC" dev bundle init "$r2" --bundle-name "$BUNDLE_ID" --bundle-id "$BUNDLE_ID" --execute >/dev/null
  cp "$APP_PACK" "$TG_PACK" "$r2/packs/"
  "$GTC" dev bundle add app-pack "$APP_PACK" --root "$r2" --execute >/dev/null
  "$GTC" dev bundle add app-pack "$TG_PACK"  --root "$r2" --execute >/dev/null
  "$GTC" dev bundle build --root "$r2" --output "$BUILD_DIR/v2.gtbundle" >/dev/null
  ok "v2.gtbundle  sha256:$(sha256sum "$BUILD_DIR/v2.gtbundle" | cut -c1-16)…"

  [ "$(sha256sum "$BUILD_DIR/v1.gtbundle" | cut -d' ' -f1)" \
    != "$(sha256sum "$BUILD_DIR/v2.gtbundle" | cut -d' ' -f1)" ] \
    || die "v1 and v2 have the same digest — nothing to update to"
}

# Write the two env-manifests for THIS machine. `bundle_path` must be absolute:
# a relative path resolves against $HOME, not the working directory.
write_manifests() {
  local v; for v in v1 v2; do
    python3 - "$BUILD_DIR/$v.gtbundle" "$ENV_ID" "$BUNDLE_ID" "$HERE/$v-manifest.json" <<'PY'
import hashlib, json, sys
path, env_id, bundle_id, out = sys.argv[1:5]
digest = hashlib.sha256(open(path, "rb").read()).hexdigest()
json.dump({"schema": "greentic.env-manifest.v1",
           "environment": {"id": env_id},
           "bundles": [{"bundle_id": bundle_id,
                        "bundle_path": path,
                        "bundle_digest": "sha256:" + digest}]},
          open(out, "w"))
PY
  done
}

# ===========================================================================
cmd_run() {
  { [ -f "$BUILD_DIR/v1.gtbundle" ] && [ -f "$BUILD_DIR/v2.gtbundle" ]; } || cmd_build

  step "check the released binaries"
  need python3; need curl; need sha256sum
  need "$DEP" "cargo binstall greentic-deployer@1.1.9"
  need "$RUNTIME" "cargo binstall greentic-start@1.1.8"
  version_at_least "$DEP" 1.1.9
  version_at_least "$RUNTIME" 1.1.8
  [ -x "$SRV_BIN" ] || die "update server not built: $SRV_BIN
    cd $SRV_DIR && cargo build"
  ok "greentic-updates-server $SRV_BIN"

  rm -rf "$HOME_DIR" "$SERVED_DIR"
  mkdir -p "$HOME_DIR" "$SERVED_DIR"
  write_manifests
  trap cleanup EXIT

  # -------------------------------------------------------------------------
  step "1. start the environment (running v1)"
  say "op env init — creates the env, mints the operator key, seeds the trust root"
  op env init | python3 -c '
import json, sys
r = json.load(sys.stdin)["result"]
print("    outcome=%s   operator_key_id=%s" % (r["outcome"], r["trust_root"]["operator_key_id"]))'
  say "op env apply — deploy v1"
  # The verb streams its convergence plan to stderr; the JSON outcome to stdout.
  op env apply --answers "$HERE/v1-manifest.json" >/dev/null 2>&1
  ok "v1 is live:"
  show_live

  # -------------------------------------------------------------------------
  step "2. start the plan server"
  say "greentic-updates-server — stores and serves signed plans; never signs"
  start_bg "$HERE/.server.log" env PORT="$SRV_PORT" PLAN_UPLOAD_TOKEN="$UPLOAD_TOKEN" "$SRV_BIN"
  local i
  for i in $(seq 1 40); do curl -sf "$SRV_URL/healthz" >/dev/null 2>&1 && break; sleep 0.25; done
  curl -sf "$SRV_URL/healthz" >/dev/null || { tail -5 "$HERE/.server.log" >&2; die "server did not come up"; }
  ok "listening on $SRV_URL"

  # -------------------------------------------------------------------------
  step "3. sign the update"
  say "op updates plan-build — DSSE-sign the v2 env-manifest with the operator key"
  op updates plan-build "$ENV_ID" --sequence 2 --target-file "$HERE/v2-manifest.json" --out-dir "$SERVED_DIR" \
    | python3 -c '
import json, sys
r = json.load(sys.stdin)["result"]
print("    plan_id=%s  sequence=%s  key_id=%s" % (r["plan_id"], r["sequence"], r["key_id"]))'
  local PLAN_ID
  PLAN_ID=$(python3 -c 'import json,sys;print(json.load(open(sys.argv[1]))["plan_id"])' "$SERVED_DIR/plan.json")
  dim "wrote $SERVED_DIR/plan.json + plan.json.sig"
  ok "the signing key never leaves this machine"

  # -------------------------------------------------------------------------
  step "4. publish the update on the server"
  say "POST /v1/environments — register the env (plan endpoints 404 until it exists)"
  curl -sS -X POST "$SRV_URL/v1/environments" -H 'Content-Type: application/json' \
    -d "{\"id\":\"$ENV_ID\",\"tenant\":\"acme\",\"env\":\"$ENV_ID\"}" -o /dev/null -w '    http %{http_code}\n'
  say "POST …/plan — upload the signed plan (X-Api-Key; sequence is strictly monotonic)"
  python3 - "$SERVED_DIR/plan.json" "$SERVED_DIR/plan.json.sig" "$SERVED_DIR/upload.json" <<'PY'
import base64, hashlib, json, sys
plan = open(sys.argv[1], "rb").read()
env  = open(sys.argv[2], "rb").read()
json.dump({"plan_bytes_b64": base64.b64encode(plan).decode(),
           "envelope_bytes_b64": base64.b64encode(env).decode(),
           "sequence": 2,
           "plan_sha256": hashlib.sha256(plan).hexdigest()}, open(sys.argv[3], "w"))
PY
  local upload
  upload=$(curl -sS -X POST "$PLAN_ENDPOINT" -H 'Content-Type: application/json' -H "X-Api-Key: $UPLOAD_TOKEN" \
    --data-binary "@$SERVED_DIR/upload.json")
  printf '    %s\n' "$upload"
  # A server built from a branch without the plan store answers 404 here. Fail
  # now, with the cause, rather than at the poll loop three steps later.
  python3 -c 'import json,sys;sys.exit(0 if json.loads(sys.argv[1]).get("status")=="stored" else 1)' "$upload" 2>/dev/null \
    || die "the plan upload was refused — is $SRV_DIR checked out on \`main\` (the branch with the plan store) and rebuilt?"

  # -------------------------------------------------------------------------
  step "5. point the environment at the plan server"
  say "op updates config-set — the channel is deny-by-default until this runs"
  op updates config-set "$ENV_ID" --enabled true --on-notify stage \
    --poll-interval-secs 60 --plan-endpoint "$PLAN_ENDPOINT" \
    | python3 -c '
import json, sys
r = json.load(sys.stdin)["result"]
print("    enabled=%s  on_notify=%s  poll_interval_secs=%s" % (r["enabled"], r["on_notify"], r["poll_interval_secs"]))
print("    plan_endpoint=%s" % r["plan_endpoint"])'

  # -------------------------------------------------------------------------
  step "6. the client updates itself"
  say "greentic-start start — serves v1; its poll loop discovers the plan on its own"
  start_bg "$HERE/.runtime.log" env HOME="$HOME_DIR" "$RUNTIME" start --env "$ENV_ID" --no-browser
  say "waiting for the runtime to fetch → DSSE-verify → stage…"
  local staged="" st
  for i in $(seq 1 120); do
    st="$HOME_DIR/.greentic/updates/$ENV_ID/$PLAN_ID/state.json"
    if [ -f "$st" ] && python3 -c 'import json,sys;sys.exit(0 if json.load(open(sys.argv[1]))["stage"]=="staged" else 1)' "$st" 2>/dev/null
    then staged=1; break; fi
    sleep 1
  done
  [ -n "$staged" ] || { tail -25 "$HERE/.runtime.log" >&2; die "the poll loop did not stage the plan (see .runtime.log)"; }
  grep -m1 "update-poll: env" "$HERE/.runtime.log" | sed 's/^.*update-poll:/    poll:/'
  ok "the RUNTIME pulled the plan, verified it against the trust root, and staged it"
  dim "nobody pushed it — the server only answered a GET"

  # -------------------------------------------------------------------------
  step "7. swap traffic onto v2"
  say "op updates apply — snapshot, converge, verify; roll back on failure"
  op updates apply "$ENV_ID" --plan-id "$PLAN_ID" 2>/dev/null | python3 -c '
import json, sys
r = json.load(sys.stdin)["result"]
for s in r["apply_result"]["steps"]:
    print("    %-20s %-8s %s" % (s["kind"], s["action"], s["detail"]))
print("    stage=%s  verify.failures=%s" % (r["stage"], r["apply_result"]["verify"]["failures"]))'
  ok "v2 is live:"
  show_live

  say "the plan is terminal — re-applying it is refused, not repeated"
  # A failing verb writes its error envelope to stderr and exits non-zero, so
  # capture it rather than piping (`set -o pipefail` would abort the script).
  local refusal
  refusal=$(op updates apply "$ENV_ID" --plan-id "$PLAN_ID" 2>&1 >/dev/null || true)
  printf '%s' "$refusal" | python3 -c 'import json,sys;print("    " + json.load(sys.stdin)["error"]["message"])'

  printf '\n%s\n' "${GRN}${BOLD}✓ sequence 1 → 2, digest changed, messaging-telegram joined webchat.${Z}"
  info "state lives under $HOME_DIR — nothing touched your real ~/.greentic"
}

case "${1:-run}" in
  run)   cmd_run ;;
  build) cmd_build ;;
  clean) cmd_clean ;;
  *)     die "usage: $0 [run|build|clean]" ;;
esac

05 The seven steps, by hand

Every command runs against an isolated $HOME, so nothing here can disturb a real environment. op is a subcommand of the deployer binary: greentic-deployer op <noun> <verb>.

bash — set this once
DEMO=~/greentic/my_demos/plan-server-update-demo
DEMO_HOME=$DEMO/home                            # the isolated $HOME the environment lives under
SERVED=$DEMO/served                             # plan.json + plan.json.sig
SRV=http://127.0.0.1:3140                       # the plan server
PLAN_ENDPOINT=$SRV/v1/environments/local/plan   # the poll loop appends /meta and .sig
TOKEN=demo-upload-token                         # X-Api-Key for uploads
OP=greentic-deployer                            # commands below read `$OP op …`
mkdir -p $DEMO_HOME $SERVED
  1. Start the environment on v1

    env init creates the environment, mints the operator key, and seeds the trust root — one command, no separate bootstrap. env apply then converges it onto the v1 manifest.

    bash
    env HOME=$DEMO_HOME $OP op env init
    env HOME=$DEMO_HOME $OP op env apply --answers $DEMO/v1-manifest.json
    env init — result
    {"noun":"env","op":"init","result":{
      "outcome":"created",
      "trust_root":{"environment_id":"local","trusted_key_count":1,
        "operator_key_id":"6ce4f50a04f814236b16ebbf9d0faae3"}}}
    ✓ Confirm v1 is the live version

    Read the environment back and pick the revision serving 100% of traffic.

    bash
    env HOME=$DEMO_HOME $OP op env show local | jq '.result.environment
      | (.traffic_splits[0].entries[] | select(.weight_bps > 0) | .revision_id) as $live
      | .revisions[] | select(.revision_id == $live)
      | {sequence, bundle_digest, packs: [.pack_list[].pack_id]}'
    env show — filtered
    {
      "sequence": 1,
      "bundle_digest": "sha256:7c70a41c63058e8b8c60ccb4fbf09ead6344f5a9b3fdcc5193a64f90144d7a6a",
      "packs": [
        "app-webchat-bot"
      ]
    }
  2. Start the plan server

    It is a Cert-CA, an environment registry, and a plan store in one binary. Here only the plan store matters. PLAN_UPLOAD_TOKEN is deny-by-default: leave it unset and uploads are refused outright.

    bash
    PORT=3140 PLAN_UPLOAD_TOKEN=$TOKEN \
      ~/greentic/GREENTIC-BIZ/greentic-updates-server/target/debug/greentic-updates-server &
    curl -sf $SRV/healthz && echo " server up"
  3. Sign the update

    The v2 manifest goes under a DSSE signature made with the operator key from step 1. plan-build refuses to sign with a key the environment's trust root does not already contain, and it self-verifies the envelope before writing it out.

    bash
    env HOME=$DEMO_HOME $OP op updates plan-build local \
      --sequence 2 \
      --target-file $DEMO/v2-manifest.json \
      --out-dir $SERVED
    plan-build — result
    {"noun":"updates","op":"plan-build","result":{
      "environment_id":"local",
      "plan_id":"01KX041WERDPHJA2PQT1N14JQQ",
      "sequence":2,
      "key_id":"6ce4f50a04f814236b16ebbf9d0faae3",
      "plan_sha256":"15f0dd78c41b7c81…",
      "plan_path":"…/served/plan.json",
      "sig_path":"…/served/plan.json.sig"}}

    The private key never leaves the machine, and it is never given to the server.

  4. Publish the plan on the server

    Register the environment first — the plan endpoints 404 until it exists. The upload is credentialed with X-Api-Key; reads are anonymous by design, because the plan's signature is what protects it. The server checks plan_sha256 and rejects any sequence that is not strictly greater than the last one it stored.

    bash
    curl -sS -X POST $SRV/v1/environments -H 'Content-Type: application/json' \
      -d '{"id":"local","tenant":"acme","env":"local"}'
    
    python3 - $SERVED/plan.json $SERVED/plan.json.sig $SERVED/upload.json <<'PY'
    import base64, hashlib, json, sys
    plan = open(sys.argv[1], "rb").read(); env = open(sys.argv[2], "rb").read()
    json.dump({"plan_bytes_b64": base64.b64encode(plan).decode(),
               "envelope_bytes_b64": base64.b64encode(env).decode(),
               "sequence": 2,
               "plan_sha256": hashlib.sha256(plan).hexdigest()}, open(sys.argv[3], "w"))
    PY
    
    curl -sS -X POST $PLAN_ENDPOINT -H 'Content-Type: application/json' \
      -H "X-Api-Key: $TOKEN" --data-binary @$SERVED/upload.json
    server
    {"id":"local", …}                     # 201 Created
    {"sequence":2,"status":"stored"}
  5. Point the environment at the plan server

    The update channel is deny-by-default. Until this runs, the runtime has no endpoint to poll and would ignore an update even if one existed. on-notify=stage means "verify and stage a discovered plan, change nothing".

    bash
    env HOME=$DEMO_HOME $OP op updates config-set local \
      --enabled true \
      --on-notify stage \
      --poll-interval-secs 60 \
      --plan-endpoint $PLAN_ENDPOINT
    config-set — result
    {"enabled":true,"on_notify":"stage","poll_interval_secs":60,
     "plan_endpoint":"http://127.0.0.1:3140/v1/environments/local/plan"}
  6. Start the runtime — it updates itself

    Nothing else happens here. greentic-start serves v1 and spawns a poll loop that fires immediately. It GETs …/plan/meta for the sequence, then …/plan and …/plan.sig, checks the plan digest against the metadata to drop torn reads, verifies the DSSE envelope against the trust root, and stages it.

    bash
    env HOME=$DEMO_HOME greentic-start start --env local --no-browser
    runtime log
    update-poll: env `local` plan sequence 2 -> 200 (staged)

    That line reads oddly on purpose: sequence 2 is the plan the server offered, 200 is the receiver's status code, staged is the outcome. Nobody pushed anything — the server only answered a GET.

    ✓ The plan is staged, not applied
    bash
    cat $DEMO_HOME/.greentic/updates/local/<plan_id>/state.json
    state.json
    {"stage":"staged", …}

    Traffic is still on v1. Discovery is not authority.

  7. Swap traffic onto v2

    The one deliberate act. apply re-verifies the plan, snapshots the environment, converges it through the same env apply pipeline, verifies the result, and restores the snapshot if anything fails.

    bash
    env HOME=$DEMO_HOME $OP op updates apply local --plan-id 01KX041WERDPHJA2PQT1N14JQQ
    apply — result
    plan (2 step(s)):
      ensure-environment  local        no-op   exists (public_base_url unchanged)
      deploy-bundle       updatedemo   update  digest sha256:7c70a41c → sha256:e2d75f53 (blue-green re-stage)
    
    {"stage":"applied","sequence":2,"snapshot_id":"01KX02SHFRF9GWB6M9GY6DQJY7",
     "apply_result":{"changed":1,"no_op":1,"verify":{"checked":1,"failures":[]}}}
    ✓ Confirm v2 is now the live version

    Run the exact same command as in step 1:

    bash
    env HOME=$DEMO_HOME $OP op env show local | jq '…same filter…'
    env show — filtered
    {
      "sequence": 2,
      "bundle_digest": "sha256:e2d75f538dbd0e01761e616f780f4daa0a43f67b90e3b2b28fbe0e8cc5ad9d17",
      "packs": [
        "app-webchat-bot",
        "messaging-telegram"
      ]
    }

    Three things flipped, all proving the update landed: sequence 1 → 2, the digest changed to the one named in the blue-green re-stage line above, and messaging-telegram appeared alongside webchat.

06 What “it worked” looks like

Four signals. If any is missing, the run did not prove what it claims.

SignalWhereWhy it matters
same key_idenv init and plan-build The plan is signed by the key the environment already trusts. A different key would fail closed at plan-build before anything was written.
-> 200 (staged)runtime log The runtime discovered, fetched and verified the plan on its own. No push, no operator shell on the box.
failures: []updates apply Post-apply verification re-read the environment and found it matches the signed target. A non-empty list would have triggered a snapshot restore.
sequence 1 → 2env show The live revision actually moved, and the monotonic sequence advanced — so the same plan cannot be replayed.
Try the negative case. Re-run op updates apply with the same --plan-id. It refuses: plan … is `applied`, not `staged`. Applied plans are terminal.

07 Cleanup

One command. The two background processes stop with the script.

bash
./demo.sh clean
Fully isolated. Everything lived under $DEMO/home/.greentic — your real ~/.greentic was never touched. The server only ever bound 127.0.0.1.

Looking for the runtime swapping its own binary from a digest pinned inside the signed plan? That is demo-binary-selfupdate.sh in the same directory. It needs source builds — the binary self-update landed on greentic-start main after the 1.1.8 release cut.