Greentic · Next-gen deployment

How Environment is going to work

A plain-language tour of what an Environment is, why bundles stop owning deployment knowledge, and the code architecture that lands across Phase A → D of plans/next-gen-deployment.md.

1. The core idea, in one sentence

Today a .gtbundle carries its deployment knowledge inside itself — which secrets backend, which telemetry exporter, which deployer, all baked into the bundle's setup-state. Tomorrow, the Environment is the deploy target — a separate, addressable thing that knows where it lives, what capabilities are attached, and what's deployed into it. Bundles become environment-agnostic; the Environment owns the wiring.

2. Kitchen vs recipe

Today — recipe owns the oven

Every .gtbundle ships with its own oven, pantry, and freezer. Want to swap to a different oven (AWS Secrets Manager instead of dev-store)? You re-cook every recipe.

Tomorrow — kitchen owns the oven

The kitchen (Environment) owns the oven, the pantry, the freezer (env-packs). Recipes (bundles) just say "bake at 200°C" and the kitchen routes that to whatever oven is installed. Swap the oven without touching the recipes.

3. The object model at a glance

Environment environment.json · one per env-id (e.g. local, prod-eu) host_config region, hostname, redis URL packs[6] capability slot → env-pack bundles[] workload artifacts in this env revisions[] history of every staged version traffic_splits[] one per deployment_id credentials_ref P5: bootstrap vs minimum runtime.json · sibling file discovered: ALB DNS, secret ARNs (written by deployer post-apply) audit/ · append-only backups/ · pre-mutation snapshots BundleDeployment one per (env, bundle, customer) route_binding (hosts, paths, tenant) current_revisions: [Revision ids] revenue_share (basis points) Revision inactive → staged → warming → ready → draining → archived TrafficSplit entries: [{revision_id, weight_bps}] sum == 10_000 · atomic ArcSwap
Environment owns the wiring. BundleDeployment is the rollout unit; one per (env, bundle, customer).

4. Three sources of config, three owners

This split is load-bearing — today's mess is that all three ride the same channel.

SourceOwnerWhen setLives inExamples
Host config Operator / admin At env create-time environment.json:host_config region, cluster endpoint, public hostname, fixed Redis URL
Setup config Wizard answers per env-pack gtc op env-packs add|update env-packs/<slot>.answers.json + secrets backend Telegram bot token, OTLP sampling rate, SMTP From
Runtime config Deployer env-pack (discovers post-apply) After deployer runs runtime.json ALB DNS, Cloud Run URL, generated secret ARNs

Resolution at runtime: components and ingress read all three through a unified resolver (secret://, runtime://, host fields). The bundle never names a concrete provider.

5. Capability slots: closed surface, open content

Closed enum — the 6 slots

pub enum CapabilitySlot {
    Deployer,
    Secrets,
    Telemetry,
    Sessions,
    State,
    Revocation,
}

Adding a slot is a deploy-spec version bump — rare.

Open descriptor — the provider

pub struct PackDescriptor(pub String);
// "greentic.deployer.k8s@1.0.0"
// "greentic.secrets.aws-sm@1.0.0"
// "greentic.telemetry.otlp@1.0.0"

Adding a new K8s deployer = publish a pack with that descriptor. No enum change anywhere.

v1 binds exactly one env-pack per slot. Fan-out cases (e.g. two telemetry exporters) are represented by a composite env-pack until a future schema version adds list semantics.

6. On-disk layout

~/.greentic/environments/ └── local/ ├── environment.json ← packs[], bundles[], revisions[], traffic_splits[], host_config ├── runtime.json ← discovered ALB DNS, secret ARNs, … ├── env-packs/├── deployer.answers.json ← wizard answers (non-secret)├── secrets.answers.json└── credentials.json ← P5 — bootstrap vs minimum-requirements ├── traffic-splits/└── <deployment_id>/├── current.json└── 2026-05-14T09-25-00.json ← rollback history ├── audit/ ← append-only audit events └── backups/ ← pre-mutation snapshots

Operator-owned in production; CLI-owned for local. The local env is auto-created on first gtc setup with five default env-packs bound: local-process deployer, dev-store secrets, stdout telemetry, in-memory sessions, in-memory state.

7. Code architecture — crate boundaries

greentic-deploy-spec NEW · canonical types Environment, Revision, TrafficSplit, … greentic-deployer extended EnvironmentStore + LocalFsStore env_packs/registry · tool_check greentic-operator extended admin_api · wizard(env_id) static_routes per (dep, bundle, rev) greentic-start extended RevisionDispatcher (NEW) ActivePacks keyed by 4-tuple greentic-config-types split keeps EnvironmentHostConfig only greentic-runner-host extended load_revision(...) · new key greentic-telemetry extended env/customer/deployment/rev stamps greentic-bundle touched wizard env_id arg NEW crate Extended significantly Touched / surgical
One new crate. Three extended crates. Four touched. No big-bang refactor.

The new crate — greentic-deploy-spec

greentic-deploy-spec/
├── Environment, EnvPackBinding, CapabilitySlot, PackDescriptor
├── Revision (lifecycle state-machine)
├── TrafficSplit
├── BundleDeployment
├── Credentials               // P5
└── RuntimeConfig

Inside greentic-deployer (extended)

src/environment/
├── model.rs              // re-exports from deploy-spec
├── store.rs              // EnvironmentStore trait + LocalFsStore impl
├── atomic_write.rs       // write-temp + rename
├── file_lock.rs          // per-env flock
├── lifecycle.rs          // state-transition matrix enforcement
├── migration.rs          // legacy dev → new layout
├── audit.rs              // append-only audit events
├── traffic.rs            // TrafficSplit persistence
├── bundle_deployment.rs
└── env_packs/
    ├── registry.rs       // kind-string → handler resolver
    └── slot.rs

src/cli.rs                // gtc op env / env-packs / bundles / traffic / ...
src/tool_check.rs         // preflight: tools, credentials, region access

Inside greentic-start (extended)

src/revision_dispatcher.rs   // NEW: in-process router (see §8)
src/runtime_config.rs        // loads runtime-config.v1, multi-bundle
src/http_routes.rs           // keyed by (deployment_id, revision_id, public_path)
src/http_ingress/mod.rs      // consults dispatcher
src/runner_host/runtime.rs   // ActivePacks: (tenant, deployment, bundle, revision) → TenantRuntime

8. Runtime request flow

Every HTTP request goes through this resolution chain. The deployment_id is resolved from authenticated context — public traffic cannot select an arbitrary deployment.

1. HTTP ingress match host / path http_ingress/mod.rs 2. Resolve deployment tenant + route_binding BundleDeployment.route_binding 3. Pick revision trusted header → cookie → session pin → weighted RevisionDispatcher 4. ActivePacks lookup key: (tenant, dep, bundle, revision) runner-host runtime.rs 5. Invoke flow capability:// lookups via env-packs TenantRuntime 6. Telemetry stamp env, customer, deployment, revision greentic-telemetry 7. Response set sticky cookie HMAC-signed SameSite=Lax, Secure
The bundle never names a concrete provider. secret:// / telemetry:// / runtime:// resolve through the env's bound packs at request time.

Revision lifecycle: inactive → staged → warming → ready → draining → archived. gtc op revisions stage and gtc op traffic set are two independent verbs. You can stage three revisions at 0% and let them sit.

9. Phase roadmap

Phase 0 in flight

~1 week
  • P0.1 secret leak fix landed 2026-05-18
  • P0.2 doctor + CI gate
  • P0.4 extraction-path hardening

Phase A — foundations

4–6 weeks
  • greentic-deploy-spec crate
  • EnvironmentStore + LocalFsStore
  • gtc op surface
  • auto-create local + dev migration
  • env-pack registry, audit, distroless

Phase B — multi-bundle + traffic

4–6 weeks
  • RevisionDispatcher
  • ActivePacks keyed by 4-tuple
  • session pinning + drain
  • BundleDeployment + telemetry stamps
  • B12a: migrate plaintext readers

Phase C — credentials + config

3–4 weeks
  • P5 credentials (bootstrap vs min)
  • pack-config.v1 non-secret channel
  • env-pack QASpec attachment

Phase D — first real deployers

milestone
  • AWS (ECS + Secrets Mgr + ALB)
  • K8s slice — Zain ship gate

deferred Phase E: per-pack rollout inside a bundle, wizard engine extraction, metric-driven auto-abort, .gtairgap.

10. Key takeaways

Environment is a state object

Not a config string. ULID-addressed, generation-tracked, audited on every mutation. LocalFsStore for dev; a real store (DB/CRD/object-storage + lock) for production.

Closed slots, open providers

Six capability slots are the only closed surface. New providers are data — publish a pack with a descriptor like greentic.deployer.k8s@1.0.0, no enum change anywhere.

Three config sources, three files, three owners

Host (operator) / Setup (wizard per pack) / Runtime (deployer post-apply). Conflating them is what created today's "non-secrets ride the secrets path" hack.

Bundle / BundleDeployment / Revision / TrafficSplit

Four objects, single responsibilities. Bundle = artifact. BundleDeployment = customer rollout unit. Revision = immutable version. TrafficSplit = percentage routing per deployment.

local is implicit, dev migrates via preflight

First gtc setup creates local with 5 default env-packs. Legacy dev usage goes through gtc op env migrate-dev local --check before any rewrite.

No big-bang refactor

One new crate; three crates extended; four touched. Wizards keep their homes — they gain an env_id parameter; the unification into greentic-wizard-engine is deferred.