feat(authority): AuthorityPolicy — versioned scope + Lane/CLI permission judgment (#172) - #215
feat(authority): AuthorityPolicy — versioned scope + Lane/CLI permission judgment (#172)#215SoloJiang wants to merge 75 commits into
Conversation
New tables backing issue #172's two independently-versioned objects: plan_revision (the append-only scope history behind plan's working head), authority_policy (the append-only AuthorityPolicy log, active = highest revision per scope with an empty revoked_at), and lane_gate_decision (a human's per-Lane Gate resolution, keyed to the exact policy revision it was decided under, so a policy change orphans stale overrides). Adds repo.rs accessors (insert/list/latest plan revisions; create/get_active/ revoke/list authority policy revisions; record/get gate decisions; downstream_direction_ids for Gate-blocks-dependents) plus migration and accessor tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Xz1Ts3AK1uNbeWZT6YFbzz
New authority.rs module: the single Lane adjudicator (adjudicate_lane) and the Permission Bridge adjudicator (bridge_decision), both pure and fully unit tested. Fail-closed checks (unknown repo, missing reason, malformed base, duplicate lane id) run before any policy rule, and outrank a permissive policy unconditionally. The hard-coded default policy (no configured authority_policy row) reproduces today's confirm-gated behavior exactly — a human-authorized Lane always reads AllowedByPolicy, verified directly by default_policy_allows_a_human_authorized_known_repo_lane_zero_regression. Every verdict carries its policy_revision + scope_revision + reason for the audit trail; nothing here caches a verdict, so materialize always re-adjudicates against current state (issue's "stale policy decision fail closed" requirement, satisfied structurally rather than by a staleness comparison). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Xz1Ts3AK1uNbeWZT6YFbzz
materialize_direction now adjudicates before EITHER path that writes a worktree (recreate-a-reclaimed-worktree, and first-time create) — never before the idempotent "already valid, already registered" fast return, which only reads existing state. A denied/needs-gate verdict returns Ok(empty) with no git/filesystem work, exactly mirroring the existing "no repo bound" convention this function already used, and always records a decision Evidence row for the audit trail. Because every in-repo direction reaching this function already exists only via a human confirm/approve, the default (unconfigured) policy is a provable no-op here: all 39 existing materialize tests and 99 planner tests pass unchanged. New tests cover a denied repo never creating a worktree, a protected-branch Gate blocking until a human override resolves it, and a tightened policy denying a lane the earlier state would have allowed (stale verdicts are structurally impossible to reuse). planner.rs now appends an immutable plan_revision snapshot on every save_proposal_value (source=lead) and every successful confirm (source=user, same version confirm decided against) — the versioned scope history behind the plan working head. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Xz1Ts3AK1uNbeWZT6YFbzz
) AuthorityPolicy now has a say in CLI permission requests via a single choke point: all three inbound routes (the ACP route and Codex app-server route in lead_chat::engine, and the PreToolUse hook route in bus::server) already funnel through AskRegistry::auto_decision, so the bridge check lives there instead of at each call site. A cached PolicySnapshot (refreshed by commands::refresh_authority_bridge_snapshot on every policy tighten/loosen/revoke) lets the check stay synchronous on this hot path with no per-ask DB round trip. Defer (no snapshot, or the policy has no opinion on this action_key) falls through to the existing full/always/read-only logic completely unchanged — every one of auto_decision's existing tests, and its 3 production call sites, keep passing with zero behavior change for any installation that never configures an AuthorityPolicy. An exact Full/Always grant still wins before the bridge runs, so a later policy tighten can't retroactively revoke a human's own already-granted standing trust out from under a running task. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Xz1Ts3AK1uNbeWZT6YFbzz
…mands (#172) New commands: get/list/set/revoke_authority_policy (set always mints the next revision and refreshes the AskRegistry Permission Bridge snapshot; revoke reverts the whole scope to the hard-coded conservative default), list_lane_gates (every direction with no worktree whose latest decision Evidence names needs_gate), resolve_lane_gate (records the human's decision keyed to the exact policy revision, then re-runs materialize so an approval takes effect immediately), and list_plan_revisions (the scope history audit trail). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Xz1Ts3AK1uNbeWZT6YFbzz
…#172) New LaneGatePanel, rendered in ScopeReview: lists a thread's pending Gates (a direction with no worktree whose latest decision evidence names needs_gate) with the lane name, why it's waiting, which rule matched, and Approve/Deny actions wired to the new resolve_lane_gate command. Renders nothing when there is nothing pending — a policy-allowed lane never produces a card. New api.ts wrappers and lib/types.ts DTOs mirror the Rust command shapes field-for-field; all new user-facing strings go through src/i18n/en.ts + zh.ts (zh carries only the _other plural form, matching this repo's existing convention). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Xz1Ts3AK1uNbeWZT6YFbzz
plan_revision, lane_gate_decision and authority_policy had no delete path anywhere in the codebase. A deleted issue left its full JSON scope history behind, a deleted Lane left the human's Gate reason on an id nothing can query, and a deleted workspace left every AuthorityPolicy revision it ever had -- rows only this cascade can ever clear, since revoking a policy stamps revoked_at instead of deleting. Covers all four paths that remove the owning rows: the per-issue cascade, the workspace cascade (which never routes through it), the repo cascade, and delete_direction, which doubles as the materialize-failure rollback. Tests assert each sweep and that a second workspace's policy survives.
A refusal from the AuthorityPolicy was returned as Ok(Vec::new()), which every one of the 8 call sites read as success: confirm marked the plan confirmed, recorded the lane's direction_id and dispatched a worker for a lane with no worktree. materialize_direction now returns a discriminated MaterializeOutcome (Ready | Gated | Denied) and each site maps it: a denied lane is rolled back with the rest of the attempt, a gated one keeps its row so its Gate card has a lane to point at, and neither is ever dispatched. The Gate card itself was unreachable even when raised. list_lane_gates reads the newest `decision` evidence row per lane, and confirm/approve appended their own `allowed_by_policy` row AFTER materialize had already recorded the needs_gate verdict — shadowing it, with a higher id. Those post-hoc writes were #174-era placeholders that #172 was meant to replace; they are removed, leaving authorize_materialize's row (real policy revision, reason, hit_rule) as the single source. The deny half stays and now writes the same key. Also fail-closed on three inputs that read as permissive: - protected_branches was skipped whenever base_branch was blank, which is the planner's own "use the repo default" value and therefore the common shape — protected_branches:["main"] matched almost nothing. The base is now resolved (a read; every write still happens after the gate) and the resolved name is what gets adjudicated. - Unparseable rules JSON degraded to empty rules via unwrap_or_default, silently dropping denied_repos/protected_branches/deny_actions. One snapshot_from_row constructor now flags it and adjudication gates instead, honoring an existing Gate resolution so a corrupt row is not a dead end. - A failed bridge refresh kept the previous snapshot, so a revoke whose refresh read failed left the revoked rules auto-approving while the UI reported success. It now clears. The Permission Bridge held ONE global snapshot and was never seeded at startup: a configured policy stopped applying to CLI asks after every restart, and whichever workspace saved last decided every other workspace's asks. Snapshots are now keyed by workspace, seeded for all of them in setup, and resolved through a thread->workspace map the async ask paths record; an unregistered thread defers rather than borrowing someone else's rules. The PreToolUse hook route compared against Some(Allow) alone, so the newly reachable Deny fell through to a human card while the ACP and Codex routes honored it. It is mapped exhaustively now. resolve_lane_gate trusted a client-supplied policy_revision, so an approval made against a superseded revision was recorded where the adjudicator would never look and the button silently did nothing. The server resolves the live revision and rejects a stale card instead.
LaneGatePanel's only mount point was inside ScopeReview, which renders in a dialog open only while the proposal is still "proposed". A Gate is raised by materialize, which runs during confirm, and confirmProposal closes that dialog on success — so the panel was unmounted exactly when gates appear and mounted only when none could exist. It now lives on the persistent board. A failed listLaneGates was caught into an empty array, which the component renders as nothing at all — identical to "no lane is waiting". That is the state in which someone concludes all the work is running and walks away from a lane that will never start. Load failure is now its own rendered state. The row's status was one "failed" value shown with the approve-failure copy, so clicking Deny and hitting an error read as "Couldn't approve" — on a permission surface, that suggests the user just did the opposite of what they did. The state is split into approveFailed/denyFailed (the denyFailed string already existed and was unreferenced) plus a stale arm for the backend's new gate_policy_changed rejection, and all arms are mapped exhaustively. Also: reload requests are sequenced, so a slow earlier response can no longer repaint an approved lane as pending or show one issue's lanes under another; the hardcoded ": " separator moved into the i18n resources; and the unreadable-policy verdict reason gets copy in both languages.
proposal_lane_policy derives a lane's policy from the proposal shape alone, so a confirmed lane with no per-lane decision reads AllowedByPolicy purely because it is confirmed and carries a direction id. That was sound before issue #172, when confirm could not produce anything else. It no longer is: a lane can now be confirmed and still be gated or denied, and reporting it as allowed hid the only signal that its worktree was never created — the lane rendered as policy-allowed AND execution-matched with nothing on disk. Readiness now reads the newest recorded `decision` verdict per lane (one query per issue, not an N+1 on a polled path) and combines it through the existing stricter_materialized_policy, so a recorded verdict can only tighten what the shape claims, never loosen it. A read failure falls back to the previous shape-only reading rather than failing the collection.
… under Keying a Gate override to its policy revision expires an APPROVAL when the policy changes — that is the fail-closed half of the design and it stays. Applying the same rule to a DENIAL inverted it: revoking the policy (or minting any new revision) made get_gate_decision miss the veto, adjudicate_lane then saw no override, and with the default policy's empty protected_branches nothing re-gated the lane — so a lane a human had explicitly refused materialized on the next dispatch, retry, or recreate. A veto is a statement about the lane, not about one revision's rules, so the denial lookup drops the revision filter and is checked first. Approvals are unchanged. Tests cover the veto holding across revisions (including the default policy's "0") and not bleeding onto a sibling lane.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 0c7ae6f33b
ℹ️ 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".
| useEffect(() => { | ||
| reload(); | ||
| }, [reload]); |
There was a problem hiding this comment.
Refresh gates after proposal confirmation
When a user confirms a proposal on the already-active thread, materialize_direction can create a new Gate, but this effect only reruns when threadId changes, and confirmProposal merely reloads the thread children without changing that ID. Consequently the initial empty result remains cached, the review dialog closes, and the blocked lane has no visible approval card until the user switches threads or reloads the app. Subscribe to the confirmation/store update or otherwise invalidate this query when lanes are materialized.
AGENTS.md reference: AGENTS.md:L32-L32
Useful? React with 👍 / 👎.
| // Issue #172: register this thread's workspace so the sync | ||
| // Permission Bridge resolves the right policy (unknown -> defer). | ||
| if let Ok(Some(row)) = repo::get_thread(&db, thread_id).await { | ||
| asks.note_thread_workspace(thread_id, row.workspace_id); |
There was a problem hiding this comment.
Register Codex approvals with their workspace
For a Codex app-server thread that has not previously emitted an ACP or PreToolUse ask, this new registration never runs, while the Codex approval handler at engine.rs:7489 calls auto_decision directly. Since authority_bridge_decision requires the thread-to-workspace map, both configured deny_actions and allow_actions defer to a human card on that route; in particular, a policy denial is not enforced consistently. Register the workspace in the Codex approval handler before its auto_decision call as is done here for ACP.
AGENTS.md reference: AGENTS.md:L58-L58
Useful? React with 👍 / 👎.
| let current = current_gate_policy_revision(&db, direction_id).await.map_err(e)?; | ||
| if policy_revision != current { | ||
| return Err("gate_policy_changed".to_string()); |
There was a problem hiding this comment.
Re-adjudicate stale gate cards before reloading
If the workspace policy is revised or revoked while a Gate is pending, this rejects the old card as intended, but list_lane_gates only rereads the existing decision evidence and never materializes or adjudicates the lane under the new revision. The frontend's stale-error reload therefore returns the same old policy_revision, so every subsequent approval is rejected with gate_policy_changed and the lane cannot progress. Refresh the lane verdict/evidence under the current policy when the policy changes or when listing a stale Gate.
AGENTS.md reference: AGENTS.md:L58-L58
Useful? React with 👍 / 👎.
| await api.resolveLaneGate(gate.direction_id, gate.policy_revision, decision); | ||
| setActionState((prev) => { | ||
| const next = { ...prev }; | ||
| delete next[gate.direction_id]; | ||
| return next; | ||
| }); | ||
| reload(); |
There was a problem hiding this comment.
Dispatch the worker after approving a gate
When a Gate is approved, the backend creates and returns the worktree, but this call discards that result and only reloads the Gate list. The lane was deliberately omitted from the original confirmation's dispatch_ids, so no other path starts its worker; because it now has a worktree, it also disappears from list_lane_gates and remains silently idle. After a successful approval, dispatch gate.direction_id through the same dispatchDirection path used after ordinary confirmation.
AGENTS.md reference: AGENTS.md:L58-L58
Useful? React with 👍 / 👎.
| // Already materialized and registered — still try deps in case a prior | ||
| // reclaim left the checkout without node_modules. No-op when ready. | ||
| bootstrap_worktree_deps(&existing.path).await; | ||
| return Ok(vec![existing]); | ||
| return Ok(MaterializeOutcome::Ready(vec![existing])); |
There was a problem hiding this comment.
Authorize existing worktrees before reuse
When a policy is tightened after a lane already has a registered worktree, this fast path returns Ready without consulting the current policy. A later confirm retry consequently treats even a newly denied lane as dispatchable and starts a new worker, and the preceding dependency bootstrap can itself write into the checkout before any policy judgment. Distinguish an already-running worker from a new reuse attempt and adjudicate before bootstrapping or returning the lane for redispatch.
AGENTS.md reference: AGENTS.md:L58-L58
Useful? React with 👍 / 👎.
| tauri::async_runtime::spawn(async move { | ||
| commands::seed_authority_bridge(&bridge_db, &bridge_asks).await; | ||
| }); |
There was a problem hiding this comment.
Seed authority policies before serving asks
This detached seed does not satisfy the comment's “before any CLI ask” ordering: the bus server is already listening before Tauri setup, and lead_chat::revive::spawn_revive is started earlier in the same setup callback. An immediate or revived ask can therefore observe an empty authority map and defer a configured deny_actions rule to the human flow. Seed synchronously alongside the existing auth_persist::seed step before the bus starts, rather than spawning the load after startup.
AGENTS.md reference: AGENTS.md:L58-L58
Useful? React with 👍 / 👎.
| let verdict = authorize_materialize(db, &dir, &repo_ref, thread.workspace_id, &base).await?; | ||
| match verdict.decision { | ||
| authority::LaneDecision::AllowedByPolicy => {} | ||
| authority::LaneDecision::NeedsGate => return Ok(MaterializeOutcome::Gated(verdict)), | ||
| authority::LaneDecision::Denied => return Ok(MaterializeOutcome::Denied(verdict)), | ||
| } | ||
| let path = worktree_path(repo_path, &dir.branch); | ||
| git::git_exclude(repo_path, ".worktrees/"); | ||
| let add = git::add_worktree_synced(repo_path, &dir.branch, &path, &base, explicit) |
There was a problem hiding this comment.
Serialize policy updates with materialization
For a first-time or reclaimed materialization, the policy can be revised after this authorization returns but before the following git_exclude and add_worktree_synced writes. Since set_authority_policy does not share a transaction or workspace lock with this path, a concurrently tightened or revoked policy can still allow the worktree to be created from the stale verdict, contradicting the stale-decision boundary. Hold a shared per-workspace gate across both policy mutation and authorization-plus-write, or otherwise atomically verify the revision at write admission.
AGENTS.md reference: AGENTS.md:L58-L58
Useful? React with 👍 / 👎.
| let has_worktree = crate::store::repo::worktree_for(&db, dir.id, dir.repo_id) | ||
| .await | ||
| .map_err(e)? | ||
| .is_some(); | ||
| if has_worktree { | ||
| continue; |
There was a problem hiding this comment.
Treat reclaimed worktree rows as gate candidates
When an existing lane's checkout has been reclaimed or replaced, materialize_direction intentionally re-adjudicates before recreating it and can return NeedsGate, but the old worktree database row is retained. This test treats that row's mere presence as a live worktree and skips the lane, so the Gate generated for the recreation is never shown. Check that the row points to an existing, registered checkout (or use the same validity predicate as materialization) rather than only calling .is_some().
AGENTS.md reference: AGENTS.md:L58-L58
Useful? React with 👍 / 👎.
| let outcome = crate::materialize::materialize_direction(&db, direction_id) | ||
| .await | ||
| .map_err(e)?; |
There was a problem hiding this comment.
Preserve the gate when materialization fails
If the human approves a Gate but git worktree add or a later materialization step fails, the approval has already been persisted and authorize_materialize has appended a newer allowed_by_policy evidence row before the failing write. After a reload, list_lane_gates sees that allowed verdict and hides the still-worktree-less lane, leaving no retry surface. On a post-approval materialization error, retain or append a pending Gate state (or roll back the approval) so the lane remains recoverable.
AGENTS.md reference: AGENTS.md:L58-L58
Useful? React with 👍 / 👎.
| materialize::MaterializeOutcome::Gated(_) => { | ||
| created_now.push(dir.id); | ||
| committed_route_markers.push((dir.id, route)); | ||
| if let Some(pd) = proposal.directions.get_mut(idx) { | ||
| pd.direction_id = dir.id; | ||
| } | ||
| continue; |
There was a problem hiding this comment.
Remove dependents from the dispatch set when gated
When this lane is an upstream dependency, the Gated arm skips only the lane itself; dependent lanes are still added to dispatch_ids elsewhere in the loop and their workers start immediately. The newly added downstream_direction_ids helper is never called outside tests, and dependency edges are only recorded after this dispatch set is finalized, so the promised behavior that a Gate blocks its lane and downstream dependents is not enforced. Filter dependents using the resolved proposal graph before returning the dispatch IDs.
AGENTS.md reference: AGENTS.md:L58-L58
Useful? React with 👍 / 👎.
The three integration tests that bind materialize_direction's result still treated it as a Vec. cargo test --lib does not build src-tauri/tests, so this only surfaced in CI.
…rage Six findings from the review on 0c7ae6f, all reachable: - The Codex app-server approval route calls auto_decision directly and never ran the thread->workspace registration, so a Codex thread that had not previously emitted an ACP or hook ask deferred BOTH allow_actions and deny_actions to a human card. Registered there too. - The bridge seed was spawned from Tauri setup, but the bus server starts serving asks before the builder runs and revive re-drives tasks from inside setup — either could see an empty map. Seeded synchronously in the same pre-serve step as auth_persist::seed. - A policy change while a Gate was pending froze the card: list_lane_gates reported the revision off the OLD evidence row, resolve_lane_gate rejected it as stale, and reloading returned the same dead revision — the lane could never progress. Stale cards are now re-adjudicated at the current policy (new materialize::readjudicate_lane, read-only), so the human gets the rule in force and a revision the command accepts. The lane may also turn out to be allowed or denied outright, which the fresh verdict reflects. - list_lane_gates treated any worktree ROW as a materialized lane, but the recreate path keeps a stale row while re-adjudicating, hiding the Gate raised for the recreation. It now requires the checkout to exist on disk — the same predicate the frontend's dispatch filter uses. - Approving a Gate materialized the lane and then nothing started its worker: confirm had deliberately excluded it from dispatch_ids, and gaining a worktree also drops it from list_lane_gates, so it vanished and idled. The panel now dispatches on a successful approval (dispatchDirection is exposed on the store for it). - The panel only refetched when threadId changed, but confirming on the already-active thread is what raises most Gates and changes neither the id nor the mount. It now also keys on the thread's lane set and the proposal version.
…tays recoverable Two more from the Codex review: - Gating a lane only skipped that lane. Its declared dependents materialized fine (their own lanes are not gated), went into dispatch_ids, and their workers started against a producer with no worktree — and the dependency edges are only recorded after the dispatch set is built, so nothing downstream noticed. dispatch_ids is now filtered through the proposal's resolved depends_on_index graph, walked to a fixpoint so A->B->C blocks C when A is gated. An unresolved upstream is left alone: upstream_merge_state already answers Unknown (blocking) for those. - Approving a Gate persists the approval before the git work, so a failed worktree add left the lane approved, unmaterialized and invisible — the override answered AllowedByPolicy, no Gate was ever raised again, and list_lane_gates (which only surfaces needs_gate) hid it, leaving no retry surface. The approval is now rolled back and the lane re-adjudicated, so the card returns.
…e admission The idempotent fast path returned Ready without consulting the policy. Leaving the existing checkout alone is deliberate — tearing down a tree a worker may be mid-flight in is not what "stop new writes after a tighten" asks for — but that return is also the confirmed fast path's re-dispatch answer, and bootstrap_worktree_deps writes into the checkout. A lane the policy has since gated or denied now stops there: the verdict is computed before either, a refusal returns no worktrees so nothing re-dispatches, and the existing worktree is still never deleted. Separately, set_authority_policy shares no lock with materialization, so a tighten could land between the verdict and the git writes. The active revision is re-read immediately before the first write and a change bails. This narrows the window to that read rather than the whole git operation; it does not close it. Fully closing it needs a shared per-workspace lock across policy mutation and materialization, which is larger than this PR should carry.
|
All ten review findings are addressed across Fixed as reported
Handled differently
CI also went red on the first push for a reason worth naming: I had verified with Generated by Claude Code |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 231ea418b0
ℹ️ 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 (decision === "approved" && worktrees.length > 0) { | ||
| void dispatchDirection(gate.direction_id); |
There was a problem hiding this comment.
Dispatch dependents when their gate clears
When a gated lane has downstream dependents, planner.rs:1825-1826 removes the entire transitive set from the original dispatch IDs, but this approval path starts only gate.direction_id. Those dependents were already materialized, have no Gate cards of their own, and therefore remain idle after the upstream Gate is approved. Dispatch the newly unblocked downstream set as part of resolving the Gate.
AGENTS.md reference: AGENTS.md:L58-L58
Useful? React with 👍 / 👎.
| if latest_decision.policy_revision != current_revision { | ||
| let refreshed = crate::materialize::readjudicate_lane(&db, dir.id).await.map_err(e)?; | ||
| if refreshed.is_none() { |
There was a problem hiding this comment.
Materialize lanes that become allowed during readjudication
If a pending Gate's policy is loosened so the lane now evaluates as allowed_by_policy, this new branch only appends fresh decision evidence. The subsequent payload check hides the card, but no worktree is created and no worker is dispatched, leaving the lane without either a Gate or a retry surface. Fresh evidence after the prior stale-card report is this new readjudicate-only path; it should materialize an allowed lane rather than merely rewriting its verdict.
AGENTS.md reference: AGENTS.md:L58-L58
Useful? React with 👍 / 👎.
| let next_revision = previous | ||
| .and_then(|p| p.revision.parse::<i64>().ok()) | ||
| .unwrap_or(0) | ||
| + 1; |
There was a problem hiding this comment.
Serialize authority policy revision allocation
When two policy updates for the same workspace overlap, both can read the same previous row and compute the same next revision because this read/increment/insert sequence has neither a transaction nor a uniqueness constraint. get_active_authority_policy then chooses an arbitrary row among the tied revisions, so a tightening can report success while different rules remain active, and Gate decisions keyed only by that revision become valid for both policies. Allocate revisions atomically per scope.
AGENTS.md reference: AGENTS.md:L58-L58
Useful? React with 👍 / 👎.
| let current = current_gate_policy_revision(&db, direction_id).await.map_err(e)?; | ||
| if policy_revision != current { | ||
| return Err("gate_policy_changed".to_string()); | ||
| } | ||
| crate::store::repo::record_gate_decision( |
There was a problem hiding this comment.
Serialize conflicting gate resolutions
If two app windows or duplicate requests resolve the same Gate concurrently, both can pass the live-revision check and append conflicting decisions. In an approve/deny race, approval can authorize and create the worktree before denial is recorded; the denial then becomes the standing verdict but does not remove that checkout, while the approving client still dispatches its worker. Serialize resolution per direction or atomically reject a second decision once resolution begins.
AGENTS.md reference: AGENTS.md:L58-L58
Useful? React with 👍 / 👎.
| if let Err(error) = | ||
| repo::insert_plan_revision(db, thread_id, &start_plan.created_at, &new_json, "user").await |
There was a problem hiding this comment.
Record every user scope mutation in plan history
Only the batch-confirm path appends a user plan_revision; individual approve_direction, deny_direction, and set_direction_base calls all mutate the stored proposal through persist_decision without adding a snapshot. Consequently the advertised scope history omits those user decisions and edits, and materialization evidence obtains scope_revision from an older proposal that may not contain the base or decision actually adjudicated. Append a revision for every successful user proposal mutation.
AGENTS.md reference: AGENTS.md:L34-L34
Useful? React with 👍 / 👎.
| let mut a: authority_policy::ActiveModel = active.into(); | ||
| a.revoked_at = Set(now()); | ||
| a.update(&db.0).await?; |
There was a problem hiding this comment.
Preserve fail-closed restrictions after policy revocation
Revoking the active row makes get_active_authority_policy return None, after which materialization uses the empty default policy; because production lanes pass human_authorized: true, that default allows any known repo and has no protected branches. Thus revoking a policy that denied a repo or gated a branch actually permits a subsequent confirm retry to materialize it, contrary to this path's stated fail-closed behavior. Represent revocation with a restrictive tombstone/default rather than treating it like a workspace that never configured policy.
AGENTS.md reference: AGENTS.md:L58-L58
Useful? React with 👍 / 👎.
| match asks.auto_decision(thread, &dir, risk, &action_key) { | ||
| Some(Decision::Allow) => return hook_decision("allow", "Auto-approved by a weft rule"), | ||
| Some(Decision::Deny) => return hook_decision("deny", "Denied by the workspace policy"), |
There was a problem hiding this comment.
Consult deny rules before builtin auto-approval
For the PreToolUse route, a configured deny_actions pattern targeting a safe builtin such as Claude Read, Grep, or Glob is never evaluated: handle_ask returns allow from the builtin branch at lines 203-218 before reaching this new policy-aware auto_decision call. This makes the same workspace policy deny the action through ACP/Codex approval handling but auto-allow it through the hook. Register the workspace and consult the policy before applying the convenience builtin allowlist.
AGENTS.md reference: AGENTS.md:L58-L58
Useful? React with 👍 / 👎.
| setAuthorityPolicy: (workspaceId: number, rules: AuthorityPolicyRules) => | ||
| invoke<AuthorityPolicyRevision>("set_authority_policy", { workspaceId, rules }), | ||
| revokeAuthorityPolicy: (workspaceId: number) => | ||
| invoke<void>("revoke_authority_policy", { workspaceId }), |
There was a problem hiding this comment.
Add a production surface for configuring policies
A repo-wide search finds setAuthorityPolicy only in this API wrapper and no frontend call site, form, or settings surface invokes it; the same is true of revoke and policy-history APIs. Normal app users therefore cannot create the first policy, so the new Gate and permission-bridge behavior always runs under the no-op default unless someone manually invokes Tauri commands from outside the product. Wire these commands into a user-accessible policy configuration flow.
Useful? React with 👍 / 👎.
- The PreToolUse route's read-only builtin allowlist returned `allow` before the policy was ever consulted, so a `deny_actions` rule naming Read/Grep/Glob was enforced on the ACP and Codex routes and ignored on the hook. The workspace is registered and an explicit policy DENY is checked before that branch; only Deny is decisive there, so a policy ALLOW still falls through and nothing is widened. - Policy revision allocation was a read-max/insert with no transaction, so two overlapping tightens could mint the SAME revision — after which the active read picks arbitrarily and a Gate decision keyed by it is valid for both policies. Added a UNIQUE (scope, scope_id, revision) index and made the allocator retry against it. - Approving a Gate started only that lane, but the previous commit removed its whole transitive dependent set from the confirm dispatch ids, and those lanes carry no Gate of their own — they idled forever. resolve_lane_gate now returns the set the clearance actually released (walking downstream and keeping only lanes whose own checkout and every declared producer are live, so a join lane still waiting on another gated producer is left alone). - A lane that re-adjudicated to allowed_by_policy with no worktree vanished from the gate list with nothing to materialize or dispatch it. Such lanes are now surfaced with their own reason and a "set up and start" action rather than hidden; the button behind it is the same resolve path. - Two windows could both pass the revision check and append conflicting gate decisions, letting an approval create a worktree that a subsequent denial never removes. Resolution is now single-flight per direction. Tests: distinct revisions under repeated allocation, and the live-worktree probe rejecting a row whose directory is gone.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: aea5160410
ℹ️ 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 let Some(decision) = self.auto_decision_exact(thread, dir, action_key) { | ||
| return Some(decision); |
There was a problem hiding this comment.
Check policy denials before standing grants
When Dangerous mode is enabled, or the thread already has a Full/Always grant, this early return skips authority_bridge_decision; the ACP and Codex approval routes both call auto_decision directly, so an action explicitly listed in the workspace's deny_actions is still approved automatically. Evaluate policy denials before these standing allows so existing CLI/Weft approval settings cannot widen a workspace restriction.
AGENTS.md reference: AGENTS.md:L58-L58
Useful? React with 👍 / 👎.
| if !lane_is_runnable(db, downstream).await? { | ||
| continue; | ||
| } | ||
| released.push(downstream); |
There was a problem hiding this comment.
Re-authorize dependents before dispatching them
If the workspace policy tightens while an upstream Gate is pending, a downstream lane can become denied even though its checkout was materialized under the old revision. This release path checks only that the checkout and upstream checkouts exist, then LaneGatePanel dispatches every returned ID, so the dependent starts without consulting the current policy. Re-run materialization/adjudication for each released dependent and return only lanes whose current verdict is allowed.
AGENTS.md reference: AGENTS.md:L58-L58
Useful? React with 👍 / 👎.
| let lane = authority::LaneCandidate { | ||
| lane_id: &dir.slug, | ||
| repo_known: true, | ||
| repo_name: &repo_ref.name, | ||
| reason: &dir.reason, | ||
| base_branch: effective_base, | ||
| human_authorized: true, |
There was a problem hiding this comment.
Exercise auto-materialize before human confirmation
Setting auto_materialize: true cannot currently auto-materialize anything: save_proposal_value only persists the proposal, and the only production construction of LaneCandidate happens after a direction has been created by confirm/approve and always sets human_authorized to true. Thus an unconfirmed lane is never adjudicated with human_authorized: false, and enabling this advertised policy option leaves the ordinary confirmation requirement unchanged. Add a proposal-time materialization path that evaluates eligible lanes before human confirmation.
AGENTS.md reference: AGENTS.md:L58-L58
Useful? React with 👍 / 👎.
| let (bridge, _verdict) = crate::authority::bridge_decision( | ||
| &snapshot, | ||
| &crate::authority::PermissionAction { action_key }, | ||
| ); |
There was a problem hiding this comment.
Persist permission-bridge verdicts for auditing
For every allow_actions or deny_actions match, this discards the constructed LaneVerdict and returns only the transient decision. Consequently policy-driven CLI approvals and denials leave no Evidence row or other durable record tying the action key to the policy revision, so after the process exits users cannot audit which sensitive requests the policy automatically accepted or rejected despite the new verdict type's full-audit contract. Persist the bridge verdict from an async caller before replying to the CLI.
AGENTS.md reference: AGENTS.md:L58-L58
Useful? React with 👍 / 👎.
A dependent materialized under an older revision, and the policy can tighten while the upstream Gate sits pending. Releasing on checkout existence alone started a worker in a lane the workspace now denies, through a path that never consulted the policy again. Each candidate is re-adjudicated at the current policy and only released when it still evaluates allowed — which also records the fresh verdict, so the ledger shows why a dependent was held back.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 3fd64b5e4b
ℹ️ 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 live_worktree { | ||
| continue; |
There was a problem hiding this comment.
Show gates for paused lanes with existing worktrees
When a confirmed lane already has a checkout and a later policy change makes its base protected, the confirmed-plan retry now records NeedsGate and omits it from dispatch, but this early skip prevents that decision from ever reaching the Gate panel. The lane can therefore remain permanently paused with no approval surface unless the policy is changed again. Fresh evidence after the earlier existing-worktree report is the new fast-path adjudication that can gate a live checkout; inspect the decision evidence before excluding live worktrees, or distinguish an actively running lane from one awaiting redispatch.
AGENTS.md reference: AGENTS.md:L58-L58
Useful? React with 👍 / 👎.
| &upstream_lanes_from_resolved(&resolved, &fastpath_proposal), | ||
| ) | ||
| .await; | ||
| matching.retain(|id| !refused_fastpath.contains(id)); |
There was a problem hiding this comment.
Remove gated lanes' dependents on confirmed retries
In the confirmed-plan retry section, this filters only the lanes whose own materialization returned a refusal. If two windows submit confirmation for a plan with gated upstream A and dependent B, the first request correctly removes both from dispatch, but the serialized second request enters this fast path, refuses A, and still returns B; the frontend then starts B while its producer remains gated. Apply the same transitive gate_blocked_direction_ids filtering used by the initial-confirm path before returning these IDs.
AGENTS.md reference: AGENTS.md:L58-L58
Useful? React with 👍 / 👎.
| let protected_hit = | ||
| !lane.base_branch.is_empty() && matches_name(&policy.rules.protected_branches, lane.base_branch); |
There was a problem hiding this comment.
Normalize branch names before applying protection rules
When a policy protects main, a lane can specify the equivalent base as origin/main, refs/heads/main, or refs/remotes/origin/main and miss this exact-name comparison. git::add_worktree_synced explicitly normalizes all of those spellings to the bare branch before resolving the checkout, so the lane still branches from main but materializes without the required Gate. Normalize the candidate and configured branch names with the same Git normalization before matching.
AGENTS.md reference: AGENTS.md:L58-L58
Useful? React with 👍 / 👎.
| .iter() | ||
| .any(|w| std::path::Path::new(&w.path).exists())) |
There was a problem hiding this comment.
Validate dependent worktrees before releasing them
When a dependent's recorded checkout path has been replaced out of band by a plain directory or a worktree for the wrong branch, this predicate still reports it as live. After an upstream Gate is approved, released_by_gate consequently returns that dependent and the frontend's matching exists check dispatches its worker into the invalid directory; materialize_direction already has the stronger registered-worktree and branch validation but is never called for the dependent. Fresh evidence after the earlier dependent-reauthorization report is this newly introduced path-only helper, which should use the same validity predicate as materialization.
AGENTS.md reference: AGENTS.md:L58-L58
Useful? React with 👍 / 👎.
| let payload = serde_json::json!({ | ||
| "decision": verdict.decision, | ||
| "reason": verdict.reason, | ||
| "hit_rule": verdict.hit_rule, | ||
| }) | ||
| .to_string(); |
There was a problem hiding this comment.
Redact and bound authority evidence payloads
When a matched repo or branch value contains a credential-shaped token, this copies the raw hit_rule into the durable Evidence ledger; unusually large configured values are also persisted without the ledger's 8 KiB bound. The Evidence entity and append_evidence contract require every write site to apply redact_secrets and truncate_bounded, but this new authority write bypasses both, so policy evaluation can leak secrets into audit views/backups and create unbounded rows. Sanitize the serialized payload before appending it.
AGENTS.md reference: AGENTS.md:L58-L58
Useful? React with 👍 / 👎.
- protected_branches compared exact strings, so a lane naming `origin/main` or `refs/heads/main` branched from protected `main` without a Gate — add_worktree_synced collapses all those spellings to the same ref. Both sides are normalized to the bare branch name before matching, with a test over the spellings. - The reuse fast-path can now gate a lane that already has a checkout, but list_lane_gates skipped every lane with one, so such a lane was paused with no approval surface at all. Card visibility is decided by the verdict: needs_gate always shows, allowed_by_policy shows only when the checkout is unusable, anything else stays hidden. - Releasing dependents tested path existence, which a plain directory or a wrong-branch checkout also passes. Replaced with the registered-worktree predicate materialization itself uses (new lane_has_valid_checkout); the path-only helper is gone. - The confirmed-plan retry filtered only lanes whose own materialization was refused, so a second confirm dropped gated A and still dispatched its dependent B. It now applies the same transitive gate_blocked_direction_ids filter as the initial confirm. - The authority evidence write bypassed the ledger's redact/bound contract, so a credential-shaped or oversized configured value landed verbatim in a durable, backed-up row. It now goes through truncate_bounded + redact_secrets.
|
Rounds 2–4 are addressed through Fixed since Round 2 ( Round 3 ( Round 4 ( Six open items — your call Two are security semantics I don't think an agent should decide unilaterally:
Four are scope:
Tell me which of these you want in this PR and I'll do them; otherwise I'd suggest 3–6 as follow-up issues so this one can land on the behavior that is now correct. Generated by Claude Code |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: bba6bd352a
ℹ️ 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".
| for upstream in crate::store::repo::upstream_direction_ids(db, direction_id).await? { | ||
| if !crate::materialize::lane_has_valid_checkout(db, upstream).await? { | ||
| return Ok(false); |
There was a problem hiding this comment.
Check each upstream's gate verdict before release
When a policy tightening gates two producers that already have valid worktrees, approving one Gate can incorrectly release their shared dependent: the other producer passes this checkout-only test even though its Gate is still pending and its worker remains paused. Re-adjudicate each upstream (or explicitly check its current pending-Gate state) before treating the dependent as runnable.
AGENTS.md reference: AGENTS.md:L58-L58
Useful? React with 👍 / 👎.
| { | ||
| eprintln!( | ||
| "[weft][evidence] materialize decision evidence for direction {}: {error}", | ||
| dir.id | ||
| ); |
There was a problem hiding this comment.
Preserve gates when decision evidence cannot persist
When this evidence insert fails transiently, materialization still returns Gated, so confirm keeps the direction, marks the plan confirmed, and omits it from dispatch; however, list_lane_gates discovers pending Gates exclusively from this evidence row and therefore renders no recovery card. Propagate the persistence failure or store the pending Gate in a correctness-critical path rather than logging and continuing.
AGENTS.md reference: AGENTS.md:L58-L58
Useful? React with 👍 / 👎.
| } | ||
| requestSeq.current += 1; | ||
| const seq = requestSeq.current; | ||
| setFetchState("loading"); |
There was a problem hiding this comment.
Clear stale gate rows when switching threads
When the user switches threads after this panel has loaded Gates, reload enters the loading state but leaves the previous gates array rendered until the new request completes. During that interval the new thread's board shows actionable approval buttons for the old thread, and clicking one resolves that old lane because the command uses the row's direction ID; clear the rows on a thread change or filter them by gate.thread_id === threadId.
AGENTS.md reference: AGENTS.md:L58-L58
Useful? React with 👍 / 👎.
…cope panel rows - lane_is_runnable checked only that each producer had a checkout. One policy tighten can gate several producers at once and they keep their worktrees while paused, so approving one released a shared dependent while the other's Gate was still pending. Each upstream is re-adjudicated too. - The authority decision evidence write was best-effort, but that row IS the Gate: list_lane_gates finds pending Gates there and nowhere else, so a transient insert failure returned Gated, kept the direction, dropped it from dispatch, and rendered no card — a lane paused with no way to appeal. The failure now propagates; a failed confirm is retryable, a silent one is not. - The gate panel kept the previous thread's rows on screen for the whole duration of the new thread's fetch, and those approve/deny buttons resolve the OLD lane (the command keys off the row's direction id). Rows are stored with the thread they were fetched for, so that window is unrepresentable.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 12dcbf21ea
ℹ️ 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".
| let stranded = verdict == "allowed_by_policy" && !checkout_ok; | ||
| if verdict != "needs_gate" && !stranded { |
There was a problem hiding this comment.
Preserve a resume path for re-allowed live checkouts
When a confirmed retry is blocked by a tightened policy, the lane can retain a valid checkout while its lane and dependents are removed from the dispatch set. If the policy is subsequently loosened, stale-card readjudication records allowed_by_policy, but this condition classifies the lane as recoverable only when checkout_ok is false, so the card disappears without dispatching anything; because the confirmed proposal UI is already closed, the paused lane has no visible resume path. Fresh evidence beyond the earlier unmaterialized-lane report is this explicit !checkout_ok filter: retain a resume card or dispatch the newly allowed live-checkout lane and its dependents.
AGENTS.md reference: AGENTS.md:L58-L58
Useful? React with 👍 / 👎.
| match crate::store::repo::get_active_authority_policy(db, "workspace", workspace_id).await { | ||
| Ok(Some(row)) => asks.set_authority_snapshot( | ||
| workspace_id, | ||
| Some(crate::authority::snapshot_from_row( | ||
| row, | ||
| crate::authority::PolicyScope::Workspace(workspace_id), | ||
| )), |
There was a problem hiding this comment.
Prevent older refreshes from replacing newer policy snapshots
When two policy mutations for the same workspace overlap, an earlier refresh can finish its database read, be descheduled, and install its older row after the later command has already cached the newer revision; a revoke refresh can similarly clear a snapshot installed by a concurrent set. Materialization then enforces the database's latest policy while CLI asks use stale or absent bridge rules, potentially auto-allowing an action the latest revision denies until another refresh or restart. Serialize mutation-plus-refresh per workspace or make set_authority_snapshot reject revisions older than the currently cached one.
AGENTS.md reference: AGENTS.md:L58-L58
Useful? React with 👍 / 👎.
| setActionState((prev) => { | ||
| const next = { ...prev }; | ||
| delete next[gate.direction_id]; | ||
| return next; | ||
| }); |
There was a problem hiding this comment.
Keep resolved gate rows disabled until reload completes
After a successful resolution, this deletes the row's resolving state before reload() replaces the existing gates array, so during a slow list request the stale card is rendered again with enabled Approve/Deny buttons. A second click is sequential and therefore passes the backend's in-flight guard, records another decision, and returns the dispatch IDs again; because both dispatches are launched without awaiting them, this can issue duplicate worker-open attempts before the first one reaches frontend session state. Remove the resolved row immediately or keep it disabled until the reload response arrives.
AGENTS.md reference: AGENTS.md:L58-L58
Useful? React with 👍 / 👎.
| pub async fn list_lane_gates(db: State<'_, Db>, thread_id: i32) -> R<Vec<LaneGateDto>> { | ||
| let directions = crate::store::repo::list_directions(&db, thread_id).await.map_err(e)?; | ||
| let mut out = Vec::new(); |
There was a problem hiding this comment.
Exclude gates that no longer belong to the current scope
When the lead re-proposes while a Gate is pending and removes or replaces that lane, the old direction and its needs_gate evidence remain in the thread, and this command enumerates every direction without checking whether the current plan still references it. The obsolete card therefore remains actionable; approving it materializes and dispatches work that is no longer in the user's reviewed scope. The same policy-only staleness check also misses a changed scope revision for a reused lane, so filter cards against the current proposal and key Gate resolution to the current scope/candidate as well as the policy revision.
AGENTS.md reference: AGENTS.md:L58-L58
Useful? React with 👍 / 👎.
…apshots - Bridge snapshot installs were unordered: two overlapping policy mutations each read then install, so the earlier read could land last and cache a superseded revision while materialization already enforced the newer row — CLI asks running on rules the database had moved past. Installs now drop a revision older than the cached one; a CLEAR is never dropped, since deferring to a human is always safe. - A re-propose that drops or replaces a gated lane left its old direction and needs_gate evidence behind, and the card stayed actionable — approving it would materialize work no longer in the reviewed scope. Cards are filtered against the direction ids the current plan still references. - "Allowed but recoverable" keyed off the checkout, but a lane gated after confirm keeps its worktree while being dropped from dispatch; if the policy is later loosened the verdict flips back to allowed with nothing started and the proposal UI closed. The signal is now whether a worker was ever opened for the lane, not whether a directory exists. - A resolved row stayed on screen with live buttons until the refetch landed, and a second sequential click passes the in-flight guard — recording another decision and opening the same workers twice. The row is dropped immediately.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 661b8c04b9
ℹ️ 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".
| None => { | ||
| g.remove(&workspace_id); | ||
| } |
There was a problem hiding this comment.
Version cache-clearing refreshes
When a revoke refresh reads None, is descheduled, and a concurrent later set_authority_policy installs its newer snapshot first, this unconditional removal lets the older refresh erase that newer policy. CLI asks then defer to the human flow instead of enforcing the active policy's deny_actions, while materialization still reads the correct database policy. Fresh evidence after the earlier refresh-order report is that revision ordering was added only to the Some(snapshot) arm; the None arm remains unversioned. Carry a revision/tombstone through clears or serialize mutation and refresh per workspace.
AGENTS.md reference: AGENTS.md:L58-L58
Useful? React with 👍 / 👎.
| if !in_scope.is_empty() && !in_scope.contains(&dir.id) { | ||
| continue; | ||
| } |
There was a problem hiding this comment.
Treat an all-zero proposal as an empty scope
When a re-proposal replaces every gated lane, all of its new lanes have direction_id == 0, so in_scope is empty and this condition skips filtering entirely. Old directions and their needs_gate evidence therefore remain actionable, allowing approval to materialize and dispatch work removed from the current proposal. Fresh evidence after the earlier stale-scope report is this new !in_scope.is_empty() guard; distinguish “a parsed current plan with no materialized IDs” from “no usable plan” and exclude every old direction in the former case.
AGENTS.md reference: AGENTS.md:L58-L58
Useful? React with 👍 / 👎.
| let evidence = crate::store::repo::list_evidence(&db, thread_id, Some(dir.id), 20) | ||
| .await | ||
| .map_err(e)?; |
There was a problem hiding this comment.
Query Gate decisions outside the bounded evidence page
When a lane accumulates more than 20 newer non-decision evidence rows after being gated—for example, a previously materialized lane with multiple PR host updates or changing verification/reconciliation evidence—this bounded mixed-kind query omits its persisted needs_gate row. The following search then skips the lane entirely, so the Gate card disappears even though the policy still blocks redispatch. Filter by EVIDENCE_KIND_DECISION in the database or use an unbounded/latest-decision accessor rather than looking for the decision inside a 20-row general evidence page.
AGENTS.md reference: AGENTS.md:L58-L58
Useful? React with 👍 / 👎.
…n empty scope Three follow-ups to the previous round, all reachable: - Revision ordering was added only to the install arm, so a slow revoke read could still ERASE a newer policy a concurrent set had just installed. My earlier note that a clear is "always safe" was wrong: erasing leaves materialization enforcing the database's rules while CLI asks quietly stop applying that policy's deny_actions, and nothing repairs the split until a restart. The cache line now carries the revision it was applied at, clears included; a clear with NO observed revision is a failed read and still applies unconditionally, which is the fail-closed direction. Test covers both directions. - The Gate card's decision was found by paging 20 mixed-kind evidence rows, so a lane that accumulated newer PR-host or verification rows pushed its needs_gate row off the page and lost its card exactly when it got busy. New latest_decision_evidence filters by kind in the database. - Scope filtering was skipped when the current plan named no direction ids, but a re-proposal that replaces every gated lane records 0 for all of them — turning filtering off precisely when the old cards became obsolete. It now keys off whether a plan was read at all.
…nswer Two more fail-open paths, both from a derived copy outranking the thing it was derived from. `spawnWorker` was `driveDirection` minus the `status !== "exited"` filter and minus the pruning of the stale entry, so it treated a dead session as an occupied slot and returned without calling `chatOpenWorker`. Recovery dispatch is exactly the case where the previous session has exited, so approving a stranded-lane card left the backend at `ReadyToStart` and the next reload drew the same card again. No caller wanted that, so the copy is deleted rather than repaired in parallel and its one call site goes through `driveDirection`. That made `dispatchDirection` and `reviveDirection` identical, so they now share one implementation and keep their distinct names for the call sites' sake. `resolve_lane_gate` deliberately does not fail a denial when the evidence write fails — the decision is already durable in `lane_gate_decision`, and reporting failure would tell a human their veto did not take when it did. But readiness read only the ledger, so a transient failure left `lane_state` saying `Denied` (hence no card) while readiness still saw the old `needs_gate` row and pinned the issue at `PolicyGatePending` with nothing to act on. `latest_lane_decisions` now overlays the durable veto last, in one query per thread. `get_gate_decision` already treats a veto as unconditional — a statement about the lane, which no policy revision expires — so this settles the split in the direction the design chose everywhere else, and closes the class: the same disagreement opened whenever a denial's evidence row was superseded by a later revision. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Xz1Ts3AK1uNbeWZT6YFbzz
|
Round 45 ( P2
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 6fb1db6f2f
ℹ️ 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".
| let len = value.len(); | ||
| (7..=40).contains(&len) && value.chars().all(|c| c.is_ascii_hexdigit()) |
There was a problem hiding this comment.
Reject SHA-256 commit IDs as bases
In a SHA-256 repository, an untrusted proposal can set base_branch to the 64-hex object ID of protected main; this 7–40 bound does not recognize it as an object ID, protected-branch name matching misses it, and git.rs:129-134 accepts the resolvable value verbatim, so the lane materializes from the protected commit without a Gate. Fresh evidence beyond the earlier SHA-1 report is that this repository explicitly supports 64-hex IDs in git.rs:74-85 and materialize.rs:600-603; locally, git rev-parse --verify <64hex>^{commit} in a --object-format=sha256 repository returns that same ID. Make this check hash-format-aware and add SHA-256 protected-branch coverage.
AGENTS.md reference: AGENTS.md:L34-L34
Useful? React with 👍 / 👎.
`looks_like_commit_id` bounded at 40 hex digits, which is the SHA-1 ceiling. Weft supports SHA-256 repositories explicitly — `is_full_commit_oid` is documented hash-agnostic and `materialize` discriminates on it — so an untrusted proposal could set `base_branch` to the 64-hex object id of a protected branch. That resolves to the protected branch's own commit, matches no `protected_branches` NAME, and materialized onto it with no Gate: the same bypass the revision-metacharacter rules exist to close, spelled in a hash format the bound had never been widened for. The bound now spans 7..=64, covering every abbreviated and full form git accepts in either format. It has to span both, because this check cannot ask which one the repository uses: `adjudicate_lane` is pure by design and reaches neither git nor the store. Fails closed for a branch genuinely named like a hash, which is the same trade the 7..=40 bound already made. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Xz1Ts3AK1uNbeWZT6YFbzz
`run_bounded_check` reported a `spawn()` failure as
`Completed{status:"fail"}` — weft asserting the user's tests are red when
what actually happened is that its own exec failed: a missing binary, a
permission, or `fork`/`posix_spawn` returning EAGAIN because the machine
was out of process slots.
That last case is what has been naming a different readiness test on
macOS CI run after run. Under parallel test load a trivially-passing
`exit 0` check intermittently loses its spawn and lands here, so the
suite reads `Failing` where it expects `Passed` — non-deterministic,
macOS-only, and never the same test twice, because which check loses the
spawn is whichever one runs when the runner is tightest.
`bounded_check_evidence_keeps_observed_failures_sticky` is the latest.
`NotProduced` is the arm that already means "no verdict was obtained",
the same answer a timeout gets, and it does not fail open — readiness
reads it as not-ready. Every neighbouring early return in this function
already goes there; this one was the exception.
This is the complement of the crash case, not a reversal of it: a child
that ran and died on a signal IS a verdict about the user's code, while a
child that never existed cannot be one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xz1Ts3AK1uNbeWZT6YFbzz
|
Round 46 ( P1
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 8fddd9d8b4
ℹ️ 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".
| let state = | ||
| crate::lane_state::lane_authority_state(db, direction_id).await.map_err(e)?; | ||
| if !state.offers_decision() { |
There was a problem hiding this comment.
Reject Gate cards when the effective base changes
When a blank-base lane has an allowed stranded_lane card after a policy loosen and the repository's default branch subsequently moves to a protected branch, this recheck now resolves the lane as AwaitingGate but still accepts the old recovery click because both states satisfy offers_decision(). The recorded approval is then honored before the protected-branch check in adjudicate_lane, so materialization proceeds from a branch the displayed card never authorized. Bind the card to the effective verdict/base and return a stale-card error when it changes, with a default-branch-change regression test.
AGENTS.md reference: AGENTS.md:L34-L34
Useful? React with 👍 / 👎.
| /// configuration", not "retire one row while an earlier one keeps governing". | ||
| /// A revoked scope adjudicates every Lane to a human Gate instead. | ||
| pub async fn revoke_authority_policy(db: &Db, scope: &str, scope_id: i32) -> Result<()> { | ||
| let Some(active) = get_active_authority_policy(db, scope, scope_id).await? else { |
There was a problem hiding this comment.
Reject malformed revisions before revoking
When the newest policy row has a nonnumeric revision, this call still uses get_active_authority_policy, which ranks that row as revision zero and can select an older numeric row instead; revocation then reports success after revoking the wrong row, while resolve_policy_snapshot continues treating the malformed newest row as unreadable and the workspace remains gated. Fresh evidence after the adjudication-side malformed-revision fix is that the mutation path still bypasses that validated resolver; reject the revoke or identify the actual newest row without numeric fallback.
AGENTS.md reference: AGENTS.md:L58-L58
Useful? React with 👍 / 👎.
"Which row is active" is decided by ranking revisions, and a revision that will not parse ranks as zero — so a malformed NEWEST row loses to an older numeric one. `revoke_authority_policy` would then stamp `revoked_at` on the wrong row and report success, leaving the scope exactly as it was: `resolve_policy_snapshot` still reading the malformed row as unreadable, still gating every lane, with the human told their revoke landed. `resolve_policy_snapshot` already refuses to rank what cannot be ordered. This is the MUTATION side of the same rule, which had been bypassing it. Failing is the honest answer — a revoke that cannot identify its target has not happened, and saying so is what lets it be fixed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Xz1Ts3AK1uNbeWZT6YFbzz
… authorizes
The policy revision was not the card's identity. `offers_decision`
admits three materially different cards — a rule's approve/deny, and the
two stranded-lane recovery prompts — and a click carries no record of
which one was on screen. So a recovery card ("nothing is running this,
start it") could be answered after the lane had become `AwaitingGate`,
and the approval read as "yes, materialize onto that protected branch".
A repository's default branch moving does not touch the policy revision,
so the revision check saw nothing wrong, and the recorded override is
honored ahead of the protected-branch rule.
The round-tripped token now carries the policy revision, the card's own
slug, and the effective base the card authorizes — resolved the way
`materialize` resolves it, so a blank column compares as the repository
default rather than as "". The base is in there for the case the slug
alone misses: a `protected_branch` card whose base moves to a DIFFERENT
protected branch keeps both its slug and its revision.
The token stays opaque to the frontend, which only round-trips it, and
is deliberately not what the decision is stored under — `adjudicate_lane`
looks an override up by the policy revision alone, so keying the row on
the token would make every approval unfindable.
Compared AFTER `offers_decision` and under the thread gate: a terminal
lane fails both checks and `gate_not_actionable:done` is the answer worth
giving, while comparing outside the gate would make it a TOCTOU read
against a concurrent re-proposal.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xz1Ts3AK1uNbeWZT6YFbzz
|
Round 47 ( P1
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: de594a1bd6
ℹ️ 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".
| } | ||
|
|
||
| fn matches_name(list: &[String], name: &str) -> bool { | ||
| list.iter().any(|entry| entry.eq_ignore_ascii_case(name)) |
There was a problem hiding this comment.
Key repository policy rules by a unique identity
When a workspace contains distinct repositories named api and API, the planner resolves those names case-sensitively (planner.rs:143-149), but this matcher folds case, so allowed_repos: ["api"] also authorizes a lane targeting API. Registration permits this because repos are deduplicated by path/remote and receive unique slugs rather than unique display names (store/repo.rs:1881-1908); consequently a policy intended to scope work to one repository can materialize or admit workers in the other. Match a stable unique repo identity, or at least use the same case-sensitive identity semantics as planner resolution.
AGENTS.md reference: AGENTS.md:L58-L58
Useful? React with 👍 / 👎.
`matches_name` folded case for both repo lists, while `planner::resolve` binds a lane to a workspace repo by EXACT name. Repos are deduplicated by local path or remote URL and never by name, so one workspace can hold genuinely distinct repositories called `api` and `API` — and `allowed_repos: ["api"]` therefore authorized a lane targeting `API`. A scope rule meant to confine work to one repository silently covered work in the other. Split into two matchers, each matching in the direction that fails CLOSED, which is why the asymmetry is deliberate rather than an oversight: - `allowlist_names_repo` is exact, agreeing with the resolver on what "this repository" means, so a hit can only ever grant what was named. - `denylist_catches_repo` keeps folding case, so a spelling difference cannot slip past a refusal. `matches_branch` is untouched: `protected_branches` only ever refuses, so its generous matching is already the fail-closed direction. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Xz1Ts3AK1uNbeWZT6YFbzz
|
Round 48 ( P1
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 3ec73aa039
ℹ️ 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".
| git::live_default_branch(repo_path) | ||
| .unwrap_or_else(|| git::recorded_base_or_default(repo_path, &repo_ref.base_ref, repo_ref.base_ref_is_default)) |
There was a problem hiding this comment.
Judge the actual fallback base before materializing
When a blank-base lane targets a detached repository with no usable remote default, main, master, or recorded base, this reports the last-resort name main, but git::resolve_base_ref later falls back to HEAD (git.rs:158-166). If detached HEAD points at a branch covered by protected_branches—for example the tip of release—the policy checks main, misses the rule, and creates the worktree from the protected commit. Resolve the same start point used by worktree creation before adjudication, with detached/fallback protected-branch coverage.
AGENTS.md reference: AGENTS.md:L34-L34
Useful? React with 👍 / 👎.
| let mut next_revision = previous | ||
| .and_then(|p| p.revision.parse::<i64>().ok()) | ||
| .unwrap_or(0) | ||
| + 1; |
There was a problem hiding this comment.
Reject malformed revision history before allocating a policy
When a scope's newest row has a nonnumeric revision, this resets allocation to revision 1 instead of rejecting the mutation. With existing numeric revisions 1–3, the retry loop eventually inserts revision 4 and set_authority_policy reports success and installs that row in the CLI bridge, while resolve_policy_snapshot still sees the malformed historical row and returns an unreadable revision-0 snapshot, so lane enforcement Gates everything under a different policy than permission asks use. Beyond the already-reviewed read/revoke paths, this setter path needs the same malformed-history validation before allocating a revision.
Useful? React with 👍 / 👎.
| if matches!(verdict.decision, crate::authority::LaneDecision::Denied) { | ||
| let _ = repo::delete_direction(&db, dir.id).await; | ||
| return Err(format!("lane_denied_by_policy:{:?}", verdict.reason)); |
There was a problem hiding this comment.
Propagate failure to remove a denied standalone lane
When delete_direction fails after policy adjudication denies a newly created standalone task—for example because the database write fails transiently—this discards that error and still returns only lane_denied_by_policy. The frontend's failure path then reloads the thread, exposing the direction that the command claimed to tear down; because it remains durably Denied, it has no worktree, worker, or Gate action, and retries can accumulate more dead rows. Propagate the cleanup failure or make creation plus denied cleanup atomic, with failure-path coverage.
AGENTS.md reference: AGENTS.md:L34-L34
Useful? React with 👍 / 👎.
`protected_branches` is matched by NAME, and the default-base chain's last resort is the literal string "main" whether or not the repository has one. `add_worktree_synced` is handed that same value, tries it, misses, and falls through its own chain to `HEAD` — so a detached checkout with no `origin/HEAD`, no `main`/`master` and no usable recorded base was judged as `main`, matched no rule, and was then created from whatever `HEAD` pointed at. Which can be the tip of exactly the branch the rule protects. `LaneCandidate` now carries whether the base is a branch that actually exists, computed through the same resolver worktree creation uses, and adjudication gates when it is not. Conditioned on the policy actually protecting something, and that condition is load-bearing rather than an optimization: a detached repository with no named default is a configuration weft deliberately supports, and an earlier version of this fix that denied unconditionally broke `materialize_recreates_detached_head_lane_from_stored_target_commit`. Where no by-name rule exists there is nothing to dodge, so that flow is untouched. Gate rather than deny: "nobody can say what this would branch from" is a question for a human, not a refusal — the posture `UnreadablePolicy` already takes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Xz1Ts3AK1uNbeWZT6YFbzz
…rt cleanup failure Two smaller holes of the same shape as fixes already in this branch. `create_authority_policy` read an unorderable revision as zero, so a malformed newest row restarted allocation at 1 and the retry loop climbed to the next free number — `set_authority_policy` reporting success and seeding the CLI bridge with that row, while `resolve_policy_snapshot` still resolved the scope as unreadable. Lane enforcement and permission asks would then run under two different policies, which is worse than either. It now refuses, like the read and revoke paths already do. `create_direction`'s denied-lane cleanup discarded the `delete_direction` error and still reported only `lane_denied_by_policy`, so the frontend's failure path reloaded the thread and showed the row the command claimed to have torn down — durably `Denied`, hence no worktree, no worker and no Gate action, with every retry adding another dead row. The caller now learns that the cleanup is what failed, because that is the part worth retrying. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Xz1Ts3AK1uNbeWZT6YFbzz
|
Round 49 ( P1
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 701e7047b3
ℹ️ 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 let Some(rest) = name.strip_prefix("refs/heads/") { | ||
| return rest; |
There was a problem hiding this comment.
Normalize composite branch prefixes consistently
When a proposal supplies base_branch: "refs/heads/origin/main", this helper returns origin/main, so protected_branches: ["main"] does not match. Fresh evidence beyond the earlier ref-spelling reports is that git::normalize_target in git.rs:94-105 removes both prefixes sequentially, after which resolve_base_ref selects refs/heads/main; the lane therefore starts from protected main without a Gate. Use the same normalization on both sides, or reject composite prefixes, and add regression coverage.
AGENTS.md reference: AGENTS.md:L34-L34
Useful? React with 👍 / 👎.
| // human has a Gate to resolve, and neither is dispatched. | ||
| match &outcome { | ||
| materialize::MaterializeOutcome::Denied(verdict) => { | ||
| let _ = repo::delete_direction(db, dir.id).await; |
There was a problem hiding this comment.
Propagate failed cleanup of denied planner lanes
When a newly created lane is denied during batch confirmation and delete_direction fails transiently, this discarded error still reports only lane_denied_by_policy. The proposal never records this direction ID, so the surviving row is absent from planner scope history, is treated as standalone, and remains durably denied with no worktree or Gate action; retries can accumulate additional dead rows. Propagate the cleanup failure or make creation and denied cleanup atomic.
AGENTS.md reference: AGENTS.md:L34-L34
Useful? React with 👍 / 👎.
| default: | ||
| return "awaiting_gate_decision"; |
There was a problem hiding this comment.
Map the unresolvable-base Gate reason
When the backend returns verdict_reason: "unresolvable_base" for a repository whose starting branch cannot be identified, this switch falls through to awaiting_gate_decision, so the card incorrectly says that no policy covers the task. The translations for the precise reason already exist in both locale files but are unreachable; add unresolvable_base to the discriminated union and this exhaustive mapping.
AGENTS.md reference: AGENTS.md:L19-L19
Useful? React with 👍 / 👎.
| if carries_authority_revision | ||
| && !row.policy_revision.is_empty() | ||
| && row.policy_revision != active |
There was a problem hiding this comment.
Reject authority evidence without a policy revision
When the newest source == "authority" evidence row has an empty policy_revision, this condition skips the revision comparison and accepts its payload as current. If that row says allowed_by_policy while the live policy has since tightened, readiness can remain green or become review-ready even though fresh lane adjudication Gates or denies the lane; treat a missing revision on authority evidence as unreadable/superseded rather than as revision-independent.
AGENTS.md reference: AGENTS.md:L34-L34
Useful? React with 👍 / 👎.
`bare_branch_name` stripped ONE prefix per call, so the composite `refs/heads/origin/main` compared as `origin/main` and matched no `protected_branches: ["main"]` rule — while `git::normalize_target` strips both prefixes in sequence and `resolve_base_ref` then selects `refs/heads/main`. The lane branched off protected `main` with no Gate. Any disagreement between what protection matching collapses and what the resolver collapses is a bypass, so this now strips to a fixpoint rather than trying to enumerate the composites git accepts. Over-collapsing is the safe direction: `matches_branch` only ever REFUSES, so a spelling that folds into a protected name gates rather than slips through. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Xz1Ts3AK1uNbeWZT6YFbzz
…anner cleanup, map the new reason Three follow-ons to fixes already in this branch. An `authority` decision row with an EMPTY `policy_revision` skipped the revision comparison and was accepted as current. `authorize_materialize` always stamps the verdict's revision and a verdict always has one, so an empty value means the row cannot be placed in the revision order at all — which is what `superseded` means. Without that, a stale `allowed_by_policy` written that way kept a lane green under a policy that had since tightened. The planner's denied-lane cleanup discarded its `delete_direction` error the same way `create_direction`'s did before last round. That row is never recorded on the proposal, so a surviving one is absent from planner scope history and reads as standalone — durably `Denied`, hence no worktree, no worker and no Gate action, with every retry adding another. And `unresolvable_base` reached the frontend with no arm in the reason union, so it fell through to `awaiting_gate_decision` and the card said no policy covered the task. The copy added for it last round was unreachable; my omission. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Xz1Ts3AK1uNbeWZT6YFbzz
|
Round 50 ( P1
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: cfa8973c01
ℹ️ 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".
| fn allowlist_names_repo(list: &[String], name: &str) -> bool { | ||
| list.iter().any(|entry| entry == name) |
There was a problem hiding this comment.
Key allowed repositories by unique identity
Fresh evidence beyond the earlier case-folding report is that exact duplicate display names remain possible: add_repo_ref_on deduplicates only by path/remote and gives distinct rows unique slugs, while the standalone create_direction path selects a repository by repo_id. If two different repositories are both named api, this comparison makes allowed_repos: ["api"] authorize either checkout, so a rule intended for one repository can auto-materialize work in the other. Match the unique slug or repository ID instead of the non-unique display name.
AGENTS.md reference: AGENTS.md:L58-L58
Useful? React with 👍 / 👎.
… name Round 48 made repo matching exact, which closed the case-folding variant but not the underlying one: `add_repo_ref` deduplicates on local path or remote URL and NEVER on name, so a workspace can hold two entirely different checkouts both called `api`. An exact match on that name still covers both, and `allowed_repos: ["api"]` authorizes work in whichever one the lane happens to bind. Only `slug` is unique, by construction (`slug::unique_slug`). So the allowlist now grants on the slug always, and on the display name only when no other repository in the workspace shares it. Adjudication is pure and cannot count rows, so the caller establishes that fact the same way it establishes `base_is_named`. Accepting the slug is what keeps such a workspace configurable rather than merely refused — `api` and `api-2` can be allowed separately — and an unambiguous name still works, so the common case is untouched. The denylist takes both identities, either casing, ambiguous or not. The asymmetry is unchanged and deliberate: each list matches in whichever direction fails closed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Xz1Ts3AK1uNbeWZT6YFbzz
|
Round 51 ( P1
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: e2758fc2dc
ℹ️ 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".
| let token = gate_card_token( | ||
| &verdict.policy_revision, | ||
| &reason, | ||
| &effective_gate_base(db, &dir).await, |
There was a problem hiding this comment.
Bind Gate tokens to the base used for adjudication
When a blank-base repository's default changes between lane_authority_states and this second effective_gate_base probe, the row combines the old verdict with the new base in its token. If both old and new defaults trigger the same reason (for example, both are protected), the user can see hit_rule for main while the token already authorizes release; current_gate_card_token repeats the same split read, so the click can pass and the revision-only override then materializes from a branch the displayed verdict did not describe. Capture the effective base with the verdict/state and use that single snapshot for both display and token validation.
AGENTS.md reference: AGENTS.md:L58-L58
Useful? React with 👍 / 👎.
| if let Some(active) = | ||
| crate::store::repo::get_active_authority_policy(&db, "workspace", workspace_id) | ||
| .await |
There was a problem hiding this comment.
Resolve policy reads through the validated snapshot
When a workspace's policy history contains a nonnumeric revision, this endpoint bypasses resolve_policy_snapshot and calls get_active_authority_policy, whose numeric ranking treats that malformed row as revision zero and may return an older active policy (or the malformed row itself). Lane enforcement and the permission bridge instead resolve the scope as unreadable and fail closed, so the API can display rules as active while the application is enforcing a different, all-Gated state. Use the validated resolver here and represent its unreadable result consistently.
AGENTS.md reference: AGENTS.md:L58-L58
Useful? React with 👍 / 👎.
| if base_branch.starts_with("refs/") && !base_branch.starts_with("refs/heads/") { | ||
| return false; |
There was a problem hiding this comment.
Reject bare tags as lane base branches
When an explicit base such as release-base exists only as a tag pointing at protected main, this check accepts it because only non-head values beginning with refs/ are rejected. The resolver in git.rs:141-155 first finds no branch and then deliberately accepts any bare ref that resolves, so the tag commit becomes the worktree start point while protected_branches: ["main"] compares against release-base and raises no Gate. Require the explicit value to resolve in a branch namespace rather than accepting a bare tag alias.
AGENTS.md reference: AGENTS.md:L58-L58
Useful? React with 👍 / 👎.
|
Closing this. Not because the review never converged, but because the review surfaced that the feature as built is redundant — and the reasoning is worth recording before 75 commits disappear from view. WhyThe door this locks is already locked. Production only builds a AuthorityPolicy then judges the same thing a second time. That is not more safety, it is one more prompt on something you already answered. The form that would have been worth building is the inverse, and it is inert. Rules exist so weft can stop asking — agent proposes, anything inside the rules materializes without a confirm, only exceptions reach a human. That is So the valuable shape was not implemented, and the implemented shape is redundant. That is a product finding, not a code-quality one, and no further review round would have reached it. What the 52 rounds actually sayRoughly forty of the findings were not independent bugs. They were four defect classes reappearing at new sites:
Most of them grew on the versioning machinery — the two independent revision axes, snapshot resolution, revision-keyed Gate decisions, bridge cache coherence. That machinery existed to let a rule change expire a human's approval. It is the single largest source of complexity here, and it bought a semantic nobody asked for. What is being kept#216 — extracted and already open. Three defects on the verification path that have nothing to do with permissions and affect users today, chief among them What is not lostThe branch stays for reference. If #172 is revisited, the thing to build is the automation — rules that let weft skip confirm, with a Gate as the exception path — not a second checkpoint behind the first one. And these are worth carrying over on day one, because each is a real bypass of a by-name rule:
Generated by Claude Code |
Closes #172.
One judgment —
allowed_by_policy|needs_gate|denied— for whether a Lane may auto-materialize, plus the same policy object consulted for CLI permission asks. The policy is versioned independently of the dynamic scope it judges.What's here
plan_revision(append-only scope history),authority_policy(append-only per scope, UNIQUE on(scope, scope_id, revision); revoke stampsrevoked_at),lane_gate_decision(a human's Gate resolution).authority.rs:adjudicate_laneis the only Lane judgment;bridge_decisionis the only CLI one. Both pure — callers fetch the current policy/scope revision immediately before calling, so there is no verdict cache a stale decision could be replayed from.lane_state.rs: the ONE authority state of a Lane (see below). Every surface that asks "may this lane proceed?" reads it and maps it exhaustively.authorize_materializefirst, before any git side effect.AskRegistry::auto_decisionconsults the same policy before falling through to the existing AskRegistry/human flow.The conservative default (no policy row) is a no-op on today's confirm-gated flow.
lane_state.rs— one state instead of six derivationsTwenty-one review rounds produced findings that looked unrelated but were all the same defect: a lane's authority state had no single source. Six call sites each answered "may this lane proceed?" from their own mix of seven inputs — decision evidence, worktree validity, session liveness, plan membership, policy revision, upstream lanes, lifecycle status. Every combination one of them missed shipped as a bug: a card that could not be dismissed, a worker started behind a producer that never ran, a finished task offered a resume button, an obsolete card that still materialized removed work. Patching them one at a time could not converge, because each fix taught only one call site about one input.
The inputs are now read in exactly one place and collapse into one discriminated value:
Consumers map it exhaustively and never touch the raw inputs again (CLAUDE.md: derive ONE discriminated value, do not re-derive the same booleans at every call site). Adding a state now forces every surface to say what it does with it, which is the property that was missing.
Three predicates on that value replace what used to be re-derived per call site —
is_dispatchable,admits_worker, andoffers_decision. The last one answers both "does this state show a Gate card?" and "may a Gate resolution be recorded against it?", so the showable set and the resolvable set are the same set by construction rather than by two call sites agreeing.list_lane_gates— cards for the states that need a humanreleased_by_gate/resolve_lane_gate— dispatch only fromReadyToStartchat_open_worker_impl— admitsReadyToStartorRunning, so a reconnect is not mistaken for a fresh start. It previously checked the raw policy verdict and would start a worker for a lane that was out of scope, finished, or behind a gated producer.lane_is_runnable,upstream_blocks_laneandlane_is_in_current_scopeare gone; the resolver subsumes them.The state is judged fresh from
judge_lanerather than read back from the newest evidence row and compared against the active revision. That comparison is what produced the stale-card deadlock and the fallback-to-an-older-row bug; asking the adjudicator now cannot be stale by construction, andjudge_lanerecords nothing.Deactivatedis deliberately distinct fromFinished: both are terminal and neither is actionable, but a finished producer satisfies a consumer's prerequisite and a cancelled one does not — a lane that was switched off produced nothing, so anything waiting on it is blocked rather than released.Review history
The six feature commits were reviewed across six dimensions before this PR opened; five commits fixed what that pass found. Since opening, Codex has run 25 review rounds. All findings are fixed except one, declined below. The per-round detail — including which fixes introduced the next round's defects — is in the PR comments rather than repeated here.
The pre-open pass fixed: the Gate being unreachable on its primary path (
materialize_directionsignalled refusal asOk(Vec::new()), indistinguishable from "this lane binds no repo", and every call site branched onErralone — it now returns a discriminatedMaterializeOutcomeeach site maps exhaustively); post-hocallowed_by_policyrows shadowing theneeds_gaterow that raised the card; the panel being mounted inside a dialog that unmounts on confirm;protected_branchesskipped wheneverbase_branchwas blank (the planner's own "use the repo default" value); unparseable rules JSON degrading to empty rules; a failed bridge refresh keeping revoked rules live; and the bridge being one global, unseeded snapshot rather than per-workspace and seeded at startup.The rounds since then fixed, among others: protected-branch matching bypassed by ref spelling (
origin/main/refs/heads/mainvsmain); the policy-vs-materialization TOCTOU, now closed with a per-workspace lock held across authorize-and-write on both the create and recreate paths, and across every policy mutation; the commit-to-refresh window in which a synchronous CLI ask was still answered by pre-change rules; out-of-order bridge refreshes in both directions, including a clear and the set it cancels sharing a revision; concurrent revision allocation producing ties; an ABBA deadlock between worker admission and Gate approval (lock order is now route gate → workspace write lock everywhere); a Gate approval not dispatching its lane or the dependents confirm had removed from the dispatch set; a denial that could materialize and dispatch the lane it refused, if the policy loosened underneath it; revocation loosening a policy (below); terminal and deactivated lanes staying actionable; and roughly a dozen ways a lane could end up paused with no card and no way to resume.Revocation is now fail-closed.
revoke_authority_policydocuments that revoking "can only make a scope MORE conservative, never more permissive", but the fallback wasdefault_policy, whosedenied_reposandprotected_branchesare empty — so revoking a policy that denied a repo un-denied it, and a refused lane came back materializable. The code contradicted its own safety claim. A revoked scope now resolves toauthority::revoked_policyand adjudicates every lane to a human Gate (with the same override escape the unreadable-policy branch has); a scope that was never configured keeps the inertdefault_policy, and collapsing those two is what caused the bug.repo::resolve_policy_snapshotis the single derivation every adjudication site reads through. This changes what "revoke" means — from back to defaults to a human decides — which is a product call worth confirming; it is the reading the doc already claimed.Also fixed here: the recurring macOS
readinessCI flake.GitSignatureProbe::samplestarted its wall-clock budget before waiting on a process-global 4-permit semaphore, so time spent queued was charged to the Git work. A probe that queued too long returned the same error a hunggitreturns,.ok()turned it intoNone, andCheckFlightread "the worktree changed" from what was really "the machine was busy" — which is why a different test was named each run and why--test-threads=1always passed. Admission and execution are now separate budgets: a probe still cannot queue forever and the fan-out is still capped, but one that gets scheduled gets its full budget. An earlier attempt raisedcfg(test)constants, which never applied totests/readiness.rsat all — an integration binary links the lib compiled WITHOUTcfg(test)— so both overrides and the test shim they existed for are gone, and the tests exercise the bound that ships.Known gaps — deliberately open, needing a decision rather than work
action_keyis not congruent across engines. The three routes mint structurally different keys (["cmd",tool,cmd]/["Bash",full]/["Acp",intent,grant_id]), so one policy pattern can only ever match one route and the others fail open.action_keycannot simply be changed — it is also the persisted key for Always/Full grants (Ask Bridge: key Always grants by a canonical exact action (cross-engine) #89), so reshaping it would invalidate stored grants. The fix is a separate normalized policy-matching identity, whose taxonomy needs agreeing first; ACP's per-requestgrant_idalso means no stable pattern can be written for it today.deny. Standing grants are checked first and a test codifies that. Whether an existing human grant should outrank a later policy tighten is a product call.setAuthorityPolicy/ revoke / history have no call site, so for a normal user the whole feature runs under the no-op default. Raised as P1 in review and declined here on purpose: a settings surface is a scope addition to an issue that specified the judgment engine, and should be designed rather than bolted on.auto_materializeis inert. Production only builds aLaneCandidateafter confirm, always withhuman_authorized: true, so the flag can never fire. It needs a proposal-time path or should come out of the schema until it has one.auto_decisionis synchronous on three hot paths, so persisting requires a new bounded-buffer + async-drain subsystem, and the ledger's(thread, direction, kind, source, source_ref)supersede semantics put a direction-lessdecisionrow right next to the lane adjudication reads. Nothing fails open or closed incorrectly — the gap is audit coverage, not behavior.plan_revisionis appended on batch confirm and on proposal saves, but not on individualapprove_direction/deny_direction/set_direction_base.direction.Also open, minor: the GUI/computer-use approval route does not consult the policy.
Verification
CI is green on all three checks —
lint-and-frontend,rust-test (ubuntu-latest)andrust-test (macos-latest), the last of which had failed on every prior run until the probe-budget fix above.Locally:
cargo test --lib— 2227 passed, 5 failed, none from this branch:checkpoint::mid_restore_failure_rolls_everything_back(runs as root, so a chmod-based "undeletable" file is still deletable) and twoproc_registryreaping tests (constrained process-group semantics);readinesstests those sameproc_registrytests take down with them —readiness::alone passes all 69,readiness:: proc_registry::reproduces exactly these two in 8s, and the failure is a probe child exiting on a signal (code -1, empty stderr) rather than on any deadline. CI runs the identical code green on both platforms.cargo test --test readiness— 49 passed, 0 failed (the binary macOS CI was failing on).pnpm build— clean.git diff --check— clean.No screenshot of the Gate card: this environment has no running Tauri surface to capture one from. Happy to add UI evidence before merge if you want it.
🤖 Generated with Claude Code