Skip to content

feat(authority): AuthorityPolicy — versioned scope + Lane/CLI permission judgment (#172) - #215

Closed
SoloJiang wants to merge 75 commits into
mainfrom
claude/issue-172-authority
Closed

feat(authority): AuthorityPolicy — versioned scope + Lane/CLI permission judgment (#172)#215
SoloJiang wants to merge 75 commits into
mainfrom
claude/issue-172-authority

Conversation

@SoloJiang

@SoloJiang SoloJiang commented Aug 10, 2026

Copy link
Copy Markdown
Owner

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

  • M0056: plan_revision (append-only scope history), authority_policy (append-only per scope, UNIQUE on (scope, scope_id, revision); revoke stamps revoked_at), lane_gate_decision (a human's Gate resolution).
  • authority.rs: adjudicate_lane is the only Lane judgment; bridge_decision is 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.
  • Write gate: every worktree write goes through authorize_materialize first, before any git side effect.
  • Permission Bridge: AskRegistry::auto_decision consults the same policy before falling through to the existing AskRegistry/human flow.
  • Gate card: lanes a rule flagged surface on the persistent board with the rule that flagged them and approve/deny.

The conservative default (no policy row) is a no-op on today's confirm-gated flow.

lane_state.rs — one state instead of six derivations

Twenty-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:

NotApplicable | Finished | Deactivated | OutOfScope | Denied | AwaitingGate
| BlockedUpstream | NeedsMaterialize | ReadyToStart | Running

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, and offers_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 human
  • released_by_gate / resolve_lane_gate — dispatch only from ReadyToStart
  • chat_open_worker_impl — admits ReadyToStart or Running, 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_lane and lane_is_in_current_scope are gone; the resolver subsumes them.

The state is judged fresh from judge_lane rather 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, and judge_lane records nothing.

Deactivated is deliberately distinct from Finished: 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_direction signalled refusal as Ok(Vec::new()), indistinguishable from "this lane binds no repo", and every call site branched on Err alone — it now returns a discriminated MaterializeOutcome each site maps exhaustively); post-hoc allowed_by_policy rows shadowing the needs_gate row that raised the card; the panel being mounted inside a dialog that unmounts on confirm; protected_branches skipped whenever base_branch was 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/main vs main); 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_policy documents that revoking "can only make a scope MORE conservative, never more permissive", but the fallback was default_policy, whose denied_repos and protected_branches are 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 to authority::revoked_policy and 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 inert default_policy, and collapsing those two is what caused the bug. repo::resolve_policy_snapshot is 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 readiness CI flake. GitSignatureProbe::sample started 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 hung git returns, .ok() turned it into None, and CheckFlight read "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=1 always 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 raised cfg(test) constants, which never applied to tests/readiness.rs at all — an integration binary links the lib compiled WITHOUT cfg(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

  1. action_key is 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_key cannot 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-request grant_id also means no stable pattern can be written for it today.
  2. Ordering of Dangerous mode / Full / Always against a policy 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.
  3. Revoke semantics. Resolved in code as described above — revoking now means "a human decides" rather than "back to defaults", which is what the doc always claimed. Flagged here because it is a semantic change worth an explicit yes.
  4. No production surface configures policies. 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.
  5. auto_materialize is inert. Production only builds a LaneCandidate after confirm, always with human_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.
  6. Bridge verdicts are not persisted to the evidence ledger, so policy-driven CLI allows/denies leave no durable audit row. Raised again as P2 and declined for this PR: auto_decision is 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-less decision row right next to the lane adjudication reads. Nothing fails open or closed incorrectly — the gap is audit coverage, not behavior.
  7. plan_revision is appended on batch confirm and on proposal saves, but not on individual approve_direction / deny_direction / set_direction_base.
  8. Lane provenance is inferred, not durable. Gate scoping decides "was this lane ever planner-owned?" by scanning plan history. The confirm-time snapshot is retried but cannot be transactional (the confirm CAS has already committed), so a lost snapshot can still misclassify a lane as standalone. The durable fix is a provenance column on direction.

Also open, minor: the GUI/computer-use approval route does not consult the policy.

Verification

CI is green on all three checkslint-and-frontend, rust-test (ubuntu-latest) and rust-test (macos-latest), the last of which had failed on every prior run until the probe-budget fix above.

Locally:

  • cargo test --lib2227 passed, 5 failed, none from this branch:
    • three are environmental and fail identically on the base commit in this container: checkpoint::mid_restore_failure_rolls_everything_back (runs as root, so a chmod-based "undeletable" file is still deletable) and two proc_registry reaping tests (constrained process-group semantics);
    • the other two are readiness tests those same proc_registry tests 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

claude added 11 commits August 8, 2026 16:56
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.

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 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".

Comment thread src/board/LaneGatePanel.tsx Outdated
Comment on lines +93 to +95
useEffect(() => {
reload();
}, [reload]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge 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 👍 / 👎.

Comment thread src-tauri/src/lead_chat/engine.rs Outdated
Comment on lines +6742 to +6745
// 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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge 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 👍 / 👎.

Comment thread src-tauri/src/commands.rs Outdated
Comment on lines +3254 to +3256
let current = current_gate_policy_revision(&db, direction_id).await.map_err(e)?;
if policy_revision != current {
return Err("gate_policy_changed".to_string());

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge 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 👍 / 👎.

Comment thread src/board/LaneGatePanel.tsx Outdated
Comment on lines +100 to +106
await api.resolveLaneGate(gate.direction_id, gate.policy_revision, decision);
setActionState((prev) => {
const next = { ...prev };
delete next[gate.direction_id];
return next;
});
reload();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge 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 👍 / 👎.

Comment thread src-tauri/src/materialize.rs Outdated
Comment on lines +292 to +295
// 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]));

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge 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 👍 / 👎.

Comment thread src-tauri/src/lib.rs Outdated
Comment on lines +278 to +280
tauri::async_runtime::spawn(async move {
commands::seed_authority_bridge(&bridge_db, &bridge_asks).await;
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge 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 👍 / 👎.

Comment on lines +471 to 479
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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge 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 👍 / 👎.

Comment thread src-tauri/src/commands.rs Outdated
Comment on lines +3172 to +3177
let has_worktree = crate::store::repo::worktree_for(&db, dir.id, dir.repo_id)
.await
.map_err(e)?
.is_some();
if has_worktree {
continue;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge 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 👍 / 👎.

Comment thread src-tauri/src/commands.rs Outdated
Comment on lines +3267 to +3269
let outcome = crate::materialize::materialize_direction(&db, direction_id)
.await
.map_err(e)?;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge 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 👍 / 👎.

Comment thread src-tauri/src/planner.rs
Comment on lines +1719 to +1725
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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge 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 👍 / 👎.

claude added 4 commits August 10, 2026 01:04
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.

Copy link
Copy Markdown
Owner Author

All ten review findings are addressed across 82bf0f0..231ea41. Eight were straightforwardly right; two I've handled with a narrower fix than suggested and want to be explicit about why.

Fixed as reported

  • Register Codex approvals with their workspace — correct, I had only patched the ACP site. The Codex app-server route registers now too, so a thread that never emitted an ACP or hook ask no longer defers both allow_actions and deny_actions.
  • Seed authority policies before serving asks — correct, the bus starts serving before the builder runs and revive re-drives from inside setup. Seeding moved into the same pre-serve block as auth_persist::seed, synchronous.
  • Re-adjudicate stale gate cards before reloading — this was a deadlock I introduced: the card reported the old evidence row's revision, resolve_lane_gate rejected it, and reloading returned the same dead revision forever. list_lane_gates now recomputes a stale lane's verdict at the current policy (new read-only materialize::readjudicate_lane), so the human sees the rule in force and gets a revision the command accepts — and the lane may turn out to be allowed or denied outright, which the fresh verdict reflects.
  • Dispatch the worker after approving a gate — also mine: confirm excluded the lane from dispatch_ids, and gaining a worktree drops it from list_lane_gates, so it vanished and idled. The panel dispatches on a successful approval.
  • Refresh gates after proposal confirmation — right, threadId doesn't change on same-thread confirm. The panel now also keys on the thread's lane set and the proposal version.
  • Treat reclaimed worktree rows as gate candidates — right, the recreate path keeps the stale row. The check is now "checkout exists on disk", matching the frontend's dispatch filter.
  • Preserve the gate when materialization fails — right, the approval is durable before the git work, so a failed worktree add left the lane approved, unmaterialized and invisible. The approval is rolled back and the lane re-adjudicated, so the card returns.
  • Remove dependents from the dispatch set when gated — right, and the transitive case too. dispatch_ids is filtered through the proposal's resolved depends_on_index graph walked to a fixpoint, so A→B→C blocks C when A is gated. Unresolved upstreams (None) are deliberately left alone: upstream_merge_state already answers Unknown (blocking) for those, so adding them here would duplicate a stop that already exists.

Handled differently

  • Authorize existing worktrees before reuse — I did not adjudicate the whole fast path, because tearing down or refusing a checkout a worker may be mid-flight in is not what the issue's "已 materialize 后…停止新的写入" boundary asks for; the existing tree is still never touched. But your two concrete points stand and are fixed: that return is the confirmed fast path's re-dispatch answer, and bootstrap_worktree_deps writes into the checkout. Both now happen only after a fresh verdict, so a newly gated or denied lane stops there and returns no worktrees — nothing re-dispatches, nothing installs, nothing is deleted.
  • Serialize policy updates with materialization — real TOCTOU, and I've taken your alternative rather than the lock: the active revision is re-read immediately before the first write and a change bails. That narrows the window to that read instead of spanning the whole git operation, but it does not close it — a policy landing inside the last gap still wins. A shared per-workspace gate across policy mutation and authorization-plus-write is the complete fix and is genuinely larger than this PR should carry; I'd rather land it deliberately than bolt a partial lock on here. Happy to open it as a follow-up if you'd like it tracked.

CI also went red on the first push for a reason worth naming: I had verified with cargo test --lib, which does not build src-tauri/tests, so three integration suites still treated materialize_direction's result as a Vec. Fixed in 82bf0f0, and I've been checking with cargo check --tests since.


Generated by Claude Code

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 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".

Comment thread src/board/LaneGatePanel.tsx Outdated
Comment on lines +122 to +123
if (decision === "approved" && worktrees.length > 0) {
void dispatchDirection(gate.direction_id);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge 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 👍 / 👎.

Comment thread src-tauri/src/commands.rs Outdated
Comment on lines +3201 to +3203
if latest_decision.policy_revision != current_revision {
let refreshed = crate::materialize::readjudicate_lane(&db, dir.id).await.map_err(e)?;
if refreshed.is_none() {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge 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 👍 / 👎.

Comment thread src-tauri/src/store/repo.rs Outdated
Comment on lines +2421 to +2424
let next_revision = previous
.and_then(|p| p.revision.parse::<i64>().ok())
.unwrap_or(0)
+ 1;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge 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 👍 / 👎.

Comment thread src-tauri/src/commands.rs Outdated
Comment on lines +3283 to +3287
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(

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge 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 👍 / 👎.

Comment thread src-tauri/src/planner.rs Outdated
Comment on lines +1796 to +1797
if let Err(error) =
repo::insert_plan_revision(db, thread_id, &start_plan.created_at, &new_json, "user").await

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge 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 👍 / 👎.

Comment on lines +2479 to +2481
let mut a: authority_policy::ActiveModel = active.into();
a.revoked_at = Set(now());
a.update(&db.0).await?;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge 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 👍 / 👎.

Comment on lines +238 to +240
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"),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge 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 👍 / 👎.

Comment thread src/lib/api.ts
Comment on lines +302 to +305
setAuthorityPolicy: (workspaceId: number, rules: AuthorityPolicyRules) =>
invoke<AuthorityPolicyRevision>("set_authority_policy", { workspaceId, rules }),
revokeAuthorityPolicy: (workspaceId: number) =>
invoke<void>("revoke_authority_policy", { workspaceId }),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge 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.

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 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".

Comment thread src-tauri/src/ask.rs
Comment on lines 2204 to 2205
if let Some(decision) = self.auto_decision_exact(thread, dir, action_key) {
return Some(decision);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge 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 👍 / 👎.

Comment thread src-tauri/src/commands.rs Outdated
Comment on lines +3424 to +3427
if !lane_is_runnable(db, downstream).await? {
continue;
}
released.push(downstream);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge 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 👍 / 👎.

Comment on lines +204 to +210
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,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge 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 👍 / 👎.

Comment thread src-tauri/src/ask.rs
Comment on lines +2299 to +2302
let (bridge, _verdict) = crate::authority::bridge_decision(
&snapshot,
&crate::authority::PermissionAction { action_key },
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge 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.

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 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".

Comment thread src-tauri/src/commands.rs Outdated
Comment on lines +3221 to +3222
if live_worktree {
continue;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge 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 👍 / 👎.

Comment thread src-tauri/src/planner.rs Outdated
&upstream_lanes_from_resolved(&resolved, &fastpath_proposal),
)
.await;
matching.retain(|id| !refused_fastpath.contains(id));

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge 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 👍 / 👎.

Comment thread src-tauri/src/authority.rs Outdated
Comment on lines +382 to +383
let protected_hit =
!lane.base_branch.is_empty() && matches_name(&policy.rules.protected_branches, lane.base_branch);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge 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 👍 / 👎.

Comment thread src-tauri/src/store/repo.rs Outdated
Comment on lines +2633 to +2634
.iter()
.any(|w| std::path::Path::new(&w.path).exists()))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge 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 👍 / 👎.

Comment on lines +217 to +222
let payload = serde_json::json!({
"decision": verdict.decision,
"reason": verdict.reason,
"hit_rule": verdict.hit_rule,
})
.to_string();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge 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.

Copy link
Copy Markdown
Owner Author

Rounds 2–4 are addressed through bba6bd3. Everything that was a correctness bug is fixed; what's left is six items that are product or scope decisions, and I've stopped rather than pick for you.

Fixed since 231ea41

Round 2 (aea5160): the PreToolUse builtin allowlist returned allow before the policy was consulted, so a deny_actions rule naming Read/Grep/Glob bound on ACP and Codex but not the hook — the workspace is registered and an explicit DENY checked before that branch, with only Deny decisive so nothing widens. Revision allocation was a read-max/insert that could mint the same revision twice; there's now a UNIQUE (scope, scope_id, revision) index and a retrying allocator. Approving a Gate started only that lane while the transitive dependent set stayed idle — resolve_lane_gate returns the set the clearance actually released. A lane re-adjudicated to allowed_by_policy with no worktree used to vanish; it's surfaced with its own reason and a "set up and start" action. Gate resolution is single-flight per direction.

Round 3 (3fd64b5): released dependents are re-adjudicated at the current policy before being returned, so a tighten during a pending Gate can't release a lane the workspace now denies.

Round 4 (bba6bd3): protected_branches compared exact strings, so origin/main or refs/heads/main branched from protected main with no Gate — both sides normalize to the bare name now, with a test over the spellings. The reuse fast-path can gate a lane that already has a checkout, but list_lane_gates skipped every such lane, leaving it paused with no surface; visibility is now decided by the verdict. Dependent release tested path existence, which a plain directory also passes — it uses the registered-worktree predicate materialization itself uses. The confirmed-plan retry filtered only refused lanes, so a second confirm dropped gated A and still dispatched B; it applies the same transitive filter. And the authority evidence write bypassed the ledger's redact/bound contract, so a credential-shaped or oversized configured value landed verbatim in a backed-up row.

Six open items — your call

Two are security semantics I don't think an agent should decide unilaterally:

  1. Should a policy deny_actions outrank Dangerous mode / Full access / Always-allow? Today the standing grants win (a test codifies it), which is what the PR body flagged as an open product call. Fail-closed says the workspace policy wins; but flipping it silently breaks someone's existing dangerous-mode setup.
  2. What should revoking a policy mean? Today revoke removes the row and the permissive default applies. Codex wants a restrictive tombstone. I lean toward the current behavior being right — "revoke" reading as "keep enforcing" is surprising — but the doc comment claims fail-closed, so at minimum one of the two is wrong and I'd rather fix the one you intend.

Four are scope:

  1. No production UI configures policies. setAuthorityPolicy/revoke/history have no call site, so for a normal user the whole feature runs under the no-op default. Whether [Roadmap][R1-02] 版本化动态 scope 与 AuthorityPolicy 判定 #172 was meant to include a settings surface is the question — it's a real chunk of work, not a patch.
  2. auto_materialize is inert. Production only builds a LaneCandidate after confirm, always with human_authorized: true, so the flag can never fire. Either it needs a proposal-time path or it should come out of the schema until it has one.
  3. Bridge verdicts aren't persisted to the evidence ledger (already listed as a known gap) — policy-driven CLI allows/denies leave no durable audit row.
  4. plan_revision is only appended on batch confirm, not on individual approve/deny/set-base.

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

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 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".

Comment thread src-tauri/src/commands.rs Outdated
Comment on lines +3425 to +3427
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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge 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 👍 / 👎.

Comment thread src-tauri/src/materialize.rs Outdated
Comment on lines +248 to +252
{
eprintln!(
"[weft][evidence] materialize decision evidence for direction {}: {error}",
dir.id
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge 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");

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge 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.

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 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".

Comment thread src-tauri/src/commands.rs Outdated
Comment on lines +3269 to +3270
let stranded = verdict == "allowed_by_policy" && !checkout_ok;
if verdict != "needs_gate" && !stranded {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge 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 👍 / 👎.

Comment thread src-tauri/src/commands.rs Outdated
Comment on lines +3107 to +3113
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),
)),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge 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 👍 / 👎.

Comment on lines +124 to +128
setActionState((prev) => {
const next = { ...prev };
delete next[gate.direction_id];
return next;
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge 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 👍 / 👎.

Comment thread src-tauri/src/commands.rs
Comment on lines +3207 to +3209
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();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge 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.

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 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".

Comment thread src-tauri/src/ask.rs Outdated
Comment on lines +2274 to +2276
None => {
g.remove(&workspace_id);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge 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 👍 / 👎.

Comment thread src-tauri/src/commands.rs Outdated
Comment on lines +3230 to +3232
if !in_scope.is_empty() && !in_scope.contains(&dir.id) {
continue;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge 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 👍 / 👎.

Comment thread src-tauri/src/commands.rs Outdated
Comment on lines +3244 to +3246
let evidence = crate::store::repo::list_evidence(&db, thread_id, Some(dir.id), 20)
.await
.map_err(e)?;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge 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

Copy link
Copy Markdown
Owner Author

Round 45 (6fb1db6). Two fixed; the third is real but I want a decision on it rather than a unilateral fix, because the obvious remedy has a prerequisite.

P2 LaneGatePanel.tsx:232 — recovery dispatch no-ops on an exited session — fixed

Correct, and the root of it was a duplicated helper. 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 ever calling chatOpenWorker. Recovery dispatch is precisely 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 the old behaviour — a dead session should never stand in for a live one — so rather than repair the copy I deleted it and pointed its single call site at driveDirection. That in turn made dispatchDirection and reviveDirection byte-identical, so they now share one implementation and keep their distinct names for the call sites' sake.

P2 commands.rs:3631 — denial evidence write failure — fixed, at the other end

Real, and I took the second of your two suggestions ("derive readiness from the durable Gate decision") because the first would trade one inconsistency for a worse one: the denial is already durable when that write fails, so failing the command would tell a human their veto did not take when it did.

The underlying problem is two sources of truth. lane_gate_decision holds the human's act, and get_gate_decision already treats a veto as unconditional — a statement about the lane, which no policy revision expires. The evidence ledger is a derived record written after the fact. Readiness consulted only the derived one, so a transient failure left lane_state saying Denied (hence no card) while readiness still read 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 for the whole thread. That settles it in the direction the design already chose everywhere else — the human's act outranks any record of it — and closes the class rather than this one path: the same split opened whenever a denial's evidence row was superseded by a later revision.

P2 readiness.rs:946 — superseded verdicts become unactionable Gates — real; needs a decision

I agree with the diagnosis and I do not want to paper over it. Confirmed by reading the paths: create_authority_policy mints a new revision on every write, and readjudicate_lane is called only from resolve_lane_gate_impl — nothing re-adjudicates on policy mutation. So editing a policy at all, even in a way that leaves a lane allowed, supersedes every materialized lane's evidence; those lanes report NeedsGate; and lane_state offers a card only for AwaitingGate | NeedsMaterialize | ReadyToStart, so a running or finished lane gets none. NeedsYou/PolicyGatePending with nothing to click.

This is a consequence of the "a superseded verdict must gate" rule I added. Gating is the right answer for a lane that can still act; it is the wrong answer for one that has already acted, because there is no decision left to offer.

The fix is re-adjudication, and the question is where:

  1. On policy mutation — sweep the scope's materialized lanes and re-judge each. Architecturally right: the event that invalidates the verdicts is the one that refreshes them, and the cost is paid once, at a moment the user initiated.
  2. On read, inside collect — also correct, but it puts database writes and possible network calls into readiness, which runs constantly.

I would take (1). But it has a prerequisite I already logged separately on this PR: judge_lane's remote probes are unbounded, and a git ls-remote per lane can hang the Gate panel for minutes. A workspace-wide sweep would multiply exactly that. So the honest sequencing is bound the probes first, then add the mutation-time sweep — a meaningful chunk of work, not a patch.

Given this PR is at 45 review rounds and ~150 threads, my recommendation is to land that pair as a follow-up rather than grow this change further. If you would rather it be in here, say so and I will do the probes first and the sweep second. What I do not want to do is add a re-adjudication sweep on top of unbounded probes and call it fixed.

Verification

cargo test --lib — 2252 passed. The same three failures are environmental to my sandbox (it runs as root, so the chmod 0o555 checkpoint test cannot make a file undeletable, and the two proc_registry reap tests depend on container process-group semantics); all three pass in CI. cargo test --test readiness 50, --test ask_builtin_allow 10, --test worktree_delete 1 — all passed. pnpm build and pnpm test (189) clean, git diff --check clean.

New test: a_durable_veto_answers_for_its_lane_even_with_no_evidence_of_it, which also asserts an un-vetoed sibling still reads from its own evidence, so the overlay cannot become a blanket.


Generated by Claude Code

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 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".

Comment thread src-tauri/src/authority.rs Outdated
Comment on lines +384 to +385
let len = value.len();
(7..=40).contains(&len) && value.chars().all(|c| c.is_ascii_hexdigit())

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge 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 👍 / 👎.

claude added 2 commits August 10, 2026 21:30
`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

Copy link
Copy Markdown
Owner Author

Round 46 (8fddd9d), plus the macOS CI failure on 6fb1db6 — which turned out to be a real product bug, and I believe the actual root of the recurring flake.

P1 authority.rs:385 — SHA-256 object ids accepted as bases — fixed

Correct, and the evidence you cite is decisive: this repository supports SHA-256 explicitly (git::is_full_commit_oid is documented hash-agnostic, materialize discriminates on it), so a 64-hex object id names a protected branch's commit exactly as well as a 40-hex one — resolves to the same commit, matches no protected_branches NAME, materializes with no Gate. The 7..=40 bound was the SHA-1 ceiling, not a considered limit.

The bound now spans 7..=64. It has to cover both formats rather than detect one, because this check cannot ask which the repository uses — adjudicate_lane is pure by design and reaches neither git nor the store. Coverage includes the full 64-hex id, the 41–63 window that only exists for SHA-256 and that the old bound let through, and 65 hex digits still passing as a name since git mints nothing that long.

macOS CI on 6fb1db6: bounded_check_evidence_keeps_observed_failures_sticky

I went looking for why a Passed assertion on two exit 0 checks read Failing, and the answer was not flakiness in the test.

run_bounded_check reported a spawn() failure as Completed{status:"fail"}. So when weft's own exec fails — a missing binary, a permission, or fork/posix_spawn returning EAGAIN because the machine is out of process slots — it told the user their tests were red. Under parallel test load a trivially-passing exit 0 check intermittently loses its spawn and lands there, which is why this has been naming a different readiness test on macOS almost every run: which check loses the spawn is whichever one runs when the runner is tightest.

That is a production bug independent of CI. NotProduced is the arm that already means "no verdict was obtained" — the same answer a timeout gets — and it does not fail open, since readiness reads it as not-ready. Every neighbouring early return in that function already goes there; this one was the exception.

Worth being explicit that this is the complement of the crash case you had me revert in round 41, not a reversal of it. A child that ran and died on a signal is a verdict about the user's code and stays fail. A child that never existed cannot be one. The line is whether a process ran, and both fixes put it in the same place.

I have left check::run_check's equivalent arm alone: CheckResult has no way to express "not produced", and that path is already dead code in this build (run_checks is unused), so changing it would be churn against its own codified test for no behavioural gain.

Verification

cargo test --lib — 2252 passed. Beyond the three sandbox-environmental failures I have flagged before (this container runs as root, so the chmod 0o555 checkpoint test cannot make a file undeletable; two proc_registry reap tests depend on container process-group semantics), one run also showed check_flight_invalidates_passing_evidence_for_uncommitted_worktree_changes, which passes in isolation — sandbox load, recorded rather than dropped.

cargo test --test readiness 50, --test ask_builtin_allow 10, --test worktree_delete 1, --test m2_worktree 1 — all passed. pnpm build clean, git diff --check clean.

New test: a_check_that_could_not_be_started_is_not_produced_rather_than_failing, which states the distinction against the crash case in its own doc so the two cannot be collapsed again.

The readiness.rs:946 question from round 45 — re-adjudicating superseded verdicts, and its prerequisite of bounding judge_lane's remote probes — is still open and still wants a decision rather than a patch.


Generated by Claude Code

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 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".

Comment thread src-tauri/src/commands.rs
Comment on lines +3538 to +3540
let state =
crate::lane_state::lane_authority_state(db, direction_id).await.map_err(e)?;
if !state.offers_decision() {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge 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 {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge 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 👍 / 👎.

claude added 2 commits August 10, 2026 22:15
"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

Copy link
Copy Markdown
Owner Author

Round 47 (de594a1). Both real, both fixed.

P1 commands.rs:3540 — a Gate card is not bound to which card it is — fixed

Correct, and the framing that made it click: the policy revision was never 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 nothing caught it, 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 it authorizes — resolved through materialize::effective_base_branch, so a blank column compares as the repository default rather than as "". I kept the base in there rather than relying on the slug alone, for the narrower case you allude to: a protected_branch card whose base moves to a different protected branch keeps both its slug and its revision.

Two things I deliberately did not do. The token is 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. And the comparison runs after offers_decision, under the thread gate: a terminal lane fails both checks and gate_not_actionable:done is the answer worth giving (the token mismatch is true but says only "reload", hiding that the lane is over), while comparing outside the gate would make it a TOCTOU read against a concurrent re-proposal.

Test: a_gate_card_is_refused_once_its_effective_base_moves. It asserts the premise explicitly — the policy revision does not move when the repository default does — so the test cannot silently start passing for the wrong reason. Checked against revision-only comparison first: it fails there, so it is not tautological.

Worth noting a wrinkle I hit building it, since it narrows the exposure: a lane that has already materialized has its resolved base written back into its row, so its base cannot move afterwards. The reachable case is an unmaterialized lane — NeedsMaterialize, blank base — which is what the test uses.

P2 repo.rs:2580 — revoke bypasses the ordering guard — fixed

Also correct. resolve_policy_snapshot already refuses to rank what cannot be ordered; the mutation path was the one place still calling get_active_authority_policy directly, and that ranking treats an unparseable revision as zero. So a malformed newest row loses to an older numeric one, the revoke stamps the wrong row, and it reports success — while the scope stays exactly as it was, still unreadable, still gating every lane, with the human told their revoke landed.

It now refuses. A revoke that cannot identify its target has not happened, and saying so is what lets it be fixed. The test also asserts the orderable row was left un-stamped, so "refused" cannot quietly mean "refused after doing half of it".

Verification

cargo test --lib — 2255 passed, with only the three sandbox-environmental failures I have flagged throughout (this container runs as root, so the chmod 0o555 checkpoint test cannot make a file undeletable; two proc_registry reap tests depend on container process-group semantics). cargo test --test readiness 50, --test ask_builtin_allow 10, --test worktree_delete 1, --test m2_worktree 1 — all passed. pnpm build clean, git diff --check clean.

Still open from round 45 and still wanting a decision rather than a patch: readiness.rs:946, re-adjudicating superseded verdicts, and its prerequisite of bounding judge_lane's remote probes.


Generated by Claude Code

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 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".

Comment thread src-tauri/src/authority.rs Outdated
}

fn matches_name(list: &[String], name: &str) -> bool {
list.iter().any(|entry| entry.eq_ignore_ascii_case(name))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge 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

Copy link
Copy Markdown
Owner Author

Round 48 (3ec73aa). Real, and fixed. Also: CI came back fully green on de594a1, macOS included — the second green run since the spawn-failure fix, which is the first time this branch has strung two together.

P1 authority.rs:332 — an allowlist authorizing a repo it never named — fixed

Confirmed by reading all three pieces rather than taking the report on faith:

  • planner::resolve binds a lane to a workspace repo with *n == dir.repo — exact.
  • matches_name compared with eq_ignore_ascii_case — folded.
  • add_repo_ref deduplicates by local path or normalized remote URL and never by name, so api and API at different paths are two genuinely distinct repositories with distinct ids and distinct slugs.

So allowed_repos: ["api"] authorized a lane the planner had bound to API. A scope rule meant to confine work to one repository silently covered work in the other.

On the suggested fix: I did not key on repo id or slug. The rules are user-authored names, so switching identities would change the documented config format and ask people to know ids — a schema change to close a matching bug. Aligning the matcher with the resolver closes it without that.

But I did not make both lists exact either, because that would have traded the hole for a different one. The two lists fail closed in opposite directions:

  • allowlist_names_repo is now 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.

The asymmetry is the point rather than an inconsistency, and it is documented as such on both functions. Same reasoning leaves matches_branch untouched: protected_branches only ever refuses, so its generous matching is already the safe direction.

Test: repo_scope_matches_exactly_to_allow_and_generously_to_deny — covers both directions, including that the repository the allowlist did name is still allowed, so the fix cannot have simply broken allowlisting. Checked against the folding matcher first; it fails there.

Verification

cargo test --lib — 2256 passed, with only the three sandbox-environmental failures flagged throughout (this container runs as root, so the chmod 0o555 checkpoint test cannot make a file undeletable; two proc_registry reap tests depend on container process-group semantics). cargo test --test readiness 50, --test ask_builtin_allow 10, --test worktree_delete 1, --test m2_worktree 1 — all passed. git diff --check clean.

Still open and still wanting a decision rather than a patch: readiness.rs:946, re-adjudicating superseded verdicts, and its prerequisite of bounding judge_lane's remote probes.


Generated by Claude Code

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 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".

Comment thread src-tauri/src/materialize.rs Outdated
Comment on lines +180 to +181
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))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge 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 👍 / 👎.

Comment on lines +2477 to +2480
let mut next_revision = previous
.and_then(|p| p.revision.parse::<i64>().ok())
.unwrap_or(0)
+ 1;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge 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 👍 / 👎.

Comment thread src-tauri/src/commands.rs
Comment on lines +3869 to +3871
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));

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge 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 👍 / 👎.

claude added 2 commits August 10, 2026 23:16
`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

Copy link
Copy Markdown
Owner Author

Round 49 (701e704). All three real, all fixed — and the first one taught me something worth writing down.

P1 materialize.rs:181 — a base with no branch name clears every by-name rule — fixed

Confirmed by tracing the value rather than the code shape: base from effective_base_branch is passed to both authorize_materialize and add_worktree_synced. The default chain's tier-4 last resort is the literal string "main" whether or not the repository has one; add_worktree_synced tries that name, misses, and falls through its own chain to HEAD. So the lane is judged as main, matches no protected_branches rule, and is then created from whatever HEAD points at.

My first fix was wrong and a test caught it. I made effective_base_branch report "HEAD" for the unnameable case and let the existing InvalidBase rule deny it. That broke materialize_recreates_detached_head_lane_from_stored_target_commit — a detached repository with no named default is a configuration weft deliberately supports, and denying it outright is a regression, not a hardening.

The rule that survives is narrower and, I think, the actually-correct one: an unnameable base only matters when a by-name rule exists. LaneCandidate now carries base_is_named, computed through the same resolver worktree creation uses, and adjudication gates when it is false and protected_branches is non-empty. Where no such rule is configured there is nothing to dodge, so the supported detached flow is untouched.

Gate rather than deny, for the same reason UnreadablePolicy gates: "nobody can say what this would branch from" is a question for a human, not a refusal. New VerdictReason::UnresolvableBase with copy in both locales.

Test an_unnameable_base_gates_only_where_branches_are_protected pins all three arms — gated under a protecting policy, allowed under an inert one, and a real branch still going through the ordinary name comparison — so neither half can regress without failing.

P2 repo.rs:2480 — allocation bypasses the ordering guard — fixed

Right, and it is the third path of the same kind: read (resolve_policy_snapshot) and revoke were fixed in earlier rounds, and the setter was still reading an unorderable revision as zero, restarting allocation at 1 and climbing to the next free number. set_authority_policy would report success and seed the CLI bridge with that row while the scope still resolved as unreadable — lane enforcement and permission asks running under two different policies, which is worse than either alone. It now refuses, and the test asserts no row was written on the way out.

P2 commands.rs:3871 — discarded cleanup failure — fixed

Also right. The command reported only lane_denied_by_policy, so the frontend's failure path reloaded the thread and showed the row it claimed to have torn down — durably Denied, hence no worktree, no worker, no Gate action, and another dead row per retry. The error now says the cleanup is what failed, since that is the part worth retrying.

Verification

cargo test --lib — 2259 passed, with only the three sandbox-environmental failures flagged throughout (root user, and two proc_registry reap tests that depend on container process-group semantics). Integration: readiness 50, ask_builtin_allow 10, worktree_delete 1, m2_worktree 1, m2_git 1 — all passed. pnpm build clean, pnpm test 189 passed, git diff --check clean.

Still open, still wanting a decision: readiness.rs:946 — re-adjudicating superseded verdicts, and its prerequisite of bounding judge_lane's remote probes.


Generated by Claude Code

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 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".

Comment thread src-tauri/src/authority.rs Outdated
Comment on lines +388 to +389
if let Some(rest) = name.strip_prefix("refs/heads/") {
return rest;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge 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 👍 / 👎.

Comment thread src-tauri/src/planner.rs Outdated
// human has a Gate to resolve, and neither is dispatched.
match &outcome {
materialize::MaterializeOutcome::Denied(verdict) => {
let _ = repo::delete_direction(db, dir.id).await;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge 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 👍 / 👎.

Comment on lines +51 to +52
default:
return "awaiting_gate_decision";

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge 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 👍 / 👎.

Comment thread src-tauri/src/store/repo.rs Outdated
Comment on lines +9324 to +9326
if carries_authority_revision
&& !row.policy_revision.is_empty()
&& row.policy_revision != active

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge 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 👍 / 👎.

claude added 2 commits August 10, 2026 23:45
`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

Copy link
Copy Markdown
Owner Author

Round 50 (cfa8973). All four real, all fixed. One of them was my own omission from last round.

P1 authority.rs:389 — composite branch prefixes — fixed

Correct, and the general lesson is sharper than the instance: any disagreement between what protection matching collapses and what the resolver collapses is a bypass. bare_branch_name stripped one prefix per call, so refs/heads/origin/main compared as origin/main and matched no protected_branches: ["main"] rule — while normalize_target strips both in sequence and resolve_base_ref then selects refs/heads/main.

So rather than add the one composite you found, it now strips to a fixpoint. Enumerating the spellings git accepts is how this class of bug keeps coming back. Over-collapsing is the safe direction here because matches_branch only ever refuses: a spelling that folds into a protected name gates rather than slips through.

Test covers plain / origin/ / refs/heads/ / the composite, the composite written in the policy rather than the lane, and an unrelated branch still not protected — so it cannot pass by matching everything. Checked against the single-strip version first; it fails there on exactly refs/heads/origin/main.

One thing your report and I differ on: refs/remotes/origin/main never reaches the branch comparison, because looks_like_valid_ref refuses that whole namespace first. That is a stronger answer than gating, so I asserted it separately rather than folding it into the protected-branch expectation.

P2 repo.rs:9326 — empty policy_revision on an authority row — fixed

Right. authorize_materialize always stamps the verdict's revision and a verdict always has one ("0" for the default, "N:revoked" for a revoked scope), so an empty value means the row cannot be placed in the revision order at all — which is precisely what superseded means. The is_empty() exemption I added in round 41 was scoped to non-authority rows in intent but not in effect. Dropping it makes the existing mismatch produce the right answer for free.

P2 planner.rs:1751 — discarded planner cleanup — fixed

The sibling of last round's create_direction fix, and worse here for the reason you give: the row is never recorded on the proposal, so a survivor is absent from planner scope history and reads as standalone — durably Denied, hence no worktree, no worker, no Gate action.

P2 LaneGatePanel.tsx:52 — unreachable copy — fixed, and mine

I added unresolvable_base to both locale files last round and never added it to the reason union, so the card fell through to awaiting_gate_decision and told the user no policy covered the task — while the accurate string sat unused two files away. Exactly the drift the discriminated-union pattern exists to prevent, and I introduced it. Now in the union and the exhaustive switch.

Verification

cargo test --lib — 2260 passed. Beyond the three sandbox-environmental failures flagged throughout (root user; two proc_registry reap tests dependent on container process-group semantics), one run also showed check_flight_invalidates_passing_evidence_for_uncommitted_worktree_changes, which passes in isolation — sandbox load, recorded rather than dropped.

Integration: readiness 50, ask_builtin_allow 10, worktree_delete 1, m2_worktree 1, m2_git 1 — all passed, exit 0. pnpm build clean, pnpm test 189 passed, git diff --check clean.

Still open, still wanting a decision rather than a patch: readiness.rs:946 — re-adjudicating superseded verdicts, and its prerequisite of bounding judge_lane's remote probes.


Generated by Claude Code

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 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".

Comment thread src-tauri/src/authority.rs Outdated
Comment on lines +358 to +359
fn allowlist_names_repo(list: &[String], name: &str) -> bool {
list.iter().any(|entry| entry == name)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge 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

Copy link
Copy Markdown
Owner Author

Round 51 (e2758fc). You are right, and this overturns the call I made in round 48 — worth saying plainly rather than folding it into a fix note.

P1 authority.rs:359 — allowlist keyed on a non-unique display name — fixed

In round 48 I declined the "key on slug or repo id" suggestion, arguing that aligning the matcher with planner::resolve's exact comparison closed the hole without changing the config format. That reasoning only covered the variant in front of me. The evidence you add here is the part I did not check: add_repo_ref deduplicates on local path or remote URL and never on name, so exact duplicate display names are possible, not merely case variants — and an exact match on api still covers both api repositories. Only slug is unique, by construction via slug::unique_slug.

So the identity argument was right the first time; I under-verified it.

What I did keep from that reasoning is the objection to a bare format change. Requiring slugs everywhere would make people look up a derived value to write any rule at all. Instead:

  • the slug always grants — it is unique, so it can only ever land on one repository;
  • the display name grants only when no other repository in the workspace shares it.

An unambiguous name still works, so the common case is untouched; a duplicate pair stays configurable through api / api-2 rather than becoming unusable. LaneCandidate carries repo_slug and repo_name_is_ambiguous, established by the caller exactly as base_is_named is, since adjudication is pure and cannot count rows.

The denylist takes both identities, either casing, ambiguous or not. That asymmetry is unchanged and still deliberate: each list matches in whichever direction fails closed.

Test extends repo_scope_matches_exactly_to_allow_and_generously_to_deny with the shared-name case — no grant on the ambiguous name, a grant on the slug, and an unambiguous name still granting. Checked against name-only matching first; it fails there on exactly the shared-name assertion.

Verification

cargo test --lib — 2261 passed, with only the three sandbox-environmental failures flagged throughout (root user; two proc_registry reap tests dependent on container process-group semantics). Integration: readiness 50, ask_builtin_allow 10, worktree_delete 1, m2_worktree 1, m2_git 1 — all passed, exit 0. pnpm build clean, git diff --check clean.

CI was fully green on cfa8973, macOS included.

Still open, still wanting a decision rather than a patch: readiness.rs:946 — re-adjudicating superseded verdicts, and its prerequisite of bounding judge_lane's remote probes.


Generated by Claude Code

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 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".

Comment thread src-tauri/src/commands.rs
Comment on lines +3407 to +3410
let token = gate_card_token(
&verdict.policy_revision,
&reason,
&effective_gate_base(db, &dir).await,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge 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 👍 / 👎.

Comment thread src-tauri/src/commands.rs
Comment on lines +3045 to +3047
if let Some(active) =
crate::store::repo::get_active_authority_policy(&db, "workspace", workspace_id)
.await

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge 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 👍 / 👎.

Comment on lines +533 to +534
if base_branch.starts_with("refs/") && !base_branch.starts_with("refs/heads/") {
return false;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Owner Author

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.

Why

The door this locks is already locked. Production only builds a LaneCandidate after confirm, always with human_authorized: true. And confirm is not a rubber stamp: ScopeReview puts every lane's repo and base branch in front of the person, with the base as an editable field. So a human has already looked at "this lane, this repo, off this branch" and approved it.

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 auto_materialize, and it can never fire: the only production path to adjudication runs after confirm, so human_authorized is always true.

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 say

Roughly forty of the findings were not independent bugs. They were four defect classes reappearing at new sites:

  • the thing judged ≠ the thing used — SHA-256 object ids, composite refs/heads/origin/main, tags posing as branches, an unnameable default base, a Gate card not bound to the base it authorized;
  • an unknown spent as a decision — a spawn failure, a signal, an unreadable verdict, an empty policy_revision, an unorderable revision at three separate entry points;
  • identity vs display name — repo case-folding, then genuinely duplicate names;
  • a derived copy outranking its source — the evidence ledger over a human's durable veto, a session map keyed wrongly, a duplicated dispatch helper.

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 run_bounded_check reporting a spawn() failure as the user's tests failing. That is also the root cause of the macOS CI flake that named a different test almost every run: under load posix_spawn returns EAGAIN, a trivially-passing exit 0 check loses its spawn, and readiness reads Failing.

What is not lost

The 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:

  • branch spellings must collapse to a fixpoint, matching whatever the resolver collapses;
  • a commit id (7–64 hex, both hash formats), a tag, and a pseudo-ref are none of them branch names;
  • repositories are unique by slug, never by display name;
  • a base that resolves to no named branch cannot be cleared against a by-name rule.

Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Roadmap][R1-02] 版本化动态 scope 与 AuthorityPolicy 判定

2 participants