Repository navigation
Governance: a pure matcher for the PRs an instance governs, and a token only it can make (#704 S1) [PROTECTED: owner merges] - #833
Conversation
…en only it can make Governance mode (#704) lets the engine judge PRs it didn't open. Before any of that is wired, the question "is this PR governed?" needs one answer that fails closed and can't be faked. This adds it as a new module that nothing calls yet, so no install changes behaviour. governedPr(source, scope, pr) governs a PR only when the owner's source names it: its author is listed (whole login, any case, bots included), a label is on it whose latest application was by an owner or writer, or "all". It never governs with the mode off, with no source, a fork or a head repo GitHub no longer has, an unresolved author or ghost, another repo or base branch, or a head that isn't an exact SHA. The answer is a GovernedPr: frozen at birth, made only by the matcher (its constructor needs a symbol the module never exports) and recognised only through a module-private WeakSet. A structural copy, Object.create, a JSON round trip, structuredClone or a Proxy is never governed, and governs(token, n, head) ties it to one PR at one head. Test (fails without): test/governance.test.ts. On the tree without src/governance.ts the build reports TS2307 and the test run fails with ERR_MODULE_NOT_FOUND; the module is new, so that is the only fail-without there is. Signed-off-by: Jayden Vasquez <jaydenvasquez1711@gmail.com>
Evaluator verdict: CHANGES NEEDED (round 1/2)Reviewed head The design is right. An opaque token checked at run time is a by-design protection, and every forgery I tried fails closed, across realms too. The required changes are four small fail-closed gaps in the matcher and its contract. Three of them over-govern on malformed or differently shaped facts, which is the dangerous direction. Acceptance (S1's done-when, from the slice plan and the issue's "Source rules")
Fail-without (method (b): a fresh worktree at the base, test written in with Write)
1. Matcher against GitHub's real data shapesProbed with a scratch script against the built Measured GitHub shapes (read-only
The token isn't bound to a repo (measured): a token minted for scope 2. Forging the token (all measured)
3. Slice plan and overlap (measured:
|
…ore it is compared Review round 1 on #833 found the matcher over-governing in three ways, all measured: a token minted for another repo passed governs() for the same number and head (forks and mirrors share heads); repo names were case-folded with Unicode rules unvalidated, so the Kelvin sign made acme/Kit match acme/kit; and an empty default branch matched an empty base. It also found the author's data shape unpinned: GraphQL drops a Bot's [bot] suffix, and User claude and Bot claude[bot] are different accounts. Now GovernedPr carries the repo it was minted for and governs(token, repo, number, head) checks it. Every repo, branch and login is validated as plain ASCII of its kind before an ASCII-only case fold, and an empty or invalid one is refused on either side. PrFacts pins REST's user.login verbatim plus user.type: a type other than User or Bot, or a [bot] login GitHub calls a User, is refused. The token's fields are defined non-writable with Object.freeze and Object.defineProperty captured at load. A test isolates the base-repo refusal (base acme/other, head acme/app). Test (fails without): test/governance.test.ts. Against the round-1 module the updated file fails 6 of 17 (repo names, author shape, branch names, the token's repo binding twice, the property test). Signed-off-by: Jayden Vasquez <jaydenvasquez1711@gmail.com>
Round 2:
|
Evaluator verdict: PASSRound 2, head All four of round 1's required changes are met, and the protection is by design. Every attack below failed closed except one: the GraphQL-shaped bot. The builder names that one as a gap, and it can't be closed in S1 from login and type alone (measured, see §2). One claim needs a small correction (§3), which doesn't block the merge. Round-1 required changes
1. Attacks (scratch script against the built
|
… as git refuses it The branch check claimed git's check-ref-format rules but refused `.lock` only at the end, so `a.lock/b` passed; git rejects any component ending in `.lock` (measured by the #833 round-2 review). The lookahead now refuses it in every component. This only refuses more, so it can't make a PR governed that wasn't. Test (fails without): test/governance.test.ts, the branch-name test with `a.lock/b` and `x/a.lock/b`; against the previous module it fails (16 of 17 pass). Signed-off-by: Jayden Vasquez <jaydenvasquez1711@gmail.com>
Intent: This is slice S1 of governance mode (#704). It answers one question: "does this instance govern this PR the engine didn't open?" The answer is one pure function that fails closed. Its "yes" is a token that only that function can make. Nothing calls it yet, so no install's behaviour changes. Later slices build on it: mergeDecision's
sourceinput (S2), thegovernancepolicy key (S3), and intake plusmergeOursin merge-flow (S4). The full slice plan is at the end.Risk class: protected. It adds
src/governance.ts, and src/** is engine (.worklane/review.yaml), so the owner merges.Part of #704 (slice S1 of 9; it does not close the issue).
Round 2 (
c074181e, after round 1's CHANGES NEEDED):authorType;Details are in each section below.
What it does
governedPr(source, scope, pr)governs a PR only when the owner's source names it. A PR it governs gets back aGovernedPrtoken. Any other PR getswhy, a reason that starts "not governed:". The source can name it three ways:authors: the author's login is on the list. The login is REST'suser.login, verbatim, and must match whole, in any ASCII case.[bot]suffix (renovate[bot]).Copilot).claudeand Botclaude[bot]are two different accounts.label: the label is on the PR now, and its latest application in the timeline was by an owner or writer.all: every same-repo PR into the default branch.sourcenull);headRepo: null);ghost, or not a valid GitHub login);user.typeisn'tUserorBot, or a[bot]login GitHub calls aUser;scope.repo,pr.baseRepo,pr.headRepo) or branch name (scope.defaultBranch,pr.base) that is empty or isn't plain ASCII of its kind. Branch names follow git's check-ref-format rules, including a.lockcomponent anywhere (a.lock/b, added after round 2's review). So two blanks never match.Kontok.GovernedPris a frozen{repo, number, head, via}with non-writable fields, and three helpers:isGoverned(x)says whetherxis a real token.governs(token, repo, n, head)is true only for a real token minted forrepo, for PRn, at exactlyhead.By-design protection
An opaque token, with a check at run time. A real token comes only from
governedPr(). Three things make this hold at run time, not just in the types:GovernedPr's constructor throws unless it gets a symbol (MINT) that the module never exports.isGovernedandgovernsaccept only objects in a module-privateWeakSet, which only the constructor adds to.Object.freezeandObject.definePropertyare captured when the module loads.So a real token can't be pointed at another repo, PR or head. That includes code that swaps
Object.freezeout later (measured below).What the later slices get. S2's
mergeDecisionand S4'smergeOurswill take aGovernedPrinstead of a boolean. A caller can't claim "governed" with a literal, a cast, ananyor a deserialized object: it throws or reads as not governed (measured below).No lint is added. The protection is the run-time brand itself, the same pattern as Required checks count only from the App or workflow pinned for their name [PROTECTED: owner merges] #788's
PinnedChecks.What this can never do
src/governance.ts(inferred: it's a new file; measured:grep -rn "governance.js" src/finds no importer).sourcenull, it governs nothing (measured: test "off (no source) governs nothing").Compromised component
src/governance.ts, for example adding a source or dropping the fork check. That's an engine path, so the PR waits for the owner (inferred from.worklane/review.yaml's engine category). At run time it has no code path to the token:MINTisn't exported, and a forged object failsisGoverned(measured).[bot]suffix. GraphQL does this:claude[bot]arrives asclaudewith typeBot. That matches a listed Userclaude(measured in the adversarial pass). Refusing a Bot with no suffix would also refuseCopilot. So the defence is S4's contract: RESTuser.login, verbatim, plus a reader test that a bot's suffix arrives (inferred; written into the slice plan).governs()for this one (measured).WeakSet.prototype.hasbefore this module loads, or swap the module. The brand isn't a sandbox against code already in-process (inferred). ReplacingObject.freezeorObject.definePropertyafter load no longer leaves a token writable (measured).How it was verified
Fail-without, round 1 (measured). The module was new, so the only fail-without was that the tests fail on the tree without it. I wrote
test/governance.test.tsfirst and built it on unchanged origin/main6e0d7d81:npm run build:error TS2307: Cannot find module '../src/governance.js'Error [ERR_MODULE_NOT_FOUND],# fail 1.Fail-without, round 2 (measured). I updated the test file first and ran it against the round-1 module (
ef3b100d, unchanged):npm run buildreported 24 type errors;# tests 17,# pass 11,# fail 6.The six failures:
not ok 6repo names validated as ASCII;not ok 8the author's REST shape;not ok 11empty or invalid branch names;not ok 14the token pinned to its repo;not ok 15a token minted for one repo never governs another;not ok 17the property test.The isolated base-repo test catches its mutant (measured). I ran it against a scratch copy of the built module with the base-repo comparison removed. No repo file was edited.
acme/other): the mutant still refuses it, through the fork check, so it did not catch the mutant.acme/other, headacme/app): the mutant governs it and the real module refuses it, so it catches the mutant.After (measured):
# tests 17,# pass 17,# fail 0.Forgery tests (measured, in
test/governance.test.ts). None of these is governed, andgovernsis false for each:Object.create(GovernedPr.prototype)plus the fields;Object.setPrototypeOf(fields, GovernedPr.prototype);structuredClone;Proxyof a real token;Object.create(real).Building one also fails: the constructor with a wrong symbol,
Reflect.constructand a subclass all throw. Writing any ofrepo,number,headorviathrowsTypeError, and each field's descriptor haswritable: false.Property test (measured). It runs 1,080 combinations: 6 sources × 5 authors × 3 head repos × 2 bases × 6 label states. In every case:
whyis set;viathe source names;governs(token, scope.repo, n, head)holds, andgoverns(token, 'acme/other', n, head)doesn't.Neighbouring tests (measured). I ran the tests that scan all of
src/(package-files, review, feature-map, brand, isolate, push-check, change-files-renames, claude-credential-guard, owner-terminal-removed, slot-dir-writes, slot-path) plus governance:# tests 248,# pass 248,# fail 0. The full suite runs in CI.Other checks (measured).
npm run typecheck: 0 errors.npm run check:denylist:denylist: 1311 files clean.node scripts/check-dco.mjs origin/main HEAD:DCO: all commits signed off.1 commit(s) and 1 tip(s) clean.Adversarial pass
I ran these with a scratch script against the built
dist/src/governance.jsatc074181e. No repo file was edited. The reviewer's round-1 probes (a second module instance,vmrealms, workers) all failed closed, and the code paths they exercise didn't change (inferred).аgent-one), underallor listeda--bacme/Kiton both sides, scopeacme/kitauthorTypemissingclaude[bot]with typeUserclaude, typeBot) against a listed UserclaudeCopilot. It's closed by S4's REST contract and reader test (named gap)governswith another repo; withacme/app\nObject.freezeandObject.definePropertyreplaced after load, then a token minted andnumberset to 99TypeError;governs(t, …, 99)false,governs(t, …, 9)trueObject.defineProperty(real, 'head', …)TypeError: Cannot redefine property: headReflect.set(real, 'number', 8)false, unchangedGovernedPr.prototype.number = 8governswith an upper-cased head, or the number as a stringconstructtrap' governed '/ blanksgoverned/ no source setacme/app-fork; baseMainagainstmainadmin)What I fixed because of it. In round 2, everything round 1's review found:
On my own initiative I also defined the fields non-writable, with
Object.freezeandObject.definePropertycaptured at load. The reviewer had made that optional.What it leaves open (named gaps).
user.loginverbatim (inferred; in the slice plan).labeledbut misses a viewer's later one would over-govern (inferred). The matcher can't detect truncation.src/dashboard-auth.tsvalidLoginacceptsa--banda-but not[bot];LOGINhere is the reverse. Neither accepts Enterprise Managed User logins (name_shortcode). Both fail closed; EMU support is a question for the owner, and one shared helper is worth its own issue (inferred).Package size
Measured with
npm pack --dry-run --json --ignore-scriptsthrough the npm wrapper. Main came from a separate worktree at6e0d7d81(this branch's base). Both trees rannpm run buildandnpm run build:web, sodist/webis included.dist/src/governance.js+4,868 (new). That's the only file that changed; the test isn't packed.dist/web. The reviewer measured round 1's head with the UI at +953; the correct round-1 figure is +953.PACKAGE_BUDGET(1,950,000 / 6,060,000) is unchanged and still has headroom (measured).CHANGELOG
None: users see nothing, because nothing is wired (inferred). S3, where the policy key appears, adds the bullet.
New dependencies
None.
Slice plan (all of #704)
source: 'engine' | GovernedPrin mergeDecision, plus the verdict property test. After Merge: a PR that removes or weakens a test, lint or guard waits for the owner (#783) [PROTECTED: owner merges] #820 and Remove voice and Siri: spoken approvals never count, no gateway, its ports and tokens removed; doctor names leftovers (#743) [PROTECTED: owner merges] #836 (both editsrc/merge-policy.ts) and Tool guard: its lanes' rules move to src/guard/, so it loads 57 modules, not 83, and only those are locked as the guard (#785) [PROTECTED: owner merges] #839 (editstest/merge-policy.test.ts).governancepolicy key. Turning it on is root or CLI only, and needsauto_mergeand Required checks count only from the App or workflow pinned for their name [PROTECTED: owner merges] #788'sgovernanceRefusal()to be null. Off works from every surface. After Single machine: policy.yaml is root's, and the engine refuses one that isn't (#726 part 1) [PROTECTED: owner merges] #769, Dashboard policy changes go through root's helper (set-policy), never a loosening (#726 part 2) [PROTECTED: owner merges] #770, docs/design: amend #698 decision 1: turning auto_merge on is root-only; off stays on every surface [PROTECTED: owner merges] #775, Local mode: an owner-only auto-merge kill switch (default off), and a hold that never names a policy.yaml local users do not have #729 and Required checks count only from the App or workflow pinned for their name [PROTECTED: owner merges] #788.src/cli.tsis also edited by doctor prints a paste-ready required_check_sources block; a new project's config starts pinned (after #788) [PROTECTED: owner merges] #803, Remove the desktop app, its source, Tauri lockfile and docs; keep the desktop check one more release as a no-op (#744) [PROTECTED: owner merges] #746 and Remove voice and Siri: spoken approvals never count, no gateway, its ports and tokens removed; doctor names leftovers (#743) [PROTECTED: owner merges] #836, andsrc/instance.tsby Tool guard: its lanes' rules move to src/guard/, so it loads 57 modules, not 83, and only those are locked as the guard (#785) [PROTECTED: owner merges] #839 and Engine: security reads of the environment see only variables that are set, never Object.prototype's (#786) [PROTECTED: owner merges] #844.pr.governed, the merge base,mergeOursviagoverns(token, repo, n, head), and Needs-you. After Merge: resuming auto-merge after a red-main or main-watch stop waits for green main or needs an explicit recorded acknowledgement #776, Required checks count only from the App or workflow pinned for their name [PROTECTED: owner merges] #788, Merge: a PR that removes or weakens a test, lint or guard waits for the owner (#783) [PROTECTED: owner merges] #820 and Approvals: every owner approval (GitHub review or dashboard) goes through the engine merge path instead of "Merge on GitHub", plus an audited owner override of the evaluator veto #732, and Tool guard: its lanes' rules move to src/guard/, so it loads 57 modules, not 83, and only those are locked as the guard (#785) [PROTECTED: owner merges] #839 (editssrc/events/types.ts). Obligations:user.loginanduser.type, with a reader test that a bot's suffix arrives;src/coordinator.tsis also edited by Remove voice and Siri: spoken approvals never count, no gateway, its ports and tokens removed; doctor names leftovers (#743) [PROTECTED: owner merges] #836.merge.donesource.merge-decisioncheck (DC4 check mode).src/dashboard.tsis also edited by Dashboard: a viewer's /api/prs shows each PR's state, never the merge decision (#821) [PROTECTED: owner merges] #826 and First run: a card follows the first task from File one to its PR; the Start card counts only steps before Start (#741) [PROTECTED: owner merges] #816.