fix(config-service): implement GET /v1/config/history and POST /v1/config/secrets/reveal - #840
Merged
Conversation
…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
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 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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Defect
Unblocks the contract gate seen on #824.
shipped code matches the frozen contract(scripts/gate_openapi_conformance.py) flags config-service: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 undersrc/.Decision: (a) implement — not (b) remove
Per the gate's own instruction, this required a real judgement call:
PUT /v1/config'srevertOfalready depends on them ("an earlier value fromGET /v1/config/history").RevealSecretRequest/RevealSecretResult,ConfigHistoryEntry, and the Identifiers section'scvh_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-onlyconfig.config_historytable.repositories/history.repository.ts—PgHistoryRepository(append + keyset-paginatedlistPage), same conventions as the existing repositories.routes/config-read.routes.ts—GET /v1/config/history, gated on a newauditaction, distinct fromread(openapi.yaml: "no audit grant... distinct from the write/read grants"). Same authz-before-existence-check ordering asGET /v1/config.routes/secrets.write.ts—POST /v1/config/secrets/reveal.config.write.ts—PUT /v1/confignow appends a history entry per applied op, inside the same transaction as the value change (a rollback drops the history entry too).isSecretkeys are redacted (oldValue/newValuealwaysnull) regardless of what was computed.registry.ts/registry.py/gate_identifier.py— registered thecvh_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/revealis reveal-once, high-privilege, and perCLAUDE.md"an id is never a capability":checkAuthorization()against a new, distinctrevealaction — decided independently by the Security API, never derived fromread/writegrants, 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.DECISION_UNAVAILABLE) → 403, same discipline as every other check in this service.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.revealhistory entry (including 404 isSet:false and 429-blocked attempts), per the spec's "every call — success or not" language.isSecretvalues were already stored as plaintext JSONB before this PR (pre-existing S6/FFRNT-158PUT /v1/configbehavior) — this PR adds the reveal-once authorization/audit contract, not a KMS integration.decryptSecretValue()is the documented seam a future encryption change hangs off; the409 SECRET_UNAVAILABLEpath 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 ownsrc(missingnamespace/keyDefinition, added in feat(config-service): catalog + values schema and resolution engine (FFRNT-154/155/156) #634 but never rebuilt) — rebuilt viatsc, which also picks up this PR'sconfigHistoryaddition.src/middleware/authz.ts'smakeNoOpProxy()(+ its test-file mocks) didn't implementAuthzClient'sgrant/revoke/listGrants, added to@fuzefront/authsince this was last touched — stubbed as throwing no-ops (config-service never calls them).tests/helpers/fakeDb.tsmatched 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 handleconfig_history.Verification
history.repository.test.ts,secrets.write.test.ts— plus new assertions inconfig.write.test.tsandconfig-read.routes.test.ts), covering happy path AND fail-closed/unauthorized/rate-limited/redaction cases.tsc --noEmitclean (production build type-check).packages/identity(74 tests) andpackages/identity-pyregistry (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