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.
policy.json declares that PRs touching the guardrail files merge only with @jwildfire's approval. obot-merge never looks at a PR's changed files, so that sentence has never been enforced by anything but a session choosing to read a comment and stop. The PR that shipped the whole policy merged on the standard lane with no attestation.--check overstates itself. It answers CHECK PASSED — policy permits merging on a PR that cannot merge at all, which is the wrong impression to give in the seconds right after @jwildfire approves something.The design closes both, and adds one hole found while designing them:
carveOut.paths, fetch the PR's changed files, and force the attested lane when they intersect. Which requires a schema change, because today the governed repo is implied and two of the six paths are not reachable by any PR at all.obot-merge reads the policy file sitting next to itself, so a session working in a linked worktree resolves the lane from its own branch's copy of the guardrail. Declare an authority ref and refuse to trust a local copy that differs from it. This is the third instance of the family that produced #60 (halt file resolved relative to the invoking worktree).--check (§6) — two independent verdicts, policy and mergeability, neither dressed as the other; and mergeability checked before the audit comment is posted, not after.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.
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 branch | attested | standard lane |
| merge into release branch(es) | attested | attested |
| merge into any other branch | refused | refused |
--auto branch / draft PR | no | yes, draft PR into integration |
--auto unattended merge | none | integration branch only |
--auto issues | read-only | full + closes |
auto: safety.viz, open.gismo, safety-histogram, gsm.safety, open.csr, demo-301, obot.roadmap, obot.agent. A repo absent from the file is refused outright; a branch with no declared role is refused under every profile.scripts/obot-policy is the single resolver — resolve (called by obot-merge), explain, matrix, validate (gated on by obot-auto), diff-legacy, add, promote. obot-merge carries no copy of the rules and fails closed if the file or resolver is missing.approved: {by, date, where}, and validate fails an auto repo whose record is incomplete.obot-merge --check (stub gh, synthetic PR JSON); diff-legacy reported 14 declared verdicts as 12 unchanged / 0 narrowed / 2 WIDENED, both widenings on the unattended axis only and both declared approved on the command line.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.
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,title — no file list. A changed path has never entered the decision, under v2 or under the pair it replaced.
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.
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 entry | Repo-qualified equivalent | Note |
|---|---|---|
scripts/policy.json | jwildfire/obot.agent → scripts/policy.json | the guardrail guarding itself |
scripts/merge-policy.jsonscripts/autonomy-grants.json | jwildfire/obot.agent → same paths | tombstones: the files are deleted, and a PR resurrecting either should still stop |
goals/ | jwildfire/obot.agent → goals/ | the goal registry --auto selects from |
workspace .claude/hooks/ | jwildfire/obot.agent → hooks/ | the workspace copy is installed from here; the installed copy is in no repo |
workspace .claude/settings.json | jwildfire/obot.agent → hooks/install.sh | the 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 /.
/ = directory prefix (goals/ matches goals/registry.json). No trailing slash = exact path equality.scripts/policy.json in safety.viz is an ordinary file and must not trip the gate — the carve-out governs the repo that owns the guardrail, not a filename everywhere.filename plus previous_filename for renames, and both are checked, so moving policy.json out of the way is not an escape.#65 suggests gh pr view <n> --json files. That command silently truncates.
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)
obot-merge.obot.agent is not a real scenario, but the completeness assertion turns it into a refusal rather than a silent pass.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.
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.
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.
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."
}
git hash-object scripts/policy.json locally, versus the blob sha from gh api repos/{repo}/contents/{path}?ref={ref}. No file content is transferred.attested, with the reason said out loud (policy: local policy.json differs from jwildfire/obot.agent@main — attested lane forced). Not a refusal: a session legitimately holds a modified copy while working on a policy PR, and that case should stop at @jwildfire's approval, which is exactly what attested means.attested too (network down, token expired). An attended session can still merge with the flag; an unattended one cannot merge at all, which is the correct failure direction.--check tells the truth (#141)--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:
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.
--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:
mergeStateStatus | Reported as | Who acts |
|---|---|---|
CLEAN | ready — GitHub will merge this now | nobody |
UNSTABLE | mergeable, but checks are failing or still running | see #37 (D7) |
BEHIND | base branch moved ahead; the branch must be updated first | agent |
DIRTY | conflicts with the base branch | agent |
BLOCKED | branch protection: a required review or check is missing | @jwildfire or agent |
DRAFT | draft — obot-merge already refuses these; gh pr ready first | agent |
HAS_HOOKS | mergeable, server-side hooks will run | nobody |
UNKNOWN after polling | GitHub has not computed mergeability yet — re-check in a moment | re-run |
UNKNOWN answer. Bounded, so --check stays a fast interactive command.reviews-queue already reads (mergeable, mergeStateStatus, statusCheckRollup), and the same bucketing vocabulary, so the reviews walkthrough and the merge lane describe a PR the same way.mergeStateStatus requires push access on the repo — true for every repo in the policy, and the UNKNOWN path covers the case where it is not.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.
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:
| Case | Expected | |
|---|---|---|
| 80 repo × branch verdicts, all profiles and roles | byte-identical to the recorded baseline | exists |
| Refusal precedence: unknown repo > draft > state > lane | unchanged | exists |
Synthetic PR touching scripts/policy.json on obot.agent/main | REFUSED without --jeremy-approved; verdict names the path | new |
Same PR with --jeremy-approved | merges; audit comment lists the matched paths | new |
PR touching scripts/policy.json in safety.viz | unaffected — standard lane, no forcing | new |
| 318-file PR whose carve-out path is at position 101+ | REFUSED — the truncation regression, pinned | new |
| File-list fetch fails | exit 1, no merge, no token minted | new |
Local policy.json differs from the authority ref | attested forced, mismatch stated | new |
--check on a CONFLICTING/DIRTY PR | policy PASS + mergeability BLOCKED, in that order, in one output | new |
--check when the first mergeability query is UNKNOWN | polls, then reports the settled state | new |
| Attested merge attempt on a conflicting PR | refused before the audit comment is posted | new |
obot-policy validate on a v3 file with an unqualified carve-out | exit 1 | new |
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.
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.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.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.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.--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.--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.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.obot-merge locally and runs it is not stopped by obot-merge. The gates raise the floor for well-behaved and unattended sessions — which is where the actual risk lives — and they do not make the lane adversarially safe.CODEOWNERS requiring @jwildfire's review on the carve-out paths, which no local tool can bypass. Explicitly out of scope here (it changes how he merges too, not just the agent), but it is the honest end state and worth its own decision later.obot.agent pays a paginated call and a contents call. Immaterial next to the merge itself.CLEAN at check time can be BEHIND ninety seconds later. The report says what GitHub thinks now; it is not a promise, and the merge step re-checks.grant_profile field in goals/registry.json (noted in #61, still unread by anything); the merge-gate-guard quoted-prose false positive (#58); --head/--url PR identity (#37).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.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.