Skip to content

Fastly chunked-config GC: operator-invoked config gc reclaims orphaned chunk entries (store-wide) - #314

Merged
aram356 merged 63 commits into
mainfrom
spec/fastly-chunk-gc
Aug 14, 2026
Merged

Fastly chunked-config GC: operator-invoked config gc reclaims orphaned chunk entries (store-wide)#314
aram356 merged 63 commits into
mainfrom
spec/fastly-chunk-gc

Conversation

@aram356

@aram356 aram356 commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Summary

Oversized Fastly app-config (an envelope over Fastly's ~8 000-byte per-entry limit) is stored as content-addressed chunks + a root pointer. Because chunk keys are addressed by the envelope SHA, re-pushing a changed config rewrites every chunk key and orphans the entire previous generation. Left unreclaimed, a store accumulates dead chunk entries on every push (both the cloud Config Store and the local fastly.toml [local_server.config_stores.<name>.contents]).

This PR reclaims those orphans safely, split by path:

  • Local (config push --local) prunes the prior generation in the same locked fastly.toml rewrite — it's a single file replaced atomically, so immediate reclamation is safe.
  • Cloud push never deletes. Fastly's Config Store is eventually consistent, records no pointer-supersession time, and offers no compare-and-swap, so a push cannot know when an old pointer stopped being served everywhere. Reclamation is therefore an explicit, operator-invoked config gc — the operator supplies the one fact the platform cannot.

The original design reclaimed on the cloud push (last-writer-wins read-back guard). That was removed during review — the eventual-consistency delete hazard isn't soundly closable at push time — in favor of the store-wide config gc below. delete_config_store_entry now has exactly one call site, reachable only through config gc.

config gc (Fastly)

myapp-cli config gc --adapter fastly                    # DRY-RUN by default: previews, deletes nothing
myapp-cli config gc --adapter fastly --dry-run          # same, stated explicitly (conflicts with --yes)
myapp-cli config gc --adapter fastly --older-than 7d --yes   # actually deletes

A store-wide sweep that is safe by construction:

  • Live set by VALUE, not key shape. Every root pointer is followed (including one parked at a chunk-shaped key); its chunks are verified against their content-address and the exact writer split layout, so live chunks are never candidates.
  • Generation proof. A generation becomes a delete candidate only if re-running the writer over its reassembled bytes reproduces it byte-for-byte (same split, same content-addressed keys, same count). Anything else is left untouched.
  • --older-than is the operator's safety assertion, and it is about the whole store: "no root here changed within this window, and no writer is targeting it." An orphan's effective age is min(its own age, how long the current config has been live), so a recently re-pushed sibling constrains the window. Required for --yes; 0 is rejected.
  • Fails closed on anything ambiguous: an unreadable/paginated/duplicate listing, an empty required field, a future/unknown format, a malformed or incomplete generation, or an unreadable timestamp — nothing is deleted.
  • Honest failure handling. Deletion stops within a generation at its first failure (a half-deleted generation can never be proven again) but continues across independent generations; the report splits survivors into stranded vs uncertain. Delete order is the canonical chunk index, so preview and recovery are deterministic.
  • Reports what it KEEPS, not only what it deletes — the retained roots by key — so a dry-run is reviewable.

Safety properties (hardened over the review rounds)

  • Future-format detection is schema-agnostic: any present envelope/pointer version that is not exactly the JSON integer 1, or an unknown edgezero_kind, fails closed everywhere destructive (GC, local prune, and the CLI overwrite path) and is never overwritten by this v1 CLI.
  • Absence is confirmed by an authoritative complete listing, never by a describe 404 (a proxy/auth/5xx failure looks the same) — so an incomplete read can never authorize an overwrite.
  • The runtime store returns arbitrary direct values verbatim; future-format remediation ("redeploy an updated build") lives in the typed app-config extractor, not the store layer.
  • Local writes are locked (advisory-lock sidecar), replaced atomically, re-check the current root under the lock for a newer format (incl. an inner envelope behind a v1 pointer), refuse a non-inline (json/file) store rather than corrupt it, and refuse hard-linked manifests.
  • Stored values, hashes, malformed keys, and serde paths are redacted from every diagnostic that can reach a log or HTTP body.

Key files

File Change
docs/superpowers/specs/2026-07-07-fastly-chunk-gc.md, .../plans/… Design spec + TDD plan
crates/edgezero-adapter-fastly/src/chunked_config.rs Chunk-pointer resolution, generation classification, canonical key parsing, future-format detection
crates/edgezero-adapter-fastly/src/cli.rs config gc (plan_gc_reclamation, generation proof, age gates, execute_gc_deletes); local prune in the locked fastly.toml rewrite; authoritative-listing reads; collision/downgrade guards
crates/edgezero-cli/src/{args,config}.rs config gc subcommand + flags (--older-than, --dry-run, --yes), untyped runner
crates/edgezero-core/src/extractor.rs Future-envelope remediation at the typed layer
docs/guide/*, .gitignore, .github/workflows/* CLI reference / migration guide, lock/temp sidecar ignores, CI feature-mode + --locked gates

Closes

Closes #313

Test plan

  • cargo test -p edgezero-adapter-fastly --features cli — 205 pass
  • cargo test -p edgezero-adapter-fastly --features fastly --target wasm32-wasip1 under Viceroy — 84 runtime + 6 contract pass
  • cargo test --workspace --all-targets — all pass; edgezero-cli — 176 pass; app-demo builds under --locked (CI now runs cargo test --locked there)
  • cargo clippy --workspace --all-targets --all-features -- -D warnings, plus cli-only and --no-default-features fastly-adapter clippy — clean
  • cargo fmt --all -- --check, feature check (fastly cloudflare spin), and the wasm clippy matrix (wasip1 fastly/fastly cli, wasip2 spin, wasm32-unknown cloudflare) — clean
  • Docs prettier — clean

Coverage highlights: value-based live-set incl. chunk-shaped root keys; generation proof (byte-identical writer reproduction); fail-closed on unknown/future/malformed/incomplete/paginated/ambiguous/duplicate state; both age gates; --yes requires a non-zero --older-than at the adapter boundary; --dry-run/--yes conflict (conflict wins over every threshold); deterministic delete order; partial-delete stranded/uncertain taxonomy; local prune / shrink-to-direct / sibling preservation / locked downgrade + collision refusal / non-inline-store refusal; redaction sentinels on every value-bearing path.

Checklist

  • Changes follow CLAUDE.md conventions
  • No Tokio deps added to core or adapter crates
  • New code has tests
  • No secrets or credentials committed

@aram356 aram356 added the documentation Improvements or additions to documentation label Jul 8, 2026
…te+scope prior_chunk_keys, offline cloud dry-run, local root inference, warning semantics, invert stale no-GC test
aram356 added 3 commits July 7, 2026 22:06
…ence unsound); Value-first prior_chunk_keys so invalid pointer-kind warns; drop 'atomic' overclaim; define local dry-run degrade semantics; state cloud GC runs only after full commit
Value-first prior_chunk_keys (pointer-kind-but-malformed warns), thread
logical roots into write_fastly_local_config_store via roots: &[&str]
(no infix inference, since --key is free-form), best-effort local
dry-run counts, post-commit-only cloud sweep. Task-by-task with TDD
steps for subagent-driven-development.
…entical re-push counts 0 (was over-counting); enumerate all 10 writer call sites + roots args; forbid --all on delete; reword failed-delete warnings as informational (inert, future config gc); note sequential-spawn latency + approximate line numbers
@aram356 aram356 removed the documentation Improvements or additions to documentation label Jul 8, 2026
aram356 added 2 commits July 8, 2026 07:41
…t root read-back guard (invariant 5); build keep-sets from per-root expand_root instead of prefix-scanning flattened entries; make reserved-infix --key rejection mandatory at the Fastly adapter boundary + flip the infix test to expect rejection; add local suspicious-pointer real-push test and cloud concurrency-guard test
… (drop 'race-safe' overclaim, add Concurrency model section + plan precondition gate); correct cost note for the post-commit read-back describe; add dry-run suspicious-pointer test
@aram356
aram356 marked this pull request as draft July 8, 2026 15:42
aram356 added 6 commits July 8, 2026 08:48
Concurrent cloud pushes are SUPPORTED: root pointer is upsert so the last
write wins on the value. GC obeys LWW via the post-commit read-back guard
— a push reclaims prior chunks only while it is still the last writer of
the root, else it yields (never deletes the winner's live chunks).
Removes the single-writer assumption and the blocking 'do not implement'
gate; keeps the honest best-effort residual-window note. No code.
…inter test (seed real chunk keys, assert they survive); expand_root errors on empty instead of silent default; reserved-key error wording drops --key assumption
…k_keys, reject_reserved_root_keys, FastlyConfigGcPlan) + unit tests

Wired into push paths in the following commits; transient dead-code warnings until then.
write_fastly_local_config_store takes exact per-root keep-sets (gc_roots)
and prunes orphaned chunk keys in the same in-memory rewrite; suspicious
prior pointers warn and delete nothing. push_config_entries_local rejects
reserved keys, threads per-root expansion, and reports best-effort orphan
counts in dry-run. Inverts the stale no-GC test; adds shrink-to-direct,
suspicious-pointer, reserved-key, and dry-run count/identical/unknown tests.
…riter-wins)

push_config_entries rejects reserved keys, reads each root's prior value
before commit, and after the commit sweeps orphaned chunks guarded by a
post-commit root read-back (deletes only while still the last writer;
yields otherwise). Adds delete_config_store_entry (--key --auto-yes, never
--all) and an offline dry-run GC-intent line. Failed deletes and
suspicious/absent priors degrade to warnings; the push still succeeds.
Adds a command-aware fake fastly harness and 7 cloud GC tests.
@aram356 aram356 changed the title Spec: Fastly chunked-config GC to reclaim orphaned chunk entries on re-push Fastly chunked-config GC to reclaim orphaned chunk entries on re-push Jul 8, 2026
aram356 added 2 commits July 8, 2026 13:56
…astly via handlebars

Moves FastlyConfigGcPlan to the struct group with alphabetical fields;
renames single-char closure idents; replaces bare arithmetic with
saturating_add; fixes map_err/shadow/assert-on-result-state/absolute-path
lints; relocates GC helper unit tests after the test-module structs. Adds
handlebars dev-dependency and rewrites the cloud fake-fastly test shim to
render its shell script from a handlebars template.
@aram356 aram356 added the rust Pull requests that update rust code label Jul 8, 2026
@aram356 aram356 changed the title Fastly chunked-config GC to reclaim orphaned chunk entries on re-push Fastly chunked-config GC: reclaim orphaned chunk entries on re-push Jul 8, 2026
- local: GC of a chunked root leaves a chunked sibling's chunks intact
  (prefix-scoping vs shared string prefix app_config / app_config_staging)
- cloud: identical-bytes re-push deletes nothing (read-back returns our
  own value, so the assertion is non-vacuous)
- cloud: prior-read failure warns and deletes nothing (extends the fake
  fastly with a describe_hard_error mode)
@aram356 aram356 self-assigned this Jul 8, 2026
@aram356 aram356 removed the rust Pull requests that update rust code label Jul 8, 2026
…t delete argv + cloud shrink-to-direct

- local dry-run: distinguish absent (0) from present-but-non-table
  ("unknown: could not read prior state") via local_contents_table, so
  --local --dry-run no longer reports 0 orphans for state the real writer
  would reject; + non-table-contents test
- cloud: assert every delete argv passes --key + --auto-yes and NEVER
  --all (blast radius); fake now logs the full delete argv
- cloud: add the shrink-to-direct test (prior chunked -> new direct
  deletes all prior chunks, root upserted not deleted)
@aram356 aram356 changed the title Fastly chunked-config GC: reclaim orphaned chunk entries on re-push Fastly chunked-config GC: reclaim orphaned chunk entries on re-push (last-writer-wins) Jul 9, 2026
@aram356
aram356 marked this pull request as ready for review July 9, 2026 06:32
Address the current review round on the chunk-GC branch.

P1 — Future envelope AND pointer versions must not be overwritten. Add
`value_is_future_format` (raw-value predicate: a bumped envelope/pointer
version, or any unknown `edgezero_kind`) and check it FIRST in the CLI read
classifier, returning a hard error instead of a repairable Corrupt. Guard the
local-prune and dry-run predicates and the cloud GC candidate arm with it so a
v2 direct envelope under a chunk-shaped key is never deleted. This restores the
v1-reader fail-closed rule from the blob spec.

P1 — Ambiguous Fastly stderr ("not found"/"does not exist"/"404") for a CHUNK
now sets `fetch_failed` (Ok(None) => incomplete read) so a partial read becomes
a hard infrastructure error, not an overwriteable Corrupt.

P2 — Make the Corrupt repair contract adapter-agnostic. Centralise it in the
generic push layer (`classify_present_body` in cli/config): a Present body that
parses+verifies is Valid; one that fails to parse or mismatches its SHA is
Corrupt (push overwrites to repair); a bumped envelope version is a hard error.
Axum/Cloudflare/Spin now get repair without each implementing Corrupt.

P2 — Local locking safe across file aliases. Refuse a manifest with more than
one hard link (an atomic rename would break the link and path-based locks
cannot serialise the other names). Symlink resolution now fails closed on a
read-link error or hop-limit instead of falling back to a writable path.

P2 — Runtime remediation is correct for future formats: a value the running
build cannot parse as its own format asks the operator to redeploy an updated
build, not to re-push (re-pushing cannot help).

P3 — Threshold-free GC dry-run now prints a usable apply instruction
(`--yes --older-than <dur>`), matching the requirement that a non-zero window
is mandatory.

P3 — Provision docs corrected: provision writes only `[setup.*]`; the
`[local_server.*]` seeding is done by `config push --local`.
@aram356

aram356 commented Jul 30, 2026

Copy link
Copy Markdown
Contributor Author

Thanks — all seven findings addressed in 2518670. Both P1s (the destructive-write paths) are the core of this round.

P1 — Future envelope AND pointer versions could be overwritten. Added value_is_future_format, a raw-value predicate that flags a bumped envelope/pointer version or any unknown edgezero_kind. The CLI read classifier now checks it first and returns a hard error rather than a repairable Corrupt, so a newer format is never overwritten by a v1 push. The same predicate guards the local-prune and dry-run keep-predicates and the cloud GC candidate arm, so a v2 direct envelope sitting under a chunk-shaped key can't be deleted either. This restores the v1-reader fail-closed rule (blob spec:3501). A v2 direct envelope passes resolve as a Foreign passthrough and is caught on the raw value; a v2 pointer fails resolve on its version check and is also caught on the raw value — both land on the hard-error path.

P1 — Ambiguous Fastly stderr for a CHUNK. The chunk-fetch closure now treats an ambiguous not-found (Ok(None)) as an incomplete read: it sets fetch_failed, so the read resolves to a hard infrastructure error ("a chunk fetch failed … the remote was not fully read, so nothing was changed") instead of an overwriteable Corrupt.

P2 — Corrupt repair contract only implemented by Fastly. Centralised the contract in the generic push layer (classify_present_body in config.rs), so it holds for every adapter: a Present body that parses and integrity-verifies is Valid (diff against it); one that fails to parse or fails its SHA is Corrupt (the push overwrites to repair); a bumped envelope version (BlobEnvelopeError::UnknownVersion) is a hard error. Axum/Cloudflare/Spin now get repair without each implementing Corrupt, and the generic push no longer aborts on a malformed envelope.

P2 — Local locking unsafe across file aliases. reject_hard_linked_manifest now refuses a manifest with nlink > 1 (an atomic rename would break the link, and a path-based lock can't serialise writers arriving via the other names) — fail closed with a fix hint. canonical_manifest_target now fails closed on a read-link error and on the hop limit (40) instead of falling back to a writable path.

P2 — Runtime remediation wrong for future formats. When the running build can't parse a stored value as its own format, config_store now asks the operator to redeploy an updated build (re-pushing can't help) rather than to re-run config push.

P3 — Threshold-free dry-run apply instruction. The GC dry-run now prints --yes --older-than <dur> (a non-zero window is required), matching what --yes actually accepts.

P3 — Provision docs. CLI reference and walkthrough now state provision writes only [setup.*]; the [local_server.*] seeding is done by config push --local.

Gates (all green): cargo fmt --all -- --check; cargo clippy --workspace --all-targets --all-features -D warnings; cargo test --workspace --all-targets (no failures); feature check fastly cloudflare spin; wasm clippy matrix (wasip1 fastly + fastly cli, wasip2 spin, wasm32-unknown cloudflare); fastly WASM contract suite under Viceroy (6/6); app-demo fmt+clippy; docs prettier.

…store reads

Address the follow-up review round. Findings 1-3 are the P1 fail-closed gaps.

P1 -- Cloud GC no longer treats a FUTURE direct envelope as a zero-reference
foreign root. A direct envelope from a newer writer classifies as `Foreign` (no
`edgezero_kind`), so the ordinary-value fallback would wave it through and plan
its (unknown-scheme) chunks for deletion. Exclude `value_is_future_format` from
that fallback so it fails closed with nothing deleted.

P1 -- Future-format detection is now TYPED through pointer resolution. The
resolver returns `ResolveFailure::{FutureFormat,Corrupt}` instead of an untyped
string, and checks `value_is_future_format` on the REASSEMBLED envelope BEFORE
deserializing it as v1 -- so a v2 envelope wrapped in a valid v1 pointer (only
knowable after reassembly) can no longer erase into repairable corruption a
downgrade push would overwrite. The generic push path does the same version
pre-check before v1 deserialization, covering adapters whose Present body no
longer parses as the exact v1 schema.

P1 -- Root and store OPERATIONAL failures no longer read as absence. Store
resolution maps to `MissingStore` only on the resolver's own unambiguous "no
fastly config-store matches" signal (a bare "not found" also matches "`fastly`
not found on PATH" and list/auth/network errors). Entry `describe` maps to
`MissingKey` only when the stderr carries a clean absence marker AND no
operational marker (auth, network, 5xx, rate-limit), so two incomplete reads can
no longer pass the pre-write recheck and authorise an overwrite.

P2 -- Reconcile the `Corrupt` contract doc: only a PROVABLY unusable, fully-read
value is repairable; a value that could not be fully read (an absent/unfetchable
chunk, indistinguishable from an incomplete read) fails closed as a hard error,
never `Corrupt`.

P2 -- A DIRECT future envelope now gets the redeploy remediation at runtime. It
passes the resolver as a foreign `Ok`, so the future-format check is applied on
the SUCCESS path too -- it no longer reaches core as a generic integrity 500.

P2 -- The hard-link check works on Windows via `MetadataExt::number_of_links()`
(stable, no new deps), so Windows hard-link aliases fail closed too, not just
Unix ones.

P3 -- CI now runs the fastly runtime `--lib` unit tests under Viceroy; the WASM
job previously ran only `--test contract`, so the runtime remediation/fail-closed
tests compiled but never executed.

Tests: typed inner-future resolution, GC fail-closed on a future direct
envelope, operational-vs-absence stderr, direct-future redeploy remediation, and
a future envelope that fails v1 deserialize.
@aram356

aram356 commented Aug 1, 2026

Copy link
Copy Markdown
Contributor Author

Thanks — all seven addressed in c2b8585. Findings 1–3 (the P1 fail-closed gaps) are the focus.

P1 — Cloud GC treated a future direct envelope as a zero-reference foreign root. A direct envelope from a newer writer classifies as Foreign (no edgezero_kind), so the ordinary-value fallback waved it through and planned its chunks for deletion. The fallback now also requires !value_is_future_format(...), so a future value falls to the "could not classify root" arm and GC fails closed with nothing deleted. Updated the spec's foreign-at-ordinary-key paragraph to call out the exception.

P1 — A v2 envelope inside a v1 pointer could be overwritten by a downgrade push. Version detection is now typed through pointer resolution: resolve_fastly_config_value_typed returns ResolveFailure::{FutureFormat, Corrupt} instead of an untyped string, and checks value_is_future_format on the reassembled envelope before deserializing it as v1 (extracted into finalize_reconstructed_envelope). A newer inner envelope — only knowable after the chunks are fetched — now surfaces as FutureFormat, which classify_resolved_read turns into a hard error rather than repairable Corrupt. The generic path (config.rs) does the same via a schema-agnostic body_is_future_envelope pre-check, so a v2 body that no longer deserializes as the exact v1 schema is still refused, not overwritten.

P1 — Root/store operational failures were classified as absence. Store resolution maps to MissingStore only on the resolver's own unambiguous no fastly config-store matches signal — a bare "not found" also matches "fastly not found on PATH" and list/auth/network errors, so those now fail closed. Entry describe maps to MissingKey only when the stderr carries a clean absence marker and no operational marker (auth / network / 5xx / rate-limit), via stderr_signals_operational_failure. Two incomplete reads can no longer pass the pre-write recheck and authorise an overwrite. The existing read_remote_returns_missing_store_on_appropriate_stderr test encoded the old behaviour (a failed list call → MissingStore); it's split into a fail-closed test for the operational case and a genuine-absence test (a successful list that omits the store → MissingStore).

P2 — Missing-chunk contract. You're right that the fail-closed chunk behaviour contradicted the Corrupt doc. Reconciled the doc rather than loosening the safety: Corrupt is now scoped to a provably unusable, fully-read value; a value that could not be fully read (an absent/unfetchable chunk, indistinguishable from an incomplete read) fails closed as a hard error the operator retries, never Corrupt. There's no trustworthy in-band absence signal from the CLI, so an explicit repair operation would be the way to offer in-band recovery — I've left that as a follow-up rather than widen the overwrite path in this round.

P2 — Direct future envelope runtime remediation. A direct future envelope passes the resolver as a foreign Ok, so the redeploy remediation is now applied on the success path too (future = future_format || outcome.err().is_future_format()) — it no longer reaches core and surfaces as a generic integrity 500. New test direct_future_envelope_asks_to_redeploy_not_repush.

P2 — Non-Unix hard links. The check now reads the link count on Windows via std::os::windows::fs::MetadataExt::number_of_links() (stable in 1.95, no new deps), alongside nlink() on Unix — so Windows hard-link aliases fail closed too. Any other target (where no count is available) still leaves the file alone.

P3 — CI runs the runtime tests. Added a fastly-only step that runs cargo test -p edgezero-adapter-fastly --features fastly --target wasm32-wasip1 --lib under Viceroy. That job previously ran only --test contract, so the runtime remediation/fail-closed unit tests compiled but never executed — the lib target under Viceroy now runs 83 of them.

Gates (all green): fmt · workspace clippy --all-features -D warnings · workspace tests · feature check fastly cloudflare spin · wasm clippy matrix (wasip1 fastly + fastly cli, wasip2 spin, wasm32-unknown cloudflare) · fastly contract + --lib runtime tests under Viceroy (6/6, 83/83) · app-demo fmt+clippy · docs prettier.

…rns verbatim

Address the follow-up review round (four P1 data-loss/overwrite paths, three P2).

P1 -- Future-format detection is now SCHEMA-AGNOSTIC. `value_is_future_format`
and the generic `body_is_future_envelope` key on the `version` field alone (and,
generically, on the presence of `edgezero_kind`), not the four v1 fields. A
future shape like `{"version":2,"payload":...}` that drops v1 fields no longer
slips through as repairable corruption (overwriteable by a downgrade push, and
treated as an inert zero-reference root by GC).

P1 -- Operational errors can no longer become absence. A read maps to
MissingKey / an absent chunk ONLY on a CONFIRMED clean absence
(`stderr_is_confirmed_absence`: a not-found token with NO operational marker);
the marker list now includes 401/403/429 and an HTML "page not found". Store
resolution is now TYPED (`resolve_remote_config_store_id` returns
`Ok(None)` only when the list SUCCEEDS and no store matches), not a substring
match on an untyped error. Ambiguous/operational output stays a hard error.

P1 -- Destructive GC no longer accepts lossy listing input. All value-bearing
`fastly` stdout (the GC listing, root/chunk describes, the store-id list) is
converted with STRICT UTF-8 via `strict_stdout`; invalid bytes fail closed
instead of becoming U+FFFD and mutating a root value or chunk.

P1 -- The generic push now refuses an unknown `edgezero_kind`. A v1-shaped
envelope carrying `edgezero_kind: "new_format"` (which serde would otherwise
accept, ignoring the extra field) is a newer format non-Fastly adapters must not
overwrite.

P2 -- A CONFIRMED-missing Fastly chunk is now repairable. A clean not-found on a
chunk fetch resolves to `Corrupt` (a push overwrites to repair, as the blob spec
prescribes for persistent loss), while an operational/ambiguous fetch failure
stays a hard error. The confirmed-absence discrimination is what lets both hold:
persistent loss is fixable, an incomplete read still never authorises overwrite.

P2 -- The runtime store returns arbitrary DIRECT values VERBATIM again. The
store-layer inspection of direct values is reverted; the future-envelope
remediation moves to the typed app-config extractor, which now maps an
`UnknownVersion` (and a schema-changed future version) to an upgrade/redeploy
error instead of a generic integrity 500. The store still errors on a newer
value in OUR namespace (an unknown `edgezero_kind`, a future pointer/inner
version), detected before the exact-v1 struct deserialize.

P2 -- The minimal CLI feature graph lints warning-free:
`cargo clippy -p edgezero-cli --all-targets -- -D warnings` no longer trips on
unused `ResolveFailure` payloads / `into_message`, which the read classifier now
surfaces in its redacted infra diagnostic.
@aram356

aram356 commented Aug 2, 2026

Copy link
Copy Markdown
Contributor Author

Thanks — all seven addressed in 477429c.

P1 — Schema-changing v2 envelopes failed open. value_is_future_format (and the generic body_is_future_envelope) now key on the version field alone, not the presence of the four v1 fields, so {"version":2,"payload":...} is detected as future everywhere — refused by push, refused after v1-pointer reconstruction, and failed-closed by GC — rather than being read as repairable corruption / an inert zero-reference root.

P1 — Operational errors becoming absence. Introduced stderr_is_confirmed_absence (a not-found token with no operational marker) as the only path to MissingKey/absent-chunk; the operational marker list now includes numeric 401/403/429 and an HTML page not found. Store resolution is now typed: resolve_remote_config_store_id returns Ok(None) only when the list call succeeds and no store matches — no more substring reclassification of an untyped error. Ambiguous/operational output stays Err.

P1 — Lossy GC listing input. All value-bearing fastly stdout (the GC listing, root/chunk describes, and the store-id list) now converts with strict UTF-8 via strict_stdout; invalid bytes fail closed instead of becoming U+FFFD and mutating a root value or chunk before deletion planning.

P1 — Generic push missed unknown edgezero_kind. body_is_future_envelope now also returns true when an edgezero_kind field is present — a v1-shaped envelope with edgezero_kind: "new_format" (which serde silently ignores) is refused on Axum/Cloudflare/Spin, not overwritten.

P2 — Permanently missing Fastly chunk had no repair path. This is the crux, and it's what reconciles this round with the prior one. A confirmed clean absence on a chunk fetch (stderr_is_confirmed_absence) now resolves to Corrupt → a push overwrites to repair, exactly as the blob spec prescribes for persistent loss (blob-app-config.md:6374). An operational/ambiguous fetch failure still sets fetch_failed and stays a hard error. So persistent loss is fixable and an incomplete read never authorises an overwrite — the confirmed-absence discrimination is the single mechanism that lets both invariants hold. read_config_entry_hard_errors_on_a_not_found_chunk is retargeted accordingly.

P2 — Store layer stopped returning direct values verbatim. Reverted the store-layer inspection of direct values (config_store.rs). The store again returns arbitrary direct values verbatim per blob-app-config.md:6336; the future-envelope remediation moved to the typed app-config extractor, which now maps an UnknownVersion (and a schema-changed future version, via a pre-deserialize version check) to an upgrade/redeploy error instead of a generic integrity 500. The store still errors on a newer value in our namespace (an unknown edgezero_kind, or a future pointer/inner-envelope version — the latter detected before the exact-v1 struct deserialize so an incomplete future pointer can't slip through as corruption).

P2 — Minimal CLI feature graph lint. cargo clippy -p edgezero-cli --all-targets -- -D warnings is now clean: the read classifier surfaces the resolver's (already-redacted) message in its infra diagnostic, so ResolveFailure's payload and into_message are used under the cli-only feature set, not just under all-features unification.

Gates (all green): fmt · workspace clippy --all-features · clippy -p edgezero-cli --all-targets (minimal graph) · workspace tests · feature check fastly cloudflare spin · wasm clippy matrix (wasip1 fastly+fastly cli, wasip2 spin, wasm32-unknown cloudflare) · Viceroy contract (6/6) + runtime --lib (83/83) · app-demo fmt+clippy · docs prettier.

Address the follow-up review round (three P1 data-loss paths, three P2, one P3).

P1 -- GC no longer trusts a future inner format behind a v1 pointer. After
reassembling a generation, GC deserialised straight into `BlobEnvelope`, which
silently ignores a bumped version or an unknown `edgezero_kind`. A newer inner
format can reference generations this build cannot see, so trusting only the
outer pointer's chunks as the live set could delete them as orphans. GC now runs
the same `value_is_future_format` check the runtime resolver does on the
reassembled bytes and fails closed.

P1 -- Absence is CONFIRMED against an authoritative complete listing, never a
describe 404. A proxy/endpoint or auth 404 looks exactly like a genuine
item-absence, so two such reads could pass the pre-write recheck and authorise an
overwrite. A root/chunk describe failure is now confirmed against a completeness-
strict `config-store-entry list` (fails closed on a paginated view or a duplicate
key): only a listing that OMITS the key reads as absence, and store resolution
returns a typed `Ok(None)`. The stderr-classification heuristics are removed.

P1 -- The local writer re-checks future-format UNDER THE LOCK. The pre-push
check ran before the write lock, so a newer writer could install a v2 value in
the TOCTOU window; the locked reread now re-classifies each root and refuses to
clobber a newer format before the upsert.

P2 -- The typed app-config extractor refuses an unknown `edgezero_kind`, not just
a bumped version. serde ignores the unknown field, so a v1-shaped envelope tagged
`edgezero_kind: "new_format"` would otherwise deserialize and apply on non-Fastly
runtimes; it now maps to the same upgrade/redeploy remediation the generic push
gives.

P2 -- Local push forces `format = "inline-toml"`. An existing `format = "json"` /
`"file"` next to the inline `contents` this writer emits left a contradictory
store the command still reported as written; it is now overwritten (with a
warning) to match what is written.

P2 -- `--no-default-features` lints clean: `chunked_config` is gated to the
features that use it, so a default-feature build no longer trips 23 dead-code
errors.

P3 -- Reconcile the `ReadConfigEntry::Corrupt` contract with the implementation:
a referenced chunk CONFIRMED absent by a complete listing is repairable `Corrupt`
(the blob spec repairs persistent loss by re-pushing); an absence inferred from a
bare 404 is NOT `Corrupt` and fails closed. One canonical contract.
@aram356

aram356 commented Aug 3, 2026

Copy link
Copy Markdown
Contributor Author

Thanks — all seven addressed in bb82733.

P1 — GC trusted a future inner format behind a v1 pointer. After reassembling a generation, gc_verify_generation deserialized straight into BlobEnvelope, which ignores a bumped version / unknown edgezero_kind. GC now runs the same value_is_future_format check the runtime resolver does on the reassembled bytes (before trusting its references) and fails closed with a newer-format error. New test gc_fails_closed_on_a_future_inner_generation (a v1 pointer whose chunks reassemble to a v2 envelope → GC aborts, nothing deleted).

P1 — A bare operational 404 was treated as confirmed absence. Replaced the stderr heuristics entirely. Absence is now confirmed against an authoritative complete listing (list_config_store_keys, completeness-strict: fails closed on a paginated/non-bare-array view or a duplicate key). A root/chunk describe failure is confirmed against it — only a listing that omits the key reads as absence; a present key (or a listing that itself fails) is a hard error. Store resolution already returns a typed Ok(None). So a proxy/endpoint/auth 404 can no longer pass the pre-write recheck and authorise an overwrite. stderr_signals_operational_failure / stderr_is_confirmed_absence and their test are gone.

P1 — Local future-format protection had a TOCTOU window. The pre-push check ran before the write lock. The local writer now re-classifies each root under the lock (reject_future_local_roots) and refuses to overwrite a newer format before the upsert. New test push_config_entries_local_refuses_to_overwrite_a_future_prior.

P2 — Extractor future-format handling was inconsistent. future_format_reason (renamed from the version-only helper) now also flags a present edgezero_kind, so a v1-shaped envelope tagged edgezero_kind: "new_format" gets the upgrade/redeploy remediation on non-Fastly runtimes instead of being silently applied — matching the generic push's refusal. New test app_config_extractor_asks_to_redeploy_on_an_unknown_edgezero_kind.

P2 — Local push preserved an incompatible store format. ensure_inline_toml_format now overwrites an existing format = "json" / "file" to inline-toml (with a warning) rather than leaving a contradictory store the command reported as written. New test write_fastly_local_config_store_replaces_incompatible_format.

P2 — Default feature graph wasn't lint-clean. chunked_config is now gated to the features that use it, so cargo clippy -p edgezero-adapter-fastly --no-default-features --lib -- -D warnings passes (was 23 dead-code errors).

P3 — Read-result contract vs implementation. Reconciled ReadConfigEntry::Corrupt's docs to the canonical rule: a referenced chunk CONFIRMED absent by a complete listing is repairable Corrupt (the blob spec repairs persistent loss by re-pushing); an absence inferred from a bare 404 is not Corrupt and fails closed. The critical qualifier is confirmed, and the Fastly impl now matches it.

Gates (all green): fmt · workspace clippy --all-features · clippy -p edgezero-cli --all-targets · clippy -p edgezero-adapter-fastly --no-default-features --lib · workspace tests · feature check fastly cloudflare spin · wasm clippy matrix (wasip1 fastly+fastly cli, wasip2 spin, wasm32-unknown cloudflare) · Viceroy contract (6/6) + runtime --lib (83/83) · app-demo fmt+clippy · docs prettier.

…ine stores

Address the follow-up review round (three P1 data-loss paths, six P2, one P3).

P1 -- Future-format detection no longer fails open on a non-integer `version`.
`value_is_future_format`, the generic `body_is_future_envelope`, and the runtime
extractor all keyed on `as_u64()`, so `"2"`, `-1`, `2.5`, or `1.0` yielded `None`
and slipped through as repairable corruption / an inert GC root. Any present
`version` that is not EXACTLY the JSON integer 1 is now treated as future.

P1 -- The locked local downgrade guard now catches a future INNER envelope
behind a valid v1 pointer (only knowable after reconstruction). Each current root
is RESOLVED against the locked `contents` table before writing; a typed
`FutureFormat` is refused just like a raw future value.

P1 -- A JSON/file-backed local store is REFUSED, not converted. Rewriting
`format` in place left a stray `file` key (a manifest the local server rejects)
or would have silently discarded the sibling entries that external file holds.
The push now hard-errors and points the operator at explicit migration.

P2 -- Generated chunk writes are preflighted (cloud and local): a content-
addressed chunk key that would clobber an existing root-like sibling or a nested
generation is refused before any write.

P2 -- `find_config_store_id` fails closed on a malformed or duplicate row: a row
lacking a non-empty `name`/`id` could BE the requested store, so a NotFound there
would authorise an overwrite. Every row must have non-empty `name` and `id`, and
names must be unique.

P2 -- The destructive age gate rounds a fractional creation timestamp UP.
Flooring both creation and now made a true age of 59.002s compute as 60s and pass
a 60s `--older-than` almost a second early.

P2 -- The hard-link refusal is re-checked AFTER acquiring the write lock and again
IMMEDIATELY before the rename, closing the lock-wait race the single pre-lock
check left open.

P2 -- CI gates the `cli`-alone and no-default-features fastly builds, which the
all-features unification could otherwise mask.

P2 -- The spec's downgrade bullet is reconciled to the implemented REFUSE policy
(a `version: 2` prior is refused, not overwritten), with the schema-agnostic
version rule and the inner-envelope + non-inline-store cases spelled out.

P3 -- Docs: the migration guide predicts chunking by the stable BYTE trigger (not
characters); the CLI walkthrough lists `config diff` and `config gc`; the
generated-project test now runs `config gc --help` to exercise its parser.
@aram356

aram356 commented Aug 4, 2026

Copy link
Copy Markdown
Contributor Author

Thanks — all ten addressed in db44807.

P1 — Future-format detection failed open on non-integer versions. value_is_future_format, the generic body_is_future_envelope, and the extractor all keyed on as_u64(), so "2", -1, 2.5, and 1.0 yielded None and slipped through as repairable corruption / an inert GC root. All three now treat any present version that isn't exactly the JSON integer 1 as future. New coverage exercises string/negative/float/null versions.

P1 — The locked downgrade guard missed future inner envelopes. reject_future_local_roots checked only the raw root. It now RESOLVES each root against the locked contents table so a future inner envelope behind a valid v1 pointer (only knowable after reconstruction) is refused too. New test push_config_entries_local_refuses_a_future_inner_prior.

P1 — JSON/file-backed local stores were converted destructively. ensure_inline_toml_format now refuses a non-inline store instead of rewriting format in place — which left a stray file key (Viceroy: "unrecognized key 'file'") or would silently discard the sibling entries that external file holds. The push hard-errors and points at explicit migration; the manifest is left untouched. (This reverses last round's overwrite fix — the destructive-conversion angle makes refusal the correct call.) Test updated to write_fastly_local_config_store_refuses_incompatible_format.

P2 — Generated chunk writes could clobber a root-like sibling. Both paths now preflight (reject_generated_key_collisions): a content-addressed chunk key that would overwrite an existing root-like value or a nested generation is refused before any write. Cloud uses a completeness-strict key listing + describes only colliding keys; local uses the in-memory contents. New test push_config_entries_local_refuses_clobbering_a_root_like_chunk_sibling.

P2 — Malformed store-list rows became authoritative MissingStore. find_config_store_id now fails closed (SchemaDrift → hard error) on any row lacking a non-empty name/id, and rejects duplicate names — a malformed row could BE the requested store. New tests for the malformed-row and duplicate-name cases.

P2 — Fractional timestamps passed the age gate ~1s early. parse_rfc3339_secs now rounds a fractional creation time up, keeping the computed age conservative. New test parse_rfc3339_secs_rounds_a_fraction_up.

P2 — Hard-link lock-wait race. The link-count check now runs again AFTER acquiring the write lock and once more IMMEDIATELY before the rename, closing the window the single pre-lock check left open.

P2 — CI feature-mode isolation. Added permanent gates for cargo clippy -p edgezero-adapter-fastly --features cli --all-targets and --no-default-features --lib, which the all-features unification could mask.

P2 — Spec vs implementation. Reconciled the spec's downgrade bullet to the implemented refuse policy (a version: 2 prior is refused, not overwritten), and spelled out the schema-agnostic version rule, the inner-envelope case, and the non-inline-store refusal.

P3 — Docs & coverage. Migration guide now predicts chunking by the stable byte trigger (not characters); the CLI walkthrough lists config diff and config gc; the generated-project test runs config gc --help to exercise its parser.

Gates (all green): fmt · workspace clippy --all-features · clippy -p edgezero-cli --all-targets · clippy -p edgezero-adapter-fastly --features cli and --no-default-features --lib · workspace tests · feature check · wasm clippy matrix · Viceroy contract (6/6) + runtime --lib (83/83) · app-demo · docs prettier.

The cloud push built a per-root `roots` vector (cloned keep-sets + root
values) that no code reads — leftover from the removed push-time cloud
reclamation. Cloud push never reclaims (GC is explicit and store-wide), so
the loop now collects only the physical entries it actually commits.
@aram356

aram356 commented Aug 6, 2026

Copy link
Copy Markdown
Contributor Author

Thanks — addressed the one P3 in c40bf7a.

P3 — Obsolete cloud-push GC bookkeeping. The cloud push built and populated a per-root roots vector (cloned keep-sets + the root value) that nothing read — leftover from the removed push-time cloud reclamation. The expansion loop now collects only the physical_entries it actually commits (let (expanded, ..) = expand_root(...)), dropping the unused allocation and the misleading "keep-set for GC" comment. Cloud push never reclaims — GC is explicit and store-wide — so there is no per-root bookkeeping to keep here.

No behavior change. Gates green: fmt · workspace clippy --all-features · clippy -p edgezero-adapter-fastly --features cli · fastly cli tests (203/203).

Appreciate the thorough review across the rounds.

@aram356
aram356 requested a review from prk-Jr August 6, 2026 07:09
aram356 added a commit to IABTechLab/trusted-server that referenced this pull request Aug 12, 2026
Point the edgezero dependency pins at the spec/fastly-chunk-gc branch
(stackpop/edgezero#314) so `cargo update` follows the PR as it evolves;
the lockfile currently resolves it to 0873ec5a.
`config gc` was safe-by-default (a run without `--yes` deletes nothing), but
there was no explicit way to state "just preview" — you had to know that
omitting `--yes` was the dry run. Add a `--dry-run` flag that documents that
intent and matches `push`/`provision`. It conflicts with `--yes` (clap-enforced),
so a single run can never both preview and delete; the dispatch already treats
`--dry-run || !--yes` as a dry run. Docs (CLI reference, migration guide) and the
spec updated; parse + conflict tests added.
`config gc` listed only what it would delete, which made a sweep hard to
trust. Surface the protected/live ROOTS it is KEEPING too — each by key, plus
the live-chunk total they hold — so a dry-run is reviewable at a glance. The
plan already computes the protected set; it is now threaded through GcPlan and
printed (in both dry-run and real runs, and even when there is nothing to
reclaim). A root shown here is never a delete candidate.

@prk-Jr prk-Jr left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR Review — approved

Summary

The design pivot since the last review round is the right call, and it is genuinely shipped rather than just claimed. Automatic cloud reclamation is gone: delete_config_store_entry now has exactly one call site (execute_gc_deletes, cli.rs:2526), reachable only through the operator-invoked config gc. That removes the eventual-consistency delete hazard and the read-back TOCTOU entirely, rather than narrowing them.

All three previously-blocking findings are resolved in code, verified independently rather than taken on the resolution comments:

  • Eager cloud delete / read-back race — no delete path exists on push. push_config_entries writes only, with an explicit comment recording why (updated_at is not bumped by an upsert, no CAS, created_at is not a supersession proxy).
  • Payload leak into push logsredact_describe_response reports size + field count only (not names), redact_stderr suppresses the body entirely, and the redaction now extends well past the original finding into core: BlobEnvelopeError has a hand-written Debug so {err:?} cannot leak what Display redacts, redact_serde_path strips string segments because a map key is indistinguishable from a struct field, and map_secret_error keeps the unused parameters in its signature specifically so the redaction is visible at the one place they could have been formatted. That last detail is a nice touch.
  • Duplicate roots in one batchreject_duplicate_root_keys on both push paths, before any expansion or I/O.

The safety architecture of config gc is the strongest part. Deriving liveness from the store rather than from metadata, grouping candidates by generation, and requiring each group to reassemble to the content-address its own keys name and round-trip byte-identically through the writer, means every destructive decision rests on a hash rather than on what an inconsistent store claims about itself. Fail-closed is applied consistently: unparseable created_at, non-bare-array listing, duplicate keys, an unclassifiable root, a pointer whose chunks do not reconstruct, a referenced chunk absent from the listing — each aborts with nothing deleted.

Approving. The findings below are all non-blocking; none of them affect correctness of the delete path.

😃 Praise

  • prove_generation's round-trip against the writer, and the explicit "What this does NOT prove: authorship" paragraph — treating content-addressing as a consistency check rather than a signature, and documenting the residual instead of overclaiming. (inline)
  • execute_gc_deletes's deleted / stranded / uncertain taxonomy, with shell-escaped recovery commands and a POSIX-vs-Windows caveat. (inline)
  • The Corrupt vs FutureFormat split threaded from the resolver through classify_resolved_read to ReadConfigEntry, so operators get "re-push to repair" or "redeploy an updated build" rather than one undifferentiated 500 — and classify_present_body makes the repair contract generic, so axum/Cloudflare/Spin get it too without each adapter reimplementing it.
  • Treating a missing chunk at runtime as Unavailable rather than Internal (config_store.rs), because the store is eventually consistent across keys and a flipped root pointer can outrun its chunks to a POP. A persistent 503 is strictly better operator guidance during that window than a spurious re-push-me 500.
  • The CI additions close a real blind spot: --all-features unifies the fastly adapter's cli and fastly features and can mask a cfg mistake, so gating cli-only, --no-default-features, and fastly cli on wasm32-wasip1 is well targeted — as is running the lib tests under Viceroy, since --test contract never exercised the colocated resolver tests.

Findings (all non-blocking)

  • 🤔 Empty item_value on any sibling entry permanently blocks config gc for that store — cli.rs:2252. Fail-closed is defensible; the diagnostic blames the listing instead of naming the key. (inline)
  • 🤔 future_format_reason adds a second full JSON parse of the blob to every request — extractor.rs:909. Two of its three cases are already caught by the v1 parse/verify below; a cheap guard keeps the happy path to one parse. (inline)
  • ♻️ Dry-run keep-predicate hand-mirrors the real prune — cli.rs:2140 vs cli.rs:1818-1836. A shared helper would enforce by construction what a comment currently asks a future edit to remember. (inline)
  • Lock/temp sidecars not ignored in this repo — the generated-project template gets .*.edgezero-lock, but the root .gitignore does not, and neither covers the .edgezero-<pid>-<n>.tmp staging file a hard kill can leave. (inline)
  • Two remaining unredacted-stderr pathsresolve_remote_config_store_id (cli.rs:3363) and create_fastly_store (cli.rs:1386) interpolate raw fastly stderr, while every value-bearing path routes through redact_stderr. Safe as written: config-store list and <kind>-store create never receive an entry value, so there is no payload to echo. Flagging only because they are the sole exceptions to an otherwise absolute invariant — a one-line comment on each saying "no entry value reaches this command, so stderr is safe to surface" would stop a future reader filing it as an oversight, or a future edit copying the pattern somewhere it is not safe.

📌 Out of scope / before merge

  • The PR title and description still describe the design this branch removed. The title says "(last-writer-wins)", and the body describes a "cloud post-commit sweep with last-writer-wins read-back guard + delete_config_store_entry" and a push that "reclaims prior chunks only while a post-commit read-back confirms it is still the last writer" — none of which ships. The body also predates most of what landed: config gc itself, Adapter::gc_config_entries, Adapter::preflight_config_write, ReadConfigEntry::Corrupt, the cross-process ManifestLock + atomic manifest replace, the core redaction work, and the CI matrix changes. Worth rewriting before merge, since on a squash-merge it becomes the permanent commit message. No code change involved — which is why this is not blocking the approval.

Verification

Run locally against 43cec87, not taken from CI:

  • cargo fmt --all -- --checkPASS
  • cargo clippy --workspace --all-targets --all-features -- -D warningsPASS
  • cargo test --workspace --all-targetsPASS (exit 0, 1304 tests across 20 suites)
  • GitHub checks — 13/13 green (fmt, test, format-docs, CodeQL x4, and the fastly/cloudflare/spin wasm clippy + test jobs)

Comment thread crates/edgezero-adapter-fastly/src/cli.rs
Comment thread crates/edgezero-adapter-fastly/src/cli.rs
Comment thread crates/edgezero-core/src/extractor.rs Outdated
Comment thread crates/edgezero-cli/src/templates/root/gitignore.hbs Outdated
Comment thread crates/edgezero-adapter-fastly/src/cli.rs
Comment thread crates/edgezero-adapter-fastly/src/cli.rs
…ling

Address the review-round nits on the recent --dry-run / kept_roots work.

P2 -- app-demo's committed lockfile still pinned edgezero-macros to syn 2,
while the workspace now requires syn 3, so `cargo run --locked` in app-demo
failed. Regenerate the app-demo lock (syn 3.0.3, matching the root lock), and
run the app-demo CI test step with `--locked` so this drift fails loudly next
time instead of being silently regenerated.

P3 -- the public `run_config_gc` now rejects `dry_run` + `yes` together up
front, so a library caller bypassing clap gets an explicit "mutually exclusive"
error rather than the wrong-threshold message or a silent preview.

P3 -- the retained-root report says "retained root(s)", not "live": the set can
include a root that is protected but NOT runtime-readable (warned separately),
which "live" mislabels.

P3 -- reattach the `gc_fastly_config_store` fail-closed doc block to that
function; adding `append_kept_roots_report` had left the doc on the helper and
`gc_fastly_config_store` undocumented.
Address the review-round P3 nits on the config gc reporting.

- The dry_run+yes conflict error no longer contradicts the valid default:
  "Pass at most one (omit both for the default dry-run preview)", and the public
  `# Errors` doc now lists the conflict. The precedence test is a table proving
  the conflict wins over every `--older-than` shape (missing/zero/malformed/
  overflow), so a library caller can never get the wrong-threshold error.

- Retained-root reporting no longer calls protected-but-unreadable roots (or
  their chunks) "live": the heading/summary now say "referenced chunk(s)", since
  a root that fails the writer split check is conservatively protected, not
  runtime-live. Added a wording/count/empty-store unit test and extended the
  unreadable-root test to assert it is reported as retained, never "live".

- Each proven generation is deleted in canonical chunk-INDEX order, not the
  remote listing order, so preview and failure-recovery (deletion stops at the
  first failure) are deterministic regardless of how Fastly lists the store.
  Adds `chunk_key_index` + its test.

- Reattached the fail-closed Rustdoc to `gc_fastly_config_store`.
… extractor parse, gitignore

Non-blocking follow-ups from the approved PR review.

- `list_config_store_entries` now names the offending KEY (not just an index) and
  tells the operator what to do when a legitimate empty-valued sibling blocks a
  sweep, instead of blaming the "listing".

- Extract `is_prunable_leaf` as the single source of truth for the local
  keep-predicate, shared by the real prune and the dry-run count, so the previewed
  orphan count can never drift from what `--yes` actually removes.

- The typed app-config extractor no longer parses the whole blob as JSON twice on
  every request: a cheap `edgezero_kind` substring gate keeps the generic `Value`
  reparse (`future_format_reason`) off the happy path. The version case is still
  caught by `verify()`; same semantics and redaction, one parse on the hot path.

- `.gitignore` (template + this repo's root) now also ignores the atomic-replace
  staging temp (`.*.edgezero-*.tmp`), which a SIGKILL/panic mid-push can strand,
  and the root globs cover `examples/app-demo`'s `fastly.toml` sidecars.
@aram356

aram356 commented Aug 14, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the approve — addressed the four non-blocking follow-ups in 4b93f0f:

Empty-value diagnostic (cli.rs:2252). Kept the fail-closed (belt-and-braces) but made it actionable: the error now names the offending key (item_key is readable even when another field is empty) and tells the operator that a legitimate empty-valued entry should be removed or given a value, instead of blaming the "listing".

Shared prune predicate (cli.rs:2140). Extracted is_prunable_leaf(contents, key) — one source of truth for the value_protected || has_nested_generation logic, used by both the real prune and the dry-run count. The comment-enforced invariant is now enforced by construction, so the previewed orphan count can't drift from what --yes removes. Documented the pre-/post-upsert asymmetry as deliberate (already unreachable via reject_generated_key_collisions), per your note.

Double JSON parse (extractor.rs:909). The hot path no longer parses the blob as Value and then as BlobEnvelope. A cheap raw.contains("edgezero_kind") gate keeps future_format_reason's generic reparse off the happy path — the version case is still caught by verify()'s UnknownVersion, and the two cases that genuinely need the pre-check (kind-tagged v1, schema-changed v2) take the rare branch. Same semantics, same redaction, one parse on a valid config. Existing redeploy tests (version + edgezero_kind) still pass.

gitignore (gitignore.hbs:17). Both the template and this repo's root .gitignore now also ignore the atomic-replace staging temp (.*.edgezero-*.tmp) that a SIGKILL/panic mid-push can strand. The root globs have no leading slash, so they cover examples/app-demo's fastly.toml sidecars too — no separate app-demo file needed.

Gates green: fmt · workspace clippy --all-features · fastly cli (205) · edgezero-cli (176) · core (extractor future-format tests) · Viceroy runtime lib (84).

@aram356 aram356 changed the title Fastly chunked-config GC: reclaim orphaned chunk entries on re-push (last-writer-wins) Fastly chunked-config GC: operator-invoked config gc reclaims orphaned chunk entries (store-wide) Aug 14, 2026
@aram356
aram356 merged commit 3d3ddf3 into main Aug 14, 2026
14 checks passed
@aram356
aram356 deleted the spec/fastly-chunk-gc branch August 14, 2026 18:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Fastly chunked-config GC: reclaim orphaned chunk entries on re-push

3 participants