Skip to content

feat: first-class signed reviewer_gate snapshot enforced at close#49

Merged
xantorres merged 5 commits into
mainfrom
feat/reviewer-gate-snapshot
Jun 2, 2026
Merged

feat: first-class signed reviewer_gate snapshot enforced at close#49
xantorres merged 5 commits into
mainfrom
feat/reviewer-gate-snapshot

Conversation

@xantorres

Copy link
Copy Markdown
Owner

Context

The native reviewer gate recorded its verdict in the shared, mutable
review.verdict / review.findings frontmatter — the same fields owned and
overwritten by rk review-sprint (built-in rule eval), rk review-panel
(panel aggregate), rk review-verdict (manual), and rk re-review (reset).
rk close trusted a single review.verdict === 'accepted'. So a gate's
changes_requested / rejected could be silently replaced by a later pass —
most damagingly by rk ship, which re-runs review-sprint (built-in only,
gate-blind) and then closes. The gate was an overwritable suggestion, not a
gate.

This PR models the reviewer gate as a first-class, per-attempt-immutable,
signed reviewer_gate snapshot
stored separately from the human/built-in/
panel verdict, and makes every close path require both the gate and the
built-in/panel verdict to be green (most-restrictive-wins).

Design

  • Snapshot, not shared field. The gate writes only reviewer_gate
    (reviewer, review_attempt, verdict, findings, base_sha, end_sha,
    reviewed_at, signature). No other command writes that key, so a verdict
    pass can't clear or overwrite it. review.verdict becomes the built-in/panel
    lane exclusively.
  • HMAC signature keyed by a machine-local secret (~/.repokernel/gate.key,
    co-located with the trust file, never in the repo). The payload binds the
    review id, the sprint being closed, attempt, verdict, findings, and the
    reviewed commit range — so a snapshot can't be forged, lifted into another
    review, or replayed across ranges.
  • Compose at close. When a gate is required, close requires the snapshot
    to be present, signature-valid, produced by the review's stamped reviewer,
    bound to the current attempt, accepted, and content-fresh against its
    end_sha and the policy/scope it reviewed — AND review.verdict === accepted. Always on; not bypassable with --skip-checks.
  • Requirement anchored on config + the linked review, never a single mutable
    field: a configured (possibly non-default) reviewer, or an existing snapshot,
    makes the gate mandatory — it can't be dodged by renaming the reviewer.
  • All close paths covered: rk close, rk ship, the sequential autonomous
    run loop, and the parallel closeAfterMerge path all enforce the gate (the
    parallel path fails closed when required but unsatisfied).
  • CI net: a structural validator rule flags shipped, gate-required sprints
    whose snapshot is not accepted / on a stale attempt / base-drifted (signature
    authenticity is enforced at close, the only path that ships). A missing
    snapshot on shipped history is audit-scope, so upgrading a repo doesn't flood
    live validation.

What changed

  • feat(core)ReviewerGateSnapshotSchema + optional reviewer_gate field;
    gate/signature.ts (canonical, key-order-stable HMAC), gate/secret.ts
    (machine-local key), gate/required.ts (gateRequired + composeVerdict);
    new finding codes.
  • feat — gate writes the signed snapshot only; close / ship / autonomous /
    parallel enforce it; integrity validator; status surfaces the gate verdict.
  • docs — trust-model signing-key section, changelog, migration note.
  • fix — hardening from review (see below).

Hardening (post-review)

A round of adversarial review found enforcement was signing a narrower thing
than it checked, and was scattered. Fixed:

  1. Verify the signature against the sprint being closed, not the review's
    own (mutable) sprint_id; reject a sprint that links another sprint's review.
  2. Reject a snapshot whose reviewer differs from the review's stamped reviewer
    (the local key is shared across reviewers).
  3. gateRequired now considers the linked review (non-default reviewer gate, or
    an existing snapshot) — a gated reviewer can't be dodged.
  4. Freshness invalidates the snapshot when the project config or the sprint's
    scope (allowed/denied/generated paths) changed since the gate (scope compared
    at base_sha, so status churn is not a false positive).
  5. The parallel closeAfterMerge path enforces the gate instead of bypassing it.
  6. Sequential autonomous runs the built-in review lane before close.
  7. The signing key is strict-loaded (never sign with a malformed key) and
    rejects non-regular files (symlink redirection).
  8. Missing-snapshot on shipped history is audit-scope, not live.
  9. The rk review-gate accepted hint points to review-sprint then close.

Migration

Existing review files parse unchanged (the field is optional). A project that
configures a reviewer gate and has a sprint already in review from before this
change has no snapshot yet — run rk review-gate <sprint-id> once before
closing. Projects with no configured reviewer gate are unaffected.

Behavior change

rk review + gate now leaves review.verdict pending (the gate records only
its snapshot). Run rk review-sprint <sprint-id> before rk closerk ship
and autonomous runs do this internally.

Test plan

  • pnpm -r build && pnpm -r typecheck — green.
  • Core: 545 tests (reviewerGateSnapshot, reviewerGateIntegrity).
  • CLI: 1572 tests, including reviewerGateEnforce: overwrite, ship-launder,
    forgery, attempt bump, snapshot strip, reviewer dodge, cross-sprint lift,
    reviewer mismatch, non-default reviewer enforcement, scope/config drift,
    --skip-checks stale, closeAfterMerge accept/block, legacy migration.
  • All prior reviewer-gate invariants preserved (compute path untouched).

Out of scope

A full unified evaluateClosePreconditions() (built-in verdict + clean-tree +
gate in one service) — the gate-lane evaluator is centralized; further
consolidation is a follow-up. Dedicated end-to-end tests for the autonomous
loops (heavy worktree harness) beyond the closeAfterMerge unit coverage.

xantorres added 4 commits June 2, 2026 13:08
Add an immutable, HMAC-signed reviewer_gate snapshot type alongside the
review frontmatter, plus the signing/verification and gate-required
helpers it will be enforced with. Inert: nothing reads or writes the
snapshot yet.

- ReviewerGateSnapshotSchema (reviewer, attempt, verdict, base/end_sha,
  reviewed_at, findings, signature); optional reviewer_gate field on the
  review schema so existing review files still parse.
- gate/signature: canonical (key-order-stable) payload + HMAC-SHA256
  sign/verify, bound to review id, sprint id, attempt, and commit range.
- gate/secret: machine-local signing key resolver (~/.repokernel/gate.key).
- gate/required: gateRequired predicate + most-restrictive composeVerdict.
- New finding codes + catalog entries for gate integrity.
The reviewer gate now records its decision only in the signed, per-attempt
reviewer_gate snapshot — never in review.verdict. close (and every path that
funnels through it: ship, autonomous run, epic close) requires BOTH the
built-in/panel review verdict AND the gate snapshot to be green, using
most-restrictive-wins composition.

The snapshot lives in a frontmatter key no other command writes, so
review-sprint, review-panel, review-verdict, and re-review can no longer
clear or overwrite the gate decision. close additionally requires the
snapshot to be present, signature-valid (machine-local key), bound to the
current review attempt, accepted, and content-fresh against its end_sha —
always on, not bypassable with --skip-checks.

- gate writer records snapshot-only (reviewerGate.ts); reviewed range moves
  into the snapshot so close stamps review.end_sha with the shipped commit.
- gateEnforce.ts: shared close/ship snapshot evaluator (presence, signature,
  attempt, verdict, freshness), anchored on config not the mutable reviewer.
- gateSecret.ts: machine-local signing key, minted on first gate run.
- reviewerGateIntegrity validator rule: CI-side structural net for shipped
  sprints (presence, verdict, attempt, base_sha) — signature is enforced at
  close, the only path that ships.
- status surfaces the gate verdict + attempt.
- adversarial tests: overwrite, forgery, attempt bump, snapshot strip,
  reviewer dodge, --skip-checks stale, legacy migration, validator tamper.
Document the signed per-attempt reviewer_gate snapshot: the machine-local
signing key in the trust model, and a changelog entry covering the
verdict-storage change, the new validation codes, status surfacing, and the
one-time re-gate migration for in-flight reviews.
Address a second-round review of the reviewer_gate snapshot. The snapshot
direction was right but enforcement signed a narrower thing than it checked
and was scattered across close/run/validate; these close the gaps.

- Bind verification to the SPRINT BEING CLOSED, not review.sprint_id: a sprint
  linking another sprint's signed review now fails closed (cross-sprint lift).
- Reject a snapshot whose reviewer differs from the review's stamped reviewer
  (the local key is shared across reviewers); mirror in validation.
- gateRequired now considers the linked review: a configured non-default
  reviewer, or an existing snapshot, makes the gate mandatory — a gated
  reviewer can no longer be dodged by an ungated project default.
- Freshness now invalidates the snapshot when the project config or the
  sprint's scope (allowed/denied/generated paths) changed since the gate,
  comparing scope fields at base_sha so status churn is not a false positive.
- Autonomous close no longer bypasses the gate: sequential runs the built-in
  review lane before close; parallel closeAfterMerge enforces the gate (and
  fails closed when required but unsatisfied).
- Gate key: strict-load only (never sign with a malformed key), reject
  non-regular files (symlink redirection); new GATE_KEY_INVALID error.
- Missing-snapshot on shipped history is audit-scope, not live, so upgrading a
  repo with pre-snapshot shipped sprints does not flood validation.
- rk review-gate accepted hint points to review-sprint then close.

Adds adversarial tests for cross-sprint lift, reviewer mismatch, non-default
reviewer enforcement, and scope/config drift.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: cf3136186b

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +429 to +436
if (gateReview) {
const gateEval = await evaluateReviewerGate({
checkPath: epicWorktree,
config: outcome.config,
sprint,
review: gateReview,
configFile: relative(epicWorktree, outcome.configPath),
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Require built-in verdict before parallel close

In the parallel close path, this new check verifies only the signed reviewer_gate snapshot and never requires gateReview.verdict === 'accepted' like runCloseCommand does. Because this commit moved the reviewer-gate result out of review.verdict, a gated sprint can have an accepted snapshot while the built-in/panel lane is still pending or has been changed to a non-accepted verdict; closeAfterMerge will still ship it in parallel/autonomous close. Please enforce the normal review verdict here before mutating the sprint to shipped.

Useful? React with 👍 / 👎.

Second hardening round addressing staff-level boundary findings:

- A recorded snapshot is now enforced regardless of current policy:
  gateRequired short-circuits true when reviewer_gate is present, and close
  evaluates it even when the sprint is no longer review-required — a post-gate
  config opt-out or threshold bump can't void a signed verdict.
- close pins snapshot.base_sha === sprint.base_sha before scope lookup, so a
  retargeted base is caught at close, not only post-ship at validate.
- rk review-gate refuses a review whose sprint_id targets a different sprint
  (no snapshot poisoning).
- Parallel close (closeAfterMerge) now requires the built-in accepted verdict
  for review-required sprints in addition to the gate; parallel runs refuse a
  gated review-required epic upfront (the parallel path runs no review pipeline).
- Signing key: the reviewer's findings/summary are scrubbed of the exact key
  value before persist (HOME is inherited); keys with group/other permissions
  are rejected on POSIX.
- rk review-gate JSON emits gate_verdict + reviewer_gate_recorded + next_actions
  so automation doesn't treat the gate verdict as close-ready.
- Sequential autonomous halts as a review failure when review-sprint records a
  non-accepted verdict, instead of surfacing a later close failure.
- Docs/CHANGELOG clarify that a missing snapshot is audit-scope, present-but-bad
  is live.

Tests: cross-config-drift enforcement, base_sha pin, closeAfterMerge both-lane
gating, key redaction path covered.
@xantorres xantorres merged commit 0d10044 into main Jun 2, 2026
2 checks passed
@xantorres xantorres deleted the feat/reviewer-gate-snapshot branch June 2, 2026 13: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.

1 participant