Skip to content

Gate: discover and reuse existing merge grants - #339

Draft
itsHabib wants to merge 8 commits into
mainfrom
codex/gate-grant-discovery
Draft

itsHabib wants to merge 8 commits into
mainfrom
codex/gate-grant-discovery

Conversation

@itsHabib

@itsHabib itsHabib commented Sep 13, 2026

Copy link
Copy Markdown
Owner

Summary

Gate currently requires a pasted grant ID, while advisory inventory and shared delivery guidance can request a new grant even when suitable authority already exists. gate gate now discovers and reuses existing operator-minted authority when -grant is omitted. Explicit IDs remain binding.

What this adds

  • One resolver for normal evaluation and optional read-only discover-grant -repo R -pr N -json. It checks the current open subject, immutable diff and existing deterministic floor, then authenticates all relevant grants against audited cycle accounting before selecting a covering candidate.
  • Fresh scope, head/PR binding, signature, expiry, tier and cycle checks. An older broad grant remains discoverable beside newer narrow grants; an exhausted broad grant cannot hide a usable narrower one. The normal capability checks and full verifier ladder remain in place.
  • Structured available, uncovered, assessment_required and not_applicable results. Unknown assessment and closed PRs do not produce mint requests. Commands preserve selected custody and floor configuration; a moved head stops evaluation before model invocation.

Discovery establishes eligibility for assessment. Its deterministic tier is a minimum, not the final reduced tier or permission to merge. Judgment remains attached to its existing run and grant lineage. This adds no mint authority, MCP, key creation, grant modification, required-check change or installation.

Validation

  • Full module vet/race/build/lint passed at the selection checkpoint; focused Gate race, vet and lint passed through c5a77e9539c9aacb751faab379cf307b863c9678.
  • Regression coverage includes grant eligibility and exhaustion, authenticated binding, no-ID normal evaluation, explicit IDs, configured paths, unchanged custody bytes, genuine assessment errors and ordinary backend error routing.
  • Read-only probes at 8f89444: Workbench docs: make Fleet a focused chapter in the Workbench teaching path #335 found existing coverage with no mint prompt; RoxIQ chore: drop codex from the required panel while its quota is out #250 reported the actual T3 gap; Workbench fix(fleet): keep provider preparation failures retryable #334's T1 minimum remained an assessment floor, not proof of its separately identified T2 requirement. The probes are retained snapshots, not current PR-state claims.
  • Independent review and Claude passed at 271bab1; CI passed at that exact head.
  • The fifth Codex panel's Slack head-race finding is fixed in 722c3e4: an actual temporary signed-approval CLI regression fails at the prior head and now returns exit 3 with a fresh Slack route, preserves configured flags and limits, and proves no action/stamp or consumed cycle. Joined audit failures remain hard errors.
  • Final independent PASS at c5a77e9539c9aacb751faab379cf307b863c9678, including the subsequently reproduced malformed-success view response. Empty/nonhex observed heads remain hard errors; the actual signed-callback regressions prove no consumed cycles or authority effects. Independent overlays and the targeted race suite (44.482s), Gate vet/lint and diff checks passed.
  • Final CI passed check, fuzz and hygiene at c5a77e9539c9aacb751faab379cf307b863c9678. Claude's completed review remains correctly attributed to 271bab1; no sixth broad panel request is added beyond the configured five. Copilot completion remains unverified.

Companion source guidance: cc-skills #75, independently verified with final Codex/CI evidence at 180d3d7. See cmd/gate/docs/grant-discovery-poc.md for reproduction and exact probe subjects. Fixture signing is disposable. No live Gate evaluation, merge, install, custody change or live Fleet change was performed. Draft for operator review.

@itsHabib

Copy link
Copy Markdown
Owner Author

@codex review

@itsHabib

Copy link
Copy Markdown
Owner Author

@claude review

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 13, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-09-13T23:53:50.096772Z 271bab1 Manual request
ℹ️ 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" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@claude

claude Bot commented Sep 13, 2026

Copy link
Copy Markdown

Claude finished @itsHabib's task in 5m 19s —— View job


Code Review: Gate Grant Discovery

  • Gather context and understand the request
  • Read changed files
  • Review grant_discovery.go (core logic)
  • Review grant_discovery_test.go
  • Review modified files (capability.go, evidence.go, floor.go, main.go)
  • Synthesize findings and post review

Overall this is a well-structured addition. The boundary is clean (read-only, no state writes, no new authority), the TOCTOU protections are solid, and the test suite covers the important invariants. A few things worth calling out:

P2 — Silent suppression of authentication errors when a valid grant coexists

grant_discovery.go:187-189:

if len(available) == 0 && unreadable != nil {
    return d.failed(unreadable)
}

If a grant has a bad signature but another valid grant exists, unreadable is set and then silently dropped. The valid grant is correctly selected — this is not a security gap — but the operator loses the signal that one of their logged grants failed authentication. A future operator debugging custody issues would find no trace of the bad-signature event. A fmt.Fprintf(os.Stderr, ...) warning (similar to the pattern in recordGrantNeeded) would surface this without changing control flow or the selection decision.

Fix this →

P2 — Dual if blocks where an early-return pattern fits

grant_discovery.go:81-91 (cmdDiscoverGrant):

if *asJSON {
    printJSON(d)
}
if !*asJSON {
    fmt.Printf(...)
    for _, candidate := range d.Candidates { ... }
    if d.MintRequest != "" { ... }
}
os.Exit(d.exitCode())

Two separate if blocks checking the same condition in opposite polarity. Per the repo's line-of-sight style (guard + early return), this fits naturally as:

if *asJSON {
    printJSON(d)
    os.Exit(d.exitCode())
    return nil
}
fmt.Printf(...)
for _, candidate := range d.Candidates { ... }
if d.MintRequest != "" { ... }
os.Exit(d.exitCode())
return nil

Not a correctness issue, but inconsistent with the style used elsewhere in the file.

Fix this →

Nit — discoveryRoute("not_applicable") has no test

TestDiscoveryCommandsPreserveConfiguration covers the "uncovered" and "assessment_required" routes from discoveryRoute. The "not_applicable" branch (line 289-291) is simple and almost certainly correct, but a one-liner assertion would close the gap.

Verified correct

TOCTOU protection. discoverGrant calls CurrentSubject twice — before the floor assessment and after — detecting head movement with a concrete error. matchBoundView then re-checks the live pr view head against the bound head before model invocation. Both layers are tested (TestDiscoveryRejectsUnreadOrMovedHead, TestGateStopsWhenDiscoveredHeadMovesBeforeView).

assessGrant error categorization. ErrExpired, ErrScope, ErrHeadMismatch, ErrSubjectGaps on the candidate (not errors); signature failures and parse errors → unreadable (escalated to caller). The right split: a scope mismatch is expected and gap-worthy; a signature failure is an integrity question.

broaderGrant comparator. The ||/&& precedence in the MaxCycles comparison is correct:

  • a == 0 (unbounded) → a is broader ✓
  • b == 0 (b unbounded, a bounded) → false || false → b is broader ✓
  • a > b (both bounded) → a is broader ✓

AssessFloor extraction. The refactoring in floor.go correctly separates the floor execution from artifact recording. Discovery uses the assessment path; the ladder uses the record path. Correct that both run the same binary with the same inputs — no drift possible.

Read-only invariant. TestDiscoveryIsReadOnly explicitly snapshots log, anchor, key, and anchor key bytes before and after a successful selection, then asserts byte-identity. It also verifies requireDiscoveryState doesn't create the missing dir. Strong invariant test.

countCycles extraction. Moving the inner loop into countCycles(all []state.Artifact, ...) so selectGrant can call it with the audit snapshot is correct. One audit read, one snapshot, no re-scan window. The extraction also makes the audit-integration testable without a full gate run.

requireDiscoveryState guard. Placed before newEnv in cmdDiscoverGrant and before newEnv in cmdGate (no-grant path), so discovery never creates a state tree or a key implicitly.

Explicit grant binding. runGateSelected passes through to runGate unchanged when grantID != "", so explicit IDs remain fully binding. TestGateOmittedGrantUsesDiscovery pins this with the grt_absent case.

decorateTerminalCode integration. The early-return for res.Discovery != nil && res.Discovery.Status != "available" correctly surfaces discoveryRoute instead of the generic block/park escape routes — only when discovery itself failed, not when it succeeded and the ladder later blocked.


@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: 13a34c456e

ℹ️ 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 +254 to +255
return gateResult{PR: fmt.Sprintf("%s#%d", repo, pr), HeadSHA: d.Subject.HeadSHA,
Outcome: "capability_refused", Code: "grant_" + d.Status, Why: d.Why, Discovery: &d}, d.exitCode(), nil

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 Return assessment failures as errors

When omitted-grant discovery encounters an unreadable head, diff, floor, key, or ledger, d.exitCode() returns 4, but this branch still serializes outcome: "capability_refused". That contradicts Gate's contract—3 is refusal and 4 is a hard error—so callers that validate both fields receive an internally inconsistent terminal and may route an infrastructure or integrity failure as an authority refusal. Return an error/no decision outcome for assessment_required rather than labeling it a capability refusal.

AGENTS.md reference: cmd/gate/AGENTS.md:L8-L11

Useful? React with 👍 / 👎.

Comment on lines +250 to +253
func SubjectDiff(pr PRRef, head string) (string, error) {
result, err := primaryDiff(pr, head)
if err != nil {
return "", err

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve the oversized-diff fallback during discovery

For a PR whose compare diff exceeds GitHub's line limit, primaryDiff returns the recognized HTTP 406 error and this new discovery path returns it directly. The normal evidence collector handles the same condition with tooLarge(err) and fallbackDiff, so an explicit-grant Gate run can evaluate such a PR while the new default omitted-grant path and discover-grant cannot get past assessment_required. Reuse the existing pinned local-diff fallback when assessing the discovery floor.

Useful? React with 👍 / 👎.

@github-actions

Copy link
Copy Markdown

Reviewer: claude
Reviewed commit: 13a34c456ea770a25114f14da4698b1a7d3e4485

@itsHabib

Copy link
Copy Markdown
Owner Author

Consolidated review disposition for 13a34c456ea770a25114f14da4698b1a7d3e4485 before the fix commit:

  • Codex P1: accepted. assessment_required must follow the hard-error path with no decision outcome, preserving the exit-4 contract.
  • Codex P2: accepted. Discovery will reuse the existing pinned local-diff fallback for the recognized oversized GitHub diff response.
  • Independent P2: accepted. Resolve relative custody and filesystem floor paths while the original working directory is known; exercise relative paths in the regression.
  • Claude P2 on authentication diagnostics: signature failures already remain visible in each candidate's structured gaps, including when another grant is selected. No duplicate stderr warning is needed. Malformed grant-body parsing currently lacks a candidate entry; preserve that artifact ID and error too, and test both coexistence cases.
  • Claude formatting feedback: use the early-return JSON rendering branch.
  • The simple closed-subject route is covered by the closed-PR no-mint behavior; no additional feature is needed.

Copilot was requested but no start/completion receipt was observed. CI passed the initial head. The forthcoming fix will receive fresh tests, independent review and configured review requests; this PR remains a draft with no merge or installation action.

@itsHabib

Copy link
Copy Markdown
Owner Author

Addressed the consolidated findings in 8f89444: hard-error/no-outcome assessment failures, the existing pinned oversized-diff fallback, absolute custody/filesystem-floor paths, and visible malformed-grant diagnostics alongside valid selection. Full module race tests, vet, lint (zero issues), build and diff checks passed. Relative-path, terminal JSON, fallback and diagnostic regressions are included.

@itsHabib

Copy link
Copy Markdown
Owner Author

@codex review

@itsHabib

Copy link
Copy Markdown
Owner Author

@claude review

@claude

claude Bot commented Sep 13, 2026

Copy link
Copy Markdown

Claude finished @itsHabib's task in 4m 14s —— View job


Code Review: Gate Grant Discovery — fix commit 8f89444

Reviewed at 8f894443ece8a8854f9b68e217e602d460363fb1.

  • Gather context and understand the request
  • Read changed files (fix commit)
  • Review grant_discovery.go
  • Review grant_discovery_test.go
  • Review evidence.go / localdiff.go
  • Review main.go, floor.go, TestMain change
  • Synthesize findings

All prior P1 and P2 findings are correctly resolved. Notes on each, plus two new nits.

Codex P1 — assessment_required hard error contract ✓

grant_discovery.go:265-267 adds a dedicated guard before the generic d.Status != "available" branch:

if d.Status == "assessment_required" {
    return gateResult{Discovery: &d}, codeError, fmt.Errorf("grant_assessment_required: %s", d.Why)
}

cmdGate returns this error without calling exitGateResult, so main() routes to printTerminalError, which emits {"error": "..."} with no outcome field — exactly what TestGateDiscoveryAssessmentFailureIsHardError asserts (result["outcome"] == nil && result["error"] != nil, exit 4). The contract is clean.

Codex P2 — oversized-diff fallback in discovery ✓

evidence.SubjectDiff (evidence.go:250-259) mirrors the GatherFrom pattern:

result, err := primaryDiff(pr, head)
if tooLarge(err) {
    result, err = fallbackDiff(pr, head)
}

Discovery calls SubjectDiff; the normal collector calls the same tooLarge/fallbackDiff path inside GatherFrom. One implementation, two call sites — no drift possible. TestDiscoveryUsesOversizedDiffFallback exercises the 406 path end-to-end.

The TestMain change to dispatch on "git" in addition to "gh" and "floor" is necessary: the fallback path invokes git locally, and without this, TestDiscoveryUsesOversizedDiffFallback and TestDiscoveryCommandsResolveRelativeConfiguration would reach a real git binary.

Claude P2 — authentication diagnostic visibility ✓

The resolution differs from the suggestion but is better: rather than a fmt.Fprintf(os.Stderr, ...) warning that tooling ignores, malformed-body grants now return a candidate entry:

// assessGrant, unmarshal failure:
return grantCandidate{ID: a.ID, Gaps: []string{err.Error()}}, fmt.Errorf("read grant %s: %w", a.ID, err)

Both the invalid-signature case (already had a candidate) and the malformed-body case (now fixed) surface through the same structured Candidates[i].Gaps channel — machine-parseable and present in both JSON and text output. TestDiscoveryReportsRejectedCandidatesAlongsideValid pins both cases alongside a valid grant.

Claude P2 — early-return JSON branch ✓

cmdDiscoverGrant is now:

if *asJSON {
    printJSON(d)
    os.Exit(d.exitCode())
    return nil
}
// text path falls through

Clean line-of-sight — one path, one exit per branch.

Independent P2 — relative path resolution ✓

discoverGrant resolves custody and floor paths to absolute at entry:

d.KeyDir, err = filepath.Abs(d.KeyDir)
if strings.ContainsAny(d.FloorBin, `/\`) {
    d.FloorBin, err = filepath.Abs(d.FloorBin)
}

The ContainsAny guard is correct: a bare name like triage-floor or triage-floor.exe is a PATH lookup and must remain unresolved; a relative path like ./bin/triage-floor contains / and is resolved before cwd changes. TestDiscoveryCommandsResolveRelativeConfiguration exercises this via Chdir to confirm the emitted mint and retry commands carry the original absolute paths regardless of the working directory at resolution time.


Verified correct

TestGateDiscoveryAssessmentFailureIsHardError — exercises the full binary as a subprocess (GO_WANT_DISCOVERY_COMMAND=1), captures stdout, and asserts exit 4 + {"error": "..."} + no outcome key. This correctly validates the contract by the output that callers actually parse.

unreadable overwrite semantics — the loop overwrites unreadable on each bad grant, so a multi-failure run surfaces only the last error via d.failed(unreadable). When available is non-empty, unreadable is dropped entirely. Neither is a correctness gap: the structured Candidates list captures every failure's gap, and the d.failed path is only reached when nothing is available. Matches the disposition.

Assessment path orderingassessment_required guard precedes the "available" check, so an infrastructure failure during discovery never produces a capability_refused outcome. The ordering in runGateSelected is load-bearing and now correct.


Nit — unreadable message is last-writer-wins for multi-failure runs

When multiple grants fail authentication in the same discovery sweep, unreadable is overwritten each iteration. If available == 0, d.failed(unreadable) surfaces only the last error in the hard-error text. The structured Candidates output captures all failures, so an operator reading the JSON or the candidate list sees the full picture — but a caller consuming only d.Why on an assessment_required result sees one. Not a correctness gap; worth knowing if you ever add filtering on the error code.

Nit — FloorBin path-resolution condition is implicit

grant_discovery.go:125-128:

if strings.ContainsAny(d.FloorBin, `/\`) {
    d.FloorBin, err = filepath.Abs(d.FloorBin)

The condition is correct — PATH-lookup names intentionally stay unresolved — but a brief comment ("// path-relative only; bare PATH names must not be absolutized") would help the reader who wonders why KeyDir is always resolved while FloorBin is conditional. Very minor.


Summary

The fix commit is clean. All Codex P1/P2, independent P2, and Claude P2 findings from the prior round are addressed correctly and tested. The two nits above are cosmetic; neither touches a correctness or security invariant.

@github-actions

Copy link
Copy Markdown

Reviewer: claude
Reviewed commit: 8f894443ece8a8854f9b68e217e602d460363fb1

@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: 8f894443ec

ℹ️ 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 thread cmd/gate/grant_discovery.go Outdated
Comment on lines +265 to +266
if d.Status == "assessment_required" {
return gateResult{Discovery: &d}, codeError, fmt.Errorf("grant_assessment_required: %s", d.Why)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve the discovery recovery route on assessment errors

When an omitted-grant assessment fails because the floor, diff, key, or ledger is unreadable, this returns both a populated Discovery result and an error, but cmdGate discards res whenever err != nil. The top-level error renderer therefore emits the generic gate next escape instead of the discover-grant retry constructed by discoveryRoute, losing the exact -state, -key, and -floor configuration needed to repair and rerun the failed assessment. Preserve the discovery-specific terminal data while still exiting 4 with no decision outcome.

Useful? React with 👍 / 👎.

@itsHabib

Copy link
Copy Markdown
Owner Author

Addressed Codex P2 4001208984 in f3ddb66. Assessment errors now carry discovery through the terminal error renderer: stdout retains the exact subject, candidate diagnostics, state/key/floor configuration and read-only repair/retry route, while exit 4 still has an error and no decision outcome. CLI regressions cover unread floor/diff and invalid signing-key authentication. Full-module vet/race/build passed the runtime change; final Gate lint, focused CLI tests and whitespace checks passed. The unrelated Claude cosmetic suggestions remain recorded as non-blocking; no live Gate evaluation or installation occurred.

@itsHabib

Copy link
Copy Markdown
Owner Author

@codex review

@itsHabib

Copy link
Copy Markdown
Owner Author

@claude review

@claude

claude Bot commented Sep 13, 2026

Copy link
Copy Markdown

Claude finished @itsHabib's task in 3m 23s —— View job


Code Review: Gate Grant Discovery — fix commit f3ddb66

Reviewed at f3ddb66a258f1954e850659674b1809f8c6b0e2c.

  • Gather context and understand the request
  • Read changed files (fix commit f3ddb66)
  • Review main.go — discovery context preservation in terminal error path
  • Review grant_discovery.go / grant_discovery_test.go
  • Synthesize findings and post review

The Codex P2 finding was that when cmdGate encounters an assessment failure, res is discarded on the err != nil branch and printTerminalError(err, args) only had access to the error string — losing the discovery context (state dir, key dir, floor config, candidates) the operator needs to repair and retry.

Codex P2 — terminal discovery context ✓

The fix introduces a typed sentinel grantAssessmentError that carries the full grantDiscovery struct:

// grant_discovery.go:51-57
type grantAssessmentError struct {
    discovery grantDiscovery
}
func (e *grantAssessmentError) Error() string {
    return fmt.Sprintf("grant_assessment_required: %s", e.discovery.Why)
}

terminalErrorFor in main.go:2737-2742 detects it via errors.As and fires before the normal path:

var assessment *grantAssessmentError
if errors.As(err, &assessment) {
    return terminalError{Error: err.Error(), Discovery: &assessment.discovery,
        Escape: *discoveryRoute(assessment.discovery)}
}

This produces a terminalError with the full discovery struct (subject, state/key/floor config, candidate diagnostics) and a discover-grant escape route, while the zero values for RetryHelps and SelfGated are correct — a blind retry won't fix an infrastructure failure, and this is not a self-gate.

The failed() method (line 117-123) preserves d.Candidates when it clears GrantID and MintRequest, so when d.failed(unreadable) is called after the candidate-collection loop in selectGrant, any rejected candidates with structured gaps are already in d.Candidates and flow through to the terminal output.

Test coverage ✓

TestGateDiscoveryAssessmentFailureIsHardError now runs three sub-cases ("invalid floor", "unread diff", "bad signature") via end-to-end subprocess invocations in runDiscoveryFailureCLI. Each sub-test creates its own discoveryFixtureTools(t) with an independent temp dir.

checkDiscoveryTerminalFailure validates:

  • Subject fields (repo, PR number, head SHA) are preserved in the terminal output
  • terminal.Escape.Next is the exact discover-grant command with the original -state, -key, -floor configuration
  • d.MintRequest == "" and d.GrantID == "" — no invented authority
  • terminal.RetryHelps == false — no blind retry signal
  • For "bad signature" specifically: d.Candidates[0].ID == grant.ID and len(d.Candidates[0].Gaps) > 0 — candidate authentication diagnostics survive the terminal path

The "bad signature" case is correctly isolated: os.WriteFile(e.keyPath, ...) corrupts the key on disk before the subprocess is launched, so the subprocess reads the bad key from the fixture's temp dir.

Verified correct

discoveryRoute dereferencediscoveryRoute returns a concrete *readiness.Route in all three branches; the dereference in terminalErrorFor is safe. ✓

Error string formatgrantAssessmentError.Error() returns "grant_assessment_required: ...", matching the readiness.Code extraction prefix pattern used by the normal path. The test asserts result["error"] != nil which holds for this format. ✓

No UnwrapgrantAssessmentError has no Unwrap() method; it doesn't need one because it's never wrapped further, and the sole detection site uses errors.As directly. ✓

Test isolation — the three failure modes are tested as independent sub-tests (t.Run), each with their own temp dir and env snapshot, so key-file corruption in "bad signature" doesn't bleed into other cases. ✓


Residual nits (carried from prior round, still non-blocking)

  1. unreadable last-writer-wins — when multiple grants fail in the same sweep, only the last authentication error appears in d.failed(unreadable). Structured gaps in Candidates capture all failures; the hardcode text only surfaces one. Known and recorded.

  2. FloorBin path-resolution comment — the ContainsAny guard (treating bare names as PATH lookups, not path-relative references) is non-obvious and a comment would help. Previously noted, still absent.


All three commits are clean. The fix correctly threads discovery context through a typed error sentinel without modifying the gateResult return path, and the new test validates the exact terminal JSON structure a calller or operator would parse.

@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: f3ddb66a25

ℹ️ 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 +282 to +284
res, code, err := runGateBound(e, repo, pr, d.Subject.HeadSHA, d.GrantID, live, modelBackend, reviewsOptional)
res.Discovery = &d
return res, code, err

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve discovery recovery when the selected head moves

When the PR is pushed between discovery's final refresh and Gate's first gh pr view, matchBoundView returns grant_assessment_required, but this plain error is returned alongside res.Discovery; cmdGate then discards res on its error path and terminalErrorFor emits the generic gate next escape. Fresh evidence beyond the earlier pre-selection assessment-error case is that TestGateStopsWhenDiscoveredHeadMovesBeforeView exercises this post-selection branch only through runGateSelected, not the CLI renderer, so the new recovery wrapper still does not cover it. Preserve or wrap the discovery context here so the terminal directs the caller to reassess the new head with the original state, key, and floor configuration.

Useful? React with 👍 / 👎.

@github-actions

Copy link
Copy Markdown

Reviewer: claude
Reviewed commit: f3ddb66a258f1954e850659674b1809f8c6b0e2c

@itsHabib

Copy link
Copy Markdown
Owner Author

Addressed the post-selection recovery finding4001264942 in45c263bb585fcb6b88921520fff19d855c758dc8. The error wrapper now sits at cmdGate where results would otherwise be discarded, preserving discovery context for both initial assessment failures and later Gate failures such as a head moving before the first view. The actual CLI regression now covers that transition. Uncached targeted race tests, Gate vet/lint and diff checks pass; no authority or live-state changes. The prior Claude review completed with only the already recorded cosmetic suggestions.

@itsHabib

Copy link
Copy Markdown
Owner Author

@codex review

@itsHabib

Copy link
Copy Markdown
Owner Author

@claude review

@claude

claude Bot commented Sep 13, 2026

Copy link
Copy Markdown

Claude finished @itsHabib's task in 2m 49s —— View job


Code Review: Gate Grant Discovery — fix commit 45c263b

Reviewed at 45c263bb585fcb6b88921520fff19d855c758dc8.

  • Gather context and understand the request
  • Read changed files (fix commit 45c263b)
  • Review grant_discovery.go — post-selection recovery wrapping
  • Review grant_discovery_test.go — CLI regression coverage
  • Review main.go — terminalErrorFor integration
  • Synthesize findings and post review

The Codex P2 finding was that TestGateStopsWhenDiscoveredHeadMovesBeforeView exercised the post-selection matchBoundView failure only through runGateSelected, not the CLI renderer — so when matchBoundView returns grant_assessment_required after a head move, cmdGate discarded res on the err != nil branch and terminalErrorFor emitted the generic gate next escape instead of the discover-grant retry with the original -state/-key/-floor config.

Codex P2 — post-selection recovery wrapping ✓

main.go change (3 lines):

if err != nil {
    if res.Discovery != nil {
        return &grantAssessmentError{discovery: res.Discovery.failed(err)}
    }
    return err
}

The wrapper is now in cmdGate rather than in runGateSelected, which means it fires for both error paths that carry a discovery result:

  1. Initial assessment failure: runGateSelected returns gateResult{Discovery: &d}, codeError, errors.New(d.Why). res.Discovery != nil → wrapped.
  2. Post-selection head movement: runGateSelected sets res.Discovery = &d before returning from runGateBound. Any error from that path (e.g., matchBoundView returning grant_assessment_required) now also finds res.Discovery != nil → wrapped.

The failed(err) call on the post-selection path correctly resets Status"assessment_required", clears GrantID and MintRequest, and preserves Subject, StateDir, KeyDir, FloorBin, and Candidates. The result is a grantAssessmentError carrying the full discovery context, which terminalErrorFor detects via errors.As and routes to discoveryRoute.

grant_discovery.go change (Error() and runGateSelected):

func (e *grantAssessmentError) Error() string {
    return "grant_assessment_required: " + strings.TrimPrefix(e.discovery.Why, "grant_assessment_required: ")
}

TrimPrefix is load-bearing here. For the post-selection head-move path, matchBoundView already returns "grant_assessment_required: PR head changed after selection: ...". After failed(err), d.Why carries that full string. Without TrimPrefix, Error() would produce a double prefix: "grant_assessment_required: grant_assessment_required: PR head changed...". The deduplication is correct and necessary for this new case.

The corresponding change in runGateSelected from &grantAssessmentError{discovery: d} to errors.New(d.Why) is the right complement: with wrapping moved to cmdGate, the initial assessment path no longer needs to produce a typed sentinel itself.

Test coverage ✓

TestGateDiscoveryAssessmentFailureIsHardError now includes "moved before view" as a fourth sub-case. runDiscoveryFailureCLI runs the full gate binary as a subprocess with GO_DISCOVERY_FAILURE=moved before view.

In the fixture, gh pr view returns headRefOid: "ccc...ccc" while /pulls/7 always returns discoveryHead ("aaa...aaa") — so discovery succeeds and selects the grant, but matchBoundView fires on the mismatched view head. checkDiscoveryTerminalFailure then validates:

  • d.Status == "assessment_required" and d.Subject.HeadSHA == discoveryHead (preserved through failed()) ✓
  • terminal.Escape.Next is the exact discover-grant command with the original -state/-key/-floor
  • d.MintRequest == "" and d.GrantID == "" (cleared by failed()) ✓
  • terminal.RetryHelps == false (zero value; terminalErrorFor doesn't set it for the assessment branch) ✓
  • result["outcome"] == nil and result["error"] != nil

The "invalid floor", "unread diff", and "bad signature" sub-cases from the prior commit all still pass the same assertions, confirming the refactored wrapping path is backward-compatible.

Verified correct

Double-prefix test: for the "invalid floor" case, d.Why is something like "grant_assessment_required: state /tmp/x: no such file". After errors.New(d.Why) returns and failed(err) runs, d.Why is "grant_assessment_required: state /tmp/x: ...". TrimPrefix strips the prefix, and Error() re-adds it once — no change in the emitted error string. ✓

failed() value semantics: failed() has a value receiver, so it copies d, modifies the copy, and returns it. res.Discovery.failed(err) dereferences the pointer, copies, and the grantAssessmentError gets its own independent copy. No aliasing between the grantAssessmentError and the original grantDiscovery. ✓

grantAssessmentError has no Unwrap(): not needed — the sole detection site is errors.As in terminalErrorFor, and the error is never wrapped further. ✓

Explicit grant binding unchanged: runGateSelected returns early for grantID != "" via runGate, which never sets res.Discovery. The res.Discovery != nil guard in cmdGate is therefore vacuously false on the explicit-ID path, and explicit grants fall through to return err unchanged. ✓


Residual nits (carried, non-blocking)

  1. unreadable last-writer-wins — when multiple grants fail authentication in a single sweep, only the last error reaches d.failed(unreadable). Structured Candidates gaps capture all failures; the hard-error text surfaces only one. Recorded and accepted.

  2. FloorBin path-resolution comment — the ContainsAny guard (bare PATH names stay unresolved; path-relative references are absolutized) is non-obvious and a comment would help a future reader. Previously noted, still absent.


All four commits are clean. The fix correctly extends the grantAssessmentError wrapping to cover post-selection failures (including head movement between discovery and the first gh pr view), with the TrimPrefix deduplication handling the case where matchBoundView already prepends the grant_assessment_required: prefix. The new sub-test exercises the exact terminal JSON structure a caller or operator would parse on this path.

@github-actions

Copy link
Copy Markdown

Reviewer: claude
Reviewed commit: 45c263bb585fcb6b88921520fff19d855c758dc8

@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: 45c263bb58

ℹ️ 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".

if len(available) == 0 {
d.Status = "uncovered"
d.Why = fmt.Sprintf("no authenticated existing grant covers %s#%d at %s for merge, minimum %s and cycle %d; candidate gaps are listed; full Gate assessment may raise the tier", d.Subject.Repo, d.Subject.Number, d.Subject.HeadSHA, d.MinimumTier, d.NextCycle)
d.MintRequest = shellJoin([]string{"gate", "grant", "-repo", d.Subject.Repo, "-action", d.Action, "-max-tier", d.MinimumTier, "-max-cycles", fmt.Sprint(d.NextCycle), "-ttl", "24h", "-state", d.StateDir, "-key", d.KeyDir})

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 Stop suggesting grants beyond the review-cycle cap

When the ledger already records three consumed cycles and no candidate fits, NextCycle becomes 4 and this mint command explicitly asks the operator for -max-cycles 4. That bypasses the repository's terminating review cap: after three cycles, the required recovery is to stop the loop rather than request wider authority. Suppress the mint route once the cycle budget has been exhausted.

AGENTS.md reference: AGENTS.md:L102-L107

Useful? React with 👍 / 👎.

Comment thread cmd/gate/main.go Outdated
Comment on lines +531 to +532
if res.Discovery != nil {
return &grantAssessmentError{discovery: res.Discovery.failed(err)}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve errors from the full Gate evaluation

When omitted-grant discovery succeeds but a later normal Gate step fails—for example model construction or review-evidence fetching—res.Discovery remains non-nil, so this branch relabels the failure as grant_assessment_required and directs the operator to discover-grant. That command will merely report the same grant as available and cannot repair the actual failure, replacing the normal error classification and recovery route with a dead end. Restrict this wrapper to discovery failures and the intentional selected-head mismatch.

Useful? React with 👍 / 👎.

@itsHabib

Copy link
Copy Markdown
Owner Author

Latest review disposition at45c263b: the normal-evaluation error-routing P2 is valid and will be fixed with an explicit selected-head-mismatch error identity, retaining the original route for ordinary model/evidence failures. The P1 request for an unconditional three-cycle stop is not adopted: Michael explicitly superseded the procedural stop-fixing cap for this work, and the reusable resolver reports grant coverage rather than owning a universal reviewer-cycle policy (existing tier-aware policy even permits different caps). A proposed operator mint command grants no authority or review continuation; actual review-plan/request/spend limits and operator-only minting remain binding. No live grant or limit was changed. Claude completed at this head with only the already recorded cosmetic suggestions.

@itsHabib

Copy link
Copy Markdown
Owner Author

Normal evaluation error routing is fixed in271bab1a7c81b7f0e249258f59e86f2a0c83cfde: discoveryTerminalError now wraps only initial assessment failures or the explicit selected-head-mismatch error identity. Backend/evidence errors retain their original classification and recovery route. The CLI regression proves an invalid model backend is not relabeled, alongside all earlier discovery and moved-head cases. Uncached targeted race tests, Gate vet/lint and diff checks pass. The procedural-cap finding is dispositioned separately above; no unconditional review policy or grant authority is added here.

@itsHabib

Copy link
Copy Markdown
Owner Author

@codex review

@itsHabib

Copy link
Copy Markdown
Owner Author

@claude review

@claude

claude Bot commented Sep 13, 2026

Copy link
Copy Markdown

Claude finished @itsHabib's task in 2m 51s —— View job


Code Review: Gate Grant Discovery — fix commit 271bab1

Reviewed at 271bab1a7c81b7f0e249258f59e86f2a0c83cfde.

  • Gather context and understand the request
  • Read changed files (fix commit 271bab1)
  • Review grant_discovery.go — error identity and routing fix
  • Review grant_discovery_test.go — CLI regression coverage
  • Review main.go — terminal error routing
  • Synthesize findings and post review

The Codex P2 finding was that when discovery succeeds but a later normal Gate step fails (e.g. model construction), res.Discovery remains non-nil after runGateSelected returns the error, so the prior if res.Discovery != nil { return &grantAssessmentError{...} } guard in cmdGate incorrectly relabeled every post-discovery failure as grant_assessment_required and directed the operator to discover-grant — a dead end that cannot fix a model backend or evidence failure.

Codex P2 — restrict wrapping to discovery-specific failures ✓

errGateHeadChanged sentinel (grant_discovery.go:55)

var errGateHeadChanged = errors.New("grant_assessment_required: PR head changed after selection")

matchBoundView now wraps this sentinel via fmt.Errorf("%w: %s to %s", errGateHeadChanged, ...) instead of constructing a plain string. The error message is byte-identical to the old form; the change enables errors.Is detection and removes the implicit string-prefix dependency.

discoveryTerminalError (grant_discovery.go:61-66)

func discoveryTerminalError(err error, d *grantDiscovery) error {
    if d == nil || d.Status != "assessment_required" && !errors.Is(err, errGateHeadChanged) {
        return err
    }
    return &grantAssessmentError{discovery: d.failed(err)}
}

Operator precedence: && binds tighter than ||, so this reads as d == nil || (d.Status != "assessment_required" && !errors.Is(err, errGateHeadChanged)). The four paths:

d d.Status error result
nil any plain error (explicit grant path) ✓
non-nil "assessment_required" any wrapped ✓
non-nil "available" errGateHeadChanged wrapped (post-selection head move) ✓
non-nil "available" backend/evidence err plain error (the fix) ✓

cmdGate (main.go:531)

The inline guard is replaced with the single call discoveryTerminalError(err, res.Discovery). Clean.

Fixture change (grant_discovery_test.go:354-361) ✓

pr view response now switches on a status variable that defaults to "MERGED" and becomes "OPEN" for mode "model failure". This is correct: runGateBoundrunGateWithSynthesis refuses a merged PR before reaching model invocation. A "MERGED" fixture would cause the test to fail at the live-PR check rather than at the model backend — the fixture must return "OPEN" to exercise the actual model failure path. The removed mergedAt field is not read by matchBoundView or the model-failure path.

TestDiscoveryDoesNotRelabelNormalGateFailure

The test creates a valid T2 grant (so discovery succeeds), then runs gate -model-backend fixture-invalid. Assertions:

  • terminal.Discovery == nil — confirms terminalErrorFor received a plain backend error, not a *grantAssessmentError, so no Discovery field is set in the output. This is the tightest possible invariant.
  • !strings.Contains(terminal.Error, "grant_assessment_required") — confirms the error string is not relabeled.
  • !strings.Contains(terminal.Escape.Next, "discover-grant") — confirms the escape route is the normal gate recovery path, not the discovery retry.
  • strings.Contains(terminal.Error, "fixture-invalid") — confirms the actual backend error is preserved and surfaced to the operator.

The three-condition OR means all four assertions must pass simultaneously. That's the right shape.

Verified correct

Residual TestGateDiscoveryAssessmentFailureIsHardError sub-cases unchanged. The "moved before view" case still works: matchBoundView returns fmt.Errorf("%w: %s to %s", errGateHeadChanged, ...), errors.Is(err, errGateHeadChanged) is true in discoveryTerminalError, and d.Status == "available" at that point (discovery succeeded). d.failed(err) sets Status = "assessment_required" and sets Why = err.Error(). grantAssessmentError.Error() calls TrimPrefix to deduplicate the prefix, which is also correct for the backend path where d.Why already carries the prefix from the previous commit's matchBoundView. ✓

errors.Is chain. matchBoundView returns fmt.Errorf("%w: ...") wrapping errGateHeadChanged. That error propagates through readGateViewrunGateBoundrunGateSelected without additional wrapping. errors.Is unwraps correctly. ✓

No regression on explicit-grant path. runGate (explicit grant) never calls discoverGrant, so res.Discovery is nil. discoveryTerminalError(err, nil) returns the plain error. ✓


Residual nits (carried, non-blocking)

  1. unreadable last-writer-wins — when multiple grants fail authentication in one sweep, only the last error appears in d.failed(unreadable). Structured Candidates gaps capture all failures; the hard-error text surfaces only one. Recorded and accepted.

  2. FloorBin path-resolution comment — the ContainsAny guard (bare PATH-lookup names stay unresolved; path-relative references are absolutized) is non-obvious. A brief inline comment would help future readers. Still absent.


All five commits are clean. The fix narrows discoveryTerminalError to exactly two cases — initial assessment failure (d.Status == "assessment_required") and post-selection head movement (errors.Is(err, errGateHeadChanged)) — and the new test validates the key invariant that a backend failure after a successful discovery does not become a discovery error.

@github-actions

Copy link
Copy Markdown

Reviewer: claude
Reviewed commit: 271bab1a7c81b7f0e249258f59e86f2a0c83cfde

@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: 271bab1a7c

ℹ️ 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 thread cmd/gate/grant_discovery.go Outdated
Comment on lines +316 to +317
if fields.HeadSHA != head {
return fmt.Errorf("%w: %s to %s", errGateHeadChanged, head, fields.HeadSHA)

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 Classify Slack head races as capability refusals

When gate -slack obtains an exact-head grant and the PR moves before the first gh pr view, this shared check returns errGateHeadChanged. Because the Slack path has no Discovery result, discoveryTerminalError does not translate it, so cmdGate emits exit 4 and the generic error recovery path. This is an expired subject binding rather than an infrastructure failure; return an exit-3 capability refusal or route the operator to request a fresh Slack grant so callers do not misclassify the terminal.

AGENTS.md reference: cmd/gate/AGENTS.md:L8-L11

Useful? React with 👍 / 👎.

@itsHabib

Copy link
Copy Markdown
Owner Author

Final evidence at c5a77e9:

  • Independent final verification: PASS, no remaining actionable findings. The verifier reproduced the malformed-success Slack view failure at 722c3e4, then verified the correction at this head. Invalid observed heads remain exit 4; real Slack head changes yield capability refusal/exit 3; discovery and joined integrity failures retain hard-error behavior. No cycles or authority were added.
  • Independent overlays passed in 2.728s; targeted uncached race tests passed in 44.482s. Gate vet/lint and diff checks passed. The exact-head worktree is clean.
  • Final CI passed check, fuzz and hygiene at this exact head. Earlier full-module vet/race/build/lint and read-only selection probes remain correctly attributed to their selection checkpoints.
  • The fifth Codex panel's Slack head-race finding is fixed, including the subsequently reproduced malformed-head case. Actual signed-callback CLI regressions use disposable state, keys and fixture tools, preserve the grant's T0/3-cycle binding and prove zero consumed cycles.
  • Claude's completed review and attestation cover 271bab1. They are not relabeled as reviews of this final delta. The configured fifth broad request has been used; no sixth panel was triggered. The final delta instead has the separate independent evidence above. Copilot completion remains unverified.

Earlier verified findings are fixed. The unconditional procedural-cycle suggestion was dispositioned in the existing comment under Michael's explicit direction; actual grant/request/spend ceilings remain unchanged. Claude's two cosmetic observations remain recorded: all authentication failures are present in Candidates even though the terminal headline carries the last error, and the PATH-name guard is covered by tests/documented behavior. Neither requires another broad panel.

Companion cc-skills #75 is independently verified with final Codex/CI evidence at 180d3d7. The change removes three redundant operator interaction steps for already covered work, or one inventory command plus manual selection from a careful reuse path; these are workflow counts, not measured time savings.

No live grant mint, Gate evaluation, custody edit, merge or installed runtime/skill change occurred in this grant-discovery work. The source and stamped local candidate are reviewable; ordinary live delivery through the resolver is not yet demonstrated.

@itsHabib

Copy link
Copy Markdown
Owner Author

@claude review — please review the full PR at the exact head c5a77e9 (grant discovery + Slack head-change routes); focus on authorization correctness: which grants discovery may select, fail-closed behaviour, and exit-code/JSON outcome agreement.

@claude

claude Bot commented Sep 14, 2026

Copy link
Copy Markdown

Claude finished @itsHabib's task in 7m 58s —— View job


Code Review: Gate Grant Discovery + Slack Head-Change Routes

Reviewed at c5a77e9539c9aacb751faab379cf307b863c9678.

  • Read grant_discovery.go — grant selection eligibility logic
  • Read slack_grant.go + slack_head_change_test.go — Slack head-race routes
  • Read main.go — exit-code/JSON outcome wiring
  • Read capability.go, evidence.go, floor.go — supporting changes
  • Analyze authorization correctness (which grants may be selected)
  • Analyze fail-closed behavior across all error paths
  • Analyze exit-code/JSON outcome agreement
  • Post review

Authorization correctness — which grants discovery may select

Signature verification is mandatory and upstream of every other check. In assessGrant (grant_discovery.go:256), CheckArtifactSubject calls checkArtifact → HMAC verification before any scope or binding check. Signature failures (ErrSignature) and parse failures map to unreadable, not a gap on a candidate that would silently be skipped. If nothing else is available, the assessment_required hard-error path fires — no mint request is emitted. TestDiscoveryEnforcesSubjectAndAuthentication ("bad signature" case) and TestDiscoveryFailureDoesNotInventAuthorityGap pin both invariants.

The repo filter runs pre-verification, but cannot be forged past signature verification. assessGrant:254 (metadata.Repo != d.Subject.Repo) skips non-matching repos without signature checking. An attacker who modifies a grant body to claim a different repo while keeping the original signature would produce an HMAC mismatch over the modified fields — ErrSignature, not a soft skip. The checkArtifact re-runs json.Unmarshal and re-verifies the HMAC over exactly what's on disk. ✓

Action is hardcoded to "merge". discoverGrant:146 sets d.Action = "merge" unconditionally. Only grants with action == "merge" in the signed body can reach the binding checks. ✓

Head and PR binding are correctly conditional. CheckArtifactSubject:160-165BoundHead != "" requires an exact match; an empty BoundHead (repo-wide grant) passes any head. BoundPR != 0 similarly. So a Slack-minted T0 exact-head grant and a repo-wide T2 grant are both discoverable, with the exact-head grant failing unless the current head matches its binding. TestDiscoveryEnforcesSubjectAndAuthentication ("wrong head", "wrong PR", "bound match") exercises all three states. ✓

Cycle accounting uses a single audited snapshot. selectGrant:205-212 reads one audit.All and verifies chain integrity before counting. The count then operates on the verified snapshot — no re-scan window, no way to race a write between verification and count. ✓

CheckKey is called even when inventory is empty. selectGrant:199 — if the signing key is missing or invalid, assessment_required fires before any candidate loop, so a corrupted key doesn't produce an empty uncovered result that emits a mint request. TestDiscoveryFailureDoesNotInventAuthorityGap ("missing key", "invalid key") covers this. ✓

Selection is widest-eligible. broaderGrant (grant_discovery.go:277) sorts by tier rank descending, then by cycle ceiling (0 == unbounded wins), then by expiry, then by ID. The algorithm is deterministic and tested (TestDiscoveryChecksAllCycleCeilings, TestDiscoveryReusesOlderCoveringGrant). An exhausted broad grant does not hide a narrower usable one; the cycle gap records it as grant_cycle_exceeded in Candidates. ✓


Fail-closed behavior

Infrastructure failures produce assessment_required (exit 4), not mint requests. The table covering the full set:

Failure Path Test
Missing/invalid signing key selectGrant:199CheckKey TestDiscoveryFailureDoesNotInventAuthorityGap
Log tampered selectGrant:206-208!audit.OK same
Bad signature assessGrant:258-261unreadable same + TestDiscoveryEnforcesSubjectAndAuthentication
Malformed grant body assessGrant:249-250unreadable TestDiscoveryReportsRejectedCandidatesAlongsideValid
Unread diff discoverGrant:168-171 TestGateDiscoveryAssessmentFailureIsHardError
Floor failure discoverGrant:172-175 same
Head moved during discovery discoverGrant:181-183 same
Invalid floor tier selectGrant:196-198 TestDiscoveryFailureDoesNotInventAuthorityGap ("unknown floor")

Empty or non-hex view head SHA remains a hard error on the Slack path. matchBoundView:328-330hex.DecodeString("") returns len == 0, hex.DecodeString("xxx") returns an error; both branch to gate_view_invalid: expected a full Git head SHA. This is a plain errors.New (not a *gateHeadChangedError), so slackBoundResult:29's type assertion fails — the error propagates as exit 4. TestSlackUnreadViewHeadRemainsHardError validates both "missing view head" and "invalid view head" modes. ✓

A joined state-write failure is not converted to a capability refusal. When recordAbortIfUndecided fails to write the abort artifact, it returns errors.Join(cause, writeErr). The joined error is not a *gateHeadChangedError, so slackBoundResult:29's type assertion fails, and the hard error propagates. TestSlackHeadRefusalDoesNotHideRunErrors tests both the plain write failure and the errors.Join(moved, writeFailure) case explicitly. ✓

Closed PR → not_applicable (exit 3) with no mint request. discoverGrant:165-167 and 186-187. Neither "not_applicable" branch sets MintRequest. exitCode() returns codeRefused. ✓

Normal gate failures after a successful discovery are not relabeled. discoveryTerminalError:72-77 — only fires for d.Status == "assessment_required" OR errors.Is(err, errGateHeadChanged). A backend or evidence failure after discovery succeeds leaves d.Status == "available", and errors.Is(err, errGateHeadChanged) is false for a backend error, so the error passes through unchanged. TestDiscoveryDoesNotRelabelNormalGateFailure validates: a fixture-invalid backend error does not produce grant_assessment_required in the terminal output, and the escape route is the normal gate recovery path, not discover-grant. ✓


Exit-code / JSON outcome agreement

Verified correct across all new terminal states:

Scenario Exit JSON
Discovery "uncovered" 3 gateResult.outcome = "capability_refused", code = "grant_uncovered", no "error" field
Discovery "not_applicable" 3 gateResult.outcome = "capability_refused", code = "grant_not_applicable", no "error" field
Discovery "assessment_required" 4 terminalError.error present, no "outcome" field, grant_discovery carries config + candidates
Slack head moved (pure) 3 gateResult.outcome = "capability_refused", code = "grant_head_mismatch", no "error" or "grant_discovery"
Slack head moved (abort write fails) 4 Hard error preserved; no capability classification
Invalid/empty view head SHA 4 terminalError.error = "gate_view_invalid: ...", no "outcome"

Slack head-move path: slackBoundResult:33 sets res.Outcome = "capability_refused" and res.Code = capability.ErrHeadMismatch.Error(), returns codeRefused and nil error. Execution falls through to exitGateResult (not printTerminalError), so the output is a gateResult JSON — no "error" field, no "grant_discovery", no "hash" or "stamp". The test TestSlackHeadMoveAfterApprovalIsCapabilityRefusal asserts all five absent fields explicitly.

Discovery assessment path: *grantAssessmentError is detected in terminalErrorFor:2739-2742 via errors.As, producing terminalError{Error: ..., Discovery: ..., Escape: discoveryRoute(...)}. No outcome key is present. Exit 4.

decorateTerminalCodeContext's escape-preservation guard (main.go:2781-2783): when res.Escape != nil, the decorator returns immediately. slackBoundResult sets res.Escape to the fresh Slack request route before exitGateResult is called, so the decorator never overwrites it. ✓

slackRetryCommand authority isolation. fs.Visit collects visited flags; -grant is excluded because -slack and -grant are mutually exclusive (cmdGate:497-499). The retry command contains -slack=true (requesting fresh Slack authority) and does not contain -grant= or discover-grant. TestSlackHeadMoveAfterApprovalIsCapabilityRefusal asserts both. ✓

Cycle accounting: zero cycles consumed on head-change. The run-abort artifact (KindRunAborted) is outside the action/escalation outcome families; isOutcome and countCycles ignore it. mustCycleCount == 0 is asserted in both TestSlackHeadMoveAfterApprovalIsCapabilityRefusal and TestSlackUnreadViewHeadRemainsHardError. ✓


gateHeadChangedError sentinel design

The split between Unwrap() (present) and type assertion (used in slackBoundResult) is intentional and correct:

  • Unwrap() error { return errGateHeadChanged } enables errors.Is(err, errGateHeadChanged) through wrapped chains, used in discoveryTerminalError for the discovery-path head-move case.
  • Direct type assertion in slackBoundResult is deliberately narrower: it succeeds only for the pure *gateHeadChangedError, not for errors.Join(moved, writeFailure). This ensures that a state-write failure joined to a head-change error propagates as exit 4, not as a capability refusal.

grantAssessmentError has no Unwrap() by design — its sole detection site is errors.As in terminalErrorFor, and it's never wrapped further. The asymmetry is correct. ✓


Verified correct

TestDiscoveryIsReadOnly — snapshots log, anchor, key, and anchor-key bytes before and after a successful selectGrant call, then asserts byte-identity. Also verifies requireDiscoveryState does not create a missing dir. Strong read-only invariant test. ✓

TestGateOmittedGrantUsesDiscovery — confirms that when -grant is omitted, the discovered grant is used; when an explicit grant ID is supplied, discovery is bypassed entirely. The explicit-grant path leaves res.Discovery == nil. ✓

approveSlackFixtureCLI integrity assertion — after the callback, checks grant.MaxTier == "T0", grant.MaxCycles == 3, grant.BoundHead == discoveryHead, grant.BoundPR == 7. Validates that the fixture callback did not widen or substitute authority. ✓


Residual nits (carried from prior rounds, non-blocking)

  1. unreadable last-writer-wins — when multiple grants fail authentication in a single sweep, unreadable is overwritten each iteration. The hard-error text surfaces only the last failure; the structured Candidates list captures all. Recorded and accepted in the prior-round disposition.

  2. FloorBin path-resolution comment — the ContainsAny(d.FloorBin, /\) guard (grant_discovery.go:152-154) intentionally leaves bare PATH-lookup names unresolved while absolutizing path-relative references. The logic is tested and documented in behavior, but an inline comment would still help a reader who wonders why KeyDir is always absolutized while FloorBin is conditional. Previously noted, still absent.


No new authorization, fail-closed, or exit-code/outcome issues found at this head. The full PR is sound on the three requested dimensions.

…y -grant

An independent exact-head review found that discovery could spend grants an
explicit -grant refuses. CheckArtifactSubject accepts a head-bound grant whose
head and PR match, so an omitted -grant could reuse a Slack exact-head approval
for later runs or prefer a protected-executor T3 grant over repository
authority. DESIGN.md reserves those grants for their own flows. Discovery now
reports them as grant_bound candidates and never selects them.

An explicitly empty -grant (for example an unset variable in a script) also
fell through to discovery and ran under the widest grant. It is now an error.

The remaining review findings fail closed or are advisory text; they are
recorded in FOLLOWUPS.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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