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.
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.
- 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.
- Revive-or-retire on the weekly stable release pipeline. Dead since
2026-05-04. Until it is resolved every stable release is manual and
mainonly advances by hand. - Whether to keep the forward-port cadence. Main-only work diverges from
developdaily; the merge gets worse the longer it waits.
Two things that are true of this lane and not of yours
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.
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
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
| Repo | research-only | develop-only | cherry-equivalent |
|---|---|---|---|
greentic-setup | 19 | 186 | 0 |
greentic-messaging-providers | 16 | 137 | 0 |
greentic-deployer | 25 | 119 | 0 |
greentic-start | 24 | 81 | 2 |
greentic-update | 1 | 50 | 0 |
greentic-bundle | 6 | 38 | 0 |
greentic-secrets | 10 | 37 | 0 |
| Total | 101 | 648 | 2 |
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.
greentic-deploy-spec — the shared deployment contract — is forked three
ways, and the version schemes cannot be reconciled by SemVer:
| Lane | How it depends on deploy-spec | Carries |
|---|---|---|
main | embedded path crate, version = "=0.2.6" | default_bundle |
develop | embedded path crate, version = ">=0.2.6, <0.3" | default_bundle and blob_base_url + insecure_http |
research | crates.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" }ingreentic-admin's researchCargo.toml— norev, 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
revat 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.rsdeclaresREQUIRED_BINS = ["greentic-bundle", "greentic-deployer"]and checks only that each is onPATHand exits successfully from--version. The output is never parsed or compared.- Why it bites
- The designer drives
greentic-deployer op … env init|show|doctoranddeploy. Those verbs have changed materially on develop/main —op env destroyexists 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
--versionand 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— andgreentic(thegtcCLI) — exist only onmain/develop. Verified by branch listing on 2026-07-31.- Why it bites
greentic-operatoris 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.
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
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
./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
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
./sync-toolchain.sh --check # fails if any repo has drifted
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
cargo binstall gtc gtc install # resolves the pinned toolchain manifest and installs exact versions gtc doctor # verifies the install
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
| Binary | Version as of 2026-07-31 | Role |
|---|---|---|
gtc | 1.1.13 | Umbrella CLI; delegates to the rest |
greentic-start | 1.1.37 | Serving runtime host |
greentic-deployer | 1.1.30 | Environments, env-packs, updates |
greentic-setup | 1.1.28 | Guided setup, provider wiring |
greentic-operator | 1.1.6 | Capability enforcement |
greentic-bundle | 1.1.2 | Bundle composition |
greentic-dev | 1.1.3 | Dev 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
# 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
| Script | What 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.sh | Move every repo back to its default branch |
./bump-dev-version.sh --repo|--tier|--cascade|--all | Bump develop-lane versions; --cascade also updates downstream lockfiles |
./clean-gone-branches.sh | Delete local branches whose remote is gone |
./clean-builds.sh | Prune stale target/ directories |
.github/scripts/forward-port.sh | Merge main → develop fleet-wide; clean merges push, conflicts open PRs |
.github/scripts/onboard-repo.sh NAME | Apply every CI convention to a new repo and register it in the manifest |
dep-graph/build_graph.py | Regenerate the dependency graph and tiers after any Cargo.toml change |
§ What you're absorbing
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.
| Subsystem | Why you'd open it |
|---|---|
| 01 Fleet CI/CD | Your repos already use this substrate — 39 shared reusable workflows. Read when CI behaves unexpectedly. |
| 02 Release & versioning | Tiers, lanes, binary bifurcation. Read before publishing anything. |
| 03 Core runtime | ABI, CBOR canonicalization, capabilities. Overlaps your lane most. |
| 04 Deployment & environments | The largest subsystem and the one the designer shells into. Four cloud targets. |
| 05 Updater, trust & air-gap | Entirely new to you; built in 31 days. Signed plans, self-update, air-gapped distribution. |
| 06 Messaging & webchat | Runtime routing and hot-reload. Newest code in the lane. |
| 07 Instance identity & slots | A cross-repo train that touched the flow model and the WIT contracts — likely relevant to your lane. |
| 08 Secrets & capability security | Workload identity, trust roots, 14 fixed findings. |
| 09 Demos, E2E & verification | The 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.
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.
| Repository | PRs | What it is |
|---|---|---|
greentic-deployer | 250 | Environments, env-packs, cloud deployers, update publishing |
greentic-start | 140 | The serving runtime host — routing, webchat, hot-reload |
greentic-setup | 65 | Guided setup, provider wiring, bundle bootstrap |
greentic-dev | 64 | Developer tooling, coverage gate |
greentic-runner | 54 | WASM execution host |
greentic-messaging-providers | 46 | Slack / Teams / Telegram / WhatsApp / webchat components |
greentic-pack | 43 | Signed pack format and builder |
greentic-operator | 41 | Capability enforcement, deny-by-default |
greentic-bundle | 39 | SquashFS deployment composition |
greentic-secrets | 38 | Tenant-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.
| Layer | Artifact | Guarantee it carries |
|---|---|---|
| Component | WASM module | Self-describing via describe(); sandboxed; capabilities explicit |
| Flow | .ygtc → CBOR | Deterministic 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 |
| Operator | runtime | Loads 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.pyscans everyCargo.tomlon 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 — thetier = Nin 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.mdis 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.
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
| Lane | Branch | Cadence | Status |
|---|---|---|---|
| Nightly develop | develop | 02:00 UTC daily | healthy Mostly green since 2026-07-10; 67 repos enrolled |
| Weekly stable | main | Mon 06:00 UTC | dead Last run 2026-05-04 and it failed; last success 04-16 |
| Research | research | manual | not yours Hand-dispatched in tier order |
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 main → develop
# 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
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
| When | Check |
|---|---|
| Daily | Nightly 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 PR | One adversarial review round, fix what is valid and in scope, simplify once. Do not loop until clean. |
| After any Cargo.toml dependency change | Re-run dep-graph/build_graph.py, then sync_manifest_tiers.py to propagate. |
| After a stable release | Forward-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
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_callworkflows in the centralgreenticai/.githubrepo. Per-repo CI became a 5–15 line caller.pr-ci.ymlis the unified PR baseline (fmt + clippy + test + build), variant-dispatched forhostvswasm. - 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.tomlvariants in.github/toolchain/, distributed bysync-toolchain.shwith--dry-run/--direct/--checkmodes. Six fragmented versions consolidated to 1.94.0, then 1.95.0. - Default-branch standardisation —
master→mainacross 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 automation —
onboard-repo.shapplies 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-ciApp across both orgs, with a per-org token-minting helper. - Coverage policy gate — nightly
cargo-llvm-cov+nextestdriven by a per-repocoverage-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;
@mainmeans one PR upgrades the whole fleet instantly. - Rejected
- Semantic
@v1tags — too much coordination overhead during the rollout phase. Per-repo inline CI — the status quo, 50+ copies to maintain. - Cost
- A bad merge to
.githubbreaks every repo at once. Mitigated by a self-test workflow. Also:gh run rerunreplays the reusable as snapshotted at run creation, so an updated@mainis 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: writepermission cap at graph-build time — even when the feature was gated off by input. GitHub validates nested-reusable permissions before evaluatingif:. - 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.jsonfiles still linger in test-flow repos.
Code
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
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.#!/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
--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.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
Gotchas found the hard way
- Inside a reusable workflow,
github.workflowresolves to the caller's name — concurrency groups built from it collide whenever a release workflow callsci.yml. Fix: hardcode a literal prefix. - App installation tokens are per-org, not per-App. A
greenticaitoken hitting a privategreentic-bizrepo 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 listunder 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-typeshadenforce_admins: trueand 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
developto apull_requestfilter made semver-checks run for the first time on a pre-release lane where it can never pass — there is no stable baseline forv1.2.*. - Runner disk exhaustion kills large builds silently — it surfaces as a
nullconclusion 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 fallbackGITHUB_TOKENcannot 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
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-enabledon 67), binary classification, and cached tiers. Every pipeline script reads it.- The dependency graph —
dep-graph/build_graph.pyscans everyCargo.tomlon 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 --checkis 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_publishedflag forces downstream rebuilds even without source changes. Post-tier, a lock-sync job runs scopedcargo updateacross every develop repo and opens bot PRs. - Binary bifurcation — 20 binary-shipping crates across 17 repos publish under a
sibling
<crate>-devname on the develop lane, socargo binstallresolves 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-devsibling. - 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 binstallneeds 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.tomledits, broken semver-check baselines, and mutatedusestatements between branches. An alternate registry —cargo binstallcannot 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-releaseflag to protect the stable name, and the-devnamespace turns out to be shared with the research lane.
Stamp dev versions with GITHUB_RUN_ID, not RUN_NUMBER
- Why
RUN_NUMBERis per-workflow and resets on re-runs, so two runs can mint the same version and collide on publish.RUN_IDis 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.30251816739are 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.tomlversion, 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 byGITHUB_TOKENdoes 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
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.
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.# 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`.
[[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"
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
-devnamespace is shared between develop and research. Research 1.3 outranks develop 1.2 by semver, so<crate>-devcan 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.tomlbut notCargo.lock— so every binary cut needs an explicit workspace update before merge, or the--lockedbinary 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
--offlinebut fail a fresh resolve. A test that scaffolds a lockless project fails while the main build stays green. dev-publishreports 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 inanon_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
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 ABI —
greentic:component@0.6.0, with two mandatory exports:describe()returning a self-describing manifest, andinvoke()taking CBOR payloads. A lighter@1.0.0world 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.gmappolicy file format the operator evaluates deny-by-default. - Multi-tenancy plumbing —
TenantCtxthreaded 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
world component {
import control;
export node;
}
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./// 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
}
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./// 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)
}
FloatNotAllowed, TagNotAllowed, NonStringMapKey. This is what makes a pack manifest's Blake3 hash stable across machines, compilers and serialization orders.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)
))
}
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
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
AsyncFnMuthelper breaksSendinference downstream. The error surfaces at thetokio::spawnsite 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 areSend. - 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
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-speccontract crate, anEnvironmentStorewith a filesystem backend, theop <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-west1and 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
--checkmode 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 command —
gtc start <x>.gtbundleauto-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
Revisionplus a mutableTrafficSplitgives 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.0is a namespaced string with a semver, resolved through a registry at runtime. - Rejected
- A closed
DeployerKindenum — 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'sunknown_kindsreveals 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()andlist()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
#[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,
}
packs[]; Messaging and Extension are N-per-environment, which is why binds_in_packs() returns false for them.#[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>;
}
apply_traffic_split must reject a non-10,000 sum before making any provider call.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
}
{
"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": ["/"] } }
]
}
kind to greentic.deployer.k8s@1.0.0 retargets the same manifest at Kubernetes.Gotchas found the hard way
- Cloud Run never delivers
GET /healthzto your container — Google's frontend intercepts it and returns a branded 404 page. Probe/readyz. Kubernetes uses/healthzlegitimately, 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
mainnot 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 startopens a public tunnel whenpublic_base_urlis unset and a messaging pack is served — and the disable flag does not win, because the override happens first.env applywrites 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 buildcan 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
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:webdocument 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
.gtupdateenvelope: 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 distribution —
gtc installresolving an OCI manifest of exact version pins, a one-commandgtc upbootstrap, and a dedicated:airgappedchannel 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
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,
}
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.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"
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 })
}
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.
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, nevernotify_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 installactively 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 usesha256-<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 putwith 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 —
--versionshows the manifest version, not the run-id in the archive filename, so dev self-update is a guaranteed 404. promoteis 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
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
demotodefault; 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
/chat302-redirects, never 301, because the redirect target changes whenever the default bundle changes.gui_enabled: truenow 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
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" }
}
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.#[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,
}
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()
}
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.#[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>,
}
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.
:latestnever reliably rotates on the provider registry — pin:stableor an explicit version. Observed drift::latestat 0.5.6 while:stablewas 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
.gtpackre-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 /chatreturning 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
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
| # | Layer | What landed |
|---|---|---|
| 1 | greentic-interfaces | A 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) |
| 2 | greentic-types · deploy-spec | A provider id on provider declarations, and a typed messaging-endpoint entity on the environment with its own CLI verbs and an admit table |
| 3 | greentic-fast2flow | Routing 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 |
| 4 | greentic-runner | The 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 |
| 5 | greentic-start | Endpoint id plumbed from header through serving, per-endpoint admit gate, wrapper payload construction, synthesised provider webhook routes, and the loopback-gated worker gateway |
| 6 | greentic-messaging-providers | Six providers implement the export — Telegram off a secret-token header, Teams off /recipient/id in the body, Slack off the team and app ids |
| 7 | flow · components · examples | Flow-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
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;
}
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.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>,
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.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),
};
// …
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
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
AsyncFnMuthelper extracted from the probe loop brokeSendinference 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
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
| Finding | Why it was exploitable | Fix |
|---|---|---|
| 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
// 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(),
));
}
/// 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}")
}
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;
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 }] }],
},
})
}
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-forwardbypasses 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 destroyleaves 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
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
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.
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 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:
| Measured | Cost | Share |
|---|---|---|
| Linker registration | 68 µs | 96% |
| Pre-instantiation | 3 µs | 4% |
| Total per invoke | 71 µs | — |
| End-to-end response | ~65 ms | 100% |
| 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
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",
];
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(())
}
# 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
}
Gotchas found the hard way
grep -x 'bundle.lock.json'matchesbundle-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 -xsilently matches nothing for names over 15 characters — it compares the kernel's truncated command field.stringsonly 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 -fin 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 fine | What is actually happening |
|---|---|
| CI is green on your PR | The 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 0 | Says 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 correct | A 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 choice | An 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 CI | They 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 job | gh run rerun replays the reusable workflow as snapshotted at run creation. Push a new commit instead. |
| Your token works | GitHub 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 protected | Protection 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 :latest | It 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 Run | Google'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 typo | They 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 helper | An 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 succeeded | It 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_waiters | It drops stored permits, so an event fired before the waiter is listening is lost forever. Use notify_one. |
| You reasoned carefully about the hot path | Two 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 fix | It 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-specfirst —0.2.xembedded-path versus1.3.0-research.1from 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,
mainonly 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
mainonly.op env destroy, named local environments and the AWS deployer exist ondeveloponly. 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.shhas 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
| Thread | State | First move |
|---|---|---|
| Air-gap Phase D — trust-root rotation: key roles, threshold verification, two-phase commit, recovery journal | Designed, blocked | Unblock 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, revocation | Phases 1–2 shipped | Phase 3 is additive to a working engine; start there. |
--target → env-packs unification | Decided 2026-07-30, sequenced | The flag still drives the legacy infrastructure-as-code path. No Azure env-pack exists yet — that is the hard constraint. |
| Stable E2E promotion gate | Build foundation done | The 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
-devbinary namespace is shared between our two lanes — and yours wins. Both lanes publish binaries under the same<crate>-devname on crates.io, and1.3.0-research.*numerically outranks1.2.*. So acargo binstall <crate>-devon 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-devversion 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.
| Surface | What it's for | Notes |
|---|---|---|
| GitHub — both orgs | Everything | Two organisations: the open one and the commercial one. You need membership in both; many scripts fail confusingly with access to only one. |
| GitHub App installation | CI automation, tagging, publishing | Tokens 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.io | Publishing the fleet | Publish rights on the whole crate namespace. |
| Container registry | Packs, bundles, toolchain manifests | Read is anonymous for public packages; publishing needs write. Beware the package-linkage trap in the demos section — an unlinked package becomes permanently unwritable. |
| Cloudflare | Trust root and update plan store | Two 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 providers | Deployment targets | Google 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. |
| Slack | CI alerting | Failure notifications route to an alerts channel. A mute rule for the automated-remediation workflows is still in place and is now removable. |
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.
| Location | What'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.md | Every repository, its org, and a one-line purpose, grouped by architectural layer. |
docs/WORKSPACE_SCRIPTS.md | What each helper script does. |
docs/SECRETS_LIFECYCLE.md | The full secret lifecycle — generation, identity, promotion, backends. |
dep-graph/graph.md | Tier-grouped adjacency, plus top-of-stack and most-coupled tables. Regenerate rather than trusting it. |
DEV_RELEASE.md | The 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.sh | The 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.