# Changelog

All notable changes to `mgf-secrets` are documented here. Format loosely follows
Keep a Changelog; versions follow SemVer.

## [0.5.2] - 2026-07-27

> Supersedes the aborted **0.5.1**: that bump changed `pyproject.toml` but left
> `__version__` at `0.5.0`, so `test_version_present` failed all three matrices
> and the publish step never ran. Tag `v0.5.1` is left in place as history — it
> points at a commit that cannot ship, and deleting a pushed tag is not worth the
> destructive act. **The version lives in two files; nothing checked they agree.**

### Fixed

- **A tombstoned credential may have no surfaces.** The validator required a
  non-empty `surfaces` list for every state, which made the `tombstone` state
  **inexpressible** — there was no valid way to record a dead credential. Faced
  with that, a sibling family's registry used an out-of-enum `state = "retired"`,
  and since `list`, `doctor` and `exec` all fail closed on an invalid registry,
  **one dead entry took the credential plane offline for both families for two
  days, undetected** (court F-36/F-37).

  An empty `surfaces` list is now valid when and only when `state = "tombstone"`.
  The invalid entry was the symptom; the unusable state was the cause.

## [Unreleased]

## [0.5.0] — 2026-07-22

### Added (Phase 5 — staged rotation)
- **`rotate <id>` — the staged, per-kind rotation verb** (SPEC-P5 §1, PROPOSAL §2.5). A
  rotation is a sequence of **gated stages**, never a prose runbook — each runs only if
  the prior succeeded:
  `generate-new` → `register-alongside` → `probe-new` → `cutover` → `verify-consumers`
  → `retire-old`. So a surface is never pointed at an unproven credential, and the old
  credential is **never retired until consumers are verified on the new one**.
  - **`--plan`** (default) shows the value-free staged plan; **`--apply`** executes it;
    **`--json`** twins both. The new value arrives via **`--value-file PATH`** / `-`
    (stdin) — **never argv** (the argv trap rejects `--value`, AG-18).
  - **register-alongside** puts the new value on the surfaces *without removing the old*
    (both valid during cutover): `store_put` the new ciphertext (the store's git history
    is the audit log), update the live Woodpecker secret to serve the new value, register
    the new deploy key beside the old.
  - **verify-consumers** runs a scope audit (declared == deployed for the entry) + the
    *fire one pipeline per event class* reminder + the **sooth canary** where the entry
    is a cross-family (org-secret) surface.
  - **retire-old is gated**: it requires `--allow-retire` AND a green verify AND a
    non-`held` entry; it records the provider-revoke directive. A `state = "held"` entry
    **refuses** — a held credential's rotation/retire is a ⚖ decision package with the
    founder, never a solo `rotate`.
  - **Elimination over rotation**: `jwt-minted` has no stored token to swap — its
    rotation is a **re-mint** (a signing-hash rotation invalidates every minted JWT; the
    reconciler re-mints). The verb enforces it: the plan collapses to
    *re-mint → probe-new → verify-consumers*, with no store write and no retire-old.
  - A completed rotation stamps the entry's **`rotation.last`** (surgical, one-field TOML
    edit) and appends a **value-free audit record** to `<store>/rotations/<id>.jsonl`.
- **Per-kind rotation drills** — one representative of **every** kind
  (`ssh-deploy-key`, `api-token`, `password`, `jwt-minted`, `openstack-appcred`,
  `service-account`, `ssh-keypair`, `oauth-app`) runs `rotate --apply` end-to-end against
  in-proc fakes with SIM values: staged, gated, retire-old refused until verified, a RED
  canary blocks retire, and redaction over the whole rotate surface.
- **Kind-extension contract tightened** (SPEC §5.3): a `kind` now MUST carry a **rotation
  playbook** (`ROTATION_PLAYBOOKS`) and a **drill fixture** as well as a probe — the
  library cannot grow a kind it has no staged rotation for.
- `mgf.secrets`: `Rotator`, `RotatePlan`, `RotateResult`, `ROTATION_PLAYBOOKS`;
  `mgf.secrets.tomledit.set_rotation_last_in_file` (the shared surgical stamp).

## [0.4.1] — 2026-07-22

### Added
- **Finding acknowledge / defer governor** — a committed `acknowledgements.toml` (beside
  `SECRETS.toml` in mgf-standard / sooth-standard) records findings the founder has
  knowingly deferred (e.g. the D-5 mgf-website deploy-path drift). Each ack carries a
  `check`, a target (`id` + optional `repo`), a **required** `reason`, and an optional
  ISO `expires`. Values-free + git-audited.
  - The doctor **still reports** an acknowledged finding — flagged `acknowledged: true`
    (with the reason) in `--json` and `[ack]` in the table — **never silently dropped**.
  - `watch`/cron pages only on **un-acknowledged** findings OR `unverifiable`. An
    all-acknowledged run (0 unverifiable) is **clean-for-paging**: it closes/does-not-open
    the deduped issue and notes "N acknowledged (not paged)" — so a known-deferred state
    does not train the operator to ignore the pager (the C9-noise anti-pattern).
  - **Expiry is load-bearing**: a past `expires` makes an ack inactive → its finding
    pages again (an ack can never hide drift forever), with an audit note in `skipped`.
  - `mgf-secrets ack --list` shows acks (active/expired); `mgf-secrets ack <check> <id>
    --reason … [--repo …] [--expires …]` appends one (removal is a deliberate hand-edit).
  - `mgf.secrets`: `Acknowledgement`, `Acknowledgements`, `load_acknowledgements`;
    `Finding.acknowledged` / `ack_reason`; `DoctorReport.pageable()` /
    `pageable_findings()` / `acknowledged_findings()`.

## [0.4.0] — 2026-07-22

### Fixed (cron-hardening, post-court-review — before the cron goes live)
- **No-checkout consumed-check false-clean (CRITICAL)** — the no-checkout config source
  fetched Woodpecker's per-pipeline config, which is **event-RENDERED** (the latest
  pipeline's event strips `from_secret` structure + event-filtered `deployment` steps),
  so `--config-source=woodpecker-api` returned **0 findings** where `local` found **13**
  (the live CIR-33 mgf-website deployment drift) — a pager reporting clean over real
  drift. Fixed by renaming the source to **`forge-raw`** and fetching the **RAW committed
  `.woodpecker.yml`** from the forge contents API (base64) — identical to a local
  checkout, so `forge-raw == local` exactly. This moves the config-fetch to Codeberg, so
  it carries the §7b machinery (disk-cache TTL + per-run budget + 429→`unverifiable`) plus
  bounded parallelism; a 404 (no committed file) is not-consumed (skipped), a 429/5xx is
  `unverifiable`, never silently 0.
- **C9 recency window** — C9 now only flags error-status-0-steps pipelines whose
  terminal time is within `error_window_hours` (default 24h; `--error-window-hours`).
  Live, the whole-history scan surfaced **22** findings including already-resolved
  incidents (the historical CIR-02 `mgf-cost-watch` cron death); a `*/30` pager with no
  window would re-file the archive every run and train the operator to ignore it. The
  default 24h window reports **0** on the current fleet — the pager fires on NEW events;
  the static audit (C1/C2) owns the root cause continuously.
- **Concurrent Woodpecker per-repo sweeps** — the woodpecker-api config-fetch (and the
  repo-secrets + recent-pipelines sweeps) run with bounded parallelism (`config_workers`,
  default 10) instead of ~50 sequential fetches. A live `watch --config-source=woodpecker-api`
  dropped from **~220s to ~23s**. Woodpecker is our own unmetered server; semantics are
  unchanged (an unreadable config/pipeline-list is `unverifiable`, never dropped).

### Added
- **Phase 4 — the paging-doctor cron + the no-checkout config source + the
  error-status watch (SPEC-P4).**
  - **`mgf-secrets watch`** — the cron systems verb (`mgf.secrets.watch`, unit-tested):
    runs the doctor, and on any finding OR `unverifiable` files/**updates** ONE deduped
    Codeberg issue on `magogi-admin/mgf-standard` (the estate pager — no new secret
    distributed); on a clean run **closes** the standing issue; on every completed run
    heartbeats `job=secret-doctor` to OpenObserve so the estate dead-man's-switch pages
    if the cron stops. The pager summary + heartbeat payload are **value-free** (AG-18,
    canary-tested). `--no-page` is the PAGE=0 fire-test hatch; `--heartbeat-on
    {always,clean}` (default `always`) keeps a drift run distinct from a dead cron.
  - **Doctor `--config-source={local,woodpecker-api}`** — the no-checkout mode: the
    consumed-check (C2/C4/C8) fetches each activated repo's rendered `.woodpecker.yml`
    from the **Woodpecker API** (unmetered) instead of a local checkout, so the doctor
    runs on agent2 which has no repo checkouts. A repo whose config can't be read is
    `unverifiable`, never silently dropped.
  - **C9 — error-status-0-steps watch** — flags recent pipelines in terminal `error`
    status with **0 executed steps** (the CIR-02 "nothing gates, nothing pages" shape);
    reports repo + pipeline + as-of.
  - `ops/secret-doctor.sh` — the deployable agent2 cron wrapper (canonical source;
    Fable deploys + wires the on-host Woodpecker mint).
- **P3-court hardening (SPEC-P4 §7 — prerequisites for a healthy cron):**
  - **§7a `ALL_MANAGED` is invocation-independent** — it resolves to the same
    MGF_LIBRARIES + SOOTH_LIBRARIES + Woodpecker-activated union regardless of which
    `--surface` flags run (the woodpecker client is built even for a forge-only run);
    when the activated source can't be reached it degrades to `unverifiable`
    (`activated_reachable=false`), never a silent shrink.
  - **§7b coverage-sweep budget** — the forge client gains a disk-cache **TTL** (a warm
    cron run = 0 live Codeberg reads; cold every ~6h) and a per-run **live-fetch budget**
    (once spent, further reads degrade to `unverifiable`), so a run's Codeberg call count
    is structurally bounded, not merely documented. `live_calls` is reported.
  - **§7c deploy-key identity is the FINGERPRINT, not the title** — C5 coverage and C3
    unregistered-actual now match by content fingerprint, so a repo carrying the fleet
    key under a different title is neither a false coverage gap nor a false rogue.

## [0.3.0] — 2026-07-22

### Added
- **Phase 3 — the `sync` reconciler + `retire` + doctor determinism hardening.**
  - `mgf.secrets.sync` — plan-first reconciler (`sync --plan/--apply
    [--surface woodpecker|forge|hosts|local] [--json]`). Per surface: create/update
    Woodpecker org/repo secrets to the exact declared event scope (a create injects
    the store value via the API body — never printed); the **CIR-34 forge deploy-key
    coverage sweep** (ensure every repo the `repos` glob / `ALL_MANAGED` covers
    carries the declared key — matched by **public-key fingerprint, not title**, so a
    repo carrying the same key under a different title is not a false gap; the
    registered key is PUBLIC material **sourced from a repo that already carries it**,
    never the store private half); host-file mode+owner over SSH (never contents in
    P3); local via `materialize`.
  - **D-7 image restriction — evaluated and dropped.** Court found it both
    unenforceable (Woodpecker's image-restriction regex rejects dotted tags →
    `python:3.12-slim` 422s) and ineffective (the whole fleet runs one image, so any
    restriction that keeps publishing working provides no isolation). The real
    `devpi_password` hardening (dedicated publish image + tag signing, DECISION-06)
    is a separate founder decision. `sync` reconciles event scopes only; a declared
    `images` is ignored and live images are preserved.
    Properties (tested): **never deletes silently** (forward-only; removal is
    `retire`'s job), **held is inviolable** (never armed), **idempotent**
    (plan→apply→plan empty), and the **contract-surface playbook** (an org-secret
    apply runs a re-plan scope audit → the event-class reminder → the sooth canary).
  - `mgf.secrets.retire` + `retire <id>` — completion-gated `active→retiring` state
    flip (surgical `SECRETS.toml` edit) + store entry/manifest removal on
    `--apply --allow-retire`; **refuses** an entry still referenced by a live
    pipeline (the mgf-hrb lesson) or founder-held (→ ⚖). The `my_stuff/` file prune
    stays a separate deliberate `materialize` concern.
  - **Doctor determinism (the cron prerequisite):** a check that could not be
    performed (forge 429/5xx, host unreachable) now emits an **`unverifiable`**
    record — distinct from clean AND from a real finding (exit 1; a `--json`
    `unverifiable` list) — never silently-clean. `ALL_MANAGED` is resolved **once**
    per run as the defined union (MGF_LIBRARIES ∪ SOOTH_LIBRARIES ∪
    Woodpecker-activated), logged with count + per-source provenance, stable across
    runs. Surface clients gained the read+write admin protocols (`WoodpeckerAdmin`/
    `ForgeAdmin`/`HostAdmin`); `SurfaceUnavailableError` carries the "couldn't-look"
    signal.
  - Extended suite: fake-surface contract tests (plan→apply→plan empty, injected
    scope/image/key/perm drift fixed, never-delete, held-inviolable), determinism
    tests (429→unverifiable, ALL_MANAGED stable + union), retire refusals + surgical
    flip, redaction over sync/retire, a seeded idempotence property, the playbook
    audit+canary path, and the real store + `ssh-keygen` provider paths. Coverage
    stays above the 80% floor.
- **Phase 2 — encrypted store + value CLI + materialized cache.**
  - `mgf.secrets.store` — the age-encrypted store engine (dep: `pyrage`):
    `encrypt`/`decrypt` (public-key multi-recipient; either secret key decrypts),
    `store_put`/`store_get`, `manifest_load`/`manifest_verify`, the no-plaintext
    `plaintext_guard`, and `recipients.txt` parsing. Values are `bytes` in-process
    only — never logged, never in an exception, never on argv (AG-18). The encrypt
    path is deterministic-by-argument (the CLI stamps `encrypted_at`).
  - `mgf.secrets.resolve(id)` — the in-process value API for sibling tools (the
    AG-18-native replacement for `$(cat my_stuff/…)`); its docstring forbids logging.
  - `mgf.secrets.materialize` — key-free `plan` + `apply` that decrypt declared
    `local-file` surfaces into bare-value files at the exact registry paths,
    enforcing 0600 files / 0700 parent dirs, refusing `held` entries, **never
    deleting** anything (leftovers reported, not pruned) — the doc-form leak class dies.
  - CLI verbs (SPEC §4): `show --value` (refuses a TTY without `--tty-ok`, never
    `--json`), `add`/`set` (value via `--value-file PATH`, stdin `-`, or hidden
    prompt — never argv), `exec --with ID[:ENV] -- CMD` (env-injected child),
    `materialize --plan/--apply`, `export-offline` (founder-only bundle), `history`
    (git audit trail). Global `--store`/`--identity`; the P1 exit-code contract holds.
  - Extended suite: roundtrip property (both recipients), redaction over every value
    verb (canary absent everywhere but `show --value`/`exec`), argv/TTY guards,
    materialize idempotence + safety, manifest consistency, doc-form elimination,
    no-plaintext guard. Coverage stays above the 80% floor.
  - Internal: `mgf.secrets.resolve` (the `ALL_MANAGED` helper) renamed to
    `mgf.secrets.managed` so the public `resolve(id)` value API owns that name.
- **Phase 1 — registry + read-only doctor.**
  - `SECRETS.toml` schema, loader, and fail-closed validator (family-namespaced;
    duplicate ids across families and armed `held` entries are load errors).
  - Read-only doctor with checks C1–C8 (Woodpecker declared↔actual, consumed↔
    declared parity with `audit_secret_scopes.py`, unregistered-actual, held
    discipline, forge deploy-key coverage, local hygiene, host-file perms,
    policy), human table + `--json`.
  - Per-kind liveness probes (one handler per registered kind).
  - CLI `mgf-secrets` with `list` / `show` / `doctor` / `probe`, `--json`, and
    the exit-code contract (0 clean · 1 findings · 2 usage/credential/load error).
  - Test suite: schema fails-closed, ported scope-logic corpus, incident replays
    (CIR-02/33/34, expired JWT, unregistered-actor, doc-form), the AG-18 redaction
    property test, the no-ungoverned gate, and the kind-extension meta-test.
