Migrating to the blob app-config
The blob app-config rewrite (spec §10) replaces per-leaf typed-config storage with a single JSON envelope per [stores.config] key. The runtime extractor swaps #[secret] field values for the resolved secret automatically; handlers no longer call config_store_default() or secret_store.require_str(...) by hand.
The cutover is atomic: there is no compatibility path. Pushing once with the new CLI brings the store into the new shape.
TL;DR
# Push your typed C as one envelope blob per environment.
<app-cli> config push --adapter <name> # remote
<app-cli> config push --adapter <name> --local # local emulator state
# Read it back, see the diff against your local TOML.
<app-cli> config diff --adapter <name> # default: unified diff
<app-cli> config diff --adapter <name> --format json # for jq / CI gates
<app-cli> config diff --adapter <name> --exit-code # CI-friendly: exit 1 if changesIn handlers, swap hand-managed reads for the new extractor:
// before
async fn greet(ctx: RequestContext) -> Result<Response, EdgeError> {
let cfg: AppDemoConfig = ctx.config_store_default()?.get("app_config").await?;
let api_token = ctx.secret_store_default()?.require_str(&cfg.api_token).await?;
// ...
}
// after
use edgezero_core::extractor::AppConfig;
#[action]
async fn greet(AppConfig(cfg): AppConfig<AppDemoConfig>) -> Result<Response, EdgeError> {
// cfg.api_token already holds the resolved secret value;
// cfg.feature.new_checkout is typed as bool, etc.
}Why this change
The pre-blob model stored each leaf at a separate Config Store key (feature.new_checkout, service.timeout_ms, api_token). Three problems made that untenable as projects grew:
- Drift detection was per-leaf. Renaming a field in code left orphans in the store; finding them required a full key-by-key audit. The blob model embeds a SHA over the canonical-form
data, so the whole config is one drift-detection unit. - Push wasn't atomic. Pushing N leaves was N round trips; a failure mid-push left the store in a partially-updated state. The blob model writes ONE envelope per
[stores.config]key. - Secret resolution was per-handler. Every handler that read a
#[secret]field had to remember to callrequire_str. The newAppConfig<C>extractor walksC::secret_fields()once and replaces each key NAME with the resolved value before handingcfgto the handler.
What's in the blob
The pushed value is a single JSON envelope:
{
"version": 1,
"generated_at": "2026-06-22T18:42:31Z",
"sha256": "1f3a…",
"data": {
"api_token": "demo_api_token",
"feature": { "new_checkout": false },
"greeting": "hello",
"service": { "timeout_ms": 1500 },
"vault": "default"
}
}data carries every typed field VERBATIM — including #[secret] fields. Per spec §3.3 Model A, the value at rest in a secret-bearing field is the operator-supplied KEY NAME (api_token = "demo_api_token", vault = "default"); the runtime extractor reads those names from data and swaps each one for the resolved secret value at request time. The blob never contains the resolved secret bytes.
sha256 covers data only in canonical form (sorted keys, ryu-shortest floats, no trailing whitespace). version and generated_at are NOT part of the hash. Two pushes ten minutes apart with the same data produce blobs with identical sha256 and different generated_at; the skip-on-equal path catches the redundant write.
Per-adapter mechanics
Axum
The push writes to .edgezero/local-config-<id>.json next to your edgezero.toml. The file is a JSON map of { "<key>": "<envelope_json>" }; the dev server reads the envelope through AxumConfigStore::get. There is no --local flag because Axum's push IS always local.
Cloudflare
The push shells out to wrangler kv bulk put --namespace-id=<id> --remote with one entry: (<key>, <envelope_json>). With --local, the push runs wrangler kv bulk put <file> --binding <BINDING> --local against .wrangler/state, selecting the namespace by binding name rather than by namespace id.
The bundled edgezero binary calls wrangler from your shell; your project's wrangler.toml selects the namespace.
Fastly
The push uses fastly config-store-entry update --upsert --stdin to write the envelope as the value of one Config Store entry.
Oversized envelopes are handled automatically. Fastly's per-entry limit is 8,000 characters. The adapter applies a conservative UTF-8 byte check (bytes are always ≥ characters, so an envelope that fits by bytes always fits by characters): if the envelope JSON is at most 8,000 bytes it's stored directly. This byte threshold is the v1 read/write contract — it is stable across upgrades, so a value stored by an earlier release always resolves. Otherwise the adapter:
- Splits the envelope JSON into UTF-8-safe chunks (target 7,000 bytes each).
- Writes each chunk under a content-addressed key:
<KEY>.__edgezero_chunks.<envelope_sha256>.<index>. - Writes a JSON root pointer at
<KEY>LAST —{ edgezero_kind: "fastly_config_chunks", version: 1, envelope_sha256, envelope_len, data_sha256, chunks: [...] }.
Reads (runtime, config diff, config push skip-on-equal) detect the pointer shape automatically and reassemble the envelope. The chunking is invisible to AppConfig<C> and to operators in normal cases. If the pointer JSON itself exceeds 8,000 characters (extremely large configs), push hard-errors before any platform write and asks you to restructure into multiple typed config structs.
config push --adapter fastly --local mirrors the same shape into fastly.toml's [local_server.config_stores.<id>.contents] table. Dotted chunk keys are written as literal TOML keys (quoted strings), NOT nested dotted-path tables — so the local store layout matches the remote store layout.
Spin
config push --adapter spin --local writes SQLite-directly into <spin.toml dir>/.spin/sqlite_key_value.db, using the vendored spin_key_value schema, at (store=<platform>, key=<key>).
Without --local, the push targets whatever the Spin runtime config declares for the store. The adapter mirrors the writer's four-branch dispatch on the read side too:
runtime-config.toml backend | config diff behaviour |
|---|---|
--local flag | SQLite-direct read |
| Manifest deploy targets Fermyon Cloud | Returns Unsupported — Spin Cloud's key-value CLI has no get; remote read-back is not in v1 |
type = "redis" / "azure_cosmos" / unknown | Errors with a pointer at the backend's native CLI (redis-cli GET <key>, etc.) |
type = "spin" (default) | SQLite-direct read, honouring path override |
Spin Cloud's Unsupported means config diff --adapter spin cannot show you the remote state. config push --adapter spin --yes writes unconditionally; without --yes, the push prompts on a TTY or exits non-zero on a non-TTY (per spec §8.3's four-branch UX).
Operator runbook
First push of a new project
Provision the backing stores. Each adapter has its own:
sh<app-cli> provision --adapter axum # no-op (file-based) <app-cli> provision --adapter cloudflare # wrangler kv namespace create <app-cli> provision --adapter fastly # fastly config-store create <app-cli> provision --adapter spin # edits spin.toml in placePre-populate
[stores.secrets]. Push doesn't write secret values into the config store — your handlers will fail withConfigOutOfDateat runtime if the operator-supplied key name doesn't resolve.sh# Cloudflare (per spec §10.2) wrangler secret put demo_api_token # Fastly fastly secret-store-entry create --store-id=<id> --name=demo_api_token --value=<value> # Spin local echo demo_api_token=<value> >> .env # Axum local demo_api_token=<value> cargo run -p <app-cli> -- serve --adapter axumPush the typed config:
sh<app-cli> config push --adapter <name>The CLI prints an inline unified diff against the current remote state.
--yes(-y) skips the consent prompt;--no-diffsuppresses the render;--dry-runshows the diff and exits without writing — except against Spin Cloud, whose read-back is unsupported, so dry-run errors there (use--localor--yes).
Per-environment key override
Spec 5.4 + 12.7: a single <app-name>.toml covers dev / staging / production. Two mechanisms swap which blob the runtime reads.
For staging, use --staging. It writes the config under the <logical-id>_staging key in the same store, so it never overwrites the production key the live service reads:
<app-cli> config push --adapter <name> --staging
<app-cli> config diff --adapter <name> --staging--staging is mutually exclusive with --key, because the staging key is derived from the store's logical id. On Fastly a staged deploy provisions a per-service edgezero_runtime_env_staging_<service-id> selector store and links it automatically, so a staged version reads staged config without any manual key override. Do not hand-set a _staging key in the production edgezero_runtime_env store; that would make production serve staged config.
For any other environment, --key is the general per-environment mechanism. Each push lands at its own key:
<app-cli> config push --adapter <name> --key app_config
<app-cli> config push --adapter <name> --key app_config_canaryThe override variable is EDGEZERO__STORES__CONFIG__<ID>__KEY -- double-underscore separators, upper-case <ID>. The runtime extractor packs default_key into the ConfigStoreBinding at adapter init. Where you set the override depends on the platform's variable mechanism.
| Adapter | Where to set EDGEZERO__STORES__CONFIG__APP_CONFIG__KEY |
|---|---|
| Axum | Process env: EDGEZERO__STORES__CONFIG__APP_CONFIG__KEY=app_config_canary <app-cli> serve --adapter axum |
| Cloudflare | .dev.vars (local) or wrangler.toml [vars] (deployed) -- wrangler surfaces it to env.var(...) in the worker |
| Spin | [application.variables] in spin.toml (defaulted) plus SPIN_VARIABLE_EDGEZERO__STORES__CONFIG__APP_CONFIG__KEY=app_config_canary spin up for a per-invocation override |
| Fastly | A dedicated edgezero_runtime_env Config Store (Compute@Edge has no process env). See below. |
Fastly specifically
Compute@Edge has no std::env, so EdgeZero reads runtime overrides from a Fastly Config Store named edgezero_runtime_env. The store is created automatically by edgezero provision --adapter fastly. After provisioning:
# Look up the platform store id (matches by name).
fastly config-store list --json | jq -r '.[] | select(.name=="edgezero_runtime_env") | .id'
# Set the override for one service. Config Store keys are case-sensitive.
fastly config-store-entry update \
--store-id=<STORE-ID> \
--key=EDGEZERO__SERVICES__<SERVICE_ID>__STORES__CONFIG__APP_CONFIG__KEY \
--value=app_config_canary \
--upsertThis store is the one the ACTIVE (production) version reads, so never point it at a _staging key. Staged config is isolated by the per-service edgezero_runtime_env_staging_<service-id> store that a staged deploy creates and links for you.
Fastly runtime overrides are service-scoped because the Config Store can be linked to multiple services. Legacy unscoped EDGEZERO__STORES__... entries are not read; migrate manually managed entries by rewriting them under the service prefix shown above. Provisioning a non-default store-name mapping requires service_id in fastly.toml or FASTLY_SERVICE_ID so the command cannot write into an ambiguous namespace. If both are set, they must match.
Locally, Viceroy reports the fixed service ID 0000000000000000000000, regardless of the deployment service_id in fastly.toml. Put local overrides under that namespace:
[local_server.config_stores.edgezero_runtime_env.contents]
EDGEZERO__SERVICES__0000000000000000000000__STORES__CONFIG__APP_CONFIG__KEY = "app_config_staging"If the local edgezero_runtime_env store is missing, EdgeZero logs a one-line warning and falls back to the binding's default id. The runtime keeps serving, but the per-environment override is inactive.
Drift detection in CI
# Exit non-zero if the deployed remote differs from your local TOML.
<app-cli> config diff --adapter <name> --exit-code --format jsonconfig diff's exit codes per spec Q10:
0— no changes (or--exit-codenot set).1— changes with--exit-codeset.2— adapter / config / read error.
Pair with --format json for machine-readable output:
{
"local_sha256": "1f3a…",
"remote_sha256": "a472…",
"added": { "vault": "default" },
"removed": {},
"changed": {
"feature.new_checkout": { "from": false, "to": true },
"service.timeout_ms": { "from": 1500, "to": 2000 }
}
}Cleanup of orphan per-leaf keys
After the cutover, your store may still hold the pre-blob per-leaf entries (feature.new_checkout, service.timeout_ms, etc.) that nothing reads. The blob model leaves them inert — they're not referenced — but they consume store quota.
| Adapter | Cleanup command |
|---|---|
| Axum | rm .edgezero/local-config-*.json (the blob push writes a fresh file) |
| Cloudflare | wrangler kv bulk delete <tempfile.json> --namespace-id=<id> --remote with the orphan keys listed |
| Fastly | fastly config-store-entry delete --store-id=<id> --key=<orphan-key> per key |
| Spin local | sqlite3 .spin/sqlite_key_value.db "DELETE FROM spin_key_value WHERE store='<id>' AND key NOT IN ('app_config', 'app_config_staging', ...)" -- preserve every key your runtime might select via EDGEZERO__STORES__CONFIG__APP_CONFIG__KEY |
Listing the orphans before deletion:
# Cloudflare
wrangler kv key list --namespace-id=<id> --remote | jq -r '.[].name'
# Fastly (the JSON field is `item_key`, not `key`)
fastly config-store-entry list --store-id=<id> --json | jq -r '.[].item_key'
# Spin
sqlite3 .spin/sqlite_key_value.db "SELECT key FROM spin_key_value WHERE store='<id>'"
config gcdoes NOT do this per-leaf cleanup. It reclaims a different kind of orphan — the chunk entries an oversized blob leaves behind when it is re-pushed (each generation is content-addressed, so a change orphans the whole previous chunk set). It never touches your old per-leaf keys. Run the per-leaf deletes above for the migration cutover; runconfig gc(below) as an ongoing reclamation for chunked stores.
Reclaiming orphaned chunk entries (Fastly, oversized configs)
If your app-config blob exceeds Fastly's 8 000-byte entry limit (the adapter applies a conservative UTF-8 byte check — see the storage-model note above — so the trigger is stable regardless of multi-byte characters) it is stored as content-addressed chunks, and every re-push orphans the previous generation. config gc reclaims them safely — it derives the live set from the store and deletes only unreferenced chunk entries you have asserted are old enough:
# Preview every orphan and its age (dry-run by default; `--dry-run` states it
# explicitly and is the same thing — it deletes nothing and conflicts with `--yes`):
<app>-cli config gc --adapter fastly
# Delete, asserting that NO root in this store changed in the last 7 days AND no
# writer is targeting the store (config gc sweeps every root, store-wide):
<app>-cli config gc --adapter fastly --older-than 7d --yes--older-than is REQUIRED for --yes and is YOUR safety assertion; do not run config gc while a config push to the same store is in flight (Fastly has no compare-and-swap). Cloudflare/Spin orphan cleanup remains manual.
Fastly chunk-pointer hygiene
If your envelope ever pushed as chunked, then later shrinks back under the 8 000-byte trigger, the old chunks remain in the store unreferenced (the root pointer is the only active commit record). To find them:
fastly config-store-entry list --store-id=<id> --json \
| jq -r '.[].item_key | select(contains(".__edgezero_chunks."))'config gc --adapter fastly (shown above) reclaims these safely — it will only delete chunk keys no live pointer references.
Troubleshooting
ConfigOutOfDate at runtime
The runtime read either:
- couldn't find the blob key (push hasn't run for the deployed code revision),
- found a blob whose
datashape doesn't match the deployedC(push ran for a different code revision), or - couldn't resolve a
#[secret]field's key name against the secret store (the operator-supplied key isn't pre-provisioned).
Retry-After: 60 accompanies the response so callers know to back off. Run <app-cli> config push --adapter <name> for the deployed code revision to fix the first two cases; provision the missing secret to fix the third.
config push reports "this command requires a typed app-config struct"
You ran edgezero config push from the BUNDLED binary instead of your project's typed downstream CLI. The bundled binary intentionally cannot push (it has no typed C in scope). Run <your-app>-cli config push instead — edgezero new generates this CLI for you.
Local fastly.toml writer prunes old chunks on re-push
The local writer upserts the keys it is pushing and prunes the chunks the prior generation of those roots referenced — except any prior chunk key whose VALUE is itself a runtime-readable root (a valid envelope or pointer), which is kept with a warning rather than deleted. It does not wholesale-replace the per-store contents block, so keys it isn't pushing (including sibling roots and their chunks) are preserved. Pruning is safe here in a way it is not in the cloud: fastly.toml is a single file that Viceroy reads at startup, so there is no propagation window and no POP that could still be serving the prior pointer.
This is STRONGER than the remote behaviour, where a config push reclaims nothing and orphan chunks remain inert until you run config gc. The runtime correctness property holds either way: a read after push B reconstructs envelope B, not A.
Reference
- Spec:
docs/superpowers/specs/2026-06-16-blob-app-config.md - Implementation plan:
docs/superpowers/plans/2026-06-17-blob-app-config.md - Extractor source:
crates/edgezero-core/src/extractor.rs - CLI push entry point:
crates/edgezero-cli/src/config.rs::run_config_push_typed - CLI diff entry point:
crates/edgezero-cli/src/config.rs::run_config_diff_typed(diff.rsholds the change collection and the format renderers) - Fastly chunk-pointer helper:
crates/edgezero-adapter-fastly/src/chunked_config.rs