Skip to content

fix(config-service): implement GET /v1/config/history and POST /v1/config/secrets/reveal - #840

Merged
claude[bot] merged 1 commit into
masterfrom
claude/config-service-contract-conformance
Aug 27, 2026
Merged

fix(config-service): implement GET /v1/config/history and POST /v1/config/secrets/reveal#840
claude[bot] merged 1 commit into
masterfrom
claude/config-service-contract-conformance

Conversation

@izzywdev

Copy link
Copy Markdown
Owner

Defect

Unblocks the contract gate seen on #824. shipped code matches the frozen contract (scripts/gate_openapi_conformance.py) flags config-service:

GET /v1/config/history is declared in services/config-service/openapi.yaml but no route implements it.
POST /v1/config/secrets/reveal is declared in services/config-service/openapi.yaml but no route implements it.
config-service: spec=9 code=7 -> 2 finding(s)

Both paths exist in the frozen services/config-service/openapi.yaml (1.1.0, FF-EPIC-18 / FFRNT-280's secret-audit flow) but were never implemented under src/.

Decision: (a) implement — not (b) remove

Per the gate's own instruction, this required a real judgement call:

  • The two endpoints are the load-bearing half of the 1.1.0 changelog (secret reveal-once + its audit trail).
  • The spec's own prose for PUT /v1/config's revertOf already depends on them ("an earlier value from GET /v1/config/history").
  • Removing them would mean editing the changelog, the Revert prose, RevealSecretRequest/RevealSecretResult, ConfigHistoryEntry, and the Identifiers section's cvh_ reservation — a much higher bar than building routes the contract already fully specifies, with nothing in the repo suggesting they were abandoned.

So: implemented both, matching the existing route/repository/error-shape conventions in services/config-service/src.

What shipped

  • migrations/004_config_history.sql — append-only config.config_history table.
  • repositories/history.repository.tsPgHistoryRepository (append + keyset-paginated listPage), same conventions as the existing repositories.
  • routes/config-read.routes.tsGET /v1/config/history, gated on a new audit action, distinct from read (openapi.yaml: "no audit grant... distinct from the write/read grants"). Same authz-before-existence-check ordering as GET /v1/config.
  • routes/secrets.write.tsPOST /v1/config/secrets/reveal.
  • config.write.tsPUT /v1/config now appends a history entry per applied op, inside the same transaction as the value change (a rollback drops the history entry too). isSecret keys are redacted (oldValue/newValue always null) regardless of what was computed.
  • registry.ts / registry.py / gate_identifier.py — registered the cvh_ TypeID prefix the contract's Identifiers section already reserved, kept in parity across TS/Python.

Security weight (per the task's explicit callout)

POST /v1/config/secrets/reveal is reveal-once, high-privilege, and per CLAUDE.md "an id is never a capability":

  • Authorization is checked via checkAuthorization() against a new, distinct reveal action — decided independently by the Security API, never derived from read/write grants, and never satisfied merely by the caller having supplied a valid namespace/scope/key address. A dedicated test (checks the reveal action as a DISTINCT grant...) asserts the exact action string reaching the authz client.
  • Fail-closed throughout: denied or undecidable (DECISION_UNAVAILABLE) → 403, same discipline as every other check in this service.
  • Real rate limiting, not decoration: an in-process sliding-window limiter (429 RATE_LIMITED), keyed per (subject, namespace, scope, key) so hammering one credential doesn't throttle a caller's other secrets. Documented per-pod limitation (no shared store across replicas today) rather than silently assumed.
  • Every reveal attempt against a resolved secret — success or not — writes its own reveal history entry (including 404 isSet:false and 429-blocked attempts), per the spec's "every call — success or not" language.
  • Explicitly out of scope, named as such in the module doc: encryption-at-rest. isSecret values were already stored as plaintext JSONB before this PR (pre-existing S6/FFRNT-158 PUT /v1/config behavior) — this PR adds the reveal-once authorization/audit contract, not a KMS integration. decryptSecretValue() is the documented seam a future encryption change hangs off; the 409 SECRET_UNAVAILABLE path is wired but not yet reachable. Raising this as a separate, pre-existing gap rather than silently expanding this PR's scope.

Incidental fixes (small, needed to get a working/verifiable baseline — found while building this, unrelated to the two missing routes)

  • packages/identity/dist/{registry.js,registry.d.ts} were stale relative to their own src (missing namespace/keyDefinition, added in feat(config-service): catalog + values schema and resolution engine (FFRNT-154/155/156) #634 but never rebuilt) — rebuilt via tsc, which also picks up this PR's configHistory addition.
  • src/middleware/authz.ts's makeNoOpProxy() (+ its test-file mocks) didn't implement AuthzClient's grant/revoke/listGrants, added to @fuzefront/auth since this was last touched — stubbed as throwing no-ops (config-service never calls them).
  • tests/helpers/fakeDb.ts matched unqualified table names (config_namespaces) against the real, schema-qualified SQL (config.config_namespaces) — every INSERT/SELECT it should have handled instead threw inside an unhandled-for-errors async route handler, hanging the request (and the whole jest run) until timeout instead of failing fast. Fixed the substring matchers; extended the fake to also handle config_history.

Verification

$ python scripts/gate_openapi_conformance.py --repo .
config-service: spec=9 code=9 -> OK
gate-openapi-conformance: 7 contracted service(s) conformant, 2 uncontracted service(s) exposing nothing beyond the allowlist.

$ python scripts/gate_identifier.py --all
gate-identifier: OK
  • Full config-service jest suite: 21 suites / 297 tests, all green (includes 2 new suites — history.repository.test.ts, secrets.write.test.ts — plus new assertions in config.write.test.ts and config-read.routes.test.ts), covering happy path AND fail-closed/unauthorized/rate-limited/redaction cases.
  • tsc --noEmit clean (production build type-check).
  • packages/identity (74 tests) and packages/identity-py registry (manual import check) unaffected.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013tMciHkPE8To7V67CsgKKc


Generated by Claude Code

…nfig/secrets/reveal

Fixes the contract-conformance defect surfaced on #824: both endpoints were
declared in services/config-service/openapi.yaml (the frozen contract) but
had no implementing route, so a client generated from the contract got a 404.

DECISION: option (a), implement — the endpoints are the load-bearing half of
the 1.1.0 changelog (secret-audit flow, FFRNT-280), the spec's own prose
already ties PUT /v1/config's `revertOf` to "an earlier value from
GET /v1/config/history", and nothing in the repo suggested they were
abandoned. Removing them would have meant editing the changelog, the
Revert prose, RevealSecretRequest/Result, ConfigHistoryEntry, and the
Identifiers section's `cvh_` reservation — a much higher bar than building
routes the contract already fully specifies.

What shipped:

- migrations/004_config_history.sql — the append-only config.config_history
  table `listConfigHistory` reads and every set/unset/lock/unlock (PUT
  /v1/config) + reveal (POST /v1/config/secrets/reveal) writes.
- repositories/history.repository.ts — PgHistoryRepository (append +
  keyset-paginated listPage), mirroring the existing repository conventions.
- routes/config-read.routes.ts — GET /v1/config/history, gated on a NEW
  'audit' action (distinct from 'read'), same authz-before-existence-check
  discipline as GET /v1/config.
- routes/secrets.write.ts — POST /v1/config/secrets/reveal. Security weight,
  per CLAUDE.md "an id is never a capability": authorization is checked via
  checkAuthorization() against a NEW 'reveal' action, decided independently
  of read/write by the Security API — never derived from the caller having
  merely supplied a valid namespace/scope/key. Fail-closed throughout
  (403 on deny/undecidable, matching the existing checkAuthorization()
  discipline), with a real in-process sliding-window rate limiter (429
  RATE_LIMITED, documented per-pod limitation) and a `reveal` history entry
  written for every attempt against a resolved secret, success or not.
  Encryption-at-rest is explicitly OUT OF SCOPE and named as such in the
  module doc: isSecret values were already stored as plaintext JSONB before
  this PR (S6/FFRNT-158's PUT /v1/config), and adding real encryption needs a
  key-management decision this change does not make. `decryptSecretValue()`
  is the documented seam a future change hangs off; the 409
  SECRET_UNAVAILABLE path is wired but not yet reachable.
- config.write.ts — PUT /v1/config now writes a history entry per applied
  op, in the SAME transaction as the value change (a rollback drops the
  history entry too). isSecret keys are redacted (oldValue/newValue always
  null) regardless of what was passed in.
- registry.ts / registry.py / gate_identifier.py — registered the `cvh_`
  TypeID prefix (configHistory) the contract's Identifiers section already
  reserved, kept in parity across the TS/Python identity packages.

Incidental fixes needed to get a working, verifiable baseline (found while
building this, unrelated to the two missing routes, scoped narrowly):
- packages/identity/dist/{registry.js,registry.d.ts} were stale relative to
  their own src (missing `namespace`/`keyDefinition`, added in #634 but
  never rebuilt) — rebuilt via `tsc`, which also picks up `configHistory`.
- src/middleware/authz.ts's makeNoOpProxy() (+ its test-file mocks) didn't
  implement AuthzClient's grant/revoke/listGrants, added since @fuzefront/auth
  grew them — stubbed as throwing no-ops (config-service never calls them).
- tests/helpers/fakeDb.ts matched unqualified table names
  (`config_namespaces`) against the real, schema-qualified SQL
  (`config.config_namespaces`), so every INSERT/SELECT it should have
  handled instead threw inside an un-awaited-for-errors async route handler
  — an unhandled rejection that hung the request (and the whole test run)
  until timeout rather than failing fast. Fixed the substring matchers and
  extended the fake to also handle config_history.

VERIFIED: python scripts/gate_openapi_conformance.py --repo . now reports
config-service OK (spec=9 code=9). Full config-service jest suite (21 suites
/ 297 tests, including new ones for the two endpoints) green; tsc --noEmit
clean; gate_identifier.py --all OK.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013tMciHkPE8To7V67CsgKKc
@izzywdev izzywdev added the auto-merge Enable squash auto-merge once CI passes label Aug 27, 2026 — with Claude
@github-actions
github-actions Bot enabled auto-merge (squash) August 27, 2026 09:00
izzywdev pushed a commit that referenced this pull request Aug 27, 2026
…n hang, not fail

Correcting my own recommendation in the previous commit. gate-code-review is
`runs-on: fuzefront` — a SELF-HOSTED runner — with no `timeout-minutes`. Requiring
it would let one offline runner block every merge in the repo, permanently, with
no red anywhere to look at.

Not hypothetical. Measured 2026-08-27 while draining the PR queue: the job
completes in 12-14 seconds when the runner picks it up (five PRs did), but
NOTHING has completed since 09:08Z and 17 PRs sit queued for up to an hour —
including #840, the PR at the front of the merge queue, queued since 08:52Z. The
runner is not slow; it stopped taking work. Meanwhile in-progress GitHub-hosted
runs fell from 14 to 5, so this is not shared capacity.

The health check in the previous commit read gate-code-review as 5 success /
0 fail / 15 pending and I counted it safe. That was the wrong reading: those
pendings were not "slow", they were never going to complete. 0 failures is not
evidence a check can fail.

Hence the general rule now stated in the runbook, which is what I actually got
wrong: A REQUIRED CHECK MUST BE ABLE TO FAIL. A check that can only pass or hang
is worse than no check, because the deadlock it produces is indistinguishable
from CI still running, so nobody knows to go look. gate-code-review and the
path-filtered gates both fail that test, in different ways — one hangs waiting
for a runner, the others are never created at all.

Requireable again once it has a GitHub-hosted fallback, or a timeout that fails
rather than hangs. Required contexts 39 -> 38; the verification count in step 3
is updated to match.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013tMciHkPE8To7V67CsgKKc
@claude
claude Bot merged commit 3c79ef6 into master Aug 27, 2026
66 checks passed
@claude
claude Bot deleted the claude/config-service-contract-conformance branch August 27, 2026 11:25
claude Bot added a commit that referenced this pull request Aug 27, 2026
A path-filtered workflow does not run at all on a PR touching none of its paths,
so its check run is never CREATED — and a required context that is never created
pins the PR at "Expected — waiting for status to be reported", with no red to
look at and nothing to re-run. This repo measured that on four sampled PRs on
2026-08-23; workspace-deps-check.yml and gate-sealed-keys.yml both carry the
resulting rule in their headers: filter INSIDE a job, never on the trigger, for
anything the ruleset requires.

The corollary went unnoticed. A path-filtered check CANNOT be required, so it
cannot stop a merge — and master is deploy-on-push. gate-openapi-conformance is
the proof: it is the only thing in the repo that measures shipped code against
the frozen contract, it is not required, and config-service shipped
`GET /v1/config/history` and `POST /v1/config/secrets/reveal` declared in
openapi.yaml with no route implementing either. Any client generated from that
contract 404s.

Both of these gates measure WHOLE-REPO invariants, not diffs:

  gate_openapi_conformance.py --repo .   every service against its own contract
  check-route-ownership.mjs              every contract route against the service
                                         the ingress actually routes it to

So there is nothing to filter inside the job either — the trigger filter was a
runner-time optimisation on a pip-install-plus-script and a node-script-over-
helm-template. It bought seconds and cost enforceability.

The old triggers did have one thing right, kept in the comments: each seam has
two sides, and a one-sided trigger reproduces the original blind spot. Running
unconditionally covers both by construction.

`push:` filters are left alone — they run post-merge on master and play no part
in whether a PR's check run gets created.

Verified on master: check-route-ownership.mjs passes (18 checks, every contract
route owned). gate_openapi_conformance.py exits 1 on the two config-service
findings, which is the gate working; #840 implements both endpoints, so this PR
goes green on that check once #840 lands.

Next in the ramp, each needing its own verification rather than a trigger edit:
image-reproducibility.yml (unfilter, then gate the expensive build-images matrix
with an internal `if:` — its cheap lockfile-discipline job is the requireable
one), helm-validate.yml, and gate-frames-first.yml, which is genuinely
diff-scoped. See docs/runbooks/widen-required-checks.md step 4.


Claude-Session: https://claude.ai/code/session_013tMciHkPE8To7V67CsgKKc

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>
claude Bot added a commit that referenced this pull request Aug 27, 2026
…te-identifier are not among them (#843)

* docs(governance): master requires 11 of 45 checks; gate-authz and gate-identifier are not among them

CLAUDE.md states the gate set IS the production guard, now that CI may approve
its own PRs: "gate-ds-conformance, gate-frames-first, gate-authz, gate-identifier,
and the full CI matrix ... A bot-authored, bot-approved PR that clears every gate
ships to prod."

The ruleset does not enforce that. Measured against the live ruleset: 11 of 45
checks are required. gate-authz and gate-identifier — both named above — are not
required. Neither is any backend, integration or e2e suite, nor CodeQL, Snyk,
Container Security Scan, gate-toolchain, gate-version, gate-pagination or
gate-vacuous-check.

auto-merge.yml arms `gh pr merge --auto --squash`, and GitHub's auto-merge waits
on REQUIRED contexts only. Master is deploy-on-push. So a PR merges and deploys
the moment those 11 pass, while the rest are still running or already red. On
2026-08-27 the bot armed auto-merge on #836 while `Build chat-service` was failing
on an unbuildable production image; that check is not required, so nothing would
have stopped the merge.

This commit is the applicable change, not a report of one: the rulesets API is
write-blocked through the agent proxy (403), so it needs repo-admin rights. The
desired set is data in governance/required-status-checks.json and the runbook
carries the exact PUT, the verification, and the rollback.

Audited every workflow trigger before choosing the set, because a required check
that does not run on every PR is never created and pins the PR at "Expected"
forever — this repo hit that on 2026-08-23 across four sampled PRs. Only
unfiltered checks are included (39 contexts). `Generate SBOM` is excluded as it
always concludes skipped.

Two findings worth more than the count:

- The most valuable gates CANNOT be required as they stand. `shipped code matches
  the frozen contract` — which would have caught config-service shipping two
  declared-but-unimplemented endpoints — is path-filtered, along with
  gate-frames-first, gate-route-ownership, image-reproducibility and helm-validate.
  Step 4 is to unfilter the trigger and filter inside the job, the correction
  workspace-deps-check.yml and gate-sealed-keys.yml already carry.

- Merge queue must NOT be enabled yet, though it is the right fix for the
  release-commit treadmill. Zero of 58 PR-triggered jobs have a `merge_group`
  trigger, so queue entries would get no checks at all and time out; and gates
  that ratchet on the PR base would compute over an empty diff under
  `merge_group`, reporting green vacuously. Trigger support and base-ref handling
  land first, proven by a gate still FAILING under a merge_group event.

Health-checked before proposing: 29 candidate contexts across all 20 open PRs
produced 0 failures, so widening stalls nothing in flight.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013tMciHkPE8To7V67CsgKKc

* docs(governance): drop gate-code-review from the required set — it can hang, not fail

Correcting my own recommendation in the previous commit. gate-code-review is
`runs-on: fuzefront` — a SELF-HOSTED runner — with no `timeout-minutes`. Requiring
it would let one offline runner block every merge in the repo, permanently, with
no red anywhere to look at.

Not hypothetical. Measured 2026-08-27 while draining the PR queue: the job
completes in 12-14 seconds when the runner picks it up (five PRs did), but
NOTHING has completed since 09:08Z and 17 PRs sit queued for up to an hour —
including #840, the PR at the front of the merge queue, queued since 08:52Z. The
runner is not slow; it stopped taking work. Meanwhile in-progress GitHub-hosted
runs fell from 14 to 5, so this is not shared capacity.

The health check in the previous commit read gate-code-review as 5 success /
0 fail / 15 pending and I counted it safe. That was the wrong reading: those
pendings were not "slow", they were never going to complete. 0 failures is not
evidence a check can fail.

Hence the general rule now stated in the runbook, which is what I actually got
wrong: A REQUIRED CHECK MUST BE ABLE TO FAIL. A check that can only pass or hang
is worse than no check, because the deadlock it produces is indistinguishable
from CI still running, so nobody knows to go look. gate-code-review and the
path-filtered gates both fail that test, in different ways — one hangs waiting
for a runner, the others are never created at all.

Requireable again once it has a GitHub-hosted fallback, or a timeout that fails
rather than hangs. Required contexts 39 -> 38; the verification count in step 3
is updated to match.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013tMciHkPE8To7V67CsgKKc

* docs(governance): widening the required set leaves #828's guard covering 2 of 38

#828 (issue #286) adds governance/required-check-triggers.json — a gate that
fails if a required context's workflow ever gains a `paths:` filter on
`pull_request:`. It is what stops the deadlock in §2 from being reintroduced, and
its own header states the limitation:

  Adding a NEW required context to the ruleset? Add its workflow here in the same
  PR — the gate is only as complete as this list.

It ships listing 2 workflows. Step 3 of this runbook takes the required set to 38.
Applied without extending that list, the guard silently covers 2 of 38 and the
other 36 can be re-filtered by anyone with nothing to catch it — a green gate
measuring almost nothing, which is the shape of failure gate-vacuous-check exists
to prevent.

Adds step 3a: the full context -> producing-workflow mapping (seven workflows
cover all 38), plus a verification snippet that prints required / guarded /
UNGUARDED counts so the two files cannot drift silently.

Mapping verified against the workflow files rather than written from memory. Two
contexts are matrix expansions and needed checking by hand: `CodeQL Analysis
(javascript)` comes from security.yml's `codeql-analysis` job (matrix: language)
and `Lint & Test (24.x)` from ci.yml's `lint-and-test` (matrix: node-version).

Also notes that gate-openapi-conformance.yml and gate-route-ownership.yml join
the list once #844 lands, since unfiltering them is precisely the property this
guard protects.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013tMciHkPE8To7V67CsgKKc

---------

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

auto-merge Enable squash auto-merge once CI passes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants