Greentic · update-channel · runbook

Run it yourself: updating a running environment from a signed plan

A hands-on guide to stand up the plan-server-update demo on your own checkout. A deployed environment enrolls with a certificate authority, pulls a signed update plan from a server, verifies every byte against a key it already trusts, and swaps v1 → v2 under a snapshot it can roll back to. Every command here was run end-to-end and its real output captured — you'll see different random ids, but the same shape.

Sharing this so you can reproduce it. This walks you through standing up the “update from a server” flow yourself. Follow the step-by-step walkthrough to run each op updates verb yourself. Paths assume the Greentic repos sit side-by-side under ~/greentic — edit the first line of each block to match your machine. Ping me if a step doesn’t reproduce.

01 What the update channel does

The updater is a set of operator verbs — today under op updates …, the same tree that becomes gtc op updates … once it ships. It gives a running environment a signed, pull-based path to a new version: enroll once, then fetch → verify → stage → apply, with a snapshot and automatic rollback around the swap.

VerbWhat it does
enrollMint a key + CSR, exchange it at the Cert-CA for a signed client certificate, and persist cert/key/CA into the env secrets backend. Re-running re-enrolls (manual rotation).
statusReport the enrolled certificate's serial and validity window.
getFetch a signed update plan (over the enrolled mTLS channel or from a local file), verify it against the env trust root, and stage it.
applyApply a staged plan: re-verify, snapshot the whole environment, converge via the env-apply pipeline, and roll back on failure.
recoverForce a plan stranded in applying by a crashed applier to failed (audited, needs --force), so a fresh get + apply can proceed.
config-setSet the notification policy: whether the runtime acts on a discovered update (record-only / stage) and the fallback poll interval. Deny-by-default.
config-showShow the notification policy (stored fields + resolved effective values). Read-only.
Two independent trust roots. The Cert-CA secures the transport (who may talk to the channel); the environment's own trust root secures the content (what it will actually apply). A plan is obeyed only if it is signed by a key the environment already holds — never on the strength of the transport alone.

02 What this demo proves

One environment starts on v1 (a WebChat bundle) and is updated to v2 (WebChat + Telegram) entirely through the signed channel:

the story
  operator key (seeded into the env trust root at `env init`)
        | signs
        v
   [ server ] --- plan.json + plan.json.sig ---> [ environment ]
        ^ enroll (CSR -> cert)      get: verify DSSE vs trust root -> stage
        |                           apply: snapshot -> swap v1->v2 -> verify
   [ Cert-CA ]                      v2 running (rollback point kept)

The payoff you can see in the output: the key id that signs the plan, the operator key id the env seeded, and the key id get verifies against are the same value. Nothing outside the env's trust root can push it a new version.

03 What is real, and what is a stand-in

The mechanism is the real, merged code. Two pieces of the "from a server" story are not built yet; each gets the smallest faithful substitute so the rest runs end-to-end today.

PieceStatusIn the demo
Cert-CA / enrollmentrealgreentic-updates-server (Axum) mints a real client cert from a real CA on enroll.
DSSE signing + trust-root verifyrealThe exact greentic-update builder/verifier the runtime links.
get → stage → apply → rollbackrealThe merged op updates verbs, run verbatim.
Plan issuance / servingPhase 6No server mints plans yet, so plan-signer signs the v2 plan with the env's own operator key and a plain python3 -m http.server hands it out.
Auto-notification (webhook + poll)Phase 4The receiver isn't built, so you run get/apply by hand; config-set records the policy it will honor.
Bundle bytes over the wirelocalThe artifact fetcher accepts only https:///oci://, so the plan is zero-artifact and references the local v2.gtbundle — still verified against the signed bundle_digest. The signed plan still comes from the server.

04 Prerequisites

Why build from source? No released binary ships the op updates verbs yet — checked 2026-07-06: stable gtc 1.0.22 and the latest dev build gtc-dev v1.1.28792739341 both reject op updates (the verbs are merged on develop but not yet compiled into a published gtc). The Cert-CA and the plan-signer have no releases either. So you build a tiny examples/op.rs shim inside the deployer checkout that exposes the same OpCommand tree; once the verbs land in a dev build, drop the shim and call gtc-dev op updates … directly — same verbs, same arguments.

05 Setup — build the three helpers + two bundles

One-time. All three cargo builds run under your real $HOME (rustup/cargo need it); only the demo runtime later uses an isolated home.

a. The op shim (the updater verbs)

Drop the shim source (full listing below) into the deployer checkout's examples/, then build it. The binary lands at target/debug/examples/op.

bash
cd ~/greentic/greentic-deployer
mkdir -p examples
# ... write examples/op.rs (see "The helper files" below) ...
cargo build --example op

b. The plan-signer (Phase-6 issuance stand-in)

The plan-signer/ crate already ships in this demo folder. Build it:

bash
cd ~/greentic/my_demos/plan-server-update-demo/plan-signer
cargo build

c. The Cert-CA server

bash
cd ~/greentic/GREENTIC-BIZ/greentic-updates-server
cargo build

d. The two bundles (v1 and v2)

Two distinct versions of one bundle id (updatedemo): v1 is WebChat only; v2 adds the Telegram pack. Built from sample packs already in the workspace with gtc-dev.

bash
DEMO=~/greentic/my_demos/plan-server-update-demo
PACKS=~/greentic/my_demos/webchat-gui-default/packs
mkdir -p $DEMO/build

# --- v1: webchat only ---
R1=$DEMO/build/bw-v1; rm -rf $R1; mkdir -p $R1/packs
gtc-dev dev bundle init $R1 --bundle-name updatedemo --bundle-id updatedemo --execute
gtc-dev dev bundle add app-pack $PACKS/app-webchat-bot.gtpack --root $R1 --execute
gtc-dev dev bundle build --root $R1 --output $DEMO/build/v1.gtbundle

# --- v2: webchat + telegram (the new version) ---
R2=$DEMO/build/bw-v2; rm -rf $R2; mkdir -p $R2/packs
gtc-dev dev bundle init $R2 --bundle-name updatedemo --bundle-id updatedemo --execute
gtc-dev dev bundle add app-pack $PACKS/app-webchat-bot.gtpack --root $R2 --execute
gtc-dev dev bundle add app-pack $PACKS/messaging-telegram.gtpack --root $R2 --execute
gtc-dev dev bundle build --root $R2 --output $DEMO/build/v2.gtbundle

# they must differ, or there is nothing to update to:
sha256sum $DEMO/build/v1.gtbundle $DEMO/build/v2.gtbundle
output (deterministic — same packs, same digests)
0b1627480b96a8032f8c6c8309add51571e1a7075baf2d681ae338bb27e908ba  v1.gtbundle
f80a6e5cc1da764abb4a36af06b92b815309661dc76deca1c1cbee09bca9f991  v2.gtbundle

06 The helper files

Everything the demo needs, as files — grab the zip, or copy each one from the listings below. env.sh/env.fish set the paths and write the manifests; the signer's full source is folded away at the bottom for reference.

⤓ Download demo files (zip) contains env.sh, env.fish, v1-manifest.json, v2-manifest.json, examples/op.rs, plan-signer/Cargo.toml, plan-signer/src/main.rs

env.sh — sets paths + digests, writes the manifests

Source it once after building the bundles. Edit the first line (WS) if your repos aren’t under ~/greentic; it writes v1-manifest.json and v2-manifest.json into the demo folder with the correct absolute bundle paths for your machine.

env.sh
#!/usr/bin/env bash
# plan-server-update-demo — sets the demo paths, computes the bundle digests, and writes
# the two greentic.env-manifest.v1 files for THIS machine. Build the helpers + bundles
# first (see the guide), then:   source env.sh
# Edit WS below if your Greentic repos aren't checked out under ~/greentic.

WS=~/greentic                   # your checkout root — everything below derives from it
DEMO=$WS/my_demos/plan-server-update-demo
DEMO_HOME=$DEMO/home            # the isolated $HOME the environment lives under
BUILD=$DEMO/build
SERVED=$DEMO/served             # plan.json + plan.json.sig the server hands out

OP=$WS/greentic-deployer/target/debug/examples/op
SIGNER=$DEMO/plan-signer/target/debug/plan-signer
CA=$WS/GREENTIC-BIZ/greentic-updates-server/target/debug/greentic-updates-server

mkdir -p $DEMO_HOME $SERVED
V1SHA=$(sha256sum $BUILD/v1.gtbundle | cut -d' ' -f1)
V2SHA=$(sha256sum $BUILD/v2.gtbundle | cut -d' ' -f1)

# target manifests: v1 = deploy FROM, v2 = update TO (absolute bundle_path + digest)
printf '{"schema":"greentic.env-manifest.v1","environment":{"id":"local"},"bundles":[{"bundle_id":"updatedemo","bundle_path":"%s","bundle_digest":"sha256:%s"}]}\n' \
  "$BUILD/v1.gtbundle" "$V1SHA" > "$DEMO/v1-manifest.json"
printf '{"schema":"greentic.env-manifest.v1","environment":{"id":"local"},"bundles":[{"bundle_id":"updatedemo","bundle_path":"%s","bundle_digest":"sha256:%s"}]}\n' \
  "$BUILD/v2.gtbundle" "$V2SHA" > "$DEMO/v2-manifest.json"

echo "env ready — OP=$OP"
echo "wrote $DEMO/v1-manifest.json  (sha256:${V1SHA:0:16}...)"
echo "wrote $DEMO/v2-manifest.json  (sha256:${V2SHA:0:16}...)"
env.fish (same thing, fish syntax)
env.fish
#!/usr/bin/env fish
# plan-server-update-demo — fish version of env.sh. Build helpers + bundles first, then:
#   source env.fish
# Edit WS below if your Greentic repos aren't checked out under ~/greentic.

set -gx WS ~/greentic
set -gx DEMO $WS/my_demos/plan-server-update-demo
set -gx DEMO_HOME $DEMO/home
set -gx BUILD $DEMO/build
set -gx SERVED $DEMO/served

set -gx OP $WS/greentic-deployer/target/debug/examples/op
set -gx SIGNER $DEMO/plan-signer/target/debug/plan-signer
set -gx CA $WS/GREENTIC-BIZ/greentic-updates-server/target/debug/greentic-updates-server

mkdir -p $DEMO_HOME $SERVED
set -gx V1SHA (sha256sum $BUILD/v1.gtbundle | cut -d' ' -f1)
set -gx V2SHA (sha256sum $BUILD/v2.gtbundle | cut -d' ' -f1)

printf '{"schema":"greentic.env-manifest.v1","environment":{"id":"local"},"bundles":[{"bundle_id":"updatedemo","bundle_path":"%s","bundle_digest":"sha256:%s"}]}\n' $BUILD/v1.gtbundle $V1SHA > $DEMO/v1-manifest.json
printf '{"schema":"greentic.env-manifest.v1","environment":{"id":"local"},"bundles":[{"bundle_id":"updatedemo","bundle_path":"%s","bundle_digest":"sha256:%s"}]}\n' $BUILD/v2.gtbundle $V2SHA > $DEMO/v2-manifest.json

echo "env ready — OP=$OP"

v1-manifest.json / v2-manifest.json — the target states

v1 is the state you deploy from, v2 the state you update to. The bundle_digest is fixed (same packs → same bytes); the bundle_path ships as a placeholder because it must be absolute — source env.sh rewrites both for your machine, or edit it by hand. v2 is identical but points at v2.gtbundle with digest sha256:f80a6e5c….

v1-manifest.json
{
  "schema": "greentic.env-manifest.v1",
  "environment": { "id": "local" },
  "bundles": [
    {
      "bundle_id": "updatedemo",
      "bundle_path": "/ABSOLUTE/PATH/TO/my_demos/plan-server-update-demo/build/v1.gtbundle",
      "bundle_digest": "sha256:0b1627480b96a8032f8c6c8309add51571e1a7075baf2d681ae338bb27e908ba"
    }
  ]
}

greentic-deployer/examples/op.rs

The whole op CLI: it parses OpCommand and dispatches. Compiling it inside the deployer checkout gives you the full verb tree from that crate's own coherent dependency graph.

examples/op.rs
//! Demo-only `op` CLI shim (plan-server-update-demo): the full `op` verb tree
//! (env, trust-root, updates, ...) straight from this crate's coherent graph.
use clap::Parser;
use greentic_deployer::cli::dispatch::{OpCommand, dispatch_op};
fn main() {
    if let Err(e) = dispatch_op(OpCommand::parse()) {
        eprintln!("{e}");
        std::process::exit(1);
    }
}

plan-signer/Cargo.toml

A standalone crate (its own [workspace]) so it never perturbs a real repo's lockfile. It pins the same dev-lane greentic-update the runtime resolves, so the signed bytes are byte-identical to what op updates get/apply verify.

plan-signer/Cargo.toml
# Demo-only helper: mints a signed `greentic.update-plan.v1` the way the (not-yet-built)
# Phase-6 plan-issuance server eventually will. Stands in for that server endpoint so the
# `op updates get` → verify → stage → apply chain can be demoed end-to-end today.
#
# Kept OUT of any Greentic workspace on purpose (standalone [workspace]) so it never
# perturbs a real repo's lockfile. It pins the SAME crates.io dev-lane `greentic-update`
# the runtime resolves, so the UpdatePlan struct + DSSE bytes are byte-identical to what
# `op updates get`/`apply` verify.
[package]
name = "plan-signer"
version = "0.0.0"
edition = "2021"
publish = false

[workspace]

[[bin]]
name = "plan-signer"
path = "src/main.rs"

[dependencies]
# Core update-plan builder + DSSE signing (no mtls/enroll features needed to sign).
greentic-update = "0.1"
# TrustRoot / TrustedKey / key_id_for_public_key_pem. Same dev-lane range greentic-update
# depends on, so cargo unifies to ONE distributor-client version (identical signing core).
greentic-distributor-client = ">=1.1.0-dev, <1.2.0-0"
ed25519-dalek = { version = "2", features = ["pkcs8", "pem"] }
chrono = "0.4"
clap = { version = "4", features = ["derive"] }
serde_json = "1"
anyhow = "1"
▸ full plan-signer/src/main.rs (already in the repo; here for completeness)
plan-signer/src/main.rs
//! plan-signer — mint a signed `greentic.update-plan.v1` for the update-from-server demo.
//!
//! This is the Phase-6 plan-issuance server stand-in. A real Greentic update server will
//! eventually author + sign update plans server-side; that endpoint is not built yet, so
//! this helper signs one locally with the environment's own operator key (the key
//! `op env trust-root bootstrap` seeded into the env trust root). Because the plan is
//! signed by a key the env trusts, `op updates get` verifies it end-to-end exactly as it
//! would a server-issued plan.
//!
//! It calls `greentic_update::plan::build_update_plan` — the SAME builder the deployer
//! links — so the signed bytes are byte-identical to what `op updates get`/`apply` verify.
//! `build_update_plan` refuses to sign with an untrusted key and self-verifies the envelope
//! before returning, so a mis-keyed plan fails here rather than at the client.

use anyhow::{Context, Result};
use chrono::{DateTime, Utc};
use clap::Parser;
use ed25519_dalek::pkcs8::spki::der::pem::LineEnding;
use ed25519_dalek::pkcs8::spki::EncodePublicKey;
use ed25519_dalek::pkcs8::DecodePrivateKey;
use ed25519_dalek::SigningKey;
use greentic_distributor_client::signing::{key_id_for_public_key_pem, TrustRoot, TrustedKey};
use greentic_update::plan::{
    build_update_plan, CompatRequirements, OnFail, PlanArtifact, RollbackKind, RollbackPolicy,
    UpdatePlan, UPDATE_PLAN_SCHEMA_V1,
};
use std::path::PathBuf;

#[derive(Parser)]
#[command(about = "Sign a greentic.update-plan.v1 with the env operator key (Phase-6 stand-in)")]
struct Args {
    /// PKCS#8 Ed25519 operator private key PEM — the key `trust-root bootstrap` seeded.
    #[arg(long)]
    operator_key: PathBuf,
    /// Target environment id. Must equal the plan header AND the manifest's environment.id.
    #[arg(long)]
    env_id: String,
    /// Path to the target `greentic.env-manifest.v1` JSON (the desired next state).
    #[arg(long)]
    manifest: PathBuf,
    /// Monotonic plan sequence. Must exceed the env's last-applied sequence (downgrade guard).
    #[arg(long)]
    sequence: u64,
    /// Plan id.
    #[arg(long, default_value = "demo-plan-v2")]
    plan_id: String,
    /// Single-use nonce (echoed by the notify layer for replay rejection).
    #[arg(long, default_value = "demo-nonce-0001")]
    nonce: String,
    /// RFC-3339 creation timestamp (demo passes a fixed value for reproducibility).
    #[arg(long)]
    created_at: String,
    /// Bundle artifact logical name (typically the bundle id). Omit for a zero-artifact
    /// (config-only) plan whose target manifest references no downloadable content.
    #[arg(long)]
    artifact_name: Option<String>,
    /// Bundle artifact version.
    #[arg(long, default_value = "2.0.0")]
    artifact_version: String,
    /// Bundle artifact content digest, `sha256:<hex>`. MUST equal the manifest bundle_digest
    /// and the actual bundle file's digest (env_apply re-verifies applied bytes against it).
    /// Presence of this flag is what makes the plan carry an artifact.
    #[arg(long)]
    artifact_digest: Option<String>,
    /// Where the DistClient fetches the bundle bytes: `file://…`, `oci://…`, or `https://…`.
    #[arg(long)]
    artifact_source: Option<String>,
    /// Output directory; writes `plan.json` + `plan.json.sig`.
    #[arg(long)]
    out_dir: PathBuf,
}

fn main() -> Result<()> {
    let args = Args::parse();

    // Load the operator PKCS#8 private key; derive its SPKI public PEM + canonical key id
    // via the SAME derivation the trust root uses, so the id matches what the env trusts.
    let priv_pem = std::fs::read_to_string(&args.operator_key)
        .with_context(|| format!("read operator key {}", args.operator_key.display()))?;
    let signing_key =
        SigningKey::from_pkcs8_pem(&priv_pem).context("parse operator key as PKCS#8 Ed25519")?;
    let public_pem = signing_key
        .verifying_key()
        .to_public_key_pem(LineEnding::LF)
        .context("encode operator SPKI public key")?;
    let key_id = key_id_for_public_key_pem(&public_pem).context("derive operator key id")?;

    // The env trusts exactly this operator key (bootstrap seeded it), so the build-side
    // trust root is that one key. build_update_plan self-verifies against it before returning.
    let trust = TrustRoot::new(vec![TrustedKey {
        key_id: key_id.clone(),
        public_key_pem: public_pem,
    }]);

    // The plan carries the target env-manifest.v1 as opaque JSON under the signature.
    let target_bytes = std::fs::read(&args.manifest)
        .with_context(|| format!("read target manifest {}", args.manifest.display()))?;
    let target: serde_json::Value =
        serde_json::from_slice(&target_bytes).context("parse target manifest JSON")?;

    let created_at: DateTime<Utc> = DateTime::parse_from_rfc3339(&args.created_at)
        .context("parse --created-at as RFC-3339")?
        .with_timezone(&Utc);

    // An artifact rides in the plan only when a digest is supplied. A config-only plan
    // (no artifact) promotes straight to `staged`; a bundle plan fetches + stages the artifact.
    let artifacts = match args.artifact_digest {
        Some(digest) => vec![PlanArtifact {
            name: args
                .artifact_name
                .context("--artifact-name is required when --artifact-digest is set")?,
            version: args.artifact_version,
            digest,
            source: Some(
                args.artifact_source
                    .context("--artifact-source is required when --artifact-digest is set")?,
            ),
        }],
        None => Vec::new(),
    };

    let plan = UpdatePlan {
        schema: UPDATE_PLAN_SCHEMA_V1.to_string(),
        plan_id: args.plan_id,
        env_id: args.env_id,
        sequence: args.sequence,
        created_at,
        nonce: args.nonce,
        target,
        artifacts,
        compat: CompatRequirements::default(),
        rollback: RollbackPolicy {
            policy: RollbackKind::Auto,
            health_timeout_s: 60,
            on_fail: OnFail::Restore,
        },
    };

    let built = build_update_plan(&plan, &priv_pem, &key_id, &trust)
        .context("build + sign update plan (the operator key must be trusted by the env)")?;

    std::fs::create_dir_all(&args.out_dir)
        .with_context(|| format!("create out dir {}", args.out_dir.display()))?;
    let plan_path = args.out_dir.join("plan.json");
    let sig_path = args.out_dir.join("plan.json.sig");
    std::fs::write(&plan_path, &built.plan_bytes)
        .with_context(|| format!("write {}", plan_path.display()))?;
    std::fs::write(&sig_path, &built.envelope_bytes)
        .with_context(|| format!("write {}", sig_path.display()))?;

    eprintln!("signed plan   -> {}", plan_path.display());
    eprintln!("dsse sidecar  -> {}", sig_path.display());
    eprintln!("plan_sha256   = {}", built.plan_sha256);
    eprintln!("signer key_id = {}", built.key_id);
    Ok(())
}

07 Manual walkthrough — seven steps

Every op and plan-signer invocation runs against an isolated home so it never touches your real ~/.greentic. Source env.sh from the download once (it sets the paths and writes both manifests), then paste each step.

bash — set once
# env.sh is in the download (full text below). It sets every path + digest and writes the
# two manifest files. Edit WS at its top if your repos aren't under ~/greentic, then:
source ~/greentic/my_demos/plan-server-update-demo/env.sh
fish shell? The download also carries env.fishsource …/env.fish instead. Both set the same variables and write the same manifests; every command below then runs as env HOME=$DEMO_HOME $OP …, identical in bash and fish.
  1. Bootstrap the environment — v1 becomes the running version

    env init creates local and seeds the trust root with a fresh operator key (this is the key that will sign update plans). env update sets the tenant that enrollment binds the cert to. env apply deploys the v1 bundle from a greentic.env-manifest.v1 that pins its bundle_digest.

    bash
    env HOME=$DEMO_HOME $OP env init
    env HOME=$DEMO_HOME $OP env update local --name local --tenant-org acme
    
    # env.sh already wrote $DEMO/v1-manifest.json (bundle path + digest); deploy it:
    env HOME=$DEMO_HOME $OP env apply --answers $DEMO/v1-manifest.json
    output (key id is random per run)
    # env init
    {"result":{"environment_id":"local","outcome":"created","pack_count":5,
     "trust_root":{"operator_key_id":"0c320d556ded73b6cf90537414465b80","trusted_key_count":1}}}
    # env apply
    plan (2 step(s)):
      ensure-environment  local       no-op   exists (public_base_url unchanged)
      deploy-bundle       updatedemo  create  sha256:0b162748 -> default binding
    {"result":{"changed":1,"verify":{"checked":1,"failures":[]}}}
  2. Start the Cert-CA (the real enrollment server)

    A genuine Axum CA that generates an in-memory CA on boot and signs CSRs at /v1/enroll. Run it in the background on loopback; capture its PID so you can stop it later.

    bash
    env HOME=$DEMO_HOME PORT=3140 $CA > $DEMO/.ca.log 2>&1 &
    CA_PID=$!
    # wait until it answers
    until curl -sf http://127.0.0.1:3140/healthz >/dev/null; do sleep 0.2; done
    echo "Cert-CA up (pid $CA_PID)"
  3. Enroll the update channel

    enroll mints a key + CSR, exchanges it at the CA for a real signed client certificate, and stores the cert/key/CA into the env secrets backend. status reads the stored identity back.

    bash
    env HOME=$DEMO_HOME $OP updates enroll local --ca-url http://127.0.0.1:3140
    env HOME=$DEMO_HOME $OP updates status local
    output (serial is random per run)
    # enroll — cert stored under secrets://local/acme/_/tls/*
    {"result":{"serial":"747e57d5cb1d5e8f9fcc36c874834fa6","tenant":"acme",
     "not_after":"2027-07-06T17:35:10Z","secrets_kind":"greentic.secrets.dev-store@0.1.0",
     "stored":[{"name":"updater_key"},{"name":"updater_ca"},{"name":"updater_ca_url"},{"name":"updater_cert"}]}}
    # status
    {"result":{"enrolled":true,"tenant":"acme","serial":"747e57d5cb1d5e8f9fcc36c874834fa6"}}
  4. The "server" publishes a signed v2 plan

    plan-signer signs a greentic.update-plan.v1 for v2 using the operator key env init just seeded — so the env will trust it. A zero-artifact plan whose target manifest points at the local v2.gtbundle. Then serve the two files over loopback HTTP.

    bash
    # env.sh already wrote $DEMO/v2-manifest.json; sign it with the env's own operator key
    env HOME=$DEMO_HOME $SIGNER \
      --operator-key $DEMO_HOME/.greentic/operator/key.pem \
      --env-id local --manifest $DEMO/v2-manifest.json \
      --sequence 1 --plan-id updatedemo-v2 --created-at 2026-07-06T11:00:00Z \
      --out-dir $SERVED
    
    # serve plan.json + plan.json.sig
    python3 -m http.server 3150 --directory $SERVED > $DEMO/.plansrv.log 2>&1 &
    SRV_PID=$!
    until curl -sf http://127.0.0.1:3150/plan.json >/dev/null; do sleep 0.2; done
    echo "plan server up (pid $SRV_PID)"
    output — note the signer key id matches step 1's operator key id
    signed plan   -> .../served/plan.json
    dsse sidecar  -> .../served/plan.json.sig
    plan_sha256   = c84cd20f8f719e8bd88bb1d4f7a2ffbb198bf3b69f1cb8ab442a628cef8c05fa
    signer key_id = 0c320d556ded73b6cf90537414465b80   <- same key the env trusts
  5. Set the notification policy

    Records what the runtime will do on a verified notification once the Phase-4 receiver lands: enabled, act by stage, fall back to polling every 900s. Until then you drive get/apply by hand (next two steps).

    bash
    env HOME=$DEMO_HOME $OP updates config-set local \
      --enabled true --on-notify stage --poll-interval-secs 900
    env HOME=$DEMO_HOME $OP updates config-show local
    output
    {"result":{"enabled":true,"on_notify":"stage","poll_interval_secs":900,
     "resolved":{"enabled":true,"on_notify":"stage","poll_interval_secs":900}}}
  6. Pull, verify, and stage — from the server

    get fetches the plan over the enrolled channel, verifies the DSSE signature against the env trust root, and stages it. If the signature or key doesn't match a trusted key, it refuses here — before anything touches staging.

    bash
    env HOME=$DEMO_HOME $OP updates get local \
      --plan-url http://127.0.0.1:3150/plan.json
    output — verified against the seeded key id
    {"result":{"plan_id":"updatedemo-v2","sequence":1,"stage":"staged",
     "verified_key_ids":["0c320d556ded73b6cf90537414465b80"],"artifacts_total":0,
     "plan_sha256":"c84cd20f8f719e8bd88bb1d4f7a2ffbb198bf3b69f1cb8ab442a628cef8c05fa"}}
  7. Apply — blue-green swap v1 → v2

    apply re-verifies the staged plan, snapshots the whole environment, converges to v2 through the env-apply pipeline (re-checking the applied bytes against the signed bundle_digest), and keeps the snapshot as a rollback point. On any convergence or health failure it rolls back automatically.

    bash
    env HOME=$DEMO_HOME $OP updates apply local --plan-id updatedemo-v2
    output — snapshot id is random per run
    plan (2 step(s)):
      ensure-environment  local       no-op   exists (public_base_url unchanged)
      deploy-bundle       updatedemo  update  digest sha256:0b162748 -> sha256:f80a6e5c (blue-green re-stage)
    {"result":{"plan_id":"updatedemo-v2","stage":"applied",
     "snapshot_id":"01KWW7Z34PF96TY9SGZREVJQ1K",
     "apply_result":{"changed":1,"verify":{"checked":1,"failures":[]}}}}

Idempotency & the downgrade guard

Re-running apply is refused — an applied plan is terminal. A plan with a lower sequence than the last applied one is refused the same way (which also blocks replay).

bash
env HOME=$DEMO_HOME $OP updates apply local --plan-id updatedemo-v2
output — correctly refused
{"error":{"kind":"invalid-argument","message":"invalid argument: plan `updatedemo-v2` is `applied`,
 not `staged`; only a staged plan can be applied"}}

08 What "it worked" looks like

Three things across the output prove the whole chain held:

09 Cleanup

Stop the two background servers (the PIDs you captured in steps 2 and 4) and wipe the isolated home.

bash
kill $CA_PID $SRV_PID 2>/dev/null
rm -rf $DEMO_HOME $SERVED $DEMO/v1-manifest.json $DEMO/v2-manifest.json
Fully isolated. Everything lived under $DEMO/home/.greentic — your real ~/.greentic was never touched. The two servers only ever bound 127.0.0.1.