Handover · develop / main lane · prepared 2026-07-31

The other half of Greentic

Bima — you already know Greentic, so this is not an introduction. It is a map of the lane you have not been working in: deployment, environments, updates and air-gap, serving and webchat routing, secrets, and the release machinery underneath all of it. It covers what exists, why it is built that way, where it collides with your research lane, and what to pick up first.

648
develop-only commits
~0
cherry-equivalent
9
subsystems
14
open threads

Divergence measured 2026-07-31 across the seven repos we both touch: git rev-list --left-right --count origin/research...origin/develop. Re-derive — it moves daily.

Start here

Skip the parts you already know. The sections worth your time in order are: Where our lanes collide (new information, and the thing most likely to cost you a week if you hit it unprepared), Operating rhythms (you have never run the develop/main release lanes), then the subsystem sections as reference when you touch them.

Three decisions waiting for you
  1. Whether the two lanes converge at all, and if so on what. Nothing has ever been cherry-picked across. The deployment contract crate is forked three ways with irreconcilable version schemes. This is the decision everything else hangs on — see Where our lanes collide.
  2. Revive-or-retire on the weekly stable release pipeline. Dead since 2026-05-04. Until it is resolved every stable release is manual and main only advances by hand.
  3. Whether to keep the forward-port cadence. Main-only work diverges from develop daily; the merge gets worse the longer it waits.

Two things that are true of this lane and not of yours

1 · This lane's blast radius is other people's running systems

Your lane's failure mode is mostly a broken build or a bad UI. This lane's failure mode is a customer environment that deploys "successfully" and then silently never replies — which is why so much of the code below is fail-closed, why capability enforcement refuses rather than guesses, and why the update system treats a mirror fetch failure as a hard error instead of degrading. When something here looks defensive to the point of paranoia, that is usually the reason.

2 · Green does not mean correct here

This lane has repeatedly produced passing builds that shipped broken artifacts — an exit-0 bundle build containing zero application packs, a three-month-old artifact served under a fresh tag, a CI gate that had never once executed. The working method section is not process decoration; every rule in it was written after one of those. The Landmines table is the short version.

Where our lanes collide

All figures re-derived 2026-07-31 · re-run before trusting them

This is the section that does not exist anywhere else, and the one worth reading closely. The two lanes have never been integrated, and several places where they touch are load-bearing but unguarded.

The divergence, measured

Reporesearch-onlydevelop-onlycherry-equivalent
greentic-setup191860
greentic-messaging-providers161370
greentic-deployer251190
greentic-start24812
greentic-update1500
greentic-bundle6380
greentic-secrets10370
Total1016482

Two cherry-equivalent commits across seven repos. Nothing has ever been ported between the lanes. Where you find byte-identical files — the deployer's bundle-upload module, for instance — that is pre-fork ancestry, not evidence that porting works. Do not cite it as one.

The hardest merge in the fleet, and it gates every other one

greentic-deploy-spec — the shared deployment contract — is forked three ways, and the version schemes cannot be reconciled by SemVer:

LaneHow it depends on deploy-specCarries
mainembedded path crate, version = "=0.2.6"default_bundle
developembedded path crate, version = ">=0.2.6, <0.3"default_bundle and blob_base_url + insecure_http
researchcrates.io, "=1.3.0-research.1"neither

0.2.x and 1.3.0-research.* are not reconcilable by version range — and the lanes disagree about whether the crate is even embedded or fetched from the registry. Any plan to converge the lanes has to answer this first. The one piece of good news: develop is now a superset of main for both fields, so develop is the natural merge target and research is the single outlier.

Three seams that are load-bearing and unguarded

1 · greentic-admin tracks a floating branch, not a pinned revision

What
greentic-deployer = { git = "…", branch = "research" } in greentic-admin's research Cargo.toml — no rev, no version.
Why it bites
Your console silently tracks whatever research HEAD happens to be, and is missing the 119 develop-only deployer commits. A deployer change on research reaches admin with no version gate; a deployer change on develop never reaches it at all.
Suggested
Pin a rev at minimum, so an admin build is reproducible and a deployer change is a deliberate bump rather than ambient drift.

2 · greentic-designer shells out to this lane's CLI with no version floor

What
src/deps.rs declares REQUIRED_BINS = ["greentic-bundle", "greentic-deployer"] and checks only that each is on PATH and exits successfully from --version. The output is never parsed or compared.
Why it bites
The designer drives greentic-deployer op … env init|show|doctor and deploy. Those verbs have changed materially on develop/main — op env destroy exists only on develop, and the stable binary still returns a stub for it. A user with any deployer version passes the check and then fails at the verb, with an error that points at the wrong layer.
Suggested
Parse --version and enforce a floor. Cheap, and it converts a confusing runtime failure into a clear startup message.

3 · Five repos in this lane have no research branch at all

What
greentic-operator, greentic-trust, greentic-oauth, greentic-gui, greentic-webchat — and greentic (the gtc CLI) — exist only on main/develop. Verified by branch listing on 2026-07-31.
Why it bites
greentic-operator is the component that enforces capabilities. Research execution paths that do not route through it are running without the deny-by-default gate this lane's security model assumes. That may be intentional for a design-time IDE, but it should be a decision rather than an accident.
Suggested
Decide per repo whether research needs a branch or should consume the published stable crate. Do not create branches reflexively — five more two-way forks is the wrong answer.
Aligned by design — please don't "fix" this one

The runner's billing meter is environment-gated, fail-open, defaults to a no-op implementation, and is byte-identical between main and research. That is deliberate: it means SaaS metering cannot break air-gapped operation, where there is nothing to meter and nowhere to report. If a cleanup pass flags it as dead code on one lane, it is not.

What this means practically

  • Do not assume a fix ports. With ~0 cherry-equivalence, every cross-lane change is a fresh implementation, not a cherry-pick. Budget accordingly.
  • Deploy-spec first. Any convergence plan that does not start by resolving the three-way fork will stall on it later, at higher cost.
  • The seams fail quietly. A floating branch pin and a presence-only binary check both produce confusing failures far from their cause. Both are cheap to harden and worth doing before any integration work.

§ Running this lane

Verified on 2026-07-31 · toolchain 1.95.0 · targets host + wasm32-wasip2

Your workspace already works, so this covers only what is different about operating the develop/main lane. Every command below was executed before being written down.

1 · Switch the fleet onto the lane

workspace root bash
./update-repos.sh --clone        # ensure every repo is present and fetched
./switch-to-develop.sh --pull    # move all repos to develop and fast-forward
./switch-to-default.sh           # …and back again

# Dirty trees are skipped by default; --stash if you want them moved anyway.
./switch-to-develop.sh --stash --dry-run
Always check which branch a repo is actually on before reasoning about it. git rev-parse --abbrev-ref HEAD — several foundation repos sit on research or a release branch, and the code differs materially. When grepping for a fact about a lane, grep the ref (git grep … origin/develop), not your worktree.

2 · Toolchain — one rule that will surprise you

canonical: .github/toolchain/{host,wasm}/rust-toolchain.toml bash
./sync-toolchain.sh --check      # fails if any repo has drifted
Never edit rust-toolchain.toml in an individual repo. It is distributed from two canonical files by sync-toolchain.sh, which reads the manifest to decide whether a repo is a host or wasm variant. A per-repo edit is silently reverted on the next sync. (Both variants are on 1.95.0; the wasm variant adds the wasm32-wasip2 target.)

3 · The product toolchain — and why it may downgrade you

the CLI and its sub-binaries bash
cargo binstall gtc
gtc install       # resolves the pinned toolchain manifest and installs exact versions
gtc doctor        # verifies the install
Note that gtc install pins exact versions from an OCI manifest — it will happily downgrade a newer binary you built locally. Publishing a crate does not reach users until that manifest is refreshed. This surprises everyone once.

4 · Verify you match the reference machine

BinaryVersion as of 2026-07-31Role
gtc1.1.13Umbrella CLI; delegates to the rest
greentic-start1.1.37Serving runtime host
greentic-deployer1.1.30Environments, env-packs, updates
greentic-setup1.1.28Guided setup, provider wiring
greentic-operator1.1.6Capability enforcement
greentic-bundle1.1.2Bundle composition
greentic-dev1.1.3Dev tooling, coverage, release publishing

These drift. Re-derive rather than trusting the table — <binary> --version on your own box, and compare against crates.io.

5 · Build and test a repo

inside any repo that has a CI script bash
# The full local gate — fmt, clippy, tests, and repo-specific checks.
bash ci/local_check.sh

# Pack building, from a pack workspace:
greentic-pack build
ci/local_check.sh is the convention in every repo that has a gate. Two historical outliers are now shims that forward to the canonical name. Important: the shared CI workflow does not run this script — it runs fmt, clippy, test and build only. Any repo-specific gate living in local_check.sh is therefore local-only. See Landmines.

Useful workspace scripts

ScriptWhat it does
./update-repos.sh [--clone]Fetch/update (and optionally clone) every repo in both orgs
./sync-toolchain.sh [--check|--dry-run|--direct|--repo N]Distribute the canonical toolchain and rustfmt config; --check is the drift gate
./switch-to-develop.sh [--stash|--pull|--dry-run]Move every repo onto develop
./switch-to-default.shMove every repo back to its default branch
./bump-dev-version.sh --repo|--tier|--cascade|--allBump develop-lane versions; --cascade also updates downstream lockfiles
./clean-gone-branches.shDelete local branches whose remote is gone
./clean-builds.shPrune stale target/ directories
.github/scripts/forward-port.shMerge maindevelop fleet-wide; clean merges push, conflicts open PRs
.github/scripts/onboard-repo.sh NAMEApply every CI convention to a new repo and register it in the manifest
dep-graph/build_graph.pyRegenerate the dependency graph and tiers after any Cargo.toml change

§ What you're absorbing

Built 2026-03-23 → 2026-07-31 · Lane develop / main · Scale 109 repos, 1,688 merged PRs of context

Nine subsystems, listed below in the order they are documented. You will not need most of this on day one — it is reference material for when you touch a given area.

SubsystemWhy you'd open it
01 Fleet CI/CDYour repos already use this substrate — 39 shared reusable workflows. Read when CI behaves unexpectedly.
02 Release & versioningTiers, lanes, binary bifurcation. Read before publishing anything.
03 Core runtimeABI, CBOR canonicalization, capabilities. Overlaps your lane most.
04 Deployment & environmentsThe largest subsystem and the one the designer shells into. Four cloud targets.
05 Updater, trust & air-gapEntirely new to you; built in 31 days. Signed plans, self-update, air-gapped distribution.
06 Messaging & webchatRuntime routing and hot-reload. Newest code in the lane.
07 Instance identity & slotsA cross-repo train that touched the flow model and the WIT contracts — likely relevant to your lane.
08 Secrets & capability securityWorkload identity, trust roots, 14 fixed findings.
09 Demos, E2E & verificationThe integration tests. Also where the "green ≠ correct" lessons come from.

How the work arrived — and why that shape matters to you

The monthly profile below tells you where the codebase is mature and where it is young. April was the CI and toolchain unification, so that substrate is settled and you should not need to touch it. July was the air-gap update train and webchat routing — the newest code, and the least soaked. If something breaks in your first month, that is where to look first.

20001500 1000500 7612029 784745 1305 MarchApril MayJune July CI bootstrapfleet rollout next-gen deployidentity + slots air-gap + webchat
Commits per month. April's spike is the fleet-wide CI and toolchain unification; July's is the air-gap update train plus multi-bundle webchat routing.

The repos you will actually live in

Of 109 repos touched, ten carry most of the weight. Expect to spend the majority of your time in the first two.

RepositoryPRsWhat it is
greentic-deployer250Environments, env-packs, cloud deployers, update publishing
greentic-start140The serving runtime host — routing, webchat, hot-reload
greentic-setup65Guided setup, provider wiring, bundle bootstrap
greentic-dev64Developer tooling, coverage gate
greentic-runner54WASM execution host
greentic-messaging-providers46Slack / Teams / Telegram / WhatsApp / webchat components
greentic-pack43Signed pack format and builder
greentic-operator41Capability enforcement, deny-by-default
greentic-bundle39SquashFS deployment composition
greentic-secrets38Tenant-scoped secret storage and backends
… and 99 further repositories

Set your expectations about the work mix

The commit mix is 1,319 chore · 699 ci · 468 feat · 465 fix · 95 docs · 39 test — roughly two-thirds infrastructure and maintenance rather than features. That is the honest shape of running a 141-repo fleet, and it will be the shape of your weeks too. Feature work here is only affordable because the substrate underneath it is mechanised; budget accordingly, and be suspicious of any plan that assumes otherwise.

§ The stack, from this lane's side

You know this pipeline — it is here for one reason: the layer boundaries are also the ownership boundaries. Your lane lives mostly at the Component and Flow end, authoring what runs. This lane owns the right-hand side: how packs become bundles, how bundles reach an environment, and how the operator enforces what a component declared. The seams in Where our lanes collide all sit at the Pack/Bundle join.

ComponentFlow PackBundle Operator WASM · WASIp2describe() / invoke() .ygtc → CBORrouting graph .gtpack ZIPEd25519 + SBOM .gtbundleSquashFS enforces capsruns revisions composed intopackaged deployed asloaded by Cross-cutting: TenantCtx (tenant / team / user / env) threaded through every call · capabilities declared up front, enforced deny-by-default Wire format: CBOR-first canonical serialization · artifacts Ed25519-signed with SBOM and checksums
The five-layer pipeline, plus the two invariants that cut across all of it.
LayerArtifactGuarantee it carries
ComponentWASM moduleSelf-describing via describe(); sandboxed; capabilities explicit
Flow.ygtc → CBORDeterministic orchestration graph; explicit routing between nodes
Pack.gtpack (ZIP)Signed, versioned; carries flows + components + manifests + SBOM
Bundle.gtbundle (SquashFS)Deployment composition: packs + tenant/team config + access rules
OperatorruntimeLoads bundles, enforces capabilities, executes flows, manages sessions

§ How the fleet is organised

Four artifacts govern the whole fleet. Learn these and most of the machinery becomes legible.

REPO_MANIFEST
.github/toolchain/REPO_MANIFEST.toml — the registry. 85 entries carrying toolchain variant, ordered publish lists, version ranges, pipeline flags, binary classification and cached tiers. Every pipeline script reads it. Note it is not the full inventory — ~59 repos on disk are not onboarded.
dep-graph/
The derived dependency graph. build_graph.py scans every Cargo.toml on disk (705 crates), derives 295 Rust edges plus 52 hand-declared architectural edges, and computes tiers by topological longest path. Tiers are derived, never hand-edited — the tier = N in the manifest is a cached copy. To change a tier, change the dependency and regenerate.
The three lanes
main = stable (1.1.x) · develop = dev (1.2.0-dev.N) · research = experimental (1.3.0-research.N, not yours). Each has its own publish cadence and registry behaviour.
CLAUDE.md / AGENTS.md
The workspace guide at the root, kept current and consulted constantly. AGENTS.md is a symlink to it, so editing one updates both.

Tier structure

Tier 0 has no internal dependencies and publishes first; each tier above waits for the one below. Current range is 0–11 with zero cycles. The shape is worth internalising because it predicts your blast radius: a change to greentic-types (tier 1, 60 consumers) cascades through most of the fleet, while a change to greentic-designer (tier 10) affects nothing downstream.

1544 17129 579 21 012 345 678 910 dependency tier — lower tiers build and publish first
Manifest-onboarded repos per tier. The tier-3 cluster is the core library layer; tiers 8–10 are the leaves you can change freely.
The rule that saves you the most pain

When you change a foundation crate, rebuild downstream in tier order and cargo check each affected repo as you go. A change to greentic-types or greentic-interfaces triggers rebuilds in 30+ repos. Adding a public field to a widely-constructed struct is a fleet break — every struct-literal site downstream stops compiling. That is why several core structs are #[non_exhaustive] with constructors: the break was taken deliberately, once.

§ Operating rhythms

This is the part that is hardest to reconstruct from the code, so it is written out explicitly.

The three release lanes

LaneBranchCadenceStatus
Nightly developdevelop02:00 UTC daily healthy Mostly green since 2026-07-10; 67 repos enrolled
Weekly stablemainMon 06:00 UTC dead Last run 2026-05-04 and it failed; last success 04-16
Researchresearchmanual not yours Hand-dispatched in tier order
Never assume a stable release will happen on its own

Verify before relying on it: gh run list --repo greenticai/.github --workflow weekly-stable-prepare.yml --limit 3. Because this lane is dead, main currently only advances by manual forward-port, and any fix that needs to reach users through a stable release is queued behind reviving it.

How a release actually happens

The signal is the version change in Cargo.toml — there are no release labels or out-of-band metadata. Merging a version bump to main triggers tag creation, which fans out to binary builds and the registry publish. Keep this property; it is what makes Cargo.toml version, git tag and artifact version permanently agree.

Binary bifurcation — read this before publishing anything

On the develop lane, binary-shipping crates publish under a sibling <crate>-dev name so that cargo binstall resolves dev builds without polluting the stable namespace. Libraries keep their canonical name. Crates that are both a library and a binary publish the library under the canonical name as a pre-release and the binary as the -dev sibling. Three manifest fields drive this; get them wrong and the publish either fails or silently lands in the wrong namespace.

Forward-porting maindevelop

.github/scripts/forward-port.sh bash
# Clean merges push straight to develop; conflicts open a PR per repo.
INPUT_DRY_RUN=true bash .github/scripts/forward-port.sh   # preview first
bash .github/scripts/forward-port.sh
Merge these PRs, never squash or rebase them. Squashing destroys ancestry, so the next comparison still shows main ahead and the next wave re-opens the same conflicts. Also: a wave is not only the conflicting PRs — the clean ones need merging too. One wave had 17 conflict PRs resolved while 15 clean PRs sat open, including tier-0 foundations.

What to check, and when

WhenCheck
DailyNightly develop run — a tier-0 failure halts the whole train, so it does not self-heal. Dependency-bot PRs regrow daily and need periodic clearing.
Before merging a PROne adversarial review round, fix what is valid and in scope, simplify once. Do not loop until clean.
After any Cargo.toml dependency changeRe-run dep-graph/build_graph.py, then sync_manifest_tiers.py to propagate.
After a stable releaseForward-port to develop immediately, while the merge is still small.
Periodically./sync-toolchain.sh --check for toolchain drift; verify the :stable toolchain manifest still points at a published release tag.

§ Conventions in this lane

You have your own working practices and this is not an attempt to replace them. But these six rules are load-bearing here, and each was written after a specific incident in this lane — dropping one tends to reproduce the failure that created it. They also explain several architectural choices that otherwise look over-built.

Evidence first
No status claim — "done", "fixed", "passing" — without a fresh verification command, its full output read, and the exit code checked. Green CI is not evidence that a gate ran; the run log has to contain the gate's own output.
Design gate
For non-trivial work, a written plan reviewed before code. The plans/ tree holds ~45 completed design documents; the gate is applied over the spec, not after implementation.
Mutation-prove
Tests written by an agent are not trusted until deliberately mutated code makes them fail — re-run personally, not on the agent's report. A uniform mutation that breaks everything proves nothing; a name-filtered harness that matches no tests exits 0 and looks green.
Fail closed
An absent "no" is never a "yes". Missing credential, unreadable secret, unknown pack kind, declined webchat path — each is an error, never a silent fallback. This rule was written after finding the same class of silent-failure bug ten times over.
Surgical scope
Every changed line traces to the request. Adjacent code is not "improved"; orphaned imports from one's own change are removed, pre-existing dead code is reported but left alone.
Re-derive
Documented counts and versions carry the command that regenerates them. The workspace guide instructs readers to re-derive rather than trust the prose — because the prose goes stale daily and a stale number is worse than no number.

01 Fleet CI/CD & GitHub infrastructure

Peak April 2026 · ~714 CI PRs · 85+ repos, 2 orgs · 39 reusable workflows

The starting condition was 85 repositories with hand-copied CI, six different Rust toolchain versions, no rustfmt.toml anywhere, 52 repos still on master, and 85 of 224 workflow files with no permissions: block. The fix was to stop maintaining CI per repo and start maintaining it once.

What was delivered

  • A reusable-workflow library — 39 workflow_call workflows in the central greenticai/.github repo. Per-repo CI became a 5–15 line caller. pr-ci.yml is the unified PR baseline (fmt + clippy + test + build), variant-dispatched for host vs wasm.
  • Fleet rollout waves, each a single-day mechanical PR blast: dependency review (57 repos), concurrency groups (36), toolchain pin to 1.94.0 (50), Codex fork-PR guard (38), scheduled-failure Slack notifier (76), branch invariants (36), nightly cron consolidation into a single 03:XX UTC window (42), version-tag/publish sync (32).
  • Toolchain standardisation — two canonical rust-toolchain.toml variants in .github/toolchain/, distributed by sync-toolchain.sh with --dry-run / --direct / --check modes. Six fragmented versions consolidated to 1.94.0, then 1.95.0.
  • Default-branch standardisationmastermain across 52 repos, preceded by an audit of 143 open PRs (130 auto-retargeted, 2 closed, 3 with hardcoded refs fixed).
  • Security hardening — explicit permissions: blocks in three batches; CodeQL from zero repos to fleet-wide; fork-PR guards on the Codex remediation workflows that hold secrets; branch-protection rulesets on every repo eligible for them.
  • Onboarding automationonboard-repo.sh applies the entire convention set to a new repo in one command (toolchain, rustfmt, CI callers, dependency review, branch invariants, Slack notifier, MSRV, manifest registration), with a remote mode that needs no local checkout.
  • GitHub App migration — PATs replaced by the greentic-ci App across both orgs, with a per-org token-minting helper.
  • Coverage policy gate — nightly cargo-llvm-cov + nextest driven by a per-repo coverage-policy.json, with distinct exit codes for build failure (4), policy violation (5), bad configuration (2) and cache poisoning (127).

Decisions

Reusable-workflow callers pin @main, not a version tag

Why
The template library was iterating daily. Tagging would mean a synchronised update across 85 repos for every fix; @main means one PR upgrades the whole fleet instantly.
Rejected
Semantic @v1 tags — too much coordination overhead during the rollout phase. Per-repo inline CI — the status quo, 50+ copies to maintain.
Cost
A bad merge to .github breaks every repo at once. Mitigated by a self-test workflow. Also: gh run rerun replays the reusable as snapshotted at run creation, so an updated @main is not picked up on re-run.

Dependency review split out of pr-ci.yml into its own reusable

Why
Nesting it forced every caller into a pull-requests: write permission cap at graph-build time — even when the feature was gated off by input. GitHub validates nested-reusable permissions before evaluating if:.
Rejected
Keeping it inline behind a boolean input; this is exactly what failed.
Cost
13 callers retained deprecated no-op inputs for backward compatibility.

All heavy gates moved off PR CI onto nightly schedules

Why
PR CI was taking 10–40 minutes in repos with WASM prebuilds. Semver-checks, coverage, perf and automated remediation are advisory; they do not need to block a merge.
Rejected
Keeping them on PRs with better caching — still too slow where prebuilds dominate.
Cost
A breaking change can merge and go unnoticed until the next nightly. Partly covered by push-time branch-invariant assertions.

GitHub App installation tokens instead of personal access tokens

Why
PATs are person-bound, broadly scoped and unauditable per workflow. App tokens are installation-scoped, expire in an hour, and are auditable.
Rejected
Fine-grained PATs (still person-bound); OIDC federation (too complex for the cross-org case).
Cost
Installation tokens are per organisation. Cross-org workflows must mint two. This failed silently four times before a systemic audit fixed it in seven scripts.

Renovate retired; nightly cargo update is the canonical freshness mechanism

Why
A three-repo Renovate pilot was net-negative — more noise than signal. Dependency freshness is better served by the nightly lock-sync job that already runs after every publish tier.
Rejected
Renovate with grouping rules (tried, still noisy); manual updates (unworkable at 85 repos).
Cost
12 removal PRs, 11 stale dependency PRs closed. Two renovate.json files still linger in test-flow repos.

Code

.github/.github/workflows/pr-ci.yml:117 yaml
main-pre-release-guard:
  if: github.event_name == 'pull_request' && github.base_ref == 'main'
  runs-on: ${{ inputs.runner }}
  steps:
    - uses: actions/checkout@v6
      with:
        submodules: ${{ inputs.checkout-submodules }}
    - name: Reject pre-release versions on main
      shell: bash
      run: |
        set -euo pipefail
        offenders=$(
          find . -name Cargo.toml -not -path './target/*' -print0 \
            | xargs -0 grep -HnE '^version = "[0-9]+\.[0-9]+\.[0-9]+-(dev|alpha|beta|rc)\.' \
            || true
        )
        if [ -n "$offenders" ]; then
          echo "::error title=Pre-release on main::..."
          exit 1
        fi
The primary defence against develop-lane version suffixes leaking into the stable namespace via an accidental forward-port. Runs on every PR targeting main, fleet-wide. Dependency-requirement strings like ">=0.6.0-dev, <…" are deliberately skipped — they start with a comparator, so the anchored pattern misses them.
greentic-deployer/ci/local_check.sh:1 bash
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT"

echo "==> cargo fmt --check"
cargo fmt --all -- --check
echo "==> cargo clippy"
cargo clippy --all-targets --all-features -- -D warnings
echo "==> fixture tool prerequisites"
ci/ensure_fixture_tools.sh
echo "==> replay deployer scaffolds"
cargo run --features internal-tools --bin replay_deployer_scaffolds
echo "==> cargo test"
cargo test --all
echo "==> cargo build --no-default-features (baseline)"
cargo build --no-default-features
The per-repo local gate. The final --no-default-features build is the repo-specific step that catches ungated feature dependencies — and it is not run by the shared pr-ci.yml reusable. That divergence is a known blind spot, proven live on greentic-demo where three gates had never once executed in CI.
.github/.github/workflows/assert-branch-invariants.yml:1 yaml
name: Assert Branch Invariants

# Why this exists (Phase D of plans/binary-bifurcation.md):
#   pr-ci.yml's main-pre-release-guard catches `0.x.y-dev.NN` versions at PR
#   time, but only via the PR-level event. Direct pushes to `main` (possible
#   on private repos under the GitHub free plan, where ruleset enforcement is
#   not available) bypass it.
#   This workflow runs on `push: [main]` AND `pull_request: [main]` so the
#   guard fires on both paths.
#
# Lane behavior:
#   main:    FAIL on `*-dev.*` version OR `<base>-dev` package name
#   develop: WARN if a manifest-declared binary basename is missing
The push-time complement to the PR-time guard. It exists specifically because private repositories on the GitHub Free plan cannot enforce branch protection — so the invariant has to be asserted by a workflow rather than by the platform.

Gotchas found the hard way

  • Inside a reusable workflow, github.workflow resolves to the caller's name — concurrency groups built from it collide whenever a release workflow calls ci.yml. Fix: hardcode a literal prefix.
  • App installation tokens are per-org, not per-App. A greenticai token hitting a private greentic-biz repo gets a privacy-preserving 404, not a 403 — so it reads as "repo doesn't exist".
  • A PR's status-check rollup cannot contain a push-only job. Nightly lock PRs auto-merged "green" on a gate that had never run; the real gate fired for the first time after merge.
  • A reusable workflow never appears in gh run list under its own name — its jobs nest inside the calling run, so it can produce artifacts that look impossible if you only check run lists.
  • Branch protection without required status checks lets red PRs merge. greentic-types had enforce_admins: true and no status checks; a PR merged with clippy failing and halted the nightly tier train.
  • Widening a CI trigger can expose a structurally-broken job. Adding develop to a pull_request filter made semver-checks run for the first time on a pre-release lane where it can never pass — there is no stable baseline for v1.2.*.
  • Runner disk exhaustion kills large builds silently — it surfaces as a null conclusion with an unretrievable log. Fixed centrally with a best-effort free-disk step reclaiming 12–15 GB.
  • Private repos on the Free plan receive no org secrets, even at visibility=all. Tag-on-version-bump then "succeeds" while publishing nothing, because the App-token mint fails and the fallback GITHUB_TOKEN cannot trigger workflows.

Status

done 39 reusable workflows · App migration · branch rename (52 repos) · CodeQL fleet-wide · auto-merge and branch cleanup on all 67 dev-publish repos · Renovate retired.

open greentic-runner CI is fmt-only — a cross-org private dependency blocks the shared reusable. greentic-types has no post-merge build at all (pull_request + workflow_call only), which let a corrupted lockfile survive three days undetected. The local_check.sh-vs-pr-ci.yml blind spot has not been surveyed fleet-wide. macOS runner spend is ~$645/month against a ~$50–110 target.

02 Release pipelines, versioning & the dependency graph

3 publish lanes · 85 manifest repos · tiers 0–11 · 347 graph edges · 705 crates

With no root workspace, publishing order is not something Cargo can work out. It has to be derived. This category is the machinery that turns 141 loose repositories into something that can be released as a coherent product.

What was delivered

  • REPO_MANIFEST.toml — the fleet registry: 85 entries carrying toolchain variant, ordered publish lists, version-track ranges, pipeline flags (dev-publish-enabled on 67), binary classification, and cached tiers. Every pipeline script reads it.
  • The dependency graphdep-graph/build_graph.py scans every Cargo.toml on disk (705 crates, 141 repos), derives 295 Rust edges plus 52 hand-declared architectural edges, and computes tiers by topological longest path. Tarjan SCC detects cycles. sync_manifest_tiers.py --check is the CI gate against drift. Deliberately does not read the manifest — on-disk source is the only authority.
  • Nightly develop lane — cron at 02:00 UTC, dispatching per-repo publishes in tier order 0→10 through a three-stage chain (prepare → release-binaries across a six-target matrix → publish). A lower_tier_published flag forces downstream rebuilds even without source changes. Post-tier, a lock-sync job runs scoped cargo update across every develop repo and opens bot PRs.
  • Binary bifurcation — 20 binary-shipping crates across 17 repos publish under a sibling <crate>-dev name on the develop lane, so cargo binstall resolves with no flags while the stable namespace stays clean. Dual-role crates (library + binary under one package name) publish the library as a pre-release under the canonical name and the binary as the -dev sibling.
  • Fleet version trains — a force-jump that unified a fragmented dev lane (repos at 0.2.x, 0.5.x, 0.6.x) onto a single base across 39 PRs; the 1.1 GA content promotion across 66 repos; the 1.2 pre-release lane at 59/59 repos.
  • Forward-porting — five main→develop waves between July 6th and 30th, each opening PRs across 30–50 repos, with the failure modes documented as they were discovered.
  • Workspace scripts — eight at the root plus ten in .github/scripts/, several of which ship their own unit tests (test_bump_cargo_versions.py, test_rewrite_binary_name.py, test_assert_branch_invariants.py).

Decisions

Bifurcate binary names only; leave libraries on the canonical name

Why
The problem is narrow: cargo binstall needs a resolvable crate name for dev binaries, without dev builds polluting the stable namespace. Only binaries have that problem.
Rejected
Full dual-name bifurcation — 300+ coordinated Cargo.toml edits, broken semver-check baselines, and mutated use statements between branches. An alternate registry — cargo binstall cannot resolve one. Dist-tags — not a Cargo concept. There is also precedent against it: a separately-named nightly package was the attack vector in the December 2022 PyTorch supply-chain compromise.
Cost
Dual-role crates need a require-pre-release flag to protect the stable name, and the -dev namespace turns out to be shared with the research lane.

Stamp dev versions with GITHUB_RUN_ID, not RUN_NUMBER

Why
RUN_NUMBER is per-workflow and resets on re-runs, so two runs can mint the same version and collide on publish. RUN_ID is globally unique, so re-running a failed workflow gets a fresh version for free.
Rejected
Sequential per-crate numbering (needs persistent state); timestamps (not monotonic across zones).
Cost
Versions like 1.2.30251816739 are ugly. But the format is a normal semver version rather than a pre-release, which sidesteps Cargo's strict pre-release matching rules — and that property is load-bearing for the whole fleet's resolution.

Tier-ordered publishing with halt-on-failure and forced cascading rebuild

Why
If tier 0 publishes a new greentic-types, every tier above it needs that version to exist on crates.io before it can build. Ordering is not an optimisation, it is a correctness requirement.
Rejected
Flat parallel publish (races on dependency resolution); lock-step monorepo publish (does not fit the multi-repo layout).
Cost
One tier-0 failure halts the entire train. And unblocking a stalled tier has a blast radius — clearing a tier-5 blocker once dispatched 31 downstream publishes in a single night, which is how the crates.io rate limit was discovered.

Diff-to-tag: the version change in Cargo.toml is the release signal

Why
No labels, no out-of-band metadata. It matches the Rust ecosystem norm (tokio, serde, clap, axum) and keeps Cargo.toml version, git tag and artifact version permanently in sync.
Rejected
Label-based triggering — mutable, leaves no git history, and easy to fire accidentally.
Cost
The tagging workflow needs an App token rather than GITHUB_TOKEN, because a tag pushed by GITHUB_TOKEN does not trigger downstream workflows.

Publish the dev lane with --no-verify

Why
Verification re-resolves dependencies from crates.io at the published version; in a cross-repo pre-release chain the upstream crate has not propagated yet, so verification fails on a correct build.
Rejected
Verified publish (breaks the chain); path-deps-only dev lane (loses registry resolution for downstream consumers).
Cost
Packaging metadata errors — bad readme path, invalid licence — only surface at the dry-run publish step, never in the test stage.

Code

dep-graph/build_graph.py:11 python
Tier formula:
    tier(repo) = 0                                if repo has no internal deps
    tier(repo) = 1 + max(tier(producer))          otherwise

Strongly-connected components (cycles) are condensed into a single super-node
before tier computation; all SCC members share the same tier. Non-trivial
SCCs are reported separately.
The core invariant: tiers are derived, never hand-edited. The tier = N in the manifest is a cached copy. If a tier looks wrong, the fix is to change the underlying Cargo.toml dependency and regenerate — not to edit the number.
.github/toolchain/REPO_MANIFEST.toml:32 toml
#   binary-crates            — Subset of `publishes` that ship a binary. On develop, each
#                              publishes as `<name>-dev` (Phase C of binary-bifurcation).
#   dual-role-binary-crates  — Subset of `binary-crates` that ALSO publish a library under
#                              the same `[package].name`. Library keeps publishing as `<name>`;
#                              binary gets a sibling `<name>-dev` copy-publish.
#   binary-bins              — Map of `package -> bin name` overrides for binary-crates whose
#                              `[[bin]].name` differs from the `[package].name`.
Three manifest fields drive the entire bifurcation pipeline. The distinction that matters is dual-role: a crate that is consumed as a library and installed as a binary cannot simply be renamed, so its two roles publish under different names from the same source.
dep-graph/extra_edges.toml:15 toml
[[edges]]
from = "greentic"
to   = "greentic-operator"
kind = "runtime-delegation"
note = "gtc CLI dispatches to greentic-operator subprocess"

[[edges]]
from = "greentic"
to   = "greentic-bundle"
kind = "runtime-delegation"
note = "gtc CLI dispatches to greentic-bundle subprocess"
Fifty-two edges that no Cargo.toml can express: runtime subprocess delegation, binstall tool dependencies, WIT contracts, CI composite actions. Without them the gtc CLI would compute as tier 0 — no Rust dependencies — instead of tier 11, and the publish order would be wrong in exactly the way that matters most.

Gotchas found the hard way

  • A path dependency hides the version floor from every CI gate. { path = "../crate", version = ">=0.2" } builds against the in-tree copy; the floor is only consulted when something resolves from the registry. Three releases shipped uncompilable this way.
  • The -dev namespace is shared between develop and research. Research 1.3 outranks develop 1.2 by semver, so <crate>-dev can serve a research binary to a develop consumer.
  • Squash-merging a forward-port destroys ancestry. The comparison keeps showing main ahead and the next wave re-opens the same conflicts. Forward-ports must merge, never squash or rebase.
  • The stable-release script bumps Cargo.toml but not Cargo.lock — so every binary cut needs an explicit workspace update before merge, or the --locked binary build fails.
  • An exact pin in a workspace member breaks dev-publish. The publish job stamps every member to a run-id version, which the pin cannot match — and PR CI cannot see it, because PR CI resolves through the path dependency.
  • Yanked crates stay usable under --offline but fail a fresh resolve. A test that scaffolds a lockless project fails while the main build stays green.
  • dev-publish reports SKIPPED, not failed, when the binary stage fails. A green nightly summary can hide the fact that nothing was built — assert the crate actually reached the registry.
  • A forward-port wave is not just the conflicting PRs. One wave had 17 conflict PRs resolved while 15 clean PRs sat open, including tier-0 foundations.
  • Cron schedules run on the default branch only. A nightly fix on develop must be back-ported to main or it never takes effect.
  • $(cargo …) inside command substitution hangs — sccache holds the pipe open, and the process sits at zero CPU in anon_pipe_read. Redirect to a file instead.

Status

healthy Nightly develop lane, mostly green since 2026-07-10, 67 repos enrolled. Graph clean: 0 cycles, max tier 11. Develop lane at 59/59 repos on the 1.2 pre-release base. Bifurcation complete across 20 crates.

dead The weekly stable lane has not run since 2026-05-04, and that run failed — last success was 2026-04-16. Stable releases since then have been cut by hand. A revive-or-retire decision is owed. Consequently main only advances by manual forward-port, and roughly a dozen main-only commits across five repos remain unported.

03 Core runtime & foundation crates

~1,718 commits across 30 repos · stable ABI greentic:component@0.6.0 · wasmtime 43→45

These are the crates everything else depends on — greentic-types alone has 60 consumers. A change here cascades through the entire fleet, which is why most of the decisions below are about making change cheap rather than about features.

What was delivered

  • The stable component ABIgreentic:component@0.6.0, with two mandatory exports: describe() returning a self-describing manifest, and invoke() taking CBOR payloads. A lighter @1.0.0 world coexists for runners that need schema introspection without the full contract. CI guards in three repos hard-fail if any downstream repo redefines the canonical WIT package.
  • CBOR canonical serialization — a serialize-then-canonicalize pipeline that sorts map keys, rejects floats, rejects tags, and rejects non-text keys, so that identical data always produces identical bytes. That property is what makes Blake3 content-addressing of pack manifests and idempotency keys possible at all.
  • Capability sandboxing — a five-crate capability system with validated cap:// URIs, plus network allow-lists and a .gmap policy file format the operator evaluates deny-by-default.
  • Multi-tenancy plumbingTenantCtx threaded through every invocation, telemetry span and session key, and a deterministic session-key format so a conversation resumes identically regardless of which runtime instance handles the next turn.
  • The flow model — YAML authored, parsed to a document, lowered to an intermediate representation of nodes and routes, serialized to CBOR for the runtime.
  • The pack format — a ZIP carrying a CBOR manifest, an SBOM, flows, WASM binaries and assets, Blake3-hashed and Ed25519-signed with an embedded certificate chain.
  • Runner work — revision-keyed runtime so multiple revisions coexist, runtime config injection, a resolver for runtime:// URIs, and a full telemetry attribution chain from deployment rollout ids through to OpenTelemetry spans.
  • Fleet migrations — the wasmtime 43→45 migration across every consumer, and four full version trains (0.4→0.5, the 1.1 unification, 1.2-dev, 1.3-research).

Decisions

CBOR, not JSON, as the canonical wire format

Why
Content-addressed hashing needs byte-identical output for identical data. JSON has insertion-ordered maps, whitespace variance and float ambiguity — all three break canonical hashing.
Rejected
JSON with a canonicalization scheme — still has float edge cases. Protobuf — not self-describing, a poor fit for dynamic component schemas.
Cost
Every consumer must serialize through the canonical module. And a subtle hazard: a dependency update can silently enable serde_json's preserve-order feature, switching maps to insertion order and breaking any hash taken over a stringified value.

Extend the ABI with additive optional exports, not cohort version bumps

Why
The instance-identity contract was added as an optional export on the existing 0.6.0 world. A version bump would have penalised every deployed component for a feature most of them do not implement — providers that lack it simply return nothing and fall through to the single-instance path.
Rejected
A mandatory 0.7.0 world — forces recompilation and redeployment of everything.
Cost
Two code paths to maintain: components that implement the export, and the fallback for those that do not.

A canonical facade module re-exports the versioned ABI types

Why
Consumers import greentic_interfaces::canonical::*. A future ABI bump retargets one line in one file instead of every import path in 40+ repos.
Rejected
Direct imports from the generated bindings module — turns every ABI bump into a scatter-shot migration.
Cost
The rule needs a CI guard to hold, because the direct import path still exists and still compiles.

No instance pre-cache in the runner — the dead field was deleted, not wired up

Why
Measurement. Per-invoke WASM instantiation costs 71 µs, of which linker registration is 68 µs (96%) and the instantiation itself 3 µs (4%) — against a ~65 ms end-to-end response, about 0.1%. Throughput stayed flat at ~12k ops/s from one thread to sixteen.
Rejected
A shared instance pool — real synchronisation complexity for a 0.1% gain.
Cost
None, and a lesson: two independent code-reading estimates put this at "4–8 seconds". They were wrong by five orders of magnitude. If runner CPU ever matters, the place to profile is store construction — a fresh WASI and TLS context per call — not the linker.

A reduced-authority linker for identify and describe probes

Why
When the host probes a component to ask "is this webhook yours?", the component must be able to read its own configuration and nothing else. The probe linker registers host imports but locks the sandbox down — no preopens, no environment, no stdio.
Rejected
A full-authority probe — violates the capability model outright. Skipping the probe and trusting gateway headers — cannot extend to third-party providers.
Cost
A second instantiation path to maintain alongside the normal one.

Code

greentic-interfaces/wit/greentic/component@0.6.0/package.wit:117 wit
world component {
  import control;
  export node;
}
Four lines that define the contract every Greentic WASM component implements. control is imported — the host offers cooperative scheduling (should-cancel, yield-now). node is exported — the component provides describe() and invoke(). Nothing else crosses the boundary.
greentic-types/src/lib.rs:1311 rust
/// Context that accompanies every invocation across Greentic runtimes.
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct TenantCtx {
    pub env: EnvId,
    pub tenant: TenantId,
    pub team: Option<TeamId>,
    pub user: Option<UserId>,
    pub session_id: Option<String>,
    pub flow_id: Option<String>,
    pub node_id: Option<String>,
    pub provider_id: Option<String>,
    pub trace_id: Option<String>,
    pub attributes: BTreeMap<String, String>,
    pub deadline: Option<InvocationDeadline>,
    pub attempt: u32,
    pub idempotency_key: Option<String>,
    pub impersonation: Option<Impersonation>,
    // … further identity and tracing fields
}
The multi-tenancy spine. Every invocation, every telemetry span and every session key derives from this. The BTreeMap — not a HashMap — matters: it keeps the derived Hash deterministic. Adding a public field here is a fleet break that fails compilation in 17+ downstream repos.
greentic-types/src/cbor/canonical.rs:40 rust
/// Encode a value as canonical CBOR bytes.
pub fn to_canonical_cbor<T: Serialize>(value: &T) -> Result<Vec<u8>> {
    let mut interim = Vec::new();
    into_writer(value, &mut interim)
        .map_err(|err| CanonicalError::Encode(err.to_string()))?;
    canonicalize(&interim)
}
Serialize, then canonicalize. The second pass re-encodes with sorted text-only map keys and rejects floats and tags outright — errors named FloatNotAllowed, TagNotAllowed, NonStringMapKey. This is what makes a pack manifest's Blake3 hash stable across machines, compilers and serialization orders.
greentic-types/src/session.rs:73 rust
pub fn canonical_session_key(
    tenant: impl AsRef<str>,
    provider: impl AsRef<str>,
    anchor: Option<&str>,
    user: Option<&str>,
) -> SessionKey {
    SessionKey::new(format!(
        "{}:{}:{}:{}",
        tenant.as_ref(), provider.as_ref(),
        anchor.unwrap_or(DEFAULT_CANONICAL_ANCHOR),
        user.unwrap_or(DEFAULT_CANONICAL_USER)
    ))
}
Deliberately human-readable rather than a UUID or a hash. Pause/resume must be deterministic across runtime instances and across restarts — and when a conversation resumes into the wrong session, a debuggable key is worth more than an opaque one.
greentic-pack/ci/check_no_duplicate_canonical_wit.sh:4 bash
PATTERN='^package\s+greentic:component@'
MATCHES="$(rg -n --hidden --glob '!.git/*' --glob '*.wit' \
  --glob '!**/target/**' --glob '!**/out/**' \
  --glob '!crates/vendor/**' --glob '!**/wit-staging/**' \
  "$PATTERN" . || true)"
if [[ -n "$MATCHES" ]]; then
  echo "ERROR: greentic-pack must not define canonical greentic:component WIT."
  exit 1
fi
Only one repo may own the canonical WIT package. A duplicate definition compiles fine and then produces a silent runtime ABI mismatch — the worst possible failure shape — so the invariant is enforced mechanically in all three repos that could plausibly violate it.

Gotchas found the hard way

  • A dependency update can silently break canonical hashing. A transitive bump enables serde_json's preserve-order feature, switching maps from sorted to insertion order — anything hashing a stringified value quietly changes its answer.
  • Extracting an AsyncFnMut helper breaks Send inference downstream. The error surfaces at the tokio::spawn site in another crate, not at the helper. A refactor that looked like a clean simplification broke three host APIs; the fix was to inline the loops back and add a compile test asserting the futures are Send.
  • Two wasmtime versions in one lockfile compile on Linux and macOS and hard-fail on Windows — the MSVC linker rejects two copies of the same rlib where ELF and Mach-O tolerate them.
  • Hotfixes merged straight to main never trigger the forward-port workflow, which only runs after a stable release or on manual dispatch. Develop diverges silently.
  • The YAML crate is aliased under two different names across the fleet via package renames — the import name proves nothing, only the package = key does.
  • #[serde(default)] cannot see sibling fields — the default runs with no access to the rest of the struct, so "absent" and "null" cannot be distinguished that way.
  • Hot-reload without a carried store registry silently wipes live conversations — every in-flight turn gets no reply and no error.

Status

stable types 1.1.1 · flow 1.1.1 · pack 1.1.5 · component 1.1.1 · runner 1.1.5 · operator 1.1.5 · i18n 1.1.1. Develop at 1.2.0-dev, research at 1.3.0-research.

open Canonical hashers still rely on stringified-value ordering rather than sorting keys explicitly — the durable fix is owed. Three repos remain on old wasmtime versions. greentic-runner CI runs no clippy and no host-target tests.

04 Deployment, environments & bundles

~1,250 commits across 7 repos · deployer PRs #155 → #540 · 4 cloud targets

This is the largest category by volume, and the one with the clearest single architectural idea: the environment, not the bundle, is the thing you deploy to. Everything else follows from making Environment a first-class, persisted entity rather than a string label.

What was delivered

  • The next-gen foundation (May) — an 1,822-line design document, then a ten-step implementation: the greentic-deploy-spec contract crate, an EnvironmentStore with a filesystem backend, the op <noun> <verb> CLI shape, environment bootstrap, a revision-transition state machine, an audit log with an ownership gate, and a registry binding capability slots to native handlers.
  • Four deployment targets, each an env-pack rather than a hardcoded branch: local process, Kubernetes (~2,500 lines of manifest rendering, bound ServiceAccount identity, Vault bootstrap), AWS ECS (eight PRs in a single day — task sets, Fargate launch config, target-group pooling, per-deployment ALB listener rules), and Google Cloud Run (typed target seam with an in-memory fake, ETag-guarded traffic updates, verified live on europe-west1 and torn down the same day).
  • The env-manifest apply engine — a declarative document describing an entire environment, applied by one command. Fifteen step kinds, two TOCTOU re-hash gates, a --check mode that validates and diffs without mutating, and idempotency keys derived from a hash covering every mutated field.
  • An operator store server — SQLite-backed HTTP service exposing the full environment API for remote management, with bearer-token RBAC, ETag optimistic concurrency, durable audit with a retention watermark, and backup/restore for disaster recovery.
  • One bundle, one commandgtc start <x>.gtbundle auto-creates an environment, deploys, warms a revision and serves it. The E2E suite migrated to this path and the readiness gate moved from parsing stdout to probing /readyz.
  • op env destroy — atomic rename-to-tombstone under a file lock as the commit point, then purge; a failed purge leaves a reapable tombstone rather than a half-destroyed environment.

Decisions

The environment is the deploy target; it is a persisted entity, not a label

Why
Real deployments need per-environment capability bindings (which secrets backend, which deployer, which telemetry sink), multi-bundle hosting, and revision isolation. A string label supports none of that.
Rejected
A bundle-centric model where bundles carry their own environment config — conflates workload with platform. Namespace-per-tenant — too tightly coupled to Kubernetes.
Cost
Every CLI verb now threads an environment id, and cross-environment references have to be scope-validated.

Deploying a version and shifting traffic are separate operations

Why
Immutable content-addressed Revision plus a mutable TrafficSplit gives blue-green, canary and instant rollback without redeploying. It is the same shape Cloud Run and ECS chose, for the same reasons.
Rejected
Overwrite-in-place — the legacy model, one bundle per tenant, structurally incompatible with canary.
Cost
Splits must sum to exactly 10,000 basis points, and convergence has to be checked as multiset equality over (digest, weight) rather than by comparing lists.

Provider taxonomy is extensible by descriptor string, not by enum

Why
A new deployer should drop in without recompiling core types. greentic.deployer.k8s@1.0.0 is a namespaced string with a semver, resolved through a registry at runtime.
Rejected
A closed DeployerKind enum — every new provider becomes an ABI break across five or more crates.
Cost
A binary can accept a descriptor it cannot execute. Apply exits 0; only op env doctor's unknown_kinds reveals the truth.

greentic-deploy-spec stays on 0.2.x — additive fields only, never a new enum variant

Why
Cargo treats 0.2 → 0.3 as semver-major, so a minor bump would force the runner, start, operator and setup to bump simultaneously. Marking the config struct #[non_exhaustive] with a constructor takes that break exactly once; every field after it is free.
Rejected
A 0.3.0 bump — forks the copy shared with the runner host and produces type mismatches at every shared-type crossing. A [patch] override — cannot move a pinned lock entry.
Cost
An exact pin becomes mandatory on main, because dev-lane run-id publishes numerically outrank the entire stable 0.2.x line if a range is used.

Fail closed, everywhere

Why
A missing credential, an unreadable secret, an unknown pack kind — each must be an error. In a deployment system a silent fallback produces the worst failure mode there is: the thing appears deployed and simply never responds.
Rejected
Best-effort / silent fallback. This exact class of bug was found and fixed more than ten separate times.
Cost
Every new handler must be routed through the registry resolve path; convenience shortcuts are not available.

Destroy commits by atomic rename to a tombstone, not by incremental deletion

Why
A partial delete leaves an environment that still passes exists() and list() but fails every operation. Renaming to a sibling tombstone is the commit point: after the rename the environment is gone from all queries even if the subsequent purge fails.
Rejected
Incremental deletion (resurrection risk on crash); a soft-delete flag (still listed, complicates every query).
Cost
A failed purge leaves a tombstone behind, so destroy has to reap stale tombstones on re-run. The tombstone suffix uses a character outside the id charset so it can never collide with a real environment.

Code

greentic-deployer/crates/greentic-deploy-spec/src/capability_slot.rs:36 rust
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum CapabilitySlot {
    Deployer, Secrets, Telemetry, Sessions, State, Revocation,
    Messaging, Extension,
}
The eight capability families an environment can bind. The first six are one-per-environment and live in packs[]; Messaging and Extension are N-per-environment, which is why binds_in_packs() returns false for them.
greentic-deployer/src/env_packs/deployer/trait_def.rs:184 rust
#[async_trait]
pub trait Deployer: std::fmt::Debug + Send + Sync {
    async fn stage_revision(&self, env: &Environment, revision_id: RevisionId)
        -> Result<StageOutcome, DeployerError>;
    async fn warm_revision(&self, env: &Environment, revision_id: RevisionId,
        answers: Option<&Value>) -> Result<WarmOutcome, DeployerError>;
    async fn drain_revision(&self, env: &Environment, revision_id: RevisionId)
        -> Result<DrainOutcome, DeployerError>;
    async fn archive_revision(&self, env: &Environment, revision_id: RevisionId,
        answers: Option<&Value>) -> Result<ArchiveOutcome, DeployerError>;
    async fn apply_traffic_split(&self, env: &Environment,
        deployment_id: DeploymentId, answers: Option<&Value>)
        -> Result<TrafficSplitOutcome, DeployerError>;
}
Five methods, provider-side effects only. Lifecycle state, credential resolution and preflight validation are deliberately not in this trait — they are shared concerns handled once, so a new cloud target implements only the parts that are genuinely cloud-specific. apply_traffic_split must reject a non-10,000 sum before making any provider call.
greentic-deployer/src/env_packs/registry.rs:88 rust
pub fn with_builtins() -> Self {
    let mut registry = Self::new();
    for handler in BUILTIN_HANDLERS {
        registry.register(Box::new(*handler))
            .expect("built-in handler paths are unique");
    }
    registry.register(Box::new(
        super::local_process::LocalProcessDeployerHandler::default(),
    )).expect("local-process deployer handler path is unique");
    #[cfg(feature = "creds-aws")]
    registry.register(Box::new(super::aws::AwsEcsDeployerHandler::default()))
        .expect("aws-ecs deployer handler path is unique");
    #[cfg(feature = "creds-gcp")]
    registry.register(Box::new(
        super::gcp_cloudrun::GcpCloudRunDeployerHandler::default(),
    )).expect("gcp-cloudrun deployer handler path is unique");
    registry.register(Box::new(super::k8s::K8sDeployerHandler::default()))
        .expect("k8s deployer handler path is unique");
    registry
}
Where the descriptor-not-enum decision pays off. Cloud SDKs sit behind feature gates so a build without them is smaller; Kubernetes is unconditional because its client has no heavy SDK dependency.
greentic-deployer/examples/cloudrun-demo/cloudrun.env.json.tmpl json
{
  "schema": "greentic.env-manifest.v1",
  "environment": { "id": "@@ENV_ID@@", "name": "cloudrun-demo" },
  "trust_root": "bootstrap",
  "packs": [
    { "slot": "deployer", "kind": "greentic.deployer.gcp-cloudrun@1.0.0",
      "pack_ref": "builtin",
      "answers": { "project": "@@PROJECT@@", "region": "@@REGION@@",
        "access_mode": "@@ACCESS_MODE@@", "runtime_image_digest": "@@IMAGE_DIGEST@@" }
    },
    { "slot": "secrets", "kind": "greentic.secrets.dev-store@1.0.0", "pack_ref": "builtin" }
  ],
  "bundles": [
    { "bundle_id": "cloudrun-demo", "bundle_source_uri": "@@BUNDLE_URI@@",
      "bundle_digest": "@@BUNDLE_DIGEST@@",
      "route_binding": { "path_prefixes": ["/"] } }
  ]
}
A whole environment as one declarative document — the deployer slot bound to Cloud Run, the secrets slot to the dev store, one digest-pinned bundle. Swapping kind to greentic.deployer.k8s@1.0.0 retargets the same manifest at Kubernetes.

Gotchas found the hard way

  • Cloud Run never delivers GET /healthz to your container — Google's frontend intercepts it and returns a branded 404 page. Probe /readyz. Kubernetes uses /healthz legitimately, in-cluster. Same path, opposite correct answer per platform. Found live.
  • A bundle has two runtime pack roots and the deployer only scanned one — provider packs were invisible to the revision runtime. The false green: the generic revision ingress returns 200 for any POST, so the endpoint looked healthy.
  • A library fix does not ship until every embedding binary is rebuilt. The deployer is both a binary and a library, pinned via lockfile by three other binaries; a floor bump is a behaviour floor, not an API one, so nothing fails to compile against the old copy.
  • Two green PRs can merge cleanly and leave main not compiling. One added a field, the other added a struct literal, CI last ran 41 hours earlier — different file regions, no textual conflict, red main.
  • A stale pack-list reference from a pre-1.1 environment bricks the whole environment, because the loader bails on the first bad reference and healthy deployments become collateral. Missing and invalid must be handled differently, and the lexical .. check has to run before touching the filesystem.
  • gtc start opens a public tunnel when public_base_url is unset and a messaging pack is served — and the disable flag does not win, because the override happens first.
  • env apply writes host config at step 2 but deploys bundles at step 4, so a manifest that declares a bundle and names it as default fails on first apply if the default is validated eagerly.
  • bundle build can emit an empty 4 KB bundle and exit 0, surfacing one command later at deploy. Size is the cheap probe — a real bundle is megabytes.
  • Bundle id must come from the manifest, never the filename stem. Two differently-named files declaring the same id would otherwise create rival deployments instead of blue-greening one.
  • An exhaustive destructure is a deliberate tripwire. A new field on the environment manifest forced an explicit decision about whether the update-broadcast surface should accept it; binding to _ would have compiled and silently opened a fleet-rebinding vector.

Status

shipped One-bundle-one-command · Kubernetes with Vault · Cloud Run · session-sticky routing · env-manifest apply phases 1–2 · operator store server · deploy-spec 0.2.6.

develop only op env destroy, named local environments, the AWS ECS deployer and the full remote-control surface exist on develop but not on main — the stable binary still returns a stub.

open Vault cold start (~15 s after idle) is worked around with a keep-warm loop, not fixed. Env-manifest phases 3–4 not started. Azure and single-VM deployers not started. The --target flag still routes to the legacy Terraform path rather than env-packs; unification was decided on 2026-07-30 but sequenced behind other work.

05 Updater, trust & air-gapped distribution

2026-06-30 → 2026-07-31 · 31 days · from zero to air-gapped fleet distribution

The headline

An entire signed, trust-anchored update system — plan format, signing, staging state machine, atomic binary self-replacement with boot-failure rollback, push and poll transports, a did:web trust anchor, a production plan store, and two tiers of air-gapped distribution — was built in 31 days. Phase C shipped on the final day of the window.

What was delivered

  • The update plan — a signed statement carrying an environment manifest as opaque JSON, with a monotonic sequence number for anti-rollback, a nonce, artifact digests, and an optional binaries list. Signed with Ed25519 over a pre-authentication encoding.
  • The staging state machine — atomic plan admission under a single lock hold, then download → staged → applying → applied, with single-flight apply admission and channel-scoped state so a channel migration cannot wedge a host.
  • Binary self-update — verified atomic binary swap with a rollback copy, an exec(2) auto-restart, a boot-attempts counter, and an anti-rollback tombstone. The rollback path was demonstrated end to end: a deliberately boot-broken version detected, restored and re-executed in 58 seconds.
  • Push and poll — a server-sent-events transport alongside the polling loop, including the fix for a shutdown hang where a blocking SSE read wedged runtime teardown.
  • The trust model — a did:web document on its own domain, separate from the plan server, so one compromise does not defeat both gates. Per-environment trust roots, a resolver that derives key ids from raw public keys, and a CLI verb that anchors an environment on the fleet root.
  • Air-gap Tier 1 — a signed .gtupdate envelope: export on the connected side, carry it across on physical media, import into a per-environment content-addressed store on the other side, with digest preflight, receipts and garbage collection.
  • Air-gap Tier 2 — one import point serving the entire in-gap fleet over HTTP. An import can write a static serving directory; runtimes poll it and fall back to a blob mirror for binaries. The serving guide ships with nginx and caddy configurations that were verified by running real containers.
  • Toolchain distributiongtc install resolving an OCI manifest of exact version pins, a one-command gtc up bootstrap, and a dedicated :airgapped channel carrying the dev-lane binaries the air-gap verbs need.
  • Production infrastructure — a Cloudflare Worker plan store with Durable Objects live at updates.greentic.cloud, and a coordinated publisher that emitted 84 signed binary artifacts (14 packages × 6 targets) in its first run.

Decisions

Keep the existing non-standard pre-authentication encoding rather than adopt the DSSE spec

Why
The signing crate predates the updater and already shipped this encoding — spaces and decimal ASCII lengths where the spec uses null bytes and little-endian. Every pack and bundle already signed uses it. Changing it would invalidate all of them.
Rejected
Migrating to spec-compliant encoding — a flag day across every signed artifact in existence.
Cost
A real trap: a spec-compliant DSSE library produces a valid signature over the wrong bytes. It fails silently, not loudly. Anything hand-building a plan outside the Rust toolchain must reproduce the exact encoding.

Binary updates apply automatically under the stage policy; only content needs an explicit apply

Why
The stage-then-apply default exists so an operator can review content convergence before it lands. Binary updates are different: they are usually security patches, and gating them behind an operator action defeats the point.
Rejected
Requiring explicit apply for binaries (blocks patches); a separate action enum (complexity with no demand behind it).
Cost
Publishing binaries to the broadcast channel upgrades every subscribed host at its next poll. The safety net is four guards: container refusal, anti-rollback, name and target matching, and boot-failure rollback.

The turnkey in-gap plan server was descoped; Tier 2 is static files plus nginx or caddy

Why
Air-gapped sites already run HTTP file servers. The serving layout needs exactly two rewrite rules, and a poll-only consumer works against any static server. Shipping a dedicated binary would add a maintenance burden to solve a problem the site has already solved.
Rejected
A dedicated Rust plan-server binary; an embedded file server inside the deployer.
Cost
Sites must write their own server config, so the documentation carries that weight — which is why those configs were verified against real running containers rather than written from memory. Push notification is unavailable in-gap; polling is the only path.

The CI signing key is the did:web root key

Why
The goal was that no operator ever hand-adds a public key. The DID document is the single source of truth, and an environment anchors on it with one command.
Rejected
A separate CI signing key trusted alongside the operator key — the original plan, abandoned once the DID train landed.
Cost
Revoking the CI key now means rotating the fleet trust root. The kill-switch section of the signing-key documentation still describes the abandoned two-key model and is stale.

A blob-mirror failure is a hard error, never swallowed

Why
The poll loop advances its sequence watermark on any successful response. If a mirror fetch failed quietly, the host would record the plan as seen, never retry, and diverge from the fleet permanently.
Rejected
Best-effort mirror fetch with a warning — exactly the failure that produces silent, unrecoverable drift.
Cost
A misconfigured mirror surfaces as a loud failure rather than degraded operation. That is the correct trade in a system whose whole purpose is convergence.

Code

greentic-update/src/plan.rs:54 rust
pub struct UpdatePlan {
    pub schema: String,
    pub plan_id: String,
    pub env_id: String,
    pub sequence: u64,
    pub created_at: DateTime<Utc>,
    pub nonce: String,
    pub target: serde_json::Value,
    pub artifacts: Vec<PlanArtifact>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub binaries: Vec<BinaryArtifact>,
    pub compat: CompatRequirements,
    pub rollback: RollbackPolicy,
}
Two design choices are visible here. target is opaque JSON so this crate does not depend on the deploy-spec crate — the update system stays decoupled from the deployment schema. And binaries skips serialization when empty, so adding the field left every previously-signed plan byte-identical and still verifiable.
greentic-distributor-client/src/signing.rs:213 rust
pub fn pae(payload_type: &str, payload: &[u8]) -> Vec<u8> {
    let mut out = Vec::with_capacity(payload_type.len() + payload.len() + 32);
    out.extend_from_slice(b"DSSEv1 ");
    out.extend_from_slice(payload_type.len().to_string().as_bytes());
    out.push(b' ');
    out.extend_from_slice(payload_type.as_bytes());
    out.push(b' ');
    out.extend_from_slice(payload.len().to_string().as_bytes());
    out.push(b' ');
    out.extend_from_slice(payload);
    out
}
// Test asserts: pae("t", b"hi") == b"DSSEv1 1 t 2 hi"
The pre-authentication encoding the whole trust chain rests on. Spaces and decimal ASCII lengths, not the specification's null bytes and little-endian integers. The unit test pins the exact byte string, which is the only defence against a well-meaning "fix" toward spec compliance silently invalidating every signature in the fleet.
greentic-update/src/binswap.rs:163 rust
pub fn swap_binary(
    new_binary: &Path, target: &Path, opts: &SwapOptions,
) -> Result<SwapOutcome, BinSwapError> {
    let new_bytes = fs::read(new_binary)?;           // read once — no TOCTOU
    if let Some(expected) = &opts.expected_digest {
        verify_digest_bytes(&new_bytes, expected)?;  // fail closed before mutation
    }
    let prev_path = prev_path_for(target);
    fs::copy(target, &prev_path)?;                    // rollback copy
    fs::File::open(&prev_path)?.sync_all()?;          // fsync for crash safety
    atomic_install(&new_bytes, target, parent)?;      // temp → fsync → chmod → rename
    Ok(SwapOutcome { prev_path })
}
A program replacing itself on disk. The bytes are read once so there is no time-of-check-to-time-of-use gap between verification and install; the digest is verified before anything mutates; the rollback copy is fsynced before the swap so a power loss cannot leave it unrecoverable; and the rename is the single atomic commit point.
Tier 2 static serving layout — written by op updates import --push-to text
<push_to>/
  plan/
    plan.json      — raw plan bytes (the signature covers these exact bytes)
    plan.json.sig  — raw signature envelope bytes
    meta           — {"sequence": <u64>, "plan_sha256": "<hex>"}
  blobs/
    sha256-<hex>   — one file per plan-referenced digest

Write order: blobs first → plan.json → plan.json.sig → meta LAST.
Each write is atomic; a per-destination lock serialises concurrent pushes;
an incoming sequence lower than the existing meta is refused.
The entire Tier 2 protocol. Writing meta last is the commit point — a poller that reads meta is guaranteed the blobs and signature it names are already fully written. Any static file server can serve this.

Gotchas found the hard way

  • Use notify_one, never notify_waiters. The former stores a permit, so an event fired before the waiter is listening is still delivered. The latter drops it, and the update is lost forever.
  • A blocking SSE read wedges runtime shutdown for up to 15 minutes. The read can never return inside any grace window, so the fix is to detach the runtime at shutdown rather than to add a timeout.
  • Broadcast bootstrap can deadlock. Older runtimes reject every broadcast-addressed plan — but the binary swap that would fix them runs after plan admission. Affected hosts need an out-of-band install.
  • gtc install actively downgrades newer binaries, because it pins exact versions from the toolchain manifest, not whatever is newest on the registry. Publishing a crate does not reach users until the manifest is refreshed.
  • The manifest version must equal a published release tag. A label with no matching release 404s self-update for every host whose version differs.
  • Digest format asymmetry is load-bearing: plans carry sha256:<hex> with a colon; served blobs use sha256-<hex> with a hyphen. The URL must use the hyphen form.
  • Plan bytes are pretty-printed JSON; the statement inside the signature envelope is compact. Confusing the two changes the digest and breaks verification silently.
  • wrangler secret put with piped stdin stores a value that does not match what you sent — and reports success. The production upload token diverged on first deploy this way.
  • "Environments: N" in the publish notification is an upload count, not fleet reach. It says how many plans were uploaded, not how many hosts received or applied them.
  • Dev-lane binaries never report their publish version--version shows the manifest version, not the run-id in the archive filename, so dev self-update is a guaranteed 404.
  • promote is a point-in-time digest copy, not an alias — every forced republish silently strands the stable tag until it is re-promoted.

Status

shipped Updater foundation (1.1.2 stable) · air-gap Tiers 1 and 2 · push and poll · auto-restart with boot-failure rollback · coordinated publisher (live) · did:web trust anchor (live) · gtc up · the :airgapped toolchain channel · production plan store (live).

descoped The turnkey in-gap plan server — deliberately not built.

open Phase D (trust-root rotation: key roles, threshold verification, two-phase commit, recovery journal) is blocked on a dependency publish. Garbage collection does not cover push-to directories, so a serving directory grows unbounded. A re-import does not repair a corrupt mirror blob. The notify endpoint is reachable off-host and should be gated.

06 Messaging, webchat & hot-reload routing

May–July 2026 · greentic-start + greentic-messaging-providers · shipped in start 1.1.37

The serving runtime has to take an inbound webhook from Slack, Teams, Telegram or a browser and route it to the right tenant, the right bundle, the right flow, and the right revision — while the environment is being reloaded underneath it. This category is that routing problem and the concurrency problems it exposed.

What was delivered

  • Multi-bundle webchat URL routing — a URL grammar of /{tenant}[/{bundle}[/{flow}]] that multiplexes many bundles on one tenant, backed by a 1,700-line routing module with pre-built bundle and flow indices. Per-bundle and per-flow URLs are printed at boot.
  • "Standard Option A" — a pack-provided single-page webchat UI supersedes the built-in console, which is retained as a fallback for bundles that ship no UI. A separate UI-only pack exists for bundles that need a browser tier without a messaging provider.
  • Hot-reload granularity — a three-way reload outcome (Unchanged / RoutingOnly / Full) so that flipping a default bundle rebuilds four routing tables instead of re-instantiating WASM. Conversation loss during reload went from 8-in-300 to 0-in-300.
  • Session-sticky revision routing — provider-specific conversation hints (Telegram chat.id, Slack channel, webchat conversation id) pin a conversation to a revision, with generation-monotonic eviction so a stale drain-phase request cannot steal a pin from the active revision. In-memory or Redis-backed, with TLS.
  • Tenant/team binding correctness — the default tenant changed from demo to default; admin requests naming no tenant are now rejected rather than guessed; unknown webchat tenants return 404 instead of failing open; mixed-tenant environments refuse to activate under a scoped secrets backend.
  • Scoped messaging endpoints — a full endpoint CLI plus an auto-resolution ladder (HeaderWins > Hit > Miss > Ambiguous > NoImpl > Skipped) across six shipping providers.

Decisions

URL classification runs before the revision dispatcher

Why
Classification needs only pre-built in-memory indices, not pack manifests or disk reads. Doing it first avoids per-request I/O on the hot path.
Rejected
Reading pack manifests per request (too slow); redirect-based routing (breaks the SPA's relative asset references).
Cost
The server strips bundle and flow segments before static-route matching, so the stripped path must retain the tenant prefix or provider path extraction breaks.

Default-bundle resolution lives in the shared contract crate, not in the server

Why
The resolution ladder — explicit config, then a lone active bundle, then the newest active — must be identical in the deployer, the server and setup. One implementation, three consumers.
Rejected
Per-consumer resolution (guaranteed drift); defaulting to the first bundle (non-deterministic).
Cost
It required a new public field on a widely-constructed struct, which is a fleet break — every struct-literal site downstream fails to compile. The deployer had to ship in lockstep.

Keep the built-in /chat console; let a pack UI supersede it

Why
Deleting the console is a three-repo schema change — 34 files in the deployer alone. Superseding it is a one-repo change, and the console still serves environments whose packs ship no UI.
Rejected
Deleting it outright (too many call sites). Serving the pack UI in place at /chat — the SPA loads its assets relatively, so it 404s its own JavaScript without a trailing slash.
Cost
/chat 302-redirects, never 301, because the redirect target changes whenever the default bundle changes. gui_enabled: true now means "this environment has a browser tier", not "serve the built-in console".

Session and state stores are keyed by six fields, not two

Why
Keyed on deployment and revision alone, a tenant change silently inherits the predecessor's sessions — the in-memory store mints opaque session keys with no tenant check of its own.
Rejected
A two-field key (cross-tenant session leak); fresh stores on every activation (loses every conversation on reload).
Cost
Stores carry across reloads only when all six fields match; any tenant, team or customer change mints fresh ones.

Code

greentic-start/src/startup_contract.rs:363 rust
pub(crate) fn forwarded_ws_scheme(headers: &[(String, String)]) -> &'static str {
    let is_https = headers
        .iter()
        .find(|(k, _)| k.eq_ignore_ascii_case("x-forwarded-proto"))
        .and_then(|(_, v)| v.split(',').next())
        .is_some_and(|proto| proto.trim().eq_ignore_ascii_case("https"));
    if is_https { "wss" } else { "ws" }
}
Seven lines that fixed a 5–12 second first-content delay which had been misattributed to WASM cold start. The DirectLine stream URL was hardcoded to ws://; a browser on an HTTPS page throws synchronously on an insecure WebSocket, and the SDK only falls back after a timeout. Only the server sees x-forwarded-proto, so this is the server's job and not the provider's.
greentic-start/src/revision_boot.rs:123 rust
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub(crate) struct RevisionStoreKey {
    pub deployment_id: String,
    pub revision_id: String,
    pub tenant: String,
    pub team: String,
    pub customer_id: String,
    pub bundle_id: String,
}
The anti-leak key. Session and state stores are only reused across an activation when every one of these matches — which is what stops a tenant rebinding from inheriting the previous tenant's live conversations. Tests construct it through a factory so that adding a field cannot silently omit it.
greentic-start/src/revision_reload.rs:465 rust
fn torn_write_newly_unbacked(
    current_unbacked: &std::collections::BTreeSet<String>,
    previously_unbacked: &std::collections::BTreeSet<String>,
) -> Vec<String> {
    current_unbacked
        .iter()
        .filter(|id| !previously_unbacked.contains(id.as_str()))
        .cloned()
        .collect()
}
The deployer writes two config files sequentially, not atomically, so a reload can observe them disagreeing. This pure function separates the decision from the I/O; the newly filter is what keeps it free in steady state, because adding a bundle legitimately creates a deployment before its block exists — once observed, it is never retried.
greentic-start/src/conv_dedup.rs:30 rust
#[derive(Clone, Eq, Hash, PartialEq, Debug)]
pub struct DedupKey {
    pub deployment_id: DeploymentId,
    pub tenant: String,
    pub team: String,
    pub user_id: String,
    /// Flow discriminator for webchat multi-flow bundles.
    pub flow_hint: Option<String>,
}
Without the flow discriminator, two flow URLs pointing at the same deployment within the dedup window returned the first flow's cached conversation — so opening flow B silently continued the conversation from flow A. A direct consequence of adding multi-flow URLs.

Gotchas found the hard way

  • Making a slow path fast exposes latent torn-read bugs. Cutting reload from seconds to milliseconds made a pre-existing non-atomic two-file write observable for the first time.
  • :latest never reliably rotates on the provider registry — pin :stable or an explicit version. Observed drift: :latest at 0.5.6 while :stable was 0.5.8.
  • A pack-list entry keys on the pack filename, not the manifest id, while the flow index reads the manifest — so renaming a .gtpack re-scopes its secrets and produces 500s.
  • Slack interactive payloads arrive form-encoded, not as JSON, so a JSON parse yields null and instance identification cannot find the app id. Multi-app deployments hit an ambiguity error.
  • A debouncer only flushes on tick boundaries at a quarter of its timeout. Widening a debounce to "harden" a timing test widens the tick proportionally and breaks it deterministically.
  • GET /chat returning 200 proves nothing about the message path. A static asset served fine while the actual POST 404'd on a tenant-name mismatch.
  • Mixed-tenant environments boot locally but fail under Vault. The dev store is unscoped, Vault is scoped — the mismatch is invisible until production.
  • Building a pack and publishing a pack are gated by different mechanisms — a filesystem glob decides what builds, a matrix file decides what publishes. A pack can be built, tested, and permanently unpublishable.

Status

shipped All of the above is on the stable lane in greentic-start 1.1.37, pinned in the :stable toolchain manifest, and validated by a demo asserting 58 and 43 checks across declarative and imperative deployment paths on a pure stable toolchain.

deferred Slack form-encoded interactive routing (needs the server to forward the raw body); Teams JWT signature verification — phase 1 decodes and validates claims but does not cryptographically verify, so a forged token with a correct audience is admitted; revision-aware event routing; the unauthenticated webchat token endpoint.

07 Instance identity & slot prefill — a nine-day cross-repo train

2026-05-29 → 2026-06-06 · ~50 PRs · 14 repositories · 5 architectural tiers

This one is worth calling out separately because of its shape: a single coherent feature delivered as a wave through the entire dependency stack — WIT contract, then types, then runtime, then serving host, then providers, then components, then a worked example — in nine calendar days.

The problem

One messaging provider deployment could only serve one bot identity. Running two Teams bots on the same runtime was impossible: the provider type ("teams") was the only routing discriminator available, so every inbound webhook for that provider class went to the same flow regardless of which bot actually received it. Operators needing multiple bots had to deploy a separate runtime per bot — which defeats the multi-tenant architecture entirely.

A second gap sat alongside it. When a user typed "Start an NDA between Acme and us, due 2026-07-15", the router could find the right flow, but the structured data inside that sentence was thrown away. The user then re-typed the counterparty and the date into a form. There was no slot-extraction component, no way to carry the originating utterance through the dispatch directive, no prefill field on the card, and no flow-level schema declaring what a flow expects.

The train, in dependency order

#LayerWhat landed
1greentic-interfacesA new optional WIT package: identify-instance (given a payload, which of my instances is this?) and describe-identify-instance (a hint declaring where my discriminator lives — a header name or a JSON pointer)
2greentic-types · deploy-specA provider id on provider declarations, and a typed messaging-endpoint entity on the environment with its own CLI verbs and an admit table
3greentic-fast2flowRouting partition switched from tenant-and-team to per-endpoint; each endpoint gets its own search index over its linked bundles, with trust-boundary scope validation
4greentic-runnerThe probe itself: instantiate the component under a locked-down sandbox, run the export, merge results across packs via a three-state lattice; plus direct provider invocation for webhook routing
5greentic-startEndpoint id plumbed from header through serving, per-endpoint admit gate, wrapper payload construction, synthesised provider webhook routes, and the loopback-gated worker gateway
6greentic-messaging-providersSix providers implement the export — Telegram off a secret-token header, Teams off /recipient/id in the body, Slack off the team and app ids
7flow · components · examplesFlow-level slot schema, a new regex slot-extractor component covering five slot types, card prefill, and a legal-NDA worked example demonstrating the whole chain

Decisions

Identity resolution lives in the provider component (a WIT export), not in the runtime

Why
Provider-specific payload knowledge belongs inside the provider's own sandbox. A third-party provider author can implement identification without forking the runtime, and adding a provider does not require a runtime release.
Rejected
A gateway header (kept as an escape hatch for single-bot environments). Native extractors in the runtime — would couple every provider's body schema into the runtime and make each new provider a runtime change.
Cost
Every provider component had to be republished with the new export. The optional contract means older components degrade to the single-instance path rather than breaking.

Providers declare where their discriminator lives, so the host passes only those headers

Why
Telegram's identity is in an HTTP header; Teams' is in the body. Without a declared hint the host would have to pass every header to every provider, leaking cross-provider routing information into each sandbox.
Rejected
Passing all allow-listed headers to everyone — simpler, and a needless information leak between tenants' provider components.
Cost
Two code paths — hinted (scoped) and unhinted (backward-compatible) — and a versioned hint schema with a forward-compatibility gate.

The identified id is treated as non-authoritative

Why
The returned identifier is derived from caller-controlled input. It selects a route; it does not prove anything. Downstream authentication must still verify.
Rejected
Treating identification as authentication — the obvious shortcut, and a direct path to cross-tenant message injection.
Cost
For Telegram the ordering inverts usefully: the shared secret header is both authenticator and discriminator, so a matched secret lets the probe be skipped entirely.

The slot schema is declared once per flow and injected by the runtime

Why
What slots a flow expects is a property of the flow, not of one node invocation. Repeating it in every extractor node payload duplicates it and invites drift.
Rejected
Requiring authors to inline definitions in each node — the original shape, kept working for backward compatibility since explicit inline definitions still win.
Cost
The runtime needs a guarded injection point that fires only for the extractor component, only when the flow declares a schema, and only when the node omitted its own.

The worker gateway is loopback-only and explicitly denies CORS

Why
The loopback check inspects the TCP peer — but a page from any origin, running in a browser on the same machine, connects from loopback. Without a CORS denial, that turns a blind write into a full read/write channel.
Rejected
Relying on the loopback check alone — this is precisely the case it does not cover.
Cost
None in practice: the built-in console uses same-origin fetch, and the desktop GUI calls it server-side where CORS does not apply.

Code

greentic-interfaces/wit/greentic/provider/instance-identity@0.1.0/package.wit:38 wit
interface instance-identity-api {
  identify-instance: func(input-json: list<u8>) -> option<string>;
}

interface instance-identity-describe-api {
  describe-identify-instance: func() -> option<list<u8>>;
}

world instance-identity {
  export instance-identity-api;
}

world instance-identity-describe {
  export instance-identity-api;
  export instance-identity-describe-api;
}
The additive-optional contract the whole train rests on. Two worlds, so a provider can implement identification alone or identification plus a hint. The option<string> return is what makes it safe to add: a component that does not implement it, and one that implements it and finds no match, are both handled by the same fallback path.
greentic-flow/src/lib.rs:50 rust
pub const SLOT_SCHEMA_METADATA_KEY: &str = "greentic.slot_schema";

// in FlowDoc / FlowIr:
#[serde(default, skip_serializing_if = "Option::is_none")]
pub slot_schema: Option<Value>,
Deliberately a free-form Value rather than a typed schema — that keeps the flow crate from depending on the extractor component's types, so the two can version independently. The skip_serializing_if means a flow without slots round-trips byte-identically to before the field existed.
component-slot-extractor/src/extract.rs:68 rust
pub(crate) fn extract(input: &SlotExtractionInput) -> SlotExtractionOutput {
    let utterance = input.utterance.as_str();
    let mut slots: Vec<ExtractedSlot> = Vec::new();
    let mut filled: Vec<String> = Vec::new();
    let mut missing: Vec<String> = Vec::new();
    for def in &input.slot_definitions {
        let matched = match def.slot_type {
            SlotType::String  => extract_string(utterance, def),
            SlotType::Enum    => extract_enum(utterance, def),
            SlotType::Number  => extract_number(utterance, def),
            SlotType::Date    => extract_date(utterance, def),
            SlotType::Boolean => extract_boolean(utterance, def),
        };
        // …
Five slot types, each with its own extractor. Dates parse ISO 8601, US and European formats; booleans use affirmative/negative token tables across eight languages. Deterministic regex extraction, not a model call — which is the "just enough AI" principle applied: the cheapest mechanism that solves the actual problem.
greentic-start/src/revision_serve.rs:1584 rust
async fn handle_worker_invoke(
    req: Request<Incoming>,
    state: Arc<ServeState>,
    peer_is_loopback: bool,
) -> Result<Response<Full<Bytes>>, Response<Full<Bytes>>> {
    if !peer_is_loopback {
        return Err(error_response(
            StatusCode::FORBIDDEN,
            "worker invoke is restricted to loopback callers",
        ));
    }
    let activation = state.current();
    let body_bytes = read_body_limited(req).await.map_err(|_| { /* … */ })?;
    let worker_req: WorkerInvokeRequest =
        serde_json::from_slice(&body_bytes).map_err(|err| { /* … */ })?;
    // resolve deployment by asserted tenant, dispatch to revision, return replies
The gate is the very first statement — before reading the body, before parsing, before touching state. Bounded body reading comes next. This endpoint executes flows under the deployment's own tenant, so the ordering is not stylistic.

Gotchas found the hard way

  • Telegram's webhook body carries no bot identity at all — the only discriminator is an HTTP header. That single fact forced the wrapper payload design: the host must pass headers, not just the body.
  • Every implementation must accept both the wrapper shape and a bare body. Shipping wrapper-only would break older hosts and every existing test harness.
  • An AsyncFnMut helper extracted from the probe loop broke Send inference in the serving host, three repos downstream. The refactor was reverted, the loops re-inlined, and a compile test added to prevent the same tidy-up from being reattempted.
  • Provider invocation fails closed on cross-pack ambiguity — if two packs in a revision bind the same provider type, the runtime refuses to guess rather than picking one.
  • Empty and whitespace secret tokens are rejected explicitly, otherwise they match falsely against the admit table.

Status

shipped The WIT contract, flow model, slot extractor, card prefill and worker gateway are on main. The runtime identity APIs and the six provider implementations are on develop. open The multi-endpoint conformance test and the concepts documentation page were planned and never landed. Multi-turn slot filling was explicitly out of scope.

08 Secrets, capability security & trust

Mar–Jul 2026 · ~430 commits across 7 repos · 14 findings fixed

Two threads run through this category: consolidating a scattered secret lifecycle into one library, and closing a series of concrete security holes — several of which were found by adversarial review or by running the code rather than reading it.

What was delivered

  • Secrets library consolidation — canonical identity functions, a data-driven generation model, capped generators, a sink abstraction, and collision-free naming for cloud backends. The trigger was concrete: the deployer's cloud path never saw generated secrets at all, so a JWT signing key and a webhook secret simply never reached the cloud.
  • Vault workload identity — replaced secret values in Kubernetes with workload identity. Pods get a service account; the deployer renders only references; the runtime exchanges the pod's projected token for a short-lived Vault token and resolves references at run time. Proven end to end on a live cluster.
  • Security tooling at fleet scale — CodeQL across 63 repos, dependency review across 59, automated remediation on a shared workflow with a fork-PR guard, and a long-red fuzz and audit pipeline repaired after three latent breakages were unstacked.
  • Six concrete vulnerability fixes, detailed below.

Security findings fixed

FindingWhy it was exploitableFix
Trust-root takeover via signed plan
2026-07-20
A signed update plan carrying a trust_root block could re-point the very trust anchor it was verified against — granting permanent signing authority over the environment. Self-perpetuating. Stripped at sign time and rejected at apply time. Five tests pin both halves.
Rollback resurrects a revoked key
2026-07-21
Snapshots captured the trust root, so restoring a snapshot could silently reinstate a signing key revoked after it was taken. Trust root excluded from the snapshot capture set.
Cloud secret ownership race
2026-07-17
Probe-then-write: if a rival environment created the secret between our read and our create, we would add our seed to their secret and grant our service account read access to all of it. One atomic create-or-report operation with a typed ownership result and a 208-bit owner stamp.
Signing-key read bypassed its own guards
2026-07-07
The plan-build path read the key with a plain file open, skipping the symlink-ancestor gate, no-follow flag and permission-mode check the auto-update path requires for the same material. Same protections applied to both read paths.
Vault unreachable behind a deny-all policy
2026-07-16
Not a code bug: a default-deny network policy covered the Vault pod and nothing opened ingress from workers. Presented as webhook 401s. A scoped ingress policy keyed on the immutable namespace label — not the ownership label, which would let two environments sharing an id cross-pollinate.
Cloud provider name collisions
2026-06-30
Azure and GCP restrict secret-name characters, so the fold from URI to name was lossy — two distinct secrets could map to one name and silently overwrite each other. Readable prefix plus a hash suffix computed over the unfolded canonical key.

Decisions

secret:// and secrets:// are two different schemes, and stay that way

Why
They differ at one byte and serve different roles: one is a deployment-time reference that appears in persisted artifacts, the other is the runtime store address. One authoritative converter maps between them, and it deliberately fails for reference shapes that are not store-aligned rather than producing a plausible-but-wrong address.
Rejected
A single scheme disambiguated by context — too fragile. The byte-level distinction is the strongest invariant available.
Cost
It looks like duplication, so every automated simplification pass tries to unify them. Unifying writes to a path that can never be found again.

Consolidate the model and the generator only — leave pack parsers in their repos

Why
The shared library owns the requirement model, the identity functions and one generator. Each consumer keeps its own parser because their declaration surfaces genuinely differ, and each parses into the shared model.
Rejected
Moving every parser into the foundation library — a layering violation that would couple the library to three unrelated file formats.
Cost
Three similarly-named requirement types still coexist, which reads as duplication until you look at their fields.

Probe-then-write is never a security boundary

Why
The cloud secret-ownership race proved it concretely: the loser of the race never reaches the error branch the author was relying on. A claim must be made in one operation.
Rejected
Compare-and-swap — the cloud API in question has no native support for it.
Cost
The ownership stamp had to be widened from 32 bits to 208. The earlier width had findable collisions between two valid environment ids — which is a complete bypass of the boundary, not a theoretical one.

The trust root is a static document on a separate domain from the plan server

Why
The threat model is one compromise defeating two gates. Hosting the trust anchor with the thing it authorises collapses them into one.
Rejected
Co-hosting (single point of compromise); multi-algorithm support (added complexity and interoperability problems — one curve only, and the alternative is explicitly rejected by a test).
Cost
A second domain and its deployment to maintain.

Code

greentic-deployer · origin/main:src/cli/updates.rs:1706 rust
// Same invariant for the trust root: a plan that seeds or rotates the
// env's signing-key trust anchor would gain permanent signing authority.
// Stripped at sign time by `strip_non_applyable_blocks`; refused here as
// the fail-closed backstop.
if manifest.trust_root.is_some() {
    return Err(OpError::InvalidArgument(
        "update plan target declares a `trust_root` block: a plan may not \
         re-point the trust root it is verified against. Configure the \
         trust root with `op env apply`."
            .to_string(),
    ));
}
The takeover fix. Two defences, not one: the block is stripped when a plan is signed, and refused when a plan is applied — because a plan built outside the standard toolchain never passes through the stripping path. The comment states the invariant so the next reader does not "simplify" one of the two away.
greentic-deployer/src/env_packs/gcp_cloudrun/deployer.rs:263 rust
/// 26 bytes of SHA-256 (208 bits, 52 hex chars — the most that fits beside a
/// 10-char prefix inside the 63-char limit). Collision-resistant, not injective:
/// two ids CAN in principle share a stamp, and one that did would be classified
/// `SecretOwnership::Ours`. At 208 bits that is infeasible to hit by accident
/// or to construct. Do not shorten it — an earlier 32-bit digest here had
/// findable collisions between two valid env ids, which is a full bypass of the
/// ownership boundary.
pub(crate) fn env_owner_stamp(env_id: &str) -> String {
    use sha2::{Digest, Sha256};
    let digest = hex::encode(&Sha256::digest(env_id.as_bytes())[..26]);
    let readable: String = env_id.to_ascii_lowercase().chars()
        .map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c } else { '-' })
        .take(10).collect();
    format!("{readable}-{digest}")
}
The most security-critical comment in the deployer. It records not just what the constant is but what happened when it was smaller — which is the only thing that reliably stops someone shortening it later to fit a nicer label.
greentic-secrets/greentic-secrets-core/src/generators.rs:17 rust
const RAW_TEXT_ALPHABET: &[u8] =
    b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_-";

/// Upper bound on a generated secret's declared `length`. The value comes from a
/// pack manifest, so a malformed or hostile pack could otherwise turn a single
/// integer into an unbounded allocation / CPU sink (`base64url`/`hex` further
/// scale it). No real secret approaches this; a pack asking for more is rejected
/// loudly rather than silently clamped.
const MAX_GENERATED_LENGTH: usize = 4096;
A pack manifest is untrusted input. The cap treats a length field from a manifest as an attack surface — and rejects loudly rather than clamping, so a pack asking for something absurd surfaces as an error instead of quietly getting something different from what it asked for.
greentic-deployer/src/env_packs/k8s/vault_infra.rs:216 rust
pub fn render_vault_network_policy(p: &VaultInfraParams) -> Value {
    json!({
        "apiVersion": "networking.k8s.io/v1",
        "kind": "NetworkPolicy",
        "metadata": { "name": "allow-vault-ingress-from-workers", "namespace": p.namespace },
        "spec": {
            "podSelector": { "matchLabels": { "app.kubernetes.io/component": VAULT_NAME } },
            "policyTypes": ["Ingress"],
            "ingress": [{ "from": [{
                "namespaceSelector": {
                    "matchLabels": { "kubernetes.io/metadata.name": p.worker_namespace }
                },
                "podSelector": {
                    "matchLabels": { "app.kubernetes.io/component": WORKER_COMPONENT }
                },
            }], "ports": [{ "protocol": "TCP", "port": VAULT_PORT }] }],
        },
    })
}
The namespace selector keys on kubernetes.io/metadata.name — the label Kubernetes sets and no one can forge — rather than the environment-ownership label. Using the ownership label would let two environments that happen to share an id reach each other's Vault.

Gotchas found the hard way

  • kubectl port-forward bypasses network policies — it reaches the pod over loopback. A port-forward that "works" can completely mask a pod-to-pod block that produces 401s in production.
  • Two normalisers in the same path are deliberately asymmetric — one segment preserves hyphens, the next folds them to underscores. Making them consistent writes to a path nothing will ever read.
  • The operator has no Vault backend at all — Vault lives only in the serving host, and the runner has a third, separate backend system. Three same-shaped abstractions with similar names coexist.
  • env destroy leaves cloud and Vault secrets behind — only provider infrastructure is torn down.
  • The spec crate contains dead source files that are never declared as modules, so any grep or code-index reports type definitions from code that does not exist at runtime.
  • Private repos on the Free plan receive no org secrets — which silently breaks both token minting and publishing. The fix for one repo was to make it public.

Status

complete Secrets consolidation (dev and stable) · Vault workload identity, proven end to end · trust-root takeover closed with dual defence · cloud secret ownership · CodeQL and dependency review at fleet scale · fuzz and audit pipeline repaired.

open The provision/promote engine is scaffolded but has no production callers yet. Secret rotation returns not-implemented. Cloud and Vault secrets are orphaned on environment destroy. The bundled-secrets backend — which would collapse N cloud secret objects into one per environment — is designed but unbuilt.

09 Demos, E2E, QA & verification discipline

18 demos · 10 E2E workflows · 10 conformance suites · a measured, not guessed, performance answer

Demos here are not marketing artifacts — they are the integration tests. Several of the most consequential bugs in this whole body of work were found because a demo asserted something a unit test could not.

What was delivered

  • A runnable demo catalog — 18 demos, each producing a signed pack, bundled with providers and answer documents, published to a container registry and releases.
  • Five new CI gates, each written in response to a real shipped defect: bundle shape verification, build-mode assertion, provider minimalism, anonymous-pull verification, and an app-pack completeness check.
  • Provider minimalism — the rule that a bundle vendors the browser tier plus whatever it actually configures, and nothing else. Measured effect across the fleet: 222 MB → 154 MB, −31%, with the worst offenders down 76–79%.
  • E2E across six platform targets plus provider, cloud, browser, telemetry and store suites; migrated from parsing stdout for readiness to probing a readiness endpoint.
  • Conformance suites asserting signed manifests, 14 mandatory telemetry span keys, deployer idempotency, and message deduplication.
  • Self-checking demos — demo scripts that double as assertion suites, so a silently-green run is structurally impossible.

Three bugs that only demos could find

A green build shipping five empty bundles

The bundle builder returned success for an unresolvable app-pack reference. The pack was silently dropped and the build exited 0. Five released bundles contained zero application packs — only providers. Every existing guard missed it because their preconditions excluded exactly this failure mode. The gate that now catches it extracts each bundle and compares declared pack counts against embedded ones: a green build proves nothing about contents.

Three months of shipping a workspace snapshot instead of a bundle

One demo's configuration said "dry run", so the wizard emitted no bundle — and the packaging script fell back to squashing the raw wizard workspace, complete with log files containing absolute home paths and no bundle manifest. Deployment rejected it. It shipped that way from late April to the end of July, undetected because bundle publication is opt-in, so 13 of 17 bundle tags served three-month-old content while their packs and answers looked fresh.

A registry package that becomes permanently unwritable

A container registry links a package to its source repository only via an annotation set at push time. Without it, the ephemeral token that created the package is gone forever and every subsequent push gets a 403. Eight packages were stuck this way. Repair required an admin token to retroactively link, and delete-and-recreate for the rest.

The performance question, settled by measurement

Should the runner cache pre-instantiated WASM components across invocations? Two independent code-reading analyses estimated the cost at 4–8 seconds. The measurement:

MeasuredCostShare
Linker registration68 µs96%
Pre-instantiation3 µs4%
Total per invoke71 µs
End-to-end response~65 ms100%
Instantiation share~0.1%

Throughput stayed flat at ~12k ops/s from one thread to sixteen — no contention cliff. The estimate was wrong by five orders of magnitude. The dead cache field was deleted rather than wired up, and the lesson was written down: measurement beats code-path reasoning.

Verification discipline

This is the part that generalises beyond Greentic. The rules below were each written after a specific failure.

Mutation-prove
Agent-written tests are validated by deliberately breaking the production code and confirming the test fails — re-run personally, not taken on report. Five distinct fake-test patterns were catalogued: tests that re-derive the logic instead of calling it; fixtures that put the target first so a first-entry shortcut is indistinguishable from a correct match; construction by struct literal so nothing between test and assertion can break; hand-built fixtures blind to a changed discriminator; and a name-filtered harness that matches nothing and exits 0.
No-fail-fast
The default test runner stops at the first failing binary, so a repository can hold several independent failures while showing one. Verification runs must not stop early.
All targets, all features
Linting only the library skips test-module code, so lints pass locally and fail in CI.
Check the real target
A host build skips architecture-gated blocks entirely — a WASM component can pass every local gate and still fail to compile for the target it actually ships to.
One adversarial round
Before merge, one round of adversarial review, then fix what is valid and in scope, then simplify once. Never loop until clean: findings drift off the PR's subject, and an out-of-scope finding becomes a follow-up rather than scope creep.
Design gate first
For non-trivial work, the spec is reviewed by independent readers — one per lens, each asked to find where the spec is wrong and to anchor every finding to a real line of code. On one deployment feature this falsified four load-bearing claims that had survived careful solo reading.
Run every command you document
A fabricated command is prose: it compiles, passes every gate, and often reads better than the real one. One documentation review caught three commands describing verbs and flags that did not exist.

Code

greentic-conformance/src/assertions.rs:12 rust
const REQUIRED_OTEL_KEYS: &[&str] = &[
    "service.name",
    "greentic.pack.id",
    "greentic.pack.version",
    "greentic.flow.id",
    "greentic.node.id",
    "greentic.component.name",
    "greentic.component.version",
    "greentic.tenant.id",
    "greentic.team.id",
    "greentic.user.id",
    "greentic.session.id",
    "greentic.run.status",
    "greentic.capability",
    "greentic.artifacts.dir",
];
Observability as a conformance requirement rather than a convention. Any component whose spans omit one of these fourteen keys fails the suite — which is what makes it possible to answer "which tenant, which flow, which capability" for an arbitrary trace across a fleet of independently-authored components.
greentic-conformance/src/deployer_suite.rs:6 rust
pub fn assert_deployer_idempotent(
    deployer: &Path, config: &Path, extra_args: &[&str],
) -> Result<()> {
    let first = run_deployer(deployer, config, extra_args)
        .context("first deployer apply failed")?;
    let second = run_deployer(deployer, config, extra_args)
        .context("second deployer apply failed")?;
    if first != second {
        bail!("deployer is not idempotent: outputs differ between runs");
    }
    Ok(())
}
Idempotency asserted directly rather than assumed: apply twice, compare outputs byte for byte. Every deployer implementation — local, Kubernetes, ECS, Cloud Run — has to satisfy it.
my_demos/multi-url-webchat-demo/lib.sh:1 bash
# lib.sh — shared by demo.sh and demo-start.sh.
# Sourced, never executed. The caller must set `HERE` and `PORT` before
# sourcing; everything else is passed as an argument so the two demos can
# differ freely in how they bring an environment up.
#
# The one piece of shared STATE is FAILURES: `fail`/`expect` bump it, and each
# script exits on it. Both demos double as checks, so a silent green run must
# be impossible.

FAILURES=0
fail() { bad "$*"; FAILURES=$((FAILURES + 1)); }
expect() {
  if [[ "$3" == *"$2"* ]]; then ok "$1"
  else fail "$1 -- expected '$2', got '$(printf '%s' "$3" | head -c 70)'"; fi
}
"A silent green run must be impossible" is the design goal stated in the file itself. These two demos carry 58 and 43 assertions and both exit 0 on a pure stable toolchain — so the demo is simultaneously the documentation and the integration test.

Gotchas found the hard way

  • grep -x 'bundle.lock.json' matches bundle-lock.json — the dot is a regex wildcard. Use a literal file test when the distinction is the whole point of the check.
  • A toolchain install prefetched 92 registry blobs all-or-nothing with no resume, so three retries were three fresh chances to lose the job rather than three chances to finish. Dropping the unused prefetch cut install from 256 s to 77 s.
  • pgrep -x silently matches nothing for names over 15 characters — it compares the kernel's truncated command field.
  • strings only reports printable ASCII runs — an em dash splits a marker phrase into two runs and the match silently fails.
  • Docker's 64 MB default shared memory caused 525 spurious test failures that presented as unrelated database migration errors. Same commit at 1 GB: 2 failures.
  • Never pkill -f in a demo script — the pattern matches the calling shell's own command line and kills the session.
  • A demo showing identical output on three different URLs was a mislabelled pack, not broken routing. Check the asset before debugging the router.

Status

verified Demo catalog at 1.1.7 with all five gates · 17 bundles republished and verified byte-reproducible · cloud update demo live · designer database CI gap closed on both lanes · E2E migrated to the environment path.

open The stable E2E promotion gate has its build foundation done but the orchestrator, full-demo suite and reviewer gating remain. Integration strategy phase 2 has been stalled since mid-May. The durable fix for the registry client's retry behaviour is owed but blocked on the dead stable release lane. Two demo bundle packages serve stale content indefinitely.

Landmines

The cross-cutting traps most likely to catch you, collected from all nine subsystems. Each cost real debugging time at least once. The per-subsystem sections above carry the rest.

What looks fineWhat is actually happening
CI is green on your PRThe shared PR workflow runs fmt, clippy, test and build — not ci/local_check.sh. Any repo-specific gate living there has never run in CI. Proven on one repo where three gates were decorative. The fleet-wide survey has not been done.
The build exited 0Says nothing about the artifact. A bundle build shipped five releases containing zero application packs. Extract the artifact and count what is inside it.
Your dependency floor is correctA path dependency builds against the in-tree copy, so the version floor is never consulted. Three releases shipped uncompilable this way. PR CI cannot see it.
An exact pin is the safe choiceAn exact pin in a workspace member breaks dev-publish, which stamps every member to a run-id version the pin cannot match. It also caps every downstream consumer transitively.
Two PRs both passed CIThey can merge cleanly and leave main not compiling — different file regions, no textual conflict, and CI that last ran 41 hours earlier. Re-gate after a sibling PR lands.
You updated the shared workflow and re-ran the jobgh run rerun replays the reusable workflow as snapshotted at run creation. Push a new commit instead.
Your token worksGitHub App installation tokens are per organisation. A token for one org hitting the other's private repo returns a privacy-preserving 404, which reads as "repo doesn't exist". This failed silently four times.
The branch is protectedProtection without required status checks lets red PRs merge. And on the Free plan, private repos cannot have protection or receive org secrets at all.
You pinned :latestIt never reliably rotates. Observed drift: :latest at 0.5.6 while :stable was 0.5.8. Pin :stable or an explicit version.
Health checks pass on Cloud RunGoogle's frontend intercepts /healthz and returns its own 404 — your container never sees it. Probe /readyz. Kubernetes uses /healthz legitimately. Same path, opposite correct answer per platform.
secret:// and secrets:// look like a typoThey are two different address spaces differing at one byte — deployment reference versus runtime store URI. Every automated simplification pass tries to unify them; unifying writes to a path nothing can ever find.
You extracted a tidy async helperAn AsyncFnMut-bounded helper propagates non-Send lifetimes to consumers that spawn. The error appears at the spawn site in another crate, not at your helper. Inline the loop.
cargo update succeededIt can silently enable serde_json's preserve-order feature through a transitive bump, switching maps to insertion order and breaking every canonical hash taken over a stringified value.
You used notify_waitersIt drops stored permits, so an event fired before the waiter is listening is lost forever. Use notify_one.
You reasoned carefully about the hot pathTwo independent code-reading estimates of runner instantiation said "4–8 seconds". Measured: 71 µs. Wrong by five orders of magnitude. Measure.
cargo test passed after your fixIt stops at the first failing test binary, so a repo can hold several independent failures while showing one. Use --no-fail-fast.

Pick up here

Ordered by what unblocks the most, not by size. Everything below is known and tracked; nothing here is a surprise discovered while writing this document.

P0 Do these first

1 · Decide the lane strategy — converge, or formalise the split

Why now
648 develop-only commits, ~0 cherry-equivalence, and a deployment contract crate forked three ways on irreconcilable version schemes. Every other cross-lane task is downstream of this decision, and the cost grows with every commit on either side.
Either answer is defensible
Converging means resolving greentic-deploy-spec first — 0.2.x embedded-path versus 1.3.0-research.1 from crates.io. Formalising the split means pinning the seams (see Where our lanes collide) so drift stops being silent. What is not defensible is leaving it undecided while both lanes keep moving.

2 · Resolve the weekly stable pipeline: revive or retire

Why now
Dead since 2026-05-04. Until it is decided, every stable release is manual, main only advances by hand, and at least two other threads are queued behind it — including the durable fix for the registry client's retry behaviour.
Note
Retiring it is a legitimate answer. Manual promotion has worked for three months. But leaving it undecided is what costs.

P1 Structural — do before building new features

3 · Clear the forward-port debt

Why
Hot-reload granularity, multi-bundle webchat routing and the tenant-binding train exist on main only. op env destroy, named local environments and the AWS deployer exist on develop only. The gap grows daily and the merge gets harder.
Watch for
Merge, never squash. Include the clean PRs, not just the conflicting ones. Expect the shared deployment-contract crate to be the hardest merge — it is forked three ways.

4 · Survey the local-gate versus CI blind spot fleet-wide

Why
One repo was found with three gates that had never executed in CI. Any repo delegating to the shared workflow while keeping gates in local_check.sh has the same hole. Nobody knows how many.
Approach
Grep run logs for each gate's own output — the presence of the workflow proves nothing.

5 · Close the two known CI gaps

Why
One foundation repo has no post-merge build at all (its workflow triggers only on pull request), which let a corrupted lockfile survive three days undetected. The runner has no clippy and no host-target tests, blocked by a cross-org private dependency.

P2 Feature continuation

ThreadStateFirst move
Air-gap Phase D — trust-root rotation: key roles, threshold verification, two-phase commit, recovery journalDesigned, blockedUnblock the dependency publish it waits on. Plan is in plans/airgap-updates-phase-c-d.md.
Env-manifest phases 3–4 — trust-root keys, endpoint update verb, webhook secrets, retention, revocationPhases 1–2 shippedPhase 3 is additive to a working engine; start there.
--target → env-packs unificationDecided 2026-07-30, sequencedThe flag still drives the legacy infrastructure-as-code path. No Azure env-pack exists yet — that is the hard constraint.
Stable E2E promotion gateBuild foundation doneThe orchestrator, full-demo suite and reviewer gating remain. Ties into the P0 stable-lane decision.

P3 Deferred by decision — revisit consciously

These were deferred deliberately, not forgotten. Two are worth re-examining early:

  • Teams JWT signature verification (phase 2). Phase 1 decodes and validates claims but does not cryptographically verify, so a forged token with the correct audience is admitted. security-relevant — this is the one deferred item with a real edge; decide whether the deployment model makes it acceptable.
  • The unauthenticated webchat token endpoint, which mints valid signed tokens from pack presence alone.
  • Slack form-encoded interactive routing — needs the server to forward the raw body.
  • Revision-aware event routing; multi-turn slot filling; Azure and single-VM deployers.
  • The bundled-secrets backend — designed but unbuilt; collapses N cloud secret objects into one per environment, which is a real cost saving at scale.
  • macOS runner spend, roughly $645/month against a $50–110 target. Mitigation path identified, not executed.

Two structural problems with no known fix

Flagged so you do not spend time rediscovering that they are hard rather than merely undone.

  • The -dev binary namespace is shared between our two lanes — and yours wins. Both lanes publish binaries under the same <crate>-dev name on crates.io, and 1.3.0-research.* numerically outranks 1.2.*. So a cargo binstall <crate>-dev on the develop lane can resolve to your research build. One package was observed serving a twelve-day-stale research binary to develop consumers. There is no structural fix today — only checking which lane a -dev version actually came from before trusting it. Worth folding into the P0 lane decision.
  • Environment destroy orphans cloud secrets. Only provider infrastructure is torn down; entries in cloud secret managers and Vault survive. Manual cleanup is currently owed after every destroy.

§ Access you'll need

You already have most of these. Listed so the gaps are obvious — the ones you are least likely to hold today are Cloudflare, the cloud deployment accounts, and crates.io publish rights across the whole namespace. Ask Vlad for the credential inventory and handover procedure; that is deliberately not recorded here.

SurfaceWhat it's forNotes
GitHub — both orgsEverythingTwo organisations: the open one and the commercial one. You need membership in both; many scripts fail confusingly with access to only one.
GitHub App installationCI automation, tagging, publishingTokens are minted per organisation by a helper script. Cross-org workflows must mint two — this is the single most common silent failure in the automation.
crates.ioPublishing the fleetPublish rights on the whole crate namespace.
Container registryPacks, bundles, toolchain manifestsRead is anonymous for public packages; publishing needs write. Beware the package-linkage trap in the demos section — an unlinked package becomes permanently unwritable.
CloudflareTrust root and update plan storeTwo separate accounts are in play. Deploying to the wrong one silently creates a duplicate Worker rather than failing. Confirm which account owns which service before deploying.
Cloud providersDeployment targetsGoogle Cloud and AWS credentials for the live deployer end-to-end tests. Both are gated and skip cleanly without credentials — which means those legs are currently masked green.
SlackCI alertingFailure notifications route to an alerts channel. A mute rule for the automated-remediation workflows is still in place and is now removable.
A platform constraint that explains several oddities

Both organisations are on the GitHub Free plan. Private repositories therefore cannot have branch protection or rulesets, and do not receive org secrets even at "all repositories" visibility. This is why some invariants are enforced by a workflow rather than by the platform, and why one repo was made public specifically to fix its publishing. If you see automation "succeed" while publishing nothing on a private repo, this is almost always why.

§ Where the knowledge lives

This document is a map, not the territory. When you need depth, these are the primary sources — in the order you will want them.

LocationWhat's there
CLAUDE.md (root)The living workspace guide — conventions, gotchas, build commands, layer model. Kept current and audited periodically. Read this second, after this document. AGENTS.md is a symlink to it.
plans/~45 completed design documents in done/, plus active plans at the top level and explicitly-shelved ones in later/ and discarded/. This is where the reasoning lives. When a subsystem looks strange, its plan usually explains why.
docs/REPO_MAP.mdEvery repository, its org, and a one-line purpose, grouped by architectural layer.
docs/WORKSPACE_SCRIPTS.mdWhat each helper script does.
docs/SECRETS_LIFECYCLE.mdThe full secret lifecycle — generation, identity, promotion, backends.
dep-graph/graph.mdTier-grouped adjacency, plus top-of-stack and most-coupled tables. Regenerate rather than trusting it.
DEV_RELEASE.mdThe full anatomy of the nightly and weekly lanes, including the binary bifurcation rules.
my_demos/Self-checking demo scripts that double as integration tests. Running one is the fastest way to confirm your environment works end to end.
Per-repo ci/local_check.shThe real gate for that repo — often stricter than CI. Read it before changing a repo you don't know.

The one idea to carry forward

If you take nothing else from this document: the through-line in every subsystem is make the invariant mechanical. Tiers are derived, not declared. Canonical bytes are enforced by a canonicaliser, not by convention. Ownership of a cloud secret is a 208-bit stamp, not a naming agreement. A trust root cannot be re-pointed by the thing it authorises — at sign time and again at apply time. A demo that could pass silently is not considered a demo.

That principle is also the honest answer to the lane problem. The two lanes did not drift because anyone was careless — they drifted because nothing mechanical was stopping them. A floating branch pin, a presence-only binary check, and a shared -dev namespace with no lane discriminator are all places where the invariant lived in someone's head. Whichever way you decide the P0 lane question, the durable part of the answer is a gate rather than an intention.