Design #140 — one write-policy decision per repo: enforcing the carve-out, and telling the truth about mergeability

Requirement jwildfire/obot.roadmap#140 · goal #73 (increased autonomy) · shipped in obot.agent#61 (merged 2026-07-29) · open children obot.agent#65, discussion #141 · adjacent obot.agent#37 · Draft — awaiting @jwildfire review

Drafted 2026-08-01 against the live artifacts — obot.agent/scripts/policy.json (v2), scripts/obot-policy, scripts/obot-merge — plus two facts measured today against real PRs (§4.3, §6.1). The requirement's own Design section describes the profile model that already shipped; this document covers what is left: making the carve-out mechanical, and making --check stop implying a merge will succeed.

1. Summary

The profile model shipped on 2026-07-29: one decision per repo, protected by default, auto by explicit promotion, branches declared by role. Two children stayed open behind it, and they are the same story told twice — a guardrail that is written down but not executed.

The design closes both, and adds one hole found while designing them:

Nothing here is implemented yet — no obot.agent code changed in this pass. §8 lists eight decisions (D1–D8) for review; §10 is the single follow-up PR that lands the answers.

2. Where this design starts: what v2 already does

scripts/policy.json v2 replaced the merge-policy.json + autonomy-grants.json pair. Both axes now come off one per-repo profile:

protected (default)auto (promoted per repo)
merge into integration branchattestedstandard lane
merge into release branch(es)attestedattested
merge into any other branchrefusedrefused
--auto branch / draft PRnoyes, draft PR into integration
--auto unattended mergenoneintegration branch only
--auto issuesread-onlyfull + closes
One correction to the requirement text. #140's Migration section still describes the pre-decision draft, in which open.csr dev and demo-301 main were written as protected (standard → attested, the strictly-narrowing choice). @jwildfire chose auto for both in the 2026-07-29 decision prompt, and the merged file records that: the two rows are widenings on the unattended axis, not narrowings on the merge lane. The shipped file is the authority; the issue body's paragraph is stale.

3. The hole in the middle of it (#65)

policy.json says this, under carveOut:

"PRs touching any of these merge only with @jwildfire's approval, even on a
 standard-lane branch in an 'auto' repo."

Nothing enforces it. The decision path in obot-merge is: fetch the PR, take baseRefName, ask obot-policy resolve <repo> <base>, apply the lane. The PR JSON it fetches is baseRefName,isDraft,state,mergeable,headRefOid,url,titleno file list. A changed path has never entered the decision, under v2 or under the pair it replaced.

Demonstrated, not theorised. obot.agent#61 — the PR that rewrote the entire write policy — merged on 2026-07-29 via obot-merge 61 -R jwildfire/obot.agent with no --jeremy-approved and no refusal. obot.agent/main is an auto-profile integration branch, so the standard lane applied and the tool offered no resistance. It would have offered none to an unattended session either.

Why this particular gap matters more than its size suggests: an --auto session is instructed never to merge a carve-out PR, but that instruction lives in skills/session-init/SKILL.md prose. The design's whole claim to safety is that the risky writes are hard-gated by obot-merge — and the one class of PR that can disable every other gate is the class the hard gate does not cover.

4. Gate A — changed paths against a repo-qualified carve-out

4.1 The schema change (v2 → v3)

Today's list cannot be checked, because the repo it governs is implied by a comment:

"carveOut": {
  "_note": "... Paths are relative to jwildfire/obot.agent unless prefixed 'workspace'.",
  "paths": [
    "scripts/policy.json", "scripts/merge-policy.json", "scripts/autonomy-grants.json",
    "goals/", "workspace .claude/hooks/", "workspace .claude/settings.json"
  ]
}

Working out what each entry means for a PR check exposes something more interesting than a syntax problem. Two of the six paths cannot be touched by any PR in any repo — the workspace .claude/ directory is not a git repository (that is precisely why hooks/install.sh exists). Their PR-visible source of truth is elsewhere:

v2 entryRepo-qualified equivalentNote
scripts/policy.jsonjwildfire/obot.agentscripts/policy.jsonthe guardrail guarding itself
scripts/merge-policy.json
scripts/autonomy-grants.json
jwildfire/obot.agent → same pathstombstones: the files are deleted, and a PR resurrecting either should still stop
goals/jwildfire/obot.agentgoals/the goal registry --auto selects from
workspace .claude/hooks/jwildfire/obot.agenthooks/the workspace copy is installed from here; the installed copy is in no repo
workspace .claude/settings.jsonjwildfire/obot.agenthooks/install.shthe installer is what writes hook registrations into settings; the file itself is unversioned

Also worth adding while the schema is open: scripts/obot-merge and scripts/obot-policy themselves. A PR that edits the enforcement code is exactly as consequential as one that edits the policy data, and today neither is gated.

Proposed v3 shape — a repo-keyed map, so the check is a lookup and the whole guardrail still reads as one block:

"carveOut": {
  "_note": "PRs touching these paths merge only with @jwildfire's approval, on any
            lane, in any profile. Keys are repos; a path ending in '/' matches by
            prefix, anything else matches exactly. obot-merge forces the attested
            lane when a PR's changed files intersect its repo's list.",
  "repos": {
    "jwildfire/obot.agent": [
      "scripts/policy.json",
      "scripts/merge-policy.json",
      "scripts/autonomy-grants.json",
      "scripts/obot-merge",
      "scripts/obot-policy",
      "goals/",
      "hooks/"
    ]
  },
  "unversioned": {
    "_note": "Governed surfaces that live outside every repo — no PR can touch them,
              so they are documented here and enforced through their sources above.",
    "workspace .claude/hooks/":     "installed from jwildfire/obot.agent hooks/",
    "workspace .claude/settings.json": "hook registrations written by hooks/install.sh"
  }
}

obot-policy validate gains matching assertions: carveOut.repos must exist and must include scripts/policy.json under jwildfire/obot.agent (v2 already asserts the flat-list version of this); every carve-out repo key must be a declared repo or a validation error; every path must be relative, with no .. and no leading /.

4.2 Matching semantics

4.3 Getting the changed paths — and the trap in the obvious way to do it

#65 suggests gh pr view <n> --json files. That command silently truncates.

Measured 2026-08-01, live PRs. gh pr view --json files returns at most 100 entries, with no error and no truncation flag:
safety.viz#114   changedFiles=318   --json files -> 100   REST --paginate -> 318
safety.viz#112   changedFiles=122   --json files -> 100   REST --paginate -> 122
safety.viz#105   changedFiles=130   --json files -> 100   REST --paginate -> 130
A carve-out path sitting at position 101 of a large PR would pass the gate unseen — a guardrail that works on small PRs and quietly fails on big ones is worse than none, because it is trusted.

So the fetch is the paginated REST endpoint, with an explicit completeness assertion:

gh api "repos/$REPO/pulls/$PR/files" --paginate --jq '.[] | .filename, .previous_filename'
# then: count(distinct filenames) == changedFiles from the PR JSON, or fail closed (exit 1)

4.4 What the gate does, and what it says

Placement: after the lane resolves, before the refusal/allow decision. The gate can only make the outcome stricter — it never turns a refusal into a merge.

standard + carve-out path touched   ->  attested   (this is the whole point)
attested + carve-out path touched   ->  attested   (unchanged, reason line names the path)
refused  + carve-out path touched   ->  refused    (refusal wins)

The verdict line follows #65's wording, so the reason is visible in the same glance as the lane:

policy: profile auto, role integration — carve-out path touched, attested lane forced
        (scripts/policy.json)

And the refusal, when the flag is absent:

obot-merge: REFUSED - PR #NN touches a carve-out path in jwildfire/obot.agent
  (scripts/policy.json, hooks/install.sh) - this is a guardrail file. Merge only with
  @jwildfire's explicit in-session approval, then re-run with --jeremy-approved '<where/when>'.

The audit comment that the attested lane already posts gains the matched paths, so the PR itself records which guardrail was changed under whose approval — the thing that is currently reconstructable only from the diff.

4.5 The unattended path

No new rule is needed: attested is never available to an unattended session (there is no in-session approval to attest), so forcing attested is the unattended refusal. The invariant already written in the file — “merge any PR touching a carveOut path unattended, on any lane, in any repo” — becomes true for the first time.

Optional refinement: have obot-auto's pre-flight print the carve-out list for the goal's repo, so an unattended run knows the shape of the fence before it walks into it rather than at the merge step.

5. Gate B — whose copy of the policy is the tool reading?

obot-merge resolves POLICY_FILE="$SCRIPT_DIR/policy.json", where SCRIPT_DIR is the directory of the invoked script. In a workspace whose convention is one linked worktree per branch ({repo}/.claude/worktrees/{branch}), a session working on a policy change and invoking ./scripts/obot-merge from its own worktree resolves the lane from the branch under review.

The consequence. A PR that widens the policy — or deletes carveOut outright — is evaluated by a tool reading that PR's own version of the rules. Gate A closes the “no one looks at the files” hole; without Gate B, Gate A can be removed by the same PR it is supposed to stop, in the same session, with no flag.

This is the third member of a family. #60 was the halt-file kill switch resolving relative to the invoking worktree and therefore silently not firing; #68/the worktree-location override is the same ambiguity in the permission layer. Guardrails must resolve to the governed state, not to the caller's working copy.

Proposed minimal form — declare the authority in the file, and compare:

"authority": {
  "repo": "jwildfire/obot.agent",
  "ref":  "main",
  "path": "scripts/policy.json",
  "_note": "The governed copy. obot-merge compares the local file's blob sha against
            this ref; a mismatch means the guardrail under the tool's feet is not the
            one in force, so the attested lane is forced and the mismatch is stated."
}

6. --check tells the truth (#141)

6.1 What it does today, and a measurement that makes it worse

--check prints CHECK PASSED - policy permits merging PR #N in R. Correct by design — it is a policy gate — but read aloud at the moment @jwildfire says “merge it”, it means “this will merge”. It answered exactly that for obot.agent#52 while that PR was CONFLICTING/DIRTY.

obot-merge does already fetch and print mergeable in its header line. That is not the mitigation it looks like:

Measured 2026-08-01, obot.agent#52. GitHub computes mergeability lazily, on demand:
first query:   mergeable=UNKNOWN      mergeStateStatus=UNKNOWN
+2 s:          mergeable=CONFLICTING  mergeStateStatus=DIRTY
+4 s:          mergeable=CONFLICTING  mergeStateStatus=DIRTY
The single query obot-merge makes today is the one most likely to return UNKNOWN — the header line prints a value that is frequently meaningless, on a PR that in fact cannot merge. A poll is not optional; it is what makes the field usable.

6.2 Two verdicts, neither dressed as the other

--check reports policy and mergeability as separate lines with separate verbs, so a pass on one never reads as a pass on both:

PR #63 https://github.com/jwildfire/obot.agent/pull/63
  title: Remote Control: fix the obot-auto lane
  base:  main   state: OPEN   draft: false
  head:  9f2c1ab…
  policy:      PASS — profile auto, role integration, standard lane
  mergeability: BLOCKED — GitHub will not merge this as it stands: conflicts with main (DIRTY).
                Rebase or resolve, then re-check.
obot-merge: CHECK — policy permits merging PR #63; GitHub does not. Nothing was merged.

The mapping from GitHub's state to a plain sentence:

mergeStateStatusReported asWho acts
CLEANready — GitHub will merge this nownobody
UNSTABLEmergeable, but checks are failing or still runningsee #37 (D7)
BEHINDbase branch moved ahead; the branch must be updated firstagent
DIRTYconflicts with the base branchagent
BLOCKEDbranch protection: a required review or check is missing@jwildfire or agent
DRAFTdraft — obot-merge already refuses these; gh pr ready firstagent
HAS_HOOKSmergeable, server-side hooks will runnobody
UNKNOWN after pollingGitHub has not computed mergeability yet — re-check in a momentre-run

6.3 The real merge path has a related defect

On the attested lane, obot-merge posts the audit comment before attempting the merge. On a conflicting PR the sequence today is: refuse nothing, mint a token, post “🔒 Protected-branch merge … explicitly approved by @jwildfire”, attempt the merge, fail with merge command failed. The audit trail then records an approved merge that never happened.

So the mergeability check belongs on the real path too, ordered before the side effects: resolve policy → check mergeability → mint token → post audit → merge. A PR that cannot merge is refused with the reason, having posted nothing.

7. Verification — what “done” means

The migration sweep that made #61 trustworthy (stub gh on PATH, synthetic PR JSON, the real obot-merge binary, 80 repo × branch pairs) exists only as evidence pasted into that PR's body. It was never committed, so “add the case to the verification sweep” (#65, step 4) means first giving the sweep a home: scripts/test/policy-sweep, runnable locally and from CI, which is the only way these gates stay closed after the next refactor.

Cases the sweep must carry — new marks what this design adds:

CaseExpected
80 repo × branch verdicts, all profiles and rolesbyte-identical to the recorded baselineexists
Refusal precedence: unknown repo > draft > state > laneunchangedexists
Synthetic PR touching scripts/policy.json on obot.agent/mainREFUSED without --jeremy-approved; verdict names the pathnew
Same PR with --jeremy-approvedmerges; audit comment lists the matched pathsnew
PR touching scripts/policy.json in safety.vizunaffected — standard lane, no forcingnew
318-file PR whose carve-out path is at position 101+REFUSED — the truncation regression, pinnednew
File-list fetch failsexit 1, no merge, no token mintednew
Local policy.json differs from the authority refattested forced, mismatch statednew
--check on a CONFLICTING/DIRTY PRpolicy PASS + mergeability BLOCKED, in that order, in one outputnew
--check when the first mergeability query is UNKNOWNpolls, then reports the settled statenew
Attested merge attempt on a conflicting PRrefused before the audit comment is postednew
obot-policy validate on a v3 file with an unqualified carve-outexit 1new

Plus the live confirmation that costs nothing to run: obot-merge --check against the real open PRs (one per profile and role) before and after, verdicts and exit codes compared.

8. Decisions for @jwildfire

D1 — carve-out schema shape. Repo-keyed map under carveOut.repos (proposed) · a flat list of {repo, path} objects · a per-repo carveOut array inside each repos entry. The proposal keeps the whole guardrail legible as one block and makes the check a dictionary lookup; the third option scatters it across eight entries, which is the drift that #140 exists to end.
D2 — the two unversioned paths. Map them to their in-repo sources (hooks/, hooks/install.sh) and keep the originals as documented unversioned entries (proposed) · drop them from the file as unenforceable · keep them as-is and accept that they match nothing. The proposal is the only one where changing a hook actually trips the gate.
D3 — does the carve-out grow? Add scripts/obot-merge and scripts/obot-policy — the enforcement code, as consequential as the data (proposed). Open question beyond that: jwildfire/obot.roadmap.github/workflows/, since ideas-triage and roadmap-audit-apply post and commit as the bot unattended. Wrinkle worth your call: the hub holds a custom.directCommits grant, so its routine changes never pass through obot-merge at all — a carve-out there would only bite PRs, and covering direct commits needs a different mechanism (a PreToolUse guard on the workspace, like the merge gate). Options: add the hub workflows entry now and accept partial coverage · leave the hub out until the guard exists · file the guard as a separate issue under #73.
D4 — how strong is the forcing? Force the existing attested lane and reuse --jeremy-approved, naming the matched paths in the refusal and the audit comment (proposed) · require a distinct --carve-out-approved '<path>: <where/when>' flag that must name the path, so the approval cannot be given without seeing what was touched. The second is stricter and more ceremony; the first reuses one gate you already know and records the same facts.
D5 — Gate B (§5) in this pass? Fold in the minimal sha-comparison form (proposed) · file it as its own issue and ship Gate A alone · skip it. Ship-Gate-A-alone is a defensible increment, but Gate A is removable by the PR it governs until Gate B exists, so the pair is worth landing together.
D6 — --check exit codes. Keep exit 0 whenever policy permits, with mergeability reported as a clearly separate line (proposed) · introduce exit 3 for “policy OK, GitHub will not merge”. The proposal is backward-compatible for anything scripted around --check; exit 3 is machine-readable but changes a documented contract, and the only consumers today are skills that read the text.
D7 — the CI gate (#37). This design reports failing and pending checks in --check and stops there; #37 asks obot-merge to refuse while required checks are pending, with a logged --force-ci override. Options: report now, refuse under #37 later (proposed) · fold #37's CI gate into this same PR, since both live in the same handful of lines · defer both. #37 also carries --head/--url PR-identity resolution, which is a separate concern and stays with #37 regardless.
D8 — where the sweep lives. Commit it as scripts/test/policy-sweep and run it in CI on every PR touching scripts/ (proposed) · commit it but run it only by hand before a policy PR · keep it ad-hoc per-PR evidence. Ad-hoc is what we have; it produced excellent evidence once and protects nothing tomorrow.

9. Risks and non-goals

10. Rollout

  1. One PR in jwildfire/obot.agent, closing #65 and #141: policy.json → v3 (repo-qualified carveOut, authority block), obot-policy (validate rules, a carve-out <repo> <path>… query subcommand for the resolver's callers), obot-merge (Gate A, Gate B, mergeability in --check and before the audit comment), scripts/test/policy-sweep, and the doc updates in README.md plus the session-reviews / session-init skills whose prose currently carries the caveats this makes untrue.
  2. Its own first test. That PR touches scripts/policy.json, so the gate it adds must refuse it without --jeremy-approved — a real end-to-end confirmation, run on the actual PR rather than a fixture, and the mirror image of what happened with #61.
  3. #140 closes on that merge, with the profile model (already shipped) and its enforcement both in place. Board: Requirement GatheringDesign now, In Progress when the PR opens.

This design document was drafted by Claude Code using Opus 5 and reviewed by @jwildfire.