# FEEDBACK — mgf-secrets

> Cross-repo feedback log (FB-05). Friction hitting a *sibling* library is filed
> in that library's FEEDBACK.md; friction with *this* library's contracts lands
> here. Newest first.

### PAPER-03 — `add`/`set` never commit: stored values sit uncommitted indefinitely, and `history` reports an audit trail that does not exist

- **Severity:** 🟠 Durability + audit (a value that exists only in an uncommitted working tree is one `git clean` from gone)
- **Filed by:** Court/CTO (the 2026-07-24 token sweep, found at session close) on 2026-07-24
- **What hurts:** `store.py` has no git-commit path, so `mgf-secrets add` / `set` write `secrets/<id>.age` into the store's working tree and stop. Nothing tells the operator a commit is owed, and nothing fails if it never happens. Found by chance while running a pre-close `git status` on the store: **9 credential values were uncommitted**, including four minted the same day whose only other copy (the operator's paste files) had just been deleted — and **five from the previous day's D-5 batch, uncommitted for a full day** across two sessions.
- **Why it matters:** the store is the credential plane's source of truth, and its git history *is* the rotation/audit record — `history <id>` is documented as "git rotation/audit trail for `<id>.age`". For an uncommitted value that verb prints an empty trail: not "uncommitted", not "unverifiable" — just nothing, which reads as "no rotations yet" rather than "this value is not saved". So the two failure modes compound: the value is unprotected, *and* the tool that should reveal that is silent about it. The same false-clean shape as PAPER-01.
- **Evidence:** `git status --short` in the store showed `?? secrets/agent_codeberg_write.age`, `?? secrets/codeberg_repo_read.age`, `?? secrets/codeberg_clone_token.age`, `?? secrets/woodpecker_oauth_app.age`, `M secrets/infomaniak_api_token.age`, plus `?? cloudflare_api_token/cloudflare_account_id/turnstile_site_key/turnstile_secret_key/resend_api_key` dated the prior session; `history agent_codeberg_write` printed 0 revisions before the commit and 1 after. No value was lost (all 14 verified readable + live first), so this is a near-miss, not an incident.
> **⚠️ CORRECTED BY EXECUTION (Court, 2026-07-24, same day as filing).** The sub-claim that
> `history` "prints an empty trail … reading as *no rotations yet*" is **imprecise**: it already
> printed *"no history for … (never committed to the store)"*, which is accurate. I inferred the
> wording instead of running the command on an uncommitted id. The genuine gap is narrower: that
> one message **conflates two very different states** — "the ciphertext exists but is unsaved"
> (dangerous) and "there is no such value" (benign). That is what got fixed.

- **Suggested shape:** (1) `add`/`set`/`rotate`/`retire` commit their own change by default (`--no-commit` to opt out), message naming the id and the verb — the store already behaves like an append-only log, so a write that does not land in it is an unfinished write; (2) failing that, a **doctor check** (C-class) that fails on any uncommitted/untracked file under `secrets/` — the doctor is the fleet's standing net and this is exactly the kind of quiet drift it exists to catch; (3) `history` should say **`uncommitted (not in git)`** for a working-tree-only value instead of printing an empty trail.
- **Status:** ✅ SHIPPED (2026-07-24) — all three moves landed: **(1)** `add`/`set` commit the store
  by default (`store_commit()`), with `--no-commit` as the opt-out that says out loud the value is
  unsaved; **(2)** a new **doctor check `C10`** (severity high) fails on any uncommitted/untracked
  path under `secrets/` — the standing net, so this can never again go unnoticed for a day across two
  sessions; **(3)** `history` now distinguishes `UNCOMMITTED — <file> exists but is NOT in git` from
  "no stored value". Pinned by 5 tests in `tests/unit/test_store_commit_and_guards.py`. One of those
  tests immediately caught a bug in the fix itself: `git status --porcelain` collapses a wholly
  untracked `secrets/` to the directory name, so the per-id report came back empty — `-uall` fixes it.

### PAPER-01 — `materialize --plan` reports a **dead** local cache as `present` — status is decided by existence + mode, never content

- **Severity:** 🟠 False clean (the class the foundation already paid for once — the "all clear" cron that had 13 real findings)
- **Filed by:** Court/CTO (the 2026-07-24 token sweep) on 2026-07-24
- **What hurts:** after re-minting `infomaniak_api_token` and `mgf-secrets set`-ing the new value, `materialize --only infomaniak_api_token --plan` reported `present … 0 pending`. The file it called `present` contained the **revoked** token — probe **401**. Anyone trusting that plan would carry a dead credential forward believing the cache was current.
- **Root cause (read, not inferred):** `materialize.py` defines `STATUS_PRESENT = "present"  # present at the declared mode → nothing to do`, and `_PENDING = {create, chmod}`. The status is computed from **file existence + file mode only**. Content is never compared against the store, so *any* stale value — including one revoked minutes earlier — is indistinguishable from a correctly materialized one.
- **Why it matters:** `materialize` is the SC-21 bare-value path, i.e. the mechanism that is supposed to make the store the single source of truth for local caches. A plan verb that cannot see staleness inverts that: the store rotates, the cache silently doesn't, and the plan says fine. It also makes `--apply` a no-op exactly when it is most needed (post-rotation), because `--apply` only acts on `create`/`chmod`. The workaround the sweep actually used was `rm` the file, then `--apply` — deleting a credential file to trick a planner is not a protocol anyone should need.
- **Evidence:** `materialize --plan` → `present  infomaniak_api_token  →  …/api_token.txt  (0600)`, `0 pending`; the same file's value probed `401` against `api.infomaniak.com/1/products` while the store's value probed `200`. Confirmed in source at `materialize.py` (`STATUS_PRESENT`, `_PENDING`).
> **⚠️ CORRECTED BY EXECUTION (Court, 2026-07-24, same day as filing).** One sub-claim above is
> **wrong**: "`--apply` is therefore a no-op exactly when it is most needed". It is not.
> `materialize.apply()` (`materialize.py`) iterates **every** planned item and rewrites each
> non-held, store-present target unconditionally — it never filters on `_PENDING`. So `--apply`
> *would* have refreshed the stale cache. The real defect is narrower and still real: **the
> plan lies**, and `--plan` is the default, so an operator sees `0 pending`, correctly concludes
> there is nothing to do, and never runs `--apply`. I asserted the mechanism from reading the
> status constants without reading `apply()`. Filed against my own paper.

- **Suggested shape:** add a `stale` status — compare the decrypted store plaintext's digest against the on-disk file's digest (the store already computes a sha256 on `add`/`set`, so this is a hash compare, no new crypto and no value in any log). `stale` joins `_PENDING` so `--apply` rewrites it. Nothing in the plan output needs to print a value. If a content compare is ever deemed too expensive, the honest fallback is to report `unverifiable`, never `present` — a check that cannot fail proves nothing.
- **Status:** ✅ SHIPPED (2026-07-24) — `materialize.plan()` now compares the on-disk bytes to the
  decrypted store value: equal → `present`, different → **`stale`** (and `stale` is in `_PENDING`,
  so `--apply` reports it as work). With no usable key the plan stays key-free and reports
  **`unverified`** — never `present`, because a check that did not run proves nothing. Verified on
  the real store (`materialize --plan` now content-verifies all 4 local-file surfaces) and pinned by
  three regression tests in `tests/unit/test_materialize.py`, including the rotate-then-stale case.

### PAPER-02 — a doc-form file imported wholesale means the **store itself** serves prose with the secret (`logship_ingest_token`)

- **Severity:** 🟡 Sharp edge (correctness of a served credential)
- **Filed by:** Court/CTO (the 2026-07-24 token sweep) on 2026-07-24
- **What hurts:** `mgf-secrets show logship_ingest_token --value` returns **two lines**: line 1 an opaque 73-char token, line 2 a 15-word English sentence. The stored ciphertext is byte-identical to the doc-form file it was imported from (sha256 match), so the note travelled into the store. Any consumer using the documented access path — `mgf-secrets exec --with logship_ingest_token:ENV` or `show --value` — receives token + prose, not the credential.
- **Why it matters:** the P2 import is what moved the fleet off cat-a-doc files, and SC-21's promise is that a stored value is a **bare** value. Here the leak-class file shape survived the migration *inside* the store, which is the one place the doctor's C6 check (a local-**file** heuristic) cannot see it. The C6 finding on the file has been correct all along and reads as mere hygiene; the real defect is one layer deeper.
- **Evidence:** file `mgf/my_stuff/infomaniak/logship_ingest_token.txt` (2 non-blank lines, 0600) and `show --value` hash-match (`sha256[:16] = 532109897ee7c57a`); line 1 matches `^[A-Za-z0-9+/=._-]+$`, line 2 has 15 space-separated words. **Not corrected in place** — rewriting a live credential's stored value on inference is the founder's call, not the court's; the host-side collector copies were not touched and are unaffected either way.
- **Suggested shape:** (1) an `add`/`set`-time guard that refuses (or loudly warns on) a multi-line value for single-line kinds like `api-token`, so a doc-form blob can never enter the store again — the same fail-closed spirit as the SC-23 empty-injection guard; (2) an audit pass over already-stored values for the same shape; (3) then re-`set` this entry to line 1 and re-materialize.
- **Status:** 🟡 PARTIALLY SHIPPED (2026-07-24) — **(1) the guard shipped**: `add`/`set` now refuse a
  multi-line value for a single-line kind (`api-token`, `jwt-minted`), with `--allow-multiline` as the
  deliberate opt-out. `password` and the keypair/appcred kinds are excluded because real entries
  (`logship_collector_env`, PEM keypairs, `clouds.yaml`) are legitimately multi-line. Fail-closed like
  the SC-23 empty-injection guard; the message reports the line COUNT only (AG-18). Verified against
  this very credential's shape. **(2) the audit ran** — every stored value checked;
  `logship_ingest_token` is the **only** offender fleet-wide, so the blast radius is exactly one.
  **(3) correcting that live value remains FOUNDER-GATED and is deliberately NOT done** — rewriting a
  live credential on the court's inference is the founder's call (he declined it once, correctly).
