Environment Manifest & gtc op env apply

One file describes what your Greentic environment should look like. One command makes it real β€” and keeps making it real, safely, every time you run it.

Design β€” Draft for review 2026-06-10 Upsert-only v1 Wiring only greentic-deployer Β· greentic-start
πŸ—£ In plain words β€” the whole idea in 30 seconds

Today, setting up a Greentic environment with two chatbots takes about 13 commands, typed in the right order, copying IDs from one command's output into the next. Get one wrong, and you debug.

This plan replaces all of that with one JSON file that says "here is what I want my environment to contain" β€” and one command (gtc op env apply) that reads the file, compares it with what currently exists, and does only what's missing. Run it twice? Nothing breaks β€” the second run says "everything is already as you asked" and stops. Like a shopping list the system fulfils itself, instead of a recipe you must cook by hand.

1The problem

Bringing up a multi-bundle environment on the new deployment model today takes ~13 imperative commands across 4 different tools. The my_demos/two-dept-telegram demo (two Telegram bots, two isolated bundles, one runtime) is the canonical example: env init, trust-root bootstrap, two secret seedings via a separate tool, two deploys, two route-binding updates, two endpoint additions, two endpoint→bundle links, a sanity check, a tunnel, and the runtime start.

The command count is a symptom. The root cause: there is no single source of truth for environment desired state. Each command is an imperative RPC, and the user is the reconciliation engine β€” capturing endpoint_id from one verb's output with jq to feed the next, ordering steps, re-running on partial failure, and plumbing secrets through a separate tool.

πŸ—£ In plain words

Imagine assembling furniture where every screw needs a separate phone call to the manufacturer, and step 7 needs a serial number that step 6 prints out. The instructions work β€” but you are the project manager, the note-taker, and the error handler. We want to hand over the blueprint and say: "make it so."

2Before / after

❌ Today β€” 13 commands, 4 tools

  1. gtc op env init
  2. gtc op trust-root bootstrap --answers …
  3. greentic-secrets apply … (legal token)
  4. greentic-secrets apply … (accounting token)
  5. gtc op deploy --answers … (legal)
  6. gtc op deploy --answers … (accounting)
  7. gtc op messaging endpoint add … + capture endpoint_id via jq
  8. gtc op messaging endpoint link-bundle …
  9. repeat 7–8 for accounting…
  10. gtc op env show … (sanity check)
  11. cloudflared tunnel …
  12. PUBLIC_BASE_URL=… gtc start

βœ… After this plan β€” 2 commands

# everything wired from one file
gtc op env apply --answers two-dept.env.json

# tunnel + webhook auto-registration:
# already built into greentic-start
gtc start --cloudflared

Re-run the first command any time: unchanged things are skipped, changed things are updated, missing things are created. The plan is printed before anything happens.

3What already exists (reused, not rebuilt)

An investigation across the workspace found ~70% of the machinery already in place β€” just disconnected:

AssetLocation
Composite single-bundle deploy (bundles add β†’ stage β†’ warm β†’ traffic 100%)greentic-deployer/src/cli/deploy.rs
EnvironmentMutations trait β€” 30 methods / 9 verb groups (PR-3a)greentic-deployer/src/environment/mutations.rs
--answers JSON + --schema on every op verbgreentic-deployer/src/cli/*.rs
Idempotency keys (ULID) on all mutating verbsgreentic-deployer/src/cli/mod.rs
Dev-store secrets put with canonical-segment validationgreentic-deployer/src/cli/secrets.rs
Tunnel supervision + PUBLIC_BASE_URL auto-webhook registration at bootgreentic-start/src/cloudflared.rs, revision_webhook_register.rs
Plan-then-execute pattern (SetupPlan/SetupStep) β€” reused as a patterngreentic-setup/src/plan.rs
πŸ—£ In plain words

We are not building a new engine β€” the engine parts exist. We are building the conductor: one component that reads the blueprint and presses the existing buttons in the right order, with the right values, skipping what's already done.

4The manifest file β€” greentic.env-manifest.v1

A JSON document with five sections. It is passed via --answers, exactly like every other op verb's payload β€” "manifest" names the schema, not a new transport. Unlike other answers files (one-shot arguments), this one is a durable desired-state document: keep it in version control, edit it, re-apply it.

// two-dept.env.json β€” the complete file for the two-bot demo
{
  "schema": "greentic.env-manifest.v1",
  "environment": { "id": "local", "public_base_url": null },
  "trust_root": "bootstrap",
  "secrets": [
    { "path": "legal/_/messaging-telegram/telegram_bot_token",
      "from_env": "TELEGRAM_LEGAL_BOT_TOKEN" },
    { "path": "accounting/_/messaging-telegram/telegram_bot_token",
      "from_env": "TELEGRAM_ACCOUNTING_BOT_TOKEN" }
  ],
  "bundles": [
    { "bundle_id": "realbot-legal",
      "bundle_path": "bundle-workspace-legal/realbot-legal.gtbundle",
      "route_binding": {
        "hosts": [], "path_prefixes": ["/legal"],
        "tenant_selector": { "tenant": "legal", "team": "default" } } },
    { "bundle_id": "realbot-accounting",
      "bundle_path": "bundle-workspace-accounting/realbot-accounting.gtbundle",
      "route_binding": {
        "hosts": [], "path_prefixes": ["/accounting"],
        "tenant_selector": { "tenant": "accounting", "team": "default" } } }
  ],
  "messaging_endpoints": [
    { "name": "realbot-legal",
      "provider_type": "messaging.telegram.bot",
      "links": ["realbot-legal"] },
    { "name": "realbot-accounting",
      "provider_type": "messaging.telegram.bot",
      "links": ["realbot-accounting"] }
  ]
}
πŸ“˜ Section-by-section: environment

id (required) β€” which environment to wire. If it doesn't exist, apply runs the env init bootstrap (default capability-slot bindings, including the dev-store secrets binding). public_base_url (optional) β€” persisted via the env set-public-url path; null/absent means "leave whatever is there" (apply never clears it).

πŸ“˜ Section-by-section: trust_root

v1 accepts only "bootstrap" or omission. Bootstrap loads (or generates) the operator key and adds it to the env trust root β€” documented idempotent: re-running with the same key is a no-op. A future { "additional_keys": […] } shape extends this without a schema bump.

πŸ—£ Plain words

The "trust root" is the list of keys the environment trusts for signed artifacts. "bootstrap" means: set up my own operator key, once.

πŸ“˜ Section-by-section: secrets[]

path β€” dev-store path <tenant>/<team>/<pack>/<name>, validated before any mutation with the same canonical-segment checks as op secrets put. from_env β€” the name of an environment variable holding the value. Secret values never appear in the manifest. Missing env var = validation failure before anything is written.

πŸ—£ Plain words

The file says where the secret should live and which environment variable to read it from β€” never the secret itself. You can safely commit this file to git.

πŸ“˜ Section-by-section: bundles[]

bundle_id β€” the natural key (unique). bundle_path β€” local .gtbundle, resolved relative to the manifest's directory (manifests must be relocatable); its sha256 digest is computed for diffing. route_binding β€” hosts / path prefixes / tenant selector, with the same structural rule as bundles.rs (a tenant-selector without a matcher is rejected). Optional: customer_id (required for non-local envs, B10 rule), config_overrides (three-valued: absent = untouched, {} = clear, non-empty = replace).

πŸ—£ Plain words

Each entry says: this app package, served under this URL path, running as this tenant. The two bots stay isolated because each gets its own path (/legal, /accounting) and tenant β€” so each resolves its own bot token.

πŸ“˜ Section-by-section: messaging_endpoints[]

name β€” manifest-local handle and the endpoint's display_name; together with provider_type it forms the upsert natural key. Ambiguous matches (two existing endpoints with the same pair) are an error, never a guess. links β€” which bundles this endpoint admits; each target must be in this manifest or already in the env (the latter produces a warning, supporting layered manifests). Optional: welcome_flow, secret_refs.

Deliberately absent: provider_id (the webhook secret token). It's a credential, not configuration β€” minted on create (reported once in the output), never touched on re-apply, rotated via the imperative rotate-webhook-secret verb.

πŸ—£ Plain words

An endpoint is one channel identity β€” e.g. one Telegram bot. Linking it to a bundle says "messages from this bot go to this app". The bot's secret handshake token is generated by the system, not written in the file.

5The command & pipeline

gtc op env apply --answers <manifest.json> [--dry-run] [--json] [--updated-by <who>] [--yes]

--dry-run prints the plan and exits without touching anything. --json emits a machine-readable plan + execution report. --updated-by sets the audit principal (default env-apply). Implementation: new module greentic-deployer/src/cli/env_apply.rs; the operator gets the verb for free via the existing OpCommand re-export.

manifest.json desired state environment store current state (read) 1 Β· Validate schema Β· files Β· env vars Β· keys 2 Β· Diff desired vs current by natural keys 3 Β· Plan printable steps --dry-run stops here 4 Β· Execute sequential fail-fast 5 Β· Verify re-read store, assert effects EnvironmentMutations trait (existing, 30 methods) env init Β· trust-root bootstrap Β· secrets put Β· deploy Β· bundles update Β· endpoint add / link / set-welcome-flow
The apply pipeline. No mutation happens before step 4; --dry-run exits after step 3. Step 5 re-reads the store and asserts every non-no-op step's effect is visible.

Execution properties

πŸ—£ In plain words

If the power goes out halfway through, just run the same command again. Finished steps are recognized and skipped; the rest completes. There is no "half-configured" trap state, and no extra bookkeeping files β€” the environment itself remembers what's done.

6Diff rules β€” upsert-only

"Upsert-only" means apply creates and updates, never deletes. Resources in the store but absent from the manifest are left untouched. Per resource type:

ResourceNatural keyExists & matchesExists & differsAbsent
Environmentidno-opupdate (only fields the manifest sets)init / bootstrap
Trust rootoperator keyno-op (idempotent)n/abootstrap
Secretpathalways put*always put*put
Bundle deploymentbundle_idno-op (digest + binding match)see decomposition ↓full deploy
Messaging endpoint(provider_type, name)no-opupdate (welcome_flow only)add + mint credential
Endpoint link(endpoint, bundle_id)no-opn/a (set member)link-bundle

* op secrets get is NotYetImplemented for all backends, so values can't be compared. v1 unconditionally writes every entry (idempotent overwrite in the dev-store) and the plan honestly says put (cannot diff) β€” never a false "no-op". When A9 lands a real get, this tightens to write-if-changed with no schema change.

Bundle decomposition β€” avoiding pointless redeploys

The live revision's bundle_digest (a real sha256: of the artifact, stamped at stage time) is compared with the local file's digest:

πŸ—£ In plain words

Apply fingerprints your app package. Same fingerprint as what's running? Nothing to do. New fingerprint? Roll out the new version safely (the old one keeps serving until the new one is ready). Only the URL path changed? Update just the routing β€” don't reinstall the app.

7Key design decisions

Upsert-only β€” no pruning

Pruning (deleting what's not in the manifest) needs ownership tracking and is the highest-blast-radius failure mode of declarative tooling. Locked out of v1; a possible --prune flag is v2 material.

Plain words: the file can add and change things, but can never accidentally delete something just because you forgot to mention it.

Wiring only β€” no pack/bundle building

The manifest starts at "artifacts exist". Authoring stays with greentic-dev / packc / gtc dev bundle. Conflating build with deploy bloats both tools.

Plain words: this tool installs and connects apps; it doesn't compile them. Baking the cake and serving the cake stay separate jobs.

Transport via --answers, schema named "manifest"

The flag convention matches every other op verb β€” the manifest is apply's payload. But the content is deliberately NOT the wizard-answers shape (LoadedAnswers/AnswerSet: question-keyed, consumed once) nor a batch of per-verb payloads. Schema-name precedent: greentic.pack-config-input.v1.

Plain words: it rides through the same door as every other command file β€” only what's written inside is different: a description of the destination, not a list of driving instructions.

Endpoint natural key = (provider_type, display_name)

Stateless matching β€” no sidecar mapping file that breaks when applying from another machine. Ambiguity (two existing endpoints matching the pair) is an error naming the colliding endpoint_ids, never a guess.

Plain words: the system recognizes "the Telegram bot called realbot-legal" by name and type. If two things answer to that name, it stops and asks rather than picking one.

Webhook credential is not in the manifest

Credentials aren't configuration. Minted (high-entropy) on create, reported once, never touched on re-apply. Rotation stays an explicit imperative verb β€” desired-state credential rotation is a foot-gun.

Plain words: passwords don't belong in blueprints. The system invents them when needed and changing them is always a deliberate, separate action.

Fail-fast, no rollback β€” the store is the checkpoint

Every underlying verb leaves the store valid; re-running apply resumes naturally thanks to upsert + deterministic idempotency keys. Checkpoint files would duplicate what the store already knows.

Plain words: if something fails, nothing is corrupted β€” fix the cause and run the same command again. It picks up where it left off, automatically.

Apply works without a running runtime

Apply's verify step is a store-level assertion (re-read, compare). Runtime readiness β€” trust root usable, secrets actually resolvable, routes served β€” belongs to the extended doctor (PR-3), which is independent.

Plain words: "apply" checks the configuration was written correctly; "doctor" checks the running system is actually healthy. Two different questions, two different tools.

8Rejected alternatives

A batch file of existing per-verb payloads ([{verb, payload}, …])

The obvious "no new schema" option β€” and it fails on the exact problem being solved. link-bundle requires the endpoint_id that endpoint add only returns at execution time, so the batch cannot even be authored. And a batch stays imperative: a deploy entry always deploys β€” no diff, no no-op re-run. It would be demo.sh serialized to JSON.

Admin gtpack inside every environment by default

Considered during the idea phase and pushed back: (a) bootstrap chicken-and-egg β€” you need a working environment to run the pack that sets up the environment; (b) it places environment-mutation authority inside the capability-sandboxed data plane, contradicting Greentic's deny-by-default / no-ambient-authority principle. The admin surface stays on the operator/start trust boundary (the mTLS admin server already exists in greentic-start/src/admin_server.rs); future web UIs are clients of that API, not packs with mutation powers.

⚠ Security stance

Secrets never appear in the manifest, in plan output, in reports, or in logs β€” only paths and env-var names. This matches the existing B12a redaction discipline and the ProvisionPlan precedent (redacted values in dry-run plans).

9Implementation roadmap

πŸ—£ In plain words

Build the engine room first (the file format and the apply command). Friendly faces β€” a question-and-answer wizard, a web admin page, even a chat-based setup with interactive cards β€” come after, and all of them produce or edit the same one file. That's why the file comes first: the orchestration logic is written once, not once per UI.

10Worked example β€” what a first run prints

# fresh environment, first apply
$ gtc op env apply --answers two-dept.env.json

ensure-environment   local                                  create
bootstrap-trust-root local                                  create
put-secret           legal/_/…/telegram_bot_token           put   (from $TELEGRAM_LEGAL_BOT_TOKEN)
put-secret           accounting/_/…/telegram_bot_token      put   (from $TELEGRAM_ACCOUNTING_BOT_TOKEN)
deploy-bundle        realbot-legal                          create (sha256:ab12… β†’ /legal, tenant=legal)
deploy-bundle        realbot-accounting                     create (sha256:cd34… β†’ /accounting, tenant=accounting)
add-endpoint         realbot-legal                          create (messaging.telegram.bot, credential minted)
add-endpoint         realbot-accounting                     create (messaging.telegram.bot, credential minted)
link-endpoint        realbot-legal β†’ realbot-legal          create
link-endpoint        realbot-accounting β†’ realbot-accounting create

Run the same command again immediately: every row reads no-op except the two put (cannot diff) secret rows. Rebuild one .gtbundle and re-apply: exactly one deploy-bundle … update row.

Old demo verbs replaced: prereqs + put-secrets + deploy + add-endpoints + show collapse into apply; tunnel + start-bg + auto-wire collapse into gtc start --cloudflared (all pre-existing greentic-start behavior). build-packs / build-bundles remain authoring; teardown remains imperative (upsert-only v1 has no delete).

11Non-goals (v1) & open items

Explicitly out of scope for v1

  • Pruning / deletes (--prune is possible v2)
  • Pack & bundle building (authoring toolchain owns it)
  • Runtime lifecycle, tunnels, webhooks (greentic-start owns them β€” already built)
  • Secret rotation / deletion (stays imperative)
  • Traffic shaping beyond deploy's 100% cut-over (canary stays imperative)
  • Non-dev-store secret backends (blocked on A9)
  • Remote-store artifact upload (blocked on A8 contract; design doesn't block it)

Open items

  • --check mode (exit 1 on pending diff) for CI β€” trivial follow-up
  • YAML manifest input β€” cheap via serde, but every sibling surface is JSON; not until someone asks
  • secret_refs drift on existing endpoints β€” warning-only until an endpoint update verb exists
  • Wizard frontend design β€” separate doc once PR-1 stabilizes the manifest shape

12Glossary β€” the simple-words dictionary

Environment
One self-contained Greentic installation: its apps, routes, secrets, and settings. The demo uses one called local.
Bundle (.gtbundle)
A packaged, deployable application β€” flows, components, and config zipped together and signed.
Deployment / revision
A bundle installed in an environment. Each new version becomes a "revision"; traffic moves to the new revision only when it's ready (blue-green).
Route binding
The rule deciding which web requests reach which deployment β€” e.g. "everything under /legal goes to the legal bundle, running as tenant legal".
Tenant
An isolation boundary. Two deployments with different tenants can't see each other's data or secrets β€” that's how each bot gets its own token.
Messaging endpoint
One channel identity (e.g. one Telegram bot) registered with the environment and linked to the bundle(s) allowed to handle its messages.
Trust root
The set of cryptographic keys the environment accepts for verifying signed artifacts. "Bootstrap" = register the operator's own key, once.
Upsert
"Update or insert" β€” create what's missing, update what differs, never delete.
Idempotent
Safe to run repeatedly: doing it twice has the same effect as doing it once.
Manifest
A file describing what should exist (desired state) β€” as opposed to a script describing what to do (instructions).
Dry-run
"Show me what you would do, but don't do it."