Phase 0, Step 5: Policy engine, report-only¶
Spec for the developer agent. Implement exactly this. Where this spec is silent, choose the simplest option and record it in README §7 ("Step 5 deviations and additions"). The Phase 0 README constraints are binding: offline build and test, Postgres from docker compose, no stored code, diffs or file contents, pnpm, Node 24, strict TypeScript. The code as it is after step 4 is described in the repo README §5 and §7; where this spec and the README disagree on an existing interface, the README wins. src/modules/intent/job.ts and src/modules/provenance/job.ts are the models for the job in this step.
Binding rules from earlier steps:
- Safety rule (development plan): the model can only raise a PR's risk class. Fixed rules set a minimum, and the policy's sensitive paths are one of those rules.
- Payloads are triggers (README §5, "Webhook replay"): handlers validate the payload and enqueue a job. Jobs write only from API data or from data Graft computed.
- Lock discipline (README §5): no slow work inside withChangeLock. Compute first, then lock, re-check freshness and publish.
- Spec fix 1 (development plan): a passing graft/policy check is not a human approval. Pilots drop required reviewers from branch protection in Phase 1 only. In Phase 0 the check always concludes neutral and decides nothing.
1. Goal¶
For every open pull request in an installed repository, Graft reads the repository's graft.yaml from the base branch, evaluates it against what Graft knows about the PR, and reports what it would do:
- Format.
graft.yamlversion 1: a zod schema, a committed JSON Schema for editors, and a fixed error catalogue (§7, §8). - Trust. The policy comes from the base branch, never from the PR head. A PR that edits
graft.yamlis flagged, its edits are ignored, and it can never be would-approved. A missing, invalid, oversized, symlinked or alias-bombed file fails safe: Graft reports it and never would-approve anything (D3 to D6). - Evaluation. A pure
evaluatePolicy(policy, facts)returnswould_approve,would_require {humans, teams}orwould_block, with every matched rule and why (§9). - Output. A
graft/policycheck run that always concludesneutral, and apolicysection in the managed PR comment (§10). - Storage. One
policy_verdictsrow per (change, head SHA, policy file SHA). It holds no file contents beyond the parsed policy's hash (§11). - Feeding step 3.
sensitive_pathsfrom the base-branchgraft.yamlraise the intent risk floor through theintentRepoSettingshook, cached by base SHA (T9). - Tooling.
pnpm graft policy check <file>, plus a GitHub Action snippet, so teams can lintgraft.yamlin CI (T12). - Step 4 follow-ups. The review 2 minors and the nit for
src/eval/redact.ts(T14).
2. Out of scope¶
Do not create stub code for any of these.
- Enforcement: a
success,failureoraction_requiredconclusion, makinggraft/policyrequired, and changing branch protection (Phase 1). - Checking team membership. It needs the
members: readpermission and comes in Phase 1. Phase 0 shows the required teams and counts approvals from anyone. - Dismissing stale approvals, a break-glass label, and an audit log (Phase 1).
- MCP
get_policy(Phase 1). - Per-repository LLM budgets from
graft.yaml.dailyTokenBudgetstays the config value. - Linting the PR's own
graft.yamlin the check run. The CLI and the Action snippet cover that. - Reading
graft.ymlor a.graft/directory. They are flagged as policy files (D5) but never read. - A
policy explainCLI that evaluates sample facts. - Re-running intent cards when the base
graft.yamlchanges. The policy engine appliessensitive_pathsitself (D12), and the card catches up on the next PR event.
3. Decisions¶
| # | Area | Choice | Rationale |
|---|---|---|---|
| D1 | Layout | Three parts. src/policy/ is a library with no imports from src/modules: schema, safe YAML parse, glob checks, the pure evaluator, the GitHub loader and its cache. src/modules/policy/ holds the handlers, jobs, facts, rendering and store. src/bin/graft.ts is the CLI. |
The intent hook (modules/intent), the policy module (which imports modules/intent and modules/provenance) and the CLI all need the parser. A library avoids an import cycle between modules. |
| D2 | Glob semantics | Move normalizeGlob and globMatcher from risk-floor.ts to src/core/glob.ts (unchanged behaviour; risk-floor.ts re-exports globMatcher) and add { nocase }. A glob without / matches at any depth, and a leading / anchors it at the root. Matching uses dot: true. Tightening matches are case-insensitive (paths, sensitive_paths, as in the floor). Loosening matches are case-sensitive (only_paths). |
One implementation for the floor, GRAFT_INTENT_SENSITIVE_PATHS and graft.yaml. A broad match may only make the verdict stricter. |
| D3 | Which commit | Policy commit = the commit heads/<pull.base.ref> points to when the job runs (git.getRef). Read graft.yaml at that SHA with repos.getContent. Never read the head ref, the head SHA, or refs/pull/*. The intent hook keeps the PR's pull.base.sha (T9). |
GitHub does not advance pull.base.sha when the base branch moves, so a policy pushed to main would never reach open PRs. The tip and base.sha are both base-branch commits, so the trust level is the same. |
| D4 | Fail safe | A missing file gives the state missing and outcome no_policy. A file that cannot be used gives invalid and outcome policy_invalid. Neither state is ever would_approve, and both are published, not thrown. Only transient GitHub errors (429, 5xx, network) throw, through toJobError. |
Reviewers must see that the policy is broken. A bad file can never widen auto-approval. |
| D5 | PR edits the policy | A PR whose changed paths (new or previous names) match POLICY_FILE_GLOBS (/graft.yaml, /graft.yml, /.graft/**, moved to src/policy/constants.ts and re-exported by risk-floor.ts) sets policyFileChanged. Its version is never read. The effective risk is sensitive, and the built-in rule policy_file_changed turns would_approve into would_require 1 human. |
A PR must not loosen the policy that judges it. The floor already makes these paths sensitive (R2); this makes it explicit in the policy too. |
| D6 | YAML safety | yaml 2.x. The file is capped at 32,768 bytes, checked from the API's size before decoding and again after. Strict UTF-8 is required and a BOM is stripped. Flow nesting of more than 16 is rejected by a pre-scan. Parsing uses YAML 1.2 core schema, uniqueKeys: true, merge: false and maxAliasCount: 0 as a backstop. Any anchor or alias is rejected (yaml_alias), and so is any tag outside the core set (yaml_tag), more than one document, and any parser error or warning. toJS runs only after these checks. |
No legitimate policy needs anchors. Rejecting them outright removes billion-laughs bombs entirely, and the limits bound parse time and memory. |
| D7 | Symlinks and non-files | The getContent response must be an object with type === "file", path === "graft.yaml" and encoding === "base64". GitHub follows a symlink to an in-repo file and returns the target's path, so a different path means a symlink. An array (directory), submodule or symlink gives file_not_regular. The CLI applies the same rule with lstat. |
The answer is the same whether or not GitHub follows the link. |
| D8 | Rule combination | All matching rules apply. Block wins. Otherwise any matching require wins over approve. would_approve needs at least one matching approve rule and no matching require or block rule. When nothing matches, default applies. |
This is deterministic and order-independent, and adding a rule can only add constraints. First-match semantics would make rule order a security property. |
| D9 | Requirements | Each matching require rule is a constraint "at least n approvals from members of team set T", where T is any-of and empty means anyone. Constraints are conjunctive: all must hold, and one approval may satisfy several. Constraints with the same team set merge by max(n). The displayed total is humans = max(n), the lower bound when overlap is possible. |
It matches how reviewers think ("2 people, one of them from security") without summing, which over-counts. |
| D10 | Author-controlled keys | author, agent and labels are controlled by whoever writes the PR (trailers can be forged, and anyone with triage access can add labels). They are not allowed in approve: auto rules (approve_untrusted_key). approve: auto may use only risk, only_paths and size, and may not use paths (approve_any_path), because paths is any-of. |
A PR author must not be able to buy auto-approval with a trailer or a label. These keys can only tighten. |
| D11 | Unknown facts | An unknown fact never loosens. In a tightening rule (require or block), a condition on an unknown fact counts as matched and is marked uncertain. In an approve rule, a condition must hold for every possible value. The unknown facts are: risk while the card is pending or unavailable (possible set {c ≥ lower bound}), author while provenance is pending, an agent name that is absent, and paths when the file listing is incomplete. |
It is conservative by construction, and it is easy to test because each key has two evaluation modes. |
| D12 | Effective risk | risk.value comes from the displayed intent card for the current head when its status is ok (final), from that card's risk_class when it is unavailable (not final), and otherwise from max(computeRiskFloor(paths, sizes), logic) (not final, card_pending). The evaluator then raises it to sensitive when a changed path matches sensitive_paths or the policy-file globs. |
The card may predate a sensitive_paths change. Applying the globs again here keeps the verdict consistent with the policy it reports. |
| D13 | Approvals | pulls.listReviews (at most 3 pages of 100). Per user, the latest review in APPROVED, CHANGES_REQUESTED or DISMISSED counts; COMMENTED and PENDING are ignored. Approvals are current when commit_id === head, otherwise stale. Bots, null users and the PR author are excluded. The count is shown, but it never changes the Phase 0 outcome. |
Pilots can compare "would require" with what actually happened. Team checks come in Phase 1. |
| D14 | Events | Add the pull_request_review and push events to the manifest. There is no new permission: reviews are covered by pull_requests (already write) and push by contents: read. pull_request.labeled and unlabeled are already delivered by the subscribed pull_request event. |
Without review events the approval count is stale until the next push. Without push events a graft.yaml change on main never reaches open PRs. Both are needed in Phase 1 anyway. |
| D15 | Cross-module triggers | New src/core/signals.ts. JobContext.signals.emit(signal, ctx) calls the listeners collected by the registry from module signalListeners exports. The intent job emits intent_published and the provenance job emits provenance_published after a successful publish (outside the lock). The policy module listens and enqueues policy.evaluate. |
The policy module imports intent and provenance, so they cannot import it back. A listener list keeps the dependency one-way. |
| D16 | Ordering | policy.evaluate takes factsReadAt = ctx.clock.now() before it reads any fact. Inside the lock it publishes only if (a) the stored change head equals the live head, (b) the stored pr_updated_at is not newer than the live updated_at, and © no policy_verdicts row of the change has a facts_read_at later than this job's. It skips GitHub writes when the stored row for the same identity has the same facts_hash and was published. |
Several triggers per push (the PR event, the card, provenance, reviews) race each other. The latest read wins, and repeats cost no writes. |
| D17 | Cache | An in-process LRU (256 entries) keyed by ${repositoryId}:${commitSha} holds the LoadedPolicy: status, file SHA, parsed policy or error list, and hash. Commits are immutable, so entries never go stale. GitHub failures are not cached. clearPolicyCache() exists for tests. |
The intent hook and every evaluation of a PR share one read per base commit. |
| D18 | Fan-out | push to a branch, where a commit's added, modified or removed lists graft.yaml (or the push is forced, or it lists 20 or more commits, where the payload may be truncated), enqueues policy.fanout. That job enqueues policy.evaluate for at most 100 open changes in the DB whose base_ref is that branch, newest pr_updated_at first, 10 every 5 s (startAfterSeconds). |
The cost of a policy push stays bounded (about 800 API calls at worst). |
| D19 | Output safety | All file and PR strings go through escapeProse or codeSpan (src/github/markdown.ts), and never into table cells. Team names are always rendered as code spans (`@acme/security`), so the comment never notifies a team. |
Untrusted text, and preventing accidental mass mentions. |
| D20 | Storage | Migration 0005_policy replaces the unused policy_verdicts table from 0001 (§11). Rows hold rule indices and reason codes, PR paths (at most 5 per reason), counts, SHAs, logins and a policy_hash. They never hold globs, rule names, team slugs or YAML text. |
"No file contents beyond the parsed policy hash". Everything else can be recomputed from the policy at policy_file_sha. Column names avoid I-DB-4's regex (no blob, no code). |
4. Files¶
New and changed files (✚ new, ✎ changed):
package.json, pnpm-lock.yaml ✎ + yaml (2.x); script "graft": "tsx src/bin/graft.ts"
github/app-manifest.json ✎ default_events + pull_request_review, push (D14)
schema/graft.v1.schema.json ✚ generated from the zod schema (T3)
docs/graft-yaml.md ✚ reference (T13)
docs/examples/graft.yaml ✚ the §6 example, valid
src/core/glob.ts ✚ normalizeGlob, globMatcher({ nocase }) (moved, D2)
src/core/signals.ts ✚ ChangeSignal, SignalListener, Signals, createSignals, noSignals
src/core/context.ts ✎ JobContext.signals
src/webhooks/registry.ts ✎ createRegistry({ signalListeners }); Registry.signalListeners
src/app/registry.ts ✎ + policy; collects signalListeners
src/bin/bootstrap.ts ✎ signals: createSignals(registry.signalListeners)
src/bin/graft.ts ✚ CLI (T12)
src/db/schema.ts ✎ PolicyVerdictsTable (new shape)
src/db/migrations/0005_policy.ts ✚ + index.ts ✎
src/policy/
constants.ts ✚ POLICY_PATH, POLICY_FILE_GLOBS, LIMITS
errors.ts ✚ PolicyErrorCode, PolicyError, messages (§8)
schema.ts ✚ zod schema, normalised Policy types
globs.ts ✚ validateGlob (pure)
parse.ts ✚ parsePolicyText(bytes) → ParseResult (pure)
evaluate.ts ✚ evaluatePolicy, countApprovals (pure)
json-schema.ts ✚ policyJsonSchema()
load.ts ✚ resolveBaseCommit, loadBasePolicy, cache
index.ts ✚ re-exports
src/modules/intent/risk-floor.ts ✎ import glob + POLICY_FILE_GLOBS from the new homes; re-export
src/modules/intent/settings.ts ✎ the hook reads graft.yaml (T9)
src/modules/intent/job.ts ✎ passes octokit to the hook; emits intent_published
src/modules/intent/index.ts ✎ + latestPublishedCard
src/modules/provenance/job.ts ✎ emits provenance_published
src/modules/provenance/index.ts ✎ + getChangeProvenance
src/modules/changes/store.ts ✎ LivePull.labels
src/modules/policy/
index.ts ✚ { webhookHandlers, jobs, signalListeners }
handlers.ts ✚ policy.schedule, policy.review, policy.rerequest, policy.base-push
facts.ts ✚ listChangedPaths, listReviews, buildFacts (pure part)
job.ts ✚ policy.evaluate, policy.fanout
render.ts ✚ section + check output (pure)
store.ts ✚ verdict rows
src/eval/redact.ts ✎ T14
scripts/generate-fixtures.ts ✎ pull_request.labeled, pull_request_review.submitted/.dismissed, push
test/fixtures/webhooks/*.json ✚ the four new fixtures
test/fixtures/policy/*.yaml ✚ valid and invalid samples used by U-PY, U-PS and U-CLI
test/helpers/fake-github-api.ts ✎ contents by ref, git ref, reviews, labels; request log has `ref`
test/helpers/github-builders.ts ✎ makePull labels; makeReview; makeContent
test/helpers/app.ts, fake-queue.ts ✎ signals in the test context
test/helpers/policy-scenario.ts ✚ shared setup for I-POL
README.md, .env.example (unchanged unless needed), docs/phase-0/HUMAN-TODO.md ✎
5. Interfaces¶
// src/core/signals.ts
export interface ChangeSignal {
kind: "intent_published" | "provenance_published";
installationId: number; repositoryId: number; owner: string; repo: string;
prNumber: number; changeId: string; headSha: string;
atMs: number; // ctx.clock.now() at emit
}
export type SignalListener = (s: ChangeSignal, ctx: JobContext) => Promise<void>;
export interface Signals { emit(s: ChangeSignal, ctx: JobContext): Promise<void> } // listeners in order; errors propagate
export function createSignals(listeners: readonly SignalListener[]): Signals;
export const noSignals: Signals;
// src/policy/schema.ts (normalised; YAML snake_case → camelCase)
export interface Policy {
version: 1;
sensitivePaths: string[];
default: { humans: number; teams: string[] }; // from `default.require`; { humans: 1, teams: [] } when absent
rules: PolicyRule[];
}
export interface PolicyRule {
index: number; // 1-based position in `rules`
name: string | null;
match: {
risk?: RiskClass[]; paths?: string[]; onlyPaths?: string[];
author?: "agent" | "human"; agents?: string[]; labels?: string[];
size?: { minLines?: number; maxLines?: number; minFiles?: number; maxFiles?: number };
};
action: { kind: "approve" } | { kind: "require"; humans: number; teams: string[] } | { kind: "block" };
}
// src/policy/errors.ts
export interface PolicyError {
code: PolicyErrorCode;
location: string | null; // e.g. "rules[2].match.paths[1]"; rule numbers AND list items 1-based
line: number | null; column: number | null;
key?: string; // unknown_key only: the key, ≤ 40 chars
detail?: number; // a number for the message (size in bytes, count), never file text
}
export function formatPolicyError(e: PolicyError): string; // the §8 message, plain text
// src/policy/parse.ts
export type ParseResult =
| { ok: true; policy: Policy; hash: string } // sha256 hex of canonical JSON of `policy`
| { ok: false; errors: PolicyError[] }; // ≤ 50, sorted by line then column
export function parsePolicyText(bytes: Uint8Array): ParseResult;
// src/policy/load.ts
export type LoadedPolicy =
| { status: "ok"; commitSha: string; fileSha: string; policy: Policy; hash: string }
| { status: "missing"; commitSha: string; fileSha: null }
| { status: "invalid"; commitSha: string; fileSha: string | null; errors: PolicyError[] };
export function resolveBaseCommit(octokit: GitHubClient, o: { owner: string; repo: string; baseRef: string }): Promise<string>;
export function loadBasePolicy(octokit: GitHubClient, o: { repositoryId: number; owner: string; repo: string; commitSha: string }): Promise<LoadedPolicy>;
export function clearPolicyCache(): void;
// src/policy/evaluate.ts
export interface PolicyFacts {
files: { path: string; previousPath: string | null }[];
listingComplete: boolean;
size: { lines: number; files: number }; // pull.additions + deletions; pull.changed_files
risk: { value: RiskClass; final: boolean; source: "card" | "card_unavailable" | "card_pending" };
author: { kind: "agent" | "human" | "unknown"; agent: string | null };
labels: string[];
}
export type MatchKey = "risk" | "paths" | "only_paths" | "author" | "agent" | "labels" | "size";
export interface MatchReason { key: MatchKey; uncertain: boolean; paths: string[] /* ≤ 5, code-point order */; count: number }
export interface MatchedRule { index: number; action: "approve" | "require" | "block"; reasons: MatchReason[]; overridden: boolean }
export interface Constraint { humans: number; teams: string[]; rules: number[] } // rules = indices, "default", or "policy_file_changed" as 0
export type PolicyVerdict =
| { outcome: "would_approve"; matched: MatchedRule[]; effectiveRisk: EffectiveRisk; policyFileChanged: false }
| { outcome: "would_require"; matched: MatchedRule[]; effectiveRisk: EffectiveRisk; policyFileChanged: boolean;
humans: number; constraints: Constraint[]; usedDefault: boolean; builtIn: "policy_file_changed" | null }
| { outcome: "would_block"; matched: MatchedRule[]; effectiveRisk: EffectiveRisk; policyFileChanged: boolean; blockedBy: number[] };
export interface EffectiveRisk { value: RiskClass; final: boolean; source: PolicyFacts["risk"]["source"]; raisedBy: ("sensitive_paths" | "policy_file")[] }
export function evaluatePolicy(policy: Policy, facts: PolicyFacts): PolicyVerdict; // pure, total, deterministic
export interface ReviewLike { user: { id: number; login: string; type: string } | null; state: string; commit_id: string | null; submitted_at: string | null }
export interface ApprovalCount { current: number; stale: number; users: string[] /* current, ≤ 10, login order */; truncated: boolean }
export function countApprovals(reviews: ReviewLike[], o: { headSha: string; prAuthorLogin: string; truncated: boolean }): ApprovalCount;
evaluatePolicy does no I/O and never throws on valid input. It is the only place where the rule semantics live.
6. graft.yaml format (version 1)¶
The file is graft.yaml at the repository root, on the base branch. Full example (docs/examples/graft.yaml, which must pass pnpm graft policy check):
# yaml-language-server: $schema=https://raw.githubusercontent.com/<org>/graft/main/schema/graft.v1.schema.json
version: 1
# Extra paths that set the intent risk floor to `sensitive` (step 3 rule R7).
sensitive_paths:
- services/billing/**
- "**/payments/**"
# Used when no rule matches. Must be a `require`. Defaults to 1 human.
default:
require: { humans: 1 }
rules:
- name: docs-only
match: { risk: trivial, only_paths: ["docs/**", "*.md"] }
approve: auto
- name: trivial
match: { risk: trivial }
approve: auto
- name: infra-and-auth
match: { paths: ["infra/**", "**/auth/**"] }
require: { humans: 1, team: security }
- name: agent-sensitive
match: { author: agent, risk: sensitive }
require: { humans: 2 }
- name: no-agent-secrets
match: { paths: ["secrets/**"], author: agent }
block: true
- name: big-change
match: { size: { min_lines: 1000 } }
require: { humans: 2 }
- name: preview-agent
match: { agent: [codex-preview] }
require: { humans: 1, team: [platform, security] }
- name: hotfix
match: { labels: [hotfix] }
require: { humans: 1, team: oncall }
6.1 Keys¶
| Key | Type | Rule |
|---|---|---|
version |
1 |
Required. Any other value: version_unsupported. |
sensitive_paths |
list of globs | Optional, at most 50. Tightening glob (case-insensitive). |
default |
{ require: {...} } |
Optional. Only require is allowed (default_invalid). Default { humans: 1 }. |
rules |
list of rules | Optional (default []), at most 200. |
rule name |
string | Optional, ^[A-Za-z0-9][A-Za-z0-9 _.:-]{0,79}$. Unique within the file (name_duplicate). |
rule match |
mapping | Required and non-empty. Keys are combined with AND. |
| rule action | one of approve: auto, require: {...}, block: true |
Exactly one (action_missing, action_conflict). |
match.risk |
class or list | trivial, logic or sensitive; a list is any-of; 1 to 3 distinct entries. |
match.paths |
list of globs | Any-of: true when at least one changed file (new or previous name) matches one glob. 1 to 50 entries. Tightening. Not allowed with approve (D10). |
match.only_paths |
list of globs | All-of: true when the listing is complete, there is at least one file, and every file's path and previous path match some glob. Case-sensitive. 1 to 50 entries. |
match.author |
agent or human |
Provenance author_kind. Not allowed with approve. |
match.agent |
name or list | Case-insensitive exact match on the resolved Graft-Agent. The value rule is the step 2 agent rule [A-Za-z0-9][A-Za-z0-9 ._:/@+()-]{0,99}. At most 20. Implies author: agent. Not allowed with approve. |
match.labels |
list | Any-of, case-insensitive exact, 1 to 50 characters each with no control characters, at most 20. Not allowed with approve. |
match.size |
mapping | min_lines, max_lines, min_files, max_files: integers from 0 to 1,000,000, at least one of them, with min ≤ max. Inclusive bounds on additions + deletions and changed_files. |
require.humans |
integer | 1 to 10. Default 1. |
require.team |
slug or list | ^[a-z0-9][a-z0-9_-]{0,99}$ (a team slug in the repository owner's org, without @ or org/). A list is any-of. At most 10. require: {} is invalid (require_empty). |
approve |
auto |
Anything else: approve_invalid. |
block |
true |
Anything else, false included: block_invalid. |
Unknown keys at any level give unknown_key.
6.2 Glob rules (validateGlob)¶
A glob is 1 to 200 characters made only of letters, digits (Unicode \p{L}\p{N}) and the characters _.-/*?[]@+=~#%, and space. That excludes:
- !, so no negation and no extglob;
- { and }, so no brace expansion or ranges;
- (, ), |, \, ^ and $.
There is no .. segment and no empty segment except a single leading /. A glob has at most 4 ** segments, at most 3 single * per segment, and balanced [...]. It must also compile with picomatch, and the regex source must be at most 2,000 characters. (Review 1: a run of three or more * and ]+ are rejected with detail 7, and matching is done by a non-backtracking segment matcher, not by picomatch regexes; paths over 1,024 characters or 128 segments answer the strict side. Review 6: ** glued to other characters is rejected (detail 7); a class [ab] also matches its own text as picomatch does (unless its body holds - * ? + .). Review 2: a glob has at most 48 characters other than * per segment and 96 in all (detail 8), and no / inside [...] (detail 6); one GlobBudget per job (60,000,000 work units (35.0 M measured for the worst ordinary case, a 30-segment path matched by every glob, x 1.7; no pair cap; the matcher is a forward NFA with early exit and prefilters, so ordinary globs cost O(1) per path), shared by the risk floor and evaluatePolicy; every inner step costs 1 unit, classes are table lookups, paths are folded once; a 10 s wall clock is only a bug guard) covers the job, and the verdict carries limits (path_too_long, budget_exhausted) when it leaned on them. See README §7.) Each violation is glob_invalid with one detail reason (§8). Across the file there are at most 500 globs (too_many_globs).
6.3 Limits¶
| Limit | Value | Error |
|---|---|---|
| File size | 32,768 bytes | file_too_large |
Flow nesting ([, { outside quotes and comments) |
16 | yaml_too_deep |
| Rules | 200 | too_many_rules |
| Globs, whole file | 500 | too_many_globs |
| Errors reported | 50, then too_many_errors |
|
| Aliases and anchors | 0 | yaml_alias |
7. Schema and JSON Schema¶
src/policy/schema.ts. A zod 4 schema over thetoJS()output. It usesz.strictObjecteverywhere, andz.union([X, z.array(X)])for "one or a list" (normalised to arrays). Every leaf sets{ error: "<code>" }, so each issue maps to a catalogue code;unrecognized_keysmaps tounknown_key. The cross-field refinements are: exactly one action, approve-key restrictions (D10), size min ≤ max, unique names, glob validity, and limits. They run as asuperRefineand emit the same codes.- Locations. The parser maps a zod path to a 1-based
location(rules[2].match.paths[1]). It maps that to a line and column throughdoc.getIn(path, true).rangeand aLineCounter. When there is no node, the location is that of the nearest existing parent. schema/graft.v1.schema.jsonis generated bypolicyJsonSchema()(z.toJSONSchema, draft 2020-12, the step 4 method), with$idhttps://raw.githubusercontent.com/mmornati/graft/main/schema/graft.v1.schema.jsonandtitlegraft.yaml v1. The refinements that JSON Schema cannot express are listed in the top-leveldescription.pnpm graft policy schema --writeregenerates it, and U-PSCH-1 fails on drift.
8. Error catalogue¶
Messages are fixed text. They never repeat a value from the file, except the key of unknown_key (through codeSpan, at most 40 characters). The plain-text form is {location}: {message} ({code}), prefixed with line L, column C, when known.
| Code | Message |
|---|---|
file_too_large |
graft.yaml is {detail} bytes; the limit is 32,768. |
file_not_regular |
graft.yaml must be a regular file, not a directory, submodule or symlink. |
file_not_utf8 |
graft.yaml is not valid UTF-8. |
yaml_syntax |
YAML syntax error. |
yaml_alias |
Anchors and aliases (& and *) are not allowed. |
yaml_tag |
Tags (! and !!) are not allowed. |
yaml_duplicate_key |
This key appears twice in the same mapping. |
yaml_multiple_documents |
graft.yaml must contain exactly one YAML document. |
yaml_too_deep |
Nesting is deeper than 16 levels. |
not_a_mapping |
The top level must be a mapping with version and rules. |
version_missing |
version is required; use version: 1. |
version_unsupported |
Only version 1 is supported. |
unknown_key |
Unknown key {key}. |
wrong_type |
Wrong type for this key; see the graft.yaml reference. |
too_many_rules |
More than 200 rules. |
too_many_globs |
More than 500 globs in the file. |
too_many_items |
This list is longer than allowed. |
match_missing |
Each rule needs a non-empty match. |
action_missing |
Each rule needs one action: approve: auto, require or block: true. |
action_conflict |
A rule can have only one of approve, require and block. |
approve_invalid |
approve must be auto. |
block_invalid |
block must be true. |
require_empty |
require needs humans, team or both. |
humans_invalid |
humans must be an integer from 1 to 10. |
team_invalid |
A team is a slug such as security (no @ and no org/). |
risk_invalid |
risk must be trivial, logic or sensitive. |
author_invalid |
author must be agent or human. |
agent_invalid |
An agent name must match the Graft-Agent trailer rule. |
label_invalid |
A label must be 1 to 50 characters without control characters. |
size_invalid |
size needs min_lines, max_lines, min_files or max_files (integers 0 to 1,000,000, min not above max). |
name_invalid |
A rule name is 1 to 80 letters, digits, spaces, or _ . : - characters. |
name_duplicate |
Two rules have the same name. |
default_invalid |
default must be require: { humans, team }. |
glob_invalid |
Invalid glob: {reason}. where detail selects the reason: 1 empty or longer than 200 characters, 2 character not allowed (no ! { } ( ) \| \\ ^ $), 3 .. or empty segment, 4 more than 4 ** segments, 5 more than 3 * in one segment, 6 unbalanced [ ], 7 does not compile |
approve_untrusted_key |
approve: auto cannot use author, agent or labels: the pull request author controls them. Use them in require or block rules. |
approve_any_path |
approve: auto cannot use paths, which matches when any file matches. Use only_paths. |
too_many_errors |
More than 50 errors; fix these first. |
9. Semantics¶
9.1 Per-key evaluation¶
Let L be the effective risk lower bound: max(facts.risk.value, sensitive if a changed path or previous path matches sensitive_paths (case-insensitive) or POLICY_FILE_GLOBS). The possible risk set is P = {L} when facts.risk.final or L = sensitive, and otherwise P = {c : c ≥ L}.
| Key | Tightening rule (require, block) |
Approve rule |
|---|---|---|
risk |
rule.risk ∩ P ≠ ∅; uncertain when |P| > 1 |
P ⊆ rule.risk |
paths |
Some file matches; when there is no match and !listingComplete, it matches as uncertain |
not allowed |
only_paths |
Every file matches (case-sensitive), with at least one file and listingComplete |
same |
author |
kind === rule.author; unknown matches as uncertain |
not allowed |
agent |
kind === "agent" and the agent is in the list (case-insensitive); kind unknown, or agent null with kind agent, matches as uncertain; human does not match |
not allowed |
labels |
Some PR label is in the list (case-insensitive) | not allowed |
size |
Inclusive bounds | same |
A rule matches when every key in its match matches. A rule is uncertain when any of its reasons is.
9.2 Combination (D8, D9)¶
- Evaluate every rule in file order.
matchedlists every matching rule. - If any matched rule is
block, the outcome iswould_blockandblockedBylists those indices. The other matched rules are listed withoverridden: true. - Else, if any matched rule is
require, the outcome iswould_require. The constraints are those rules'{humans, teams}, with equal team sets (sorted) merged bymax, andhumans = max(n). Matched approve rules getoverridden: true. - Else, if any matched rule is
approve: whenpolicyFileChangedor the listing is incomplete (!listingComplete, review 1: the PR may change the policy file, D11), the outcome iswould_requirewith the constraint{humans: 1, teams: [], rules: [0]}andbuiltIn: "policy_file_changed", and the approve rules are overridden. Otherwise the outcome iswould_approve. - Else the outcome is
would_requirewith thedefaultconstraint (rules: []) andusedDefault: true.builtInis"policy_file_changed"when that applies, so the note shows. - Constraints are ordered by team constraints in rule order, then the anyone constraint.
9.3 Worked examples (policy of §6; each one is a U-PE case)¶
| ID | Facts | Matched | Outcome |
|---|---|---|---|
| E1 | docs/a.md; card ok trivial; human |
1, 2 (approve) | would_approve |
| E2 | src/x.ts; card ok trivial; agent claude-code |
2 | would_approve |
| E3 | infra/main.tf; card ok sensitive; agent |
3 {1, security}, 4 {2} | would_require, humans 2, constraints [{1,[security]},{2,[]}] |
| E4 | secrets/prod.env; card ok sensitive; agent |
4, 5 | would_block, blockedBy [5], 4 overridden |
| E5 | src/x.ts; card pending, floor trivial → value logic, not final; agent |
4 (risk uncertain: P = {logic, sensitive}); 2 does not match (P ⊄ {trivial}) | would_require 2, uncertain |
| E6 | services/billing/pay.ts; card ok logic; agent |
L = sensitive (sensitive_paths) → 4 |
would_require 2, raisedBy: [sensitive_paths] |
| E7 | graft.yaml (the PR deletes rule 5); card ok trivial; human |
L = sensitive (policy_file); none match |
would_require 1, usedDefault, builtIn: policy_file_changed, policyFileChanged |
| E8 | secrets/x; card ok sensitive; author unknown |
4, 5, 7 (all uncertain on author) | would_block, blockedBy [5]; 4 and 7 overridden |
| E9 | src/x.ts; card ok trivial; human; label HotFix |
2 (approve), 8 | would_require 1 from oncall; 2 overridden |
| E10 | src/x.ts; card ok logic; agent with agent: null (bot account) |
7 (agent uncertain) | would_require 1 from platform or security |
| E11 | docs/a.md; card ok trivial; human; listingComplete: false |
2 (approve), 3 (paths uncertain: file list incomplete); 1 does not match | would_require 1 from security; 2 overridden |
| E12 | Docs/A.md; card ok trivial; human |
1 does not match (only_paths is case-sensitive); 2 matches | would_approve (via rule 2) |
| E13 | policy {version: 1} only; any facts |
none | would_require 1, usedDefault |
| E14 | policy with only rule {risk: [trivial, logic]} approve; card pending, L = logic |
1 does not match (P = {logic, sensitive}) | would_require 1 (default) |
10. Output (lines shown literally)¶
10.1 Check run graft/policy¶
conclusion: "neutral"always, for every outcome and every policy state.status: "completed",external_id: change.id, noactions.output.title(at most 200 characters; longer titles are cut with…):would_approve→Would auto-approvewould_requirewith one team constraint whosen == humans→Would require {H} human{s} from {teams}; otherwiseWould require {H} human{s}plus({n} from {teams}; …)when there are team constraints.{teams}is@{owner}/{slug}joined withor. A default with no team gets(default).would_block→Would block: rule {i}orWould block: rules {i}, {j}policy_invalid→Policy invalid: no decision ({n} error{s})no_policy→No graft.yaml on {baseRef}, withbaseRefcut to 100 characters- Append
· risk provisionalwhen!effectiveRisk.finaland the outcome is notwould_block. output.summary= the section markdown (§10.2).output.text= the details list (§10.3).
Examples: Would require 1 human from @acme/security; Would require 2 humans (1 from @acme/security); Would require 1 human (default) · risk provisional; Would block: rule 5.
10.2 Section policy (renderPolicySection)¶
would_require (E3, with one current approval and one stale):
### Policy
**Would require 2 humans (1 from `@acme/security`)** · report only
- Rule 3 infra\-and\-auth: paths match `infra/main.tf`
- Rule 4 agent\-sensitive: risk `sensitive` (card), author agent `claude-code`
**Approvals so far:** 1 on this head (`alice`), 1 on an older commit. Team membership is not checked in Phase 0.
Policy `graft.yaml` on `main` at `a1b2c3d` · head `ec26c3e` · Graft only reports in Phase 0; it approves and blocks nothing.
Rules:
- The headline is the check title in bold, with team names as code spans (D19). Then · report only.
- One bullet per matched rule, in index order: - Rule {i}{ name}: {reasons joined ", "}, with (overridden) appended when overridden. The name goes through escapeProse(…, 80). Reasons:
- risk({source text}), where the source text is card, card unavailable or rules; card pending, plus , raised by sensitive_paths or , raised by graft.yaml change;
- paths match {first 3 codeSpan}{ and K more};
- for only_paths: all {n} paths match;
- author {kind}, plus `agent` when known;
- label {codeSpan};
- size {lines} lines, {files} files.
An uncertain reason is suffixed (unknown: {provenance pending | card pending | agent not reported | file list incomplete}).
- When nothing matched: - No rule matched; the default applies.
- Blocks and notes go between the bullets and the approvals line, in this order:
- When policyFileChanged:
> [!IMPORTANT]
> This pull request changes `graft.yaml`. Its version is ignored: the policy on `main` applies, and the change needs at least 1 human.
!effectiveRisk.final:
> [!NOTE]
> The intent card for this head is not ready, so the risk used is at least `logic` and rules that depend on it are applied conservatively. Graft re-evaluates when the card is published.
would_approve with zero approvals. Logins go through codeSpan. More than 10 logins gives and K more. truncated gives (first 300 reviews).
- Footer: Policy `graft.yaml` on `{baseRef}` at `{commit7}` · head `{head7}` · Graft only reports in Phase 0; it approves and blocks nothing.
would_block (E4): headline **Would block: rule 5** · report only. Bullets: rule 4 (overridden), then rule 5.
policy_invalid:
### Policy
**Policy invalid: no decision (2 errors)** · report only
`graft.yaml` on `main` at `a1b2c3d` cannot be used, so Graft would not auto-approve anything:
- line 12, column 9, rules\[2\].match.paths\[1\]: Invalid glob: .. or empty segment. (glob\_invalid)
- line 20, column 3, rules\[3\]: Unknown key `aprove`. (unknown\_key)
Fix the file on `main`, and check it locally with `pnpm graft policy check graft.yaml`.
Head `ec26c3e` · Graft only reports in Phase 0; it approves and blocks nothing.
At most 20 errors are listed, then - and K more. Each error line is escapeProse(formatPolicyError(e), 300), except the unknown key, which is spliced in through codeSpan.
no_policy:
### Policy
**No policy** · report only
There is no `graft.yaml` on `main` at `a1b2c3d`. Add one to report what Graft would require; see the graft.yaml reference in the Graft docs.
Head `ec26c3e` · Graft only reports in Phase 0; it approves and blocks nothing.
When the PR adds graft.yaml, no_policy also shows the [!IMPORTANT] block with the text This pull request addsgraft.yaml. It takes effect after merge; until then there is no policy.
10.3 Check output.text (details)¶
**Matched rules**
- Rule 3 infra\-and\-auth → require 1 from `@acme/security`: paths match `infra/main.tf`
- Rule 4 agent\-sensitive → require 2: risk `sensitive` (card), author agent `claude-code`
**Facts used**
- Risk `sensitive` from the card (final)
- Author agent `claude-code` (provenance for head `ec26c3e`)
- 1 file changed, 12 lines; file list complete
- Labels: none
- Policy file SHA `9f8e7d6`, policy hash `3c1a0b2e`
7 other rules did not match.
10.4 Managed comment¶
The step 2 shape is unchanged. policy renders after intent and provenance (SECTION_ORDER), and the other sections are preserved byte for byte.
11. Schema (migration 0005_policy)¶
up drops the 0001 policy_verdicts (no code writes it) and creates:
CREATE TABLE policy_verdicts (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
change_id uuid NOT NULL REFERENCES changes(id) ON DELETE CASCADE,
head_sha text NOT NULL CHECK (head_sha ~ '^[0-9a-f]{40}$'),
base_ref text NOT NULL,
policy_commit_sha text NOT NULL CHECK (policy_commit_sha ~ '^[0-9a-f]{40}$'),
policy_file_sha text CHECK (policy_file_sha ~ '^[0-9a-f]{40}$'), -- NULL: no file (missing, directory)
policy_status text NOT NULL CHECK (policy_status IN ('ok','missing','invalid')),
policy_hash text CHECK (policy_hash ~ '^[0-9a-f]{64}$'), -- set iff policy_status = 'ok'
mode text NOT NULL DEFAULT 'report' CHECK (mode IN ('report','enforce')),
outcome text NOT NULL CHECK (outcome IN ('would_approve','would_require','would_block','no_policy','policy_invalid')),
check_conclusion text NOT NULL DEFAULT 'neutral' CHECK (check_conclusion IN ('neutral','success','failure','action_required')),
risk_class text CHECK (risk_class IN ('trivial','logic','sensitive')),
risk_final boolean NOT NULL DEFAULT false,
risk_source text CHECK (risk_source IN ('card','card_unavailable','card_pending')),
author_kind text CHECK (author_kind IN ('agent','human','unknown')),
policy_file_changed boolean NOT NULL DEFAULT false,
rules_matched jsonb NOT NULL DEFAULT '[]', -- MatchedRule[]: indices, actions, reason keys, PR paths ≤ 5
required jsonb NOT NULL DEFAULT '{}', -- {humans, constraints:[{humans, teamCount, rules}], usedDefault, builtIn}
approvals jsonb NOT NULL DEFAULT '{}', -- ApprovalCount
errors jsonb NOT NULL DEFAULT '[]', -- PolicyError[] without `key`
facts_hash text NOT NULL CHECK (facts_hash ~ '^[0-9a-f]{64}$'),
facts_read_at timestamptz NOT NULL,
check_run_id bigint,
published_at timestamptz,
evaluated_at timestamptz NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CHECK ((policy_status = 'ok') = (policy_hash IS NOT NULL)),
UNIQUE NULLS NOT DISTINCT (change_id, head_sha, policy_file_sha)
);
CREATE INDEX policy_verdicts_change_idx ON policy_verdicts (change_id, facts_read_at DESC);
downdrops this table and restores the0001definition and index exactly.facts_hash= sha256 of canonical JSON of{verdict, approvals, policyStatus, policyFileSha, policyHash, errors, headSha, baseRef, commitSha}.PolicyVerdictsTableinsrc/db/schema.tsmatches the table.TABLE_NAMESis unchanged (13).
12. Tasks¶
Each task has acceptance criteria (AC). Test IDs are in §13.
T1. Dependencies and library verification¶
pnpm add yaml(2.x). Add the script"graft": "tsx src/bin/graft.ts".- Verify against
node_modulesbefore use, and record the version and every difference in README §7: - in
yaml:parseDocumentandparseAllDocuments;LineCounter; the optionsversion,schema: "core",uniqueKeys,merge,maxAliasCount,strict,prettyErrors;visitwithisAliasandnode.anchor; how!custom,!!binaryand!!setare reported (error, warning or tag);doc.errorsanddoc.warningscodes;doc.getIn(path, true)andnode.range; - in Octokit:
git.getRef(ref: "heads/<branch>") andrepos.getContent(object vs array response,encoding: "none"above 1 MB, symlink behaviour per the GitHub docs); - in
@octokit/webhooks: thepull_request_reviewandpushpayload types.
AC: pnpm install --frozen-lockfile works. README §7 lists the yaml version and the findings.
T2. Shared pieces: glob, constants, signals¶
- Move
normalizeGlobandglobMatchertosrc/core/glob.tswith an option{ nocase?: boolean }(defaulttrue). The cache key includesnocase.risk-floor.tsimports them and re-exportsglobMatcher; behaviour is unchanged. - Move
POLICY_FILE_GLOBStosrc/policy/constants.ts;risk-floor.tsimports and re-exports it. src/core/signals.ts(§5).JobContext.signalsis required.createRegistrytakessignalListeners(default[]) and exposes them.src/app/registry.tscollects them from modules that exportsignalListeners.bootstrap.tsandtest/helpers/app.tsbuildcreateSignals(registry.signalListeners). Unit-test contexts may usenoSignals.
AC: every existing test passes unchanged except for the added signals field. U-GLOB-1, U-SIG-1.
T3. Schema, errors, JSON Schema (src/policy/schema.ts, errors.ts, json-schema.ts)¶
Implement §6, §7 and §8. Normalisation: lists are deduplicated (globs keep the first occurrence and file order); team and agent become arrays; a missing default becomes { humans: 1, teams: [] }; and require.humans defaults to 1.
AC: U-PS-1 to U-PS-12, U-PSCH-1.
T4. Safe parsing (src/policy/parse.ts, globs.ts)¶
Do these in order, and stop at the first failure of steps 1 to 4:
1. byte length ≤ 32,768;
2. strict UTF-8 decode (new TextDecoder("utf-8", { fatal: true })), then strip the BOM;
3. flow-depth pre-scan;
4. parseAllDocuments: exactly one document, with no errors and no warnings;
5. visit: any Alias node or anchor gives yaml_alias, and any explicit tag gives yaml_tag (all occurrences are reported, with lines);
6. toJS({ maxAliasCount: 0 });
7. zod and the refinements;
8. hash.
The function is pure and never throws for any input bytes (U-PY-10 fuzz).
AC: U-PY-1 to U-PY-10, U-PG-1 to U-PG-6.
T5. Evaluator (src/policy/evaluate.ts)¶
Implement §9 exactly, plus countApprovals (D13). Reasons hold at most 5 paths in code-point order, and count holds the total.
AC: U-PE-1 to U-PE-14 (= E1 to E14), U-PE-15 to U-PE-22, U-PA-1 to U-PA-5.
T6. Loader and cache (src/policy/load.ts)¶
resolveBaseCommit:git.getRef({ ref: "heads/" + baseRef })→object.sha(40-hex, or a programmer error). A 404 throwsPermanentJobError("base branch not found", "base_missing"). Other errors go throughtoJobError.loadBasePolicy: check the cache, then callgetContent({ path: "graft.yaml", ref: commitSha }).- A 404 gives
missing. - An array gives
invalid/file_not_regularwithfileSha: null. - When
typeis not"file"orpathis not"graft.yaml", the result isfile_not_regularwithfileSha: data.sha. size > 32768, orencoding !== "base64", givesfile_too_large(withdetail= size).- Otherwise decode and call
parsePolicyText. - Store the result in the cache (LRU of 256) and return it.
- Never log the content, the globs or the rule names. Log
{repositoryId, commitSha7, status, errors: n}.
AC: U-LOAD-1 to U-LOAD-6.
T7. Facts (src/modules/policy/facts.ts)¶
listChangedPaths(octokit, {owner, repo, prNumber, changedFiles}): whenchangedFiles > 1000, return{files: [], complete: false}without a call. Otherwise callpulls.listFileswith 100 per page, at most 10 pages. Keepfilename,previous_filename,status,additionsanddeletions, and droppatchat once.complete=files.length === changedFiles.listReviews: 100 per page, at most 3 pages, withtruncated.buildFacts(pure):- risk. Use
latestPublishedCard(db, changeId)(newly exported by intent) when itshead_shaequals the live head:okgives{risk_class, final: true, "card"}, andunavailablegives{risk_class, final: false, "card_unavailable"}. Otherwise use{max(computeRiskFloor({files as ChangedFile with patch null, pr sizes, listingTruncated: !complete, tooLarge: changedFiles > 1000, partialPaths: [], untrustedTexts: []}, {extraSensitiveGlobs: config ∪ policy.sensitivePaths}).floor, "logic"), false, "card_pending"}. - author. Use
getChangeProvenance(newly exported by provenance) when itshead_shaequals the live head:{author_kind, agent}. Otherwise{unknown, null}. - labels.
pull.labels[].name. size. Frompull.
AC: U-FACT-1 to U-FACT-4.
T8. Rendering (src/modules/policy/render.ts, pure)¶
Implement §10 byte for byte. Every string from the file or the PR goes through escapeProse or codeSpan, and teams always through codeSpan (D19). Section ≤ MAX_SECTION_CHARS: when it is longer, keep the headline, the notes and the footer, cut the rule bullets, and add - and K more rules.
AC: U-PR-1 to U-PR-9.
T9. The intent hook (src/modules/intent/settings.ts, job.ts)¶
- The signature becomes
intentRepoSettings(ctx: Pick<JobContext, "config" | "log">, target: IntentSettingsTarget, octokit: GitHubClient).target.baseShamust be a 40-hex SHA; the intent job passespull.base.sha, orawait resolveBaseCommit(...)when it is absent. loadBasePolicyatbaseSha. Onok,extraSensitiveGlobs = dedupe([...config.intentSensitivePaths, ...policy.sensitivePaths]). Onmissingorinvalid, use the config globs only, and logpolicy_statuswithout throwing. Transient GitHub errors propagate, and the intent job maps them withtoJobError.- The cache is the D17 LRU, so the hook and the policy job share reads for the same commit.
- The intent job emits
intent_publishedafterpublishedis true, withatMs = ctx.clock.now().getTime(). The provenance job emitsprovenance_publishedafter the outcomepublished. Both emit outside the lock.
AC: I-POLH-1 to I-POLH-6. Existing I-INT tests pass (the fake API serves no graft.yaml, which gives missing).
T10. Store (src/modules/policy/store.ts)¶
upsertVerdict(tx, row)on(change_id, head_sha, policy_file_sha).maxFactsReadAt(tx, changeId).findVerdict(tx, identity).checkRunIdForHead(tx, changeId, headSha): any row of that head with acheck_run_id.
T11. Handlers and jobs (handlers.ts, job.ts, index.ts)¶
Handlers (payload = trigger; each parses with zod through parsePayload, checks getActiveInstallation, and enqueues):
| Handler | On | Job and singletonKey |
|---|---|---|
policy.schedule |
pull_request.opened, synchronize, reopened, edited, labeled, unlabeled |
policy.evaluate, policy:pr:{repoId}:{pr}:{head}:{baseRef}:{updated_at}:{action}:{sha16(label?.name ?? "")} |
policy.review |
pull_request_review.submitted, dismissed |
policy.evaluate, policy:review:{repoId}:{pr}:{review.id}:{action} |
policy.rerequest |
check_run.rerequested with name graft/policy and our app id |
policy.evaluate, policy-rerun:{repoId}:{pr}:{floor(nowMs/60000)} |
policy.base-push |
push: ref starts with refs/heads/, deleted false, after 40-hex non-zero, and (a commit lists graft.yaml in added/modified/removed, or forced, or commits.length >= 20) |
policy.fanout, policy-push:{repoId}:{ref}:{after} |
| signal listener | intent_published, provenance_published |
policy.evaluate, policy:sig:{kind}:{changeId}:{headSha}:{atMs} |
policy.evaluate. Data: {installationId, repositoryId, owner, repo, prNumber, trigger: "pr" | "review" | "rerequested" | "intent" | "provenance" | "base_push", deliveryId?}. Options: retryLimit 5, retryDelaySeconds 30, retryBackoff true, expireInSeconds 120. Steps:
1. Get the installation (or skipped), octokit, and pulls.get. Check for a repo mismatch (PermanentJobError), call ensureRepository (skipped if removed), then upsertChangeFromPull (stale when not applied). Stop with closed on a closed PR.
2. factsReadAt = ctx.clock.now().
3. commit = resolveBaseCommit(base.ref), then loaded = loadBasePolicy(commit).
4. When loaded.status === "ok": listChangedPaths, listReviews, buildFacts, evaluatePolicy, countApprovals. When it is missing: only listChangedPaths, to detect "this PR adds graft.yaml". When it is invalid: no listing.
5. Render, then compute facts_hash.
6. withChangeLock, with the D16 checks (a), (b) and ©; otherwise stale. When a row with the same identity has the same facts_hash, a check_run_id and published_at, the result is unchanged (no GitHub call). Otherwise:
- check run (update checkRunIdForHead, or a listForRef hit, or create);
- upsertManagedSection("policy");
- store graft_comment_id;
- upsertVerdict with the ids, published_at = now and facts_read_at.
Errors go through toJobError("publish").
7. Log {outcome, policyStatus, verdict outcome, matched: n, uncertain: bool}, never names, globs or paths.
policy.fanout. Data: {installationId, repositoryId, owner, repo, branch, afterSha, deliveryId}.
1. Check that the installation and the repository are active.
2. Select open changes with base_ref = branch, ordered by pr_updated_at DESC NULLS LAST, limit 101. If 101 rows come back, log fanout_truncated and keep 100.
3. For each row i, send policy.evaluate (trigger: "base_push") with the key policy:base:{repoId}:{pr}:{afterSha} and startAfterSeconds = floor(i / 10) * 5.
index.ts exports webhookHandlers, jobs (policyEvaluateJob, policyFanoutJob) and signalListeners. src/app/registry.ts adds policy last.
AC: I-POL-1 to I-POL-20, I-POLF-1 to I-POLF-5, U-PH-1 to U-PH-4.
T12. CLI (src/bin/graft.ts)¶
pnpm graft policy check <file> [--json]:lstat: a symlink or non-file givesfile_not_regular;- read the bytes and call
parsePolicyText; - text output:
{file}: OK (version 1, {n} rules, {m} sensitive paths)on success, or one line per error,{file}:{line}:{col}: error {code} {location}: {message}; --jsonprints{ok, errors};- exit 0 when valid, 1 when invalid, and 2 for usage errors and an unreadable or missing file.
pnpm graft policy schema [--write | --check]prints, writes or comparesschema/graft.v1.schema.json. A drift under--checkexits 1.- Any other command prints the usage and exits 2.
- The CLI imports only
src/policy/andnode:*. It needs no env, no DB and no config.
AC: U-CLI-1 to U-CLI-6.
T13. Manifest, fixtures, docs¶
- In
github/app-manifest.json,default_eventsbecomes["check_run", "pull_request", "pull_request_review", "push"]. Permissions are unchanged. Update U-MAN-1. scripts/generate-fixtures.tsaddspull_request.labeled,pull_request_review.submitted,pull_request_review.dismissedandpush, rewritten to the fixture world. U-FIX-1 covers them.- Extend the fake GitHub API:
GET /repos/{o}/{r}/git/ref/heads/{branch}fromstate.branches;GET /repos/{o}/{r}/contents/{path}?ref=fromstate.contents[ref][path], which supports file, dir, symlink, submodule, large (encoding: "none") and 5xx;GET /pulls/{n}/reviews(paged);labelsinmakePull.
The request log records ref for contents calls.
4. docs/graft-yaml.md (new):
- where the file lives, and the trust model (base branch, PR edits ignored);
- the §6 keys table and the glob rules;
- the §9 semantics, with a short version of the worked examples (E1, E3, E4, E5, E7, E9);
- the limits, the error catalogue, the editor setup (yaml-language-server comment with the schema URL);
- the CLI and the Action snippet below;
- "Phase 0 only reports", and "teams are not checked until Phase 1".
5. The Action snippet (copy into docs/graft-yaml.md and README):
# .github/workflows/graft-yaml.yml
name: graft.yaml
on:
pull_request:
paths: ["graft.yaml"]
permissions:
contents: read
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4 # pin to a commit SHA per your org policy
- uses: actions/checkout@v4
with:
repository: <org>/graft # HUMAN-TODO: final location
ref: <pinned commit SHA>
path: .graft-tool
token: ${{ secrets.GRAFT_TOOL_TOKEN }} # only while the Graft repo is private
- uses: pnpm/action-setup@v4
with: { package_json_file: .graft-tool/package.json }
- uses: actions/setup-node@v4
with: { node-version: 24, cache: pnpm, cache-dependency-path: .graft-tool/pnpm-lock.yaml }
- run: pnpm install --frozen-lockfile --dir .graft-tool
- run: pnpm --dir .graft-tool graft policy check "$GITHUB_WORKSPACE/graft.yaml"
It uses pull_request, never pull_request_target. The file is only parsed, never executed.
6. README:
- §1 intro: step 5;
- §5: layout (src/policy, modules/policy, src/core/signals.ts, src/core/glob.ts), and a "Policy contract" paragraph: base branch only; src/policy imports no module; payloads are triggers; nothing from the file is stored but the hash; teams are rendered as code spans;
- a new "Policy (graft.yaml)" section linking docs/graft-yaml.md;
- §6: the events table gains pull_request_review and push, contents: read is "now used", and pull_request_review comes off the deferred list;
- §7: "Step 5 deviations and additions", and the step 4 redaction paragraph updated for T14;
- the "Sensitive paths" paragraph says graft.yaml sensitive_paths now extend the list.
7. HUMAN-TODO: §14.
T14. Step 4 review 2 follow-ups (src/eval/redact.ts)¶
- Linear-time scan (minor 1).
valueSpanscans at most 512 characters for a quoted close or an unquoted end.- An unquoted run that is longer than 512 characters is redacted as a whole: one marker up to the run's real end, found by a single forward scan.
- After every head, whether the value is accepted, rejected or empty,
ASSIGN_HEAD.lastIndex = max(lastIndex, end of the scanned span). - Every character is therefore scanned by
valueSpanat most once per line. - The README sentence about adversarial lines covers this loop too.
- Value terminators (minor 2). An unquoted value ends at whitespace, at a quote, or at
,,;,),},]or&. - Leak forms (minor 3).
- Next-line values. After a secret-named key whose value is empty, a YAML block indicator (
^[|>][+-]?[1-9]?$or^[|>][1-9][+-]$), a line ending in\, or an unterminated quote, the following lines are redacted.- For a block indicator or an empty value: lines indented more than the key line, but for an empty value only lines that are not themselves
key:lines. - For a
\continuation: the next line, and the one after it again while each ends in\. - For a quote: lines up to and including the one with the closing quote.
- At most 50 lines. The body becomes indentation +
[REDACTED:assignment], and the prefixes and line count are kept. - A hunk header or a
\ No newlineline ends the state, as for key blocks.
- For a block indicator or an empty value: lines indented more than the key line, but for an empty value only lines that are not themselves
- CLI flags.
--name valueand-name value, where the name passesisSecretNameonce the dashes are stripped and the value is 8 or more characters and notNOT_A_VALUE, give kindcli_flag.-p<value>(4 or more characters) only on a line that contains\b(mysql|mysqldump|mysqladmin|mariadb)\b.-uor--userwithuser:pass(optionally quoted) loses only the password. - Redis-style URLs. The user part of
URL_CREDENTIALSbecomes{0,100}(redis://:pass@host). - Key names. Add
(?:encryption|master|signing)_?keytoKEY_SUBSTRING. - Internal capitals.
isSecretNametestsKEY_SUBSTRINGagainst the camel-split form and against the flat form (lower-cased,_-.removed), soPassWordis caught. - Slack webhooks. Add the secret pattern
slack_webhook,/\bhooks\.slack\.com\/(?:services|workflows|triggers)\/[A-Za-z0-9_\/-]{10,200}/g. - Nit.
BENIGN_FIRST = /^(?:max|min|limit|budget|num|total)_/on the split name:max_tokens = 100000000stays readable.
AC: U-RED-12 (extended), U-RED-14 to U-RED-18. Every existing U-RED test passes. pnpm eval:ci is unchanged (redaction does not run on the synthetic set).
13. Test plan¶
Everything is offline: fake GitHub API, fake LLM, real Postgres and pg-boss for I-. Test names carry the IDs.
Unit: parsing and schema
| ID | Asserts |
|---|---|
| U-PY-1 | docs/examples/graft.yaml parses; the normalised Policy deep-equals a pinned object; the hash is stable across key order and whitespace changes |
| U-PY-2 | 32,769 bytes → file_too_large with detail; 32,768 valid bytes parse |
| U-PY-3 | A billion-laughs document (9 levels of &a/*a) → yaml_alias in under 50 ms, and toJS is never called (spy) |
| U-PY-4 | Any single anchor without an alias → yaml_alias; a merge key <<: *x → yaml_alias |
| U-PY-5 | !custom, !!binary, !!python/object → yaml_tag with lines |
| U-PY-6 | Duplicate key → yaml_duplicate_key; two documents → yaml_multiple_documents; a syntax error → yaml_syntax with line and column |
| U-PY-7 | Invalid UTF-8 → file_not_utf8; a BOM is accepted |
| U-PY-8 | 17 nested [ → yaml_too_deep; [ inside quotes and comments is not counted |
| U-PY-9 | A top-level list or scalar → not_a_mapping; no version → version_missing; version: 2 → version_unsupported |
| U-PY-10 | Fuzz: 2,000 seeded random byte strings and 2,000 mutations of the example never throw, and each returns ok or ≤ 50 errors |
| U-PS-1 | Every unknown_key level (top, rule, match, size, require), with key set and line numbers |
| U-PS-2 | Action errors: none, two, approve: yes, block: false |
| U-PS-3 | approve_untrusted_key for each of author, agent and labels; approve_any_path for paths; only_paths + risk + size are allowed |
| U-PS-4 | humans 0, 11, 1.5 and "2" → humans_invalid; require: {} → require_empty |
| U-PS-5 | team @acme/security, acme/security and Security → team_invalid; a list of 11 → too_many_items |
| U-PS-6 | risk, author, agent and label invalid values |
| U-PS-7 | size: empty, negative, min > max, and over 1,000,000 |
| U-PS-8 | Names: invalid and duplicate |
| U-PS-9 | default with approve or block → default_invalid; an absent default → {1, []} |
| U-PS-10 | 201 rules → too_many_rules; 501 globs → too_many_globs; 60 errors → 50 + too_many_errors |
| U-PS-11 | Normalisation: scalars become lists, deduplicated in file order, humans defaults to 1 |
| U-PS-12 | No error message contains any value from the file (property test over the invalid fixtures: the message ∩ the file's scalar values = ∅, except key) |
| U-PSCH-1 | policyJsonSchema() equals schema/graft.v1.schema.json; ajv validates the example and rejects a missing version, an unknown key and two actions |
| U-PG-1 | Every glob_invalid detail 1 to 7, with one example each |
| U-PG-2 | The same globs behave identically under globMatcher from src/core/glob.ts and through the risk-floor.ts re-export (table of 30 path/glob pairs) |
| U-PG-3 | nocase: false (only_paths) distinguishes Docs/ from docs/; the default does not |
| U-PG-4 | A pattern with 3 * in a segment, against a 255-character non-matching segment, takes under 50 ms |
| U-PG-5 | A leading / anchors; no / means any depth; dot: true |
| U-PG-6 | 500 globs × 1,000 paths are evaluated in under 1 s |
Unit: evaluation, loader, facts, render, handlers, CLI
| ID | Asserts |
|---|---|
| U-PE-1 … U-PE-14 | §9.3 E1 to E14: the exact verdict object, including the uncertain flags and overridden |
| U-PE-15 | Block wins regardless of rule order (rules permuted, same outcome) |
| U-PE-16 | Requirements merge: same team set → max; different sets → separate constraints; humans = max |
| U-PE-17 | An approve rule with risk: trivial never matches while !final (all 3 floors) |
| U-PE-18 | only_paths is false with zero files, with listingComplete: false, or with a renamed file whose old path is outside |
| U-PE-19 | paths matches the previous path of a rename (into and out of secrets/) |
| U-PE-20 | agent with a known human author never matches; with an unknown author it matches as uncertain |
| U-PE-21 | sensitive_paths and policy-file raises set raisedBy, and P = {sensitive} makes final irrelevant |
| U-PE-22 | Determinism: the same input gives a deep-equal output, and input arrays are not mutated |
| U-PA-1 | Latest-review-per-user wins (APPROVED then CHANGES_REQUESTED → 0) |
| U-PA-2 | COMMENTED does not reset an approval; DISMISSED does |
| U-PA-3 | Author, bots and null users are excluded |
| U-PA-4 | current vs stale by commit_id; users sorted and capped at 10 |
| U-PA-5 | truncated is passed through |
| U-LOAD-1 | 404 → missing; a file → ok, with the ref equal to the commit SHA given |
| U-LOAD-2 | Directory (array), symlink, submodule, and path ≠ graft.yaml (followed symlink) → file_not_regular |
| U-LOAD-3 | size 40,000 or encoding: "none" → file_too_large, with the content never decoded |
| U-LOAD-4 | Cache: two loads of the same commit make one request; a different commit makes a new one; a 5xx is not cached and throws a retryable error |
| U-LOAD-5 | resolveBaseCommit 404 → PermanentJobError base_missing |
| U-LOAD-6 | The log lines contain no glob, name or YAML text (log capture) |
| U-FACT-1 | listChangedPaths drops patch (the returned objects have no patch key); over 1,000 files makes no call and returns complete: false |
| U-FACT-2 | Risk source: card ok for the head; card unavailable; card for another head → pending floor with max(…, logic) |
| U-FACT-3 | Provenance for another head → unknown |
| U-FACT-4 | The pending floor uses the policy's sensitive_paths |
| U-PR-1 | §10.2 would_require byte for byte |
| U-PR-2 | would_block, would_approve (no approvals line), policy_invalid, no_policy and "adds graft.yaml": byte for byte |
| U-PR-3 | Titles: every §10.1 form, including cutting at 200 and · risk provisional |
| U-PR-4 | A team is always inside a code span: no @ outside backticks in the section (regex over the output) |
| U-PR-5 | A rule name **x** <!-- graft: @everyone [l](http://e) is escaped: no <!--, no live link, no mention |
| U-PR-6 | A path containing ` and <!-- goes through codeSpan |
| U-PR-7 | A 200-rule match keeps the section ≤ 20,000 characters, with and K more rules |
| U-PR-8 | The invalid list is capped at 20 errors; the unknown_key key is in a code span |
| U-PR-9 | The [!IMPORTANT] and [!NOTE] blocks appear exactly when expected |
| U-PH-1 | policy.schedule keys: the same body gives the same key; labeled A, unlabeled A, then labeled B differ |
| U-PH-2 | policy.base-push filter: graft.yaml modified, added or removed → enqueue; other files → no enqueue; forced and 20 commits → enqueue; deleted, a tag ref or a zero after → no enqueue |
| U-PH-3 | policy.rerequest ignores other check names and other app ids |
| U-PH-4 | A payload shape mismatch → payload_shape_mismatch logged, nothing enqueued |
| U-CLI-1 | A valid file → exit 0, with the OK line |
| U-CLI-2 | An invalid file → exit 1, one file:line:col: error code location: message line per error |
| U-CLI-3 | --json output shape |
| U-CLI-4 | A missing file and a bad command → exit 2; a symlink → exit 1 file_not_regular |
| U-CLI-5 | schema --check exits 0 on the committed file and 1 on a modified copy (temp path option for the test) |
| U-CLI-6 | Runs with an empty env (no DATABASE_URL); a grep-style test asserts that src/bin/graft.ts and src/policy/** import nothing from src/modules, src/db, src/config or src/queue |
| U-GLOB-1 | globMatcher cache keys separate nocase |
| U-SIG-1 | createSignals calls the listeners in order and propagates errors; noSignals does nothing |
| U-MAN-1 | (updated) events ["check_run", "pull_request", "pull_request_review", "push"]; permissions unchanged |
| U-RED-12 | (extended) "password=${".repeat(20_000), 'password="'.repeat(20_000), "token=$(".repeat(20_000), "password=a(".repeat(20_000) and "password=process.env.".repeat(10_000) each finish in under 300 ms |
| U-RED-14 | var a={password:null,user:"bob"} and x={token:e.token,user:u.name,flags:[1,2,3]} are unchanged; q?password=hunter2hunter2&user=bob keeps &user=bob |
| U-RED-15 | YAML password: |, token: >- and password: (empty) followed by indented values; password = \ continuation; an unterminated quoted value over 3 lines. All are redacted with the line count and prefixes kept; credentials: followed by username: bob keeps username: bob |
| U-RED-16 | --password hunter2hunter2, mysql -pS3cretPass, curl -u admin:hunter2hunter2 and --user "admin:hunter2hunter2" are redacted; gcc -pthread and --password-file /x are unchanged |
| U-RED-17 | redis://:hunter2hunter2@cache, encryption_key/master_key/signing_key assignments, PassWord = … and a Slack webhook URL are redacted |
| U-RED-18 | max_tokens = 100000000, limit_secret_count = 12345678 and num_tokens = 123456789 stay; the step 4 negatives stay |
Integration (test/helpers/policy-scenario.ts: an installed repo, a PR, a base branch main at B1, contents per ref, an optional card and provenance)
| ID | Asserts |
|---|---|
| I-DB-10 | 0005 applies; the rollback restores the 0001 policy_verdicts exactly (columns, checks, index); up again works. I-DB-2/⅞ roll back and re-apply 0005 too. The I-DB insert-all test uses the new columns. I-DB-4 passes |
| I-POL-1 | pull_request.opened → policy.evaluate with the §T11 key → a graft/policy check neutral, the section is published, and one verdict row has published_at |
| I-POL-2 | The same body under a new delivery id → no new job (deterministic id) |
| I-POL-3 | Base only. Base B1 blocks secrets/** for agents; the PR head deletes that rule and touches secrets/a. The verdict is would_block, the [!IMPORTANT] note shows, and every contents request has ref = B1 (never the head SHA or a refs/pull ref) |
| I-POL-4 | Missing file → no_policy, the No graft.yaml on main title, neutral |
| I-POL-5 | Alias bomb, symlink, directory and 40 KB file → policy_invalid with the right code; never would_approve; neutral |
| I-POL-6 | The base moves from B1 to B2 (the ref is updated in the fake): the next evaluation reads B2 |
| I-POL-7 | Card pending → risk provisional; the intent job publishes a trivial card → the intent_published signal enqueues a re-evaluation → would_approve, final |
| I-POL-8 | Provenance pending → author unknown; provenance publishes → the signal → the author is known and the uncertain reason is gone |
| I-POL-9 | pull_request_review.submitted (APPROVED on the head) → the approvals line shows 1; dismissed → 0 |
| I-POL-10 | labeled hotfix → rule 8 applies (require overrides approve) |
| I-POL-11 | Stale head. The head moves between the fact read and the lock (gate as in I-INT-9) → no GitHub write, outcome stale |
| I-POL-12 | Older facts. Job A reads facts at t1 and job B at t2 > t1; B publishes; then A reaches the lock → stale; the stored row is B's |
| I-POL-13 | Same facts twice → the second run makes no check-run or comment request (unchanged) |
| I-POL-14 | check_run.rerequested for graft/policy twice in one minute → one job; next minute → another |
| I-POL-15 | Closed PR → no publish, no row |
| I-POL-16 | Across E1, E3, E4, invalid and missing, every check-run create or update body has conclusion: "neutral" |
| I-POL-17 | No file contents stored. After I-POL-1 and I-POL-5, no column of policy_verdicts, no pgboss.job.data and no log line contains any rule name, glob or team slug from the YAML, or a YAML line |
| I-POL-18 | The comment's intent and provenance sections are byte-identical before and after the policy publish; the policy section's team names are in code spans |
| I-POL-19 | A transient 502 on contents → the job retries (pg-boss retry) and publishes nothing; a 404 base branch → permanent, no retry |
| I-POL-20 | A PR that adds graft.yaml where the base has none → no_policy with the "adds" note |
| I-POLF-1 | A push to main that modifies graft.yaml → one policy.fanout → policy.evaluate for the open PRs with base main only (not closed PRs, not base develop), with keys policy:base:…:{after} |
| I-POLF-2 | A push that does not touch graft.yaml → no fan-out; a replay → deduplicated |
| I-POLF-3 | 101 open PRs → 100 evaluate jobs, fanout_truncated logged, startAfter staggered |
| I-POLF-4 | A removed repository or an inactive installation → no jobs |
| I-POLF-5 | End to end: after the fan-out, a PR's verdict flips from would_approve to would_require when the new base adds a require rule |
| I-POLH-1 | Base graft.yaml with sensitive_paths: ["services/billing/**"] → a PR touching services/billing/a.ts gets intent floor sensitive, with reason configured_path |
| I-POLH-2 | The head's graft.yaml has different sensitive_paths → ignored; contents are requested only at pull.base.sha |
| I-POLH-3 | Invalid or missing base graft.yaml → config globs only; the card is generated |
| I-POLH-4 | The intent hook and the policy job on the same base commit → one contents request (the shared cache) |
| I-POLH-5 | Contents 502 in the hook → the intent job retries; no card row, no spend |
| I-POLH-6 | pull.base.sha absent → git/ref is called and that SHA is used |
14. HUMAN-TODO additions (append a "Step 5: Policy engine (report-only)" section)¶
- Update the App's events. In the registered App's settings (Permissions & events), subscribe to Pull request review and Push. No permission changes. Check whether the installations must accept the change. Then confirm that one push and one review on
sample-tsarrive (select event, action from webhook_deliveries order by received_at desc limit 5). Who: org owner. - Pilot teams write their
graft.yamlon the default branch through a normal reviewed PR, starting fromdocs/examples/graft.yaml. Each pilot runspnpm graft policy check graft.yamland adds the Action fromdocs/graft-yaml.md. Who: each pilot's tech lead with their security owner. Produces: one validgraft.yamlper pilot repo. - Move
GRAFT_INTENT_SENSITIVE_PATHSintograft.yaml(sensitive_paths) for each pilot, then clear the env value. Who: product owner. - Check team slugs exist in each pilot org (
gh api orgs/<org>/teams --jq '.[].slug'). Graft cannot check them until Phase 1 (members: read). Who: pilot tech lead. - Do not change branch protection in Phase 0. Keep required reviewers. Do not make
graft/policya required check. Removing required reviewers and requiringgraft/policyhappens in Phase 1 (development plan, spec fix 1). Who: pilot repo admins. - Publish the JSON Schema URL. Decide the final Graft repo location, replace
<org>indocs/examples/graft.yaml,docs/graft-yaml.mdand the Action snippet, and setGRAFT_TOOL_TOKENin pilot repos while the Graft repo is private (a fine-grained, read-only token for that one repository). Who: repo owner. - Check on GitHub. On a sandbox PR, check that:
- the
graft/policycheck isneutralwith a "Would …" title; - the comment's policy section renders, with no team notification;
- a push to
mainthat editsgraft.yamlre-evaluates open PRs.
Who: repo owner.
- [ ] Plan Phase 1 permission. Note that team approvals need members: read (org), which installations must accept. Who: product owner.
15. Definition of done¶
From a clean clone with Docker running, and no network beyond install and the image pull:
pnpm install --frozen-lockfile
pnpm db:up
pnpm lint
pnpm typecheck
pnpm test # every ID in §13 present and passing; earlier steps' IDs still passing
pnpm build
pnpm eval:ci # exit 0, baseline unchanged
pnpm graft policy schema --check # exit 0
pnpm graft policy check docs/examples/graft.yaml # exit 0
pnpm graft policy check test/fixtures/policy/alias-bomb.yaml; test $? -eq 1
pnpm db:migrate && pnpm db:status # 0001..0005 applied
pnpm db:rollback && pnpm db:status && pnpm db:migrate # only 0005 rolled back, then re-applied
grep -rnE "@ts-(ignore|expect-error)" src/ && exit 1 || true
grep -rn "modules/" src/db/ src/policy/ src/bin/graft.ts && exit 1 || true
grep -rnE "pull_request_target|refs/pull" src/ && exit 1 || true
grep -rn "U-PE-\|U-PY-\|U-PS-\|I-POL-\|I-POLF-\|I-POLH-\|U-RED-1[4-8]" test/ | wc -l # non-zero
Manual smoke test (record it in the PR description):
1. Run pnpm dev:server and pnpm dev:worker.
2. Send pnpm webhook:send installation.created --delivery s5-1, then pnpm webhook:send pull_request.opened --delivery s5-2 → 202.
3. select name, data from pgboss.job where name like 'policy.%' shows policy.evaluate with IDs only.
4. pnpm webhook:send push --delivery s5-3 (the fixture lists graft.yaml) → one policy.fanout.
5. The worker logs a classified GitHub token failure (offline), not a crash.
Mutation checks (the reviewer reverts each one; the named tests must fail):
| Mutation | Caught by |
|---|---|
loadBasePolicy called with the head SHA instead of the base commit |
I-POL-3, I-POLH-2 |
| Block no longer wins (first match decides) | U-PE-4, U-PE-15 |
| Approve no longer overridden by require | U-PE-9, I-POL-10 |
| Unknown facts treated as non-matching in tightening rules | U-PE-5, U-PE-8, U-PE-10 |
| A provisional risk treated as final in approve rules | U-PE-14, U-PE-17 |
Alias and anchor rejection removed (only maxAliasCount) |
U-PY-3, U-PY-4 |
approve_untrusted_key refinement removed |
U-PS-3 |
policy_file_changed built-in removed |
U-PE-7, I-POL-3 |
sensitive_paths raise removed from evaluatePolicy |
U-PE-6, U-PE-21 |
The hook ignores graft.yaml |
I-POLH-1 |
conclusion set from the outcome (success/failure) |
I-POL-16 |
codeSpan on team names replaced by plain text |
U-PR-4, I-POL-18 |
In-lock head check removed in policy/job.ts |
I-POL-11 |
facts_read_at guard removed |
I-POL-12 |
patch kept in listChangedPaths |
U-FACT-1 |
lastIndex advance after a rejected value removed in redact.ts |
U-RED-12 |
Also required:
- No column, job payload, log line or error message holds YAML text, globs, rule names, team slugs or diff content (I-POL-17, U-LOAD-6 and code reading).
- README §1, §5, §6 and §7, the new "Policy (graft.yaml)" section, docs/graft-yaml.md, docs/examples/graft.yaml, schema/graft.v1.schema.json and HUMAN-TODO.md are updated per T13. github/app-manifest.json changes only default_events, and U-MAN-1 matches it.