gitstalk
Docs: Approval rules

Approval rules beta

Let comment-only and docs changes land on their own, and send risky paths to a person. One file on your stalk, .gitstalk/policy.yml, says which kinds of change land by themselves, which a small model triages, and which wait for a human. Like the checks and automations, it is YAML: all .gitstalk/ configuration is.

Try it

  1. Open Settings, Approval rules

    Owners and maintainers edit. Everyone else with a role can read the file and sees why they cannot save.

  2. Write a rule

    The editor checks your YAML as you type, with the same parser the engine runs, so the messages and line numbers match.

  3. Save

    Saving pushes a bean with you as its author. It lands through the pre-land check like any change, and the rules apply once the stalk takes it.

.gitstalk/policy.ymlat 3fa9c01
classes:
  - name: api
    match: { paths: ["src/api/**"] }
    route: maybe
line 4: classes[0].route must be auto, triage or card invalid
Save rulesSaving pushes a bean with you as its author; it lands through the pre-land check.
Save rules
Saved as commit 91be3d2 on bean/rules-4kq the rules apply once the stalk takes it checking
Each problem shows its line (click it to jump there). After a save the page follows the bean until it lands.

The file

.gitstalk/policy.yml
# yaml-language-server: $schema=https://gitstalk.dev/schemas/policy.json
groups:
  security: ["@alice", "@bob"]
classes:
  - name: comment-only
    match: { syntax: comments-and-whitespace }
    route: auto
  - name: docs
    match: { class: docs-only }
    route: auto
  - name: migration
    match: { paths: ["src/db/migrations/**"] }
    route: card
    reviewers: ["@data"]
  - name: protected
    match: { paths: ["src/auth/**"] }
    route: card
    writers: ["@security"]
  - name: api
    match: { paths: ["src/api/**"] }
    route: triage
  - name: everything-else
    match: { otherwise: true }
    route: triage
Key Meaning
groups Names for lists of people: security: ["@alice", "@bob"].
classes[].name Lower-case letters, digits and dashes, unique. policy-change is built in.
description Optional: what the class is, in a sentence (at most 200 characters). Clef reads it when it triages the class's beans.
match One of { paths: [...] } (any changed file matches a pattern; the patterns are the same as protected paths), { syntax: comments-and-whitespace } (every changed file differs only in comments and whitespace), { class: docs-only | tests-only | lockfile | comment-only } (a built-in class), or { otherwise: true } (the fallback class: everything no other class matched).
route auto, triage or card (below).
reviewers Who the card is addressed to. Shown, not enforced yet.
writers The only principals who may change this class (below).

Parsing is strict: YAML 1.2 (so no stays a string), unique keys, and no anchors, aliases or merge keys. A file that is invalid on the stalk leaves only the built-in rules in force, and the push says so. With no policy file at all, every bean is auto and pushes show no rules lines, exactly as before.

Classes

A class is a kind of change. Classes are computed by code from the changed paths and the file contents, never by a model.

Built-in class Holds when every changed file is
docs-only *.md, *.mdx, *.markdown, *.txt, *.rst, *.adoc, or under docs/
tests-only under test/, tests/, __tests__/, or named *.test.*, *.spec.*, *_test.*, test_*.py
lockfile a package manager's lockfile
comment-only unchanged in code: only comments and whitespace differ (below)

How comment-only is judged

Each side of each changed file is read into code tokens with comments and whitespace dropped; the two streams must be equal. Strings, template literals, regular expressions and Rust raw strings stay whole. It is judged for TypeScript and JavaScript (not JSX), Rust, C and C++, Java and Go. Every doubt means not comment-only: an added, deleted, binary or unknown file, anything unterminated, or a file over 512 KB. A reformat such as )+( to ) + ( counts as code.

policy-change

Any change under .gitstalk/ (except backlog.md, which agents tick with ordinary beans) is a policy-change. From an agent it is always a card, whatever the file says. When it is a person's own push and they may decide (an owner or maintainer with a personal token or SSH key, or the Settings editor), the push is the decision and the other classes route it. The policy file is also always a protected path.

Everything else: the fallback class

A class with match: { otherwise: true } applies only when no other class matched, so "everything else is triaged" needs no list of source folders. When another class matches, the fallback stays out of it entirely: its route, reviewers and writers do not count. An agent's change to .gitstalk/ is still a card, and a change made only inside .gitstalk/ (your own save from Settings, an agent ticking the backlog) is never routed by it. The built-in classes count only when one of your classes names them: without a docs class, a docs-only change takes the fallback's route.

A file may have one fallback class, and otherwise cannot be combined with paths, syntax or class; either mistake is shown with its line and column. The fallback is an ordinary class otherwise: it has a name on receipts, a description for Clef, and may have reviewers and writers.

Starter rules

A repository without a file opens Settings, Approval rules on these recommended rules; nothing changes until you save them.

.gitstalk/policy.yml
classes:
  - name: comments
    description: Only comments and whitespace changed.
    match: { syntax: comments-and-whitespace }
    route: auto
  - name: docs
    description: Documentation only.
    match: { class: docs-only }
    route: auto
  - name: tests
    description: Tests only.
    match: { class: tests-only }
    route: auto
  - name: lockfile
    description: A package manager's lockfile only.
    match: { class: lockfile }
    route: auto
  - name: migrations
    description: Database schema and data migrations.
    match: { paths: ["**/migrations/**", "**/*.sql"] }
    route: card
  - name: auth
    description: Authentication, sessions, permissions and secrets handling.
    match: { paths: ["**/auth/**", "**/permissions/**", "**/security/**"] }
    route: card
  - name: everything-else
    description: Any other change.
    match: { otherwise: true }
    route: triage

Routes

  1. Classifypaths and syntax
  2. Autoland on its tests
  3. TriageClef picks auto, recheck or card
  4. Cardheld for a person

When several classes match, the strictest route wins: card over triage over auto. A comment-only change takes the route of your comment-only class when you have one, even inside a sensitive path, because no behaviour changed. When none matches, the fallback class routes the bean if you have one; otherwise it is auto.

Route What happens
auto The bean lands and promotes as usual, on its tests.
triage Clef, a small model that sees the classes and their descriptions, the paths, the diff's size, the bean's intent and commit message, and the beans it never saw (never file contents), picks auto or card within 2.5 seconds, and can send the bean back to its author to recheck. If it is late or unavailable, fixed rules decide: auto when the bean is also comment-only, docs-only or tests-only, otherwise card, never recheck. Clef holds a bean only when it is at least 65% sure a card is needed; a less certain card lands on its tests.
card Held before landing until an owner or maintainer approves it (the Held panel on the bean's page). The check answers a red, awaits approval by a person, that says nothing needs fixing. The approval is for that exact revision; a new push needs a new approval.

Clef and Jev only pick or rank. They never write code. The receipt names the model that decided.

Recheck: beans it never saw beta

Two beans can each pass their tests, merge with no conflict and still break together. One makes price() return dollars; another, written at the same time, adds shipping as price() + 500 cents. Checkout now charges $600, and no test fails unless one covers the combination. When a bean is triaged, Clef also reads the beans it never saw: those that landed after it forked, and those pushed before it and still being checked, which may land first. For each it gets their intent and commit message, the files both changed, and how the two connect: a file one changed imports a file the other changed, or a function, constant or type one changed is used on the other's new lines. Clef answers one yes-or-no question per bean (up to four, the most connected first): do the two disagree about something both rely on, such as a unit, a format, a meaning or a value?

The clash questions go to Clef 27B, a larger Clef that answers them better than the model that picks the route. When it is at least 30% sure of a clash, the bean goes back to its author agent, not to a person: an ordinary red that names the other bean, its intent and the links. A recheck is cheaper than a card, so its bar is lower. The author reads the other bean, fixes theirs if the two disagree, and pushes again with a trailer naming what they checked. The trailer takes those beans off the list; the tests still decide. When a test of the other bean only pins the assumption the two disagree on (a unit, a format), the author updates that test so the other bean's intent still holds, and says so: reconciling, not weakening it. A clash goes to the agent first even when the change would also need a person; the fixed push is judged again, so a real risk still reaches a person and a simple misalignment never does. The bean pushed later is the one rechecked, so the bean that lands second owns the repair.

$ git push -o wait origin HEAD:refs/heads/bean/shipping
remote: gitstalk: approval rules: checkout -> recheck (clef, p=0.66)
remote: gitstalk: back to its author: recheck against price-dollars
remote: gitstalk: RED: shipping was not landed. Merged onto the sprout, these tests failed:
remote: gitstalk:   - .gitstalk/policy.yml > recheck against beans that landed after it forked
remote: gitstalk:     price-dollars: Price in dollars, not cents
remote: gitstalk:       imports: src/checkout.mjs -> src/price.mjs
remote: gitstalk:       it changed, you use: price
remote: gitstalk:     Intent-checked: price-dollars
Fix the bean so both intents hold (here: shipping in dollars), then push again with Intent-checked: price-dollars in the commit message.

Links are read for JavaScript and TypeScript, Python, Ruby, Java, Kotlin, C#, Swift, Go, Rust, C and C++, and PHP, from the changed files' text alone: no build system is run. They point Clef at a contract; they never decide. Recheck is for repositories; benchmark races list no other beans.

Writers

writers limits who may change a class. A bean from a principal outside the list falls off at the pre-land check, the same way a protected path does, naming the classes and who may change them.

Principal Means
@handle A person: any push acting for them, by any credential.
@group A name under groups. A group wins over a handle of the same name.
agent:a<slot> An agent slot in a benchmark race. Not used for ordinary repositories.

Receipts

Every judged bean leaves a receipt: its classes, the route, which class decided, who decided (policy, clef or rules), Clef's probability, the approval state, and the policy file and stalk commit it was judged by. The push prints it on its remote lines, the bean's page shows it in the Held panel, and the MCP bean_status tool reports it with held: true, the page's address (approve_url) and a next step when a bean is held.

$ git push -o wait origin HEAD:refs/heads/bean/token-refresh
remote: gitstalk: approval rules: protected -> card (policy)
remote: gitstalk: awaiting a person's approval
remote: gitstalk: ...
The held bean is not broken: its check answers red with awaits approval by a person. The rules line for a triaged bean reads, for example, api -> card (clef, p=0.81), or checkout -> recheck (clef, p=0.66) when it goes back to its author (its receipt is route auto with the beans in recheck).

Approving a held bean

A held bean shows a Held badge in its repository's Changes list, and a Held filter lists only those beans. Its page opens with the Held panel: the classes, the rule and who decided (the policy, Clef with its probability, or the triage rule), the revision waiting with a link to its diff, and the reviewers the policy names.

held for approval

Revision 4e1c9a2 waits for a person. Nothing in it needs fixing.

Classesprotected, tests-only
Ruleclass protected in .gitstalk/policy.yml, by the policy
Reviewers@security (named, not required)
Note for the recordOptional; kept with the approval.
Checked the token expiry change.
Approve 4e1c9a2Asks once more, then the pre-land check runs again.
Approve
Owners and maintainers see Approve. Everyone else reads who can approve.
Question Answer
Who approves The repository's owner and maintainers (for an organization's repository, its owners too). Named reviewers are shown, not required.
What is approved One revision, the bean's pushed head. The page sends the revision it showed; if a new push arrived meanwhile, the approval is refused as stale. A new push needs a new approval.
What happens next The bean is checked again at once and lands on the sprout if it passes. The page shows who approved and when, without a reload.
Where it is recorded The repository's access log (Settings, Collaborators, for the owner): who approved, the client (web or MCP and its name), the revision and the note.

From an agent: bean_approve

An agent session with the write scope, acting for an owner or maintainer, can approve for its person with bean_approve (see MCP tools). Approving is the person's decision, so the tool tells agents to ask first and never to approve on their own initiative. The web button and the tool make the same call, with the same checks.

Where the rules are read from

Always from the stalk, never from the bean being judged. A bean cannot change the rules that judge it. If the stalk cannot be read, the check is retried; it is never treated as "no policy".

Not done yet

  • reviewers are shown, not enforced: any owner or maintainer approves.
  • Approvals are for repositories. A benchmark race has no approve path, which is why races keep the rules off.
  • Clef's answers are not yet compared with later human decisions.
  • The recheck threshold (30%) comes from a labelled set of 35 pairs (16 of 17 clashes caught, 2 false rechecks of 18). Clashes between files that meet only over the network, such as an API and its client, have no links yet. The bean's page shows a recheck as a red; it has no panel of its own yet.

Sources