Skip to content

Decisions, library versions and deviations

Docs index · Architecture

Each Phase 0 step was specified in docs/phase-0/step-N-*.md, built, and reviewed until approved (the loop is described in docs/phase-0/README.md; the review notes are in docs/phase-0/reviews/). This page records, per step, the library facts that were checked against node_modules, the choices made where a spec was silent, and every place the implementation deviates from its spec. IDs such as U-WH-3, I-INT-9 or D13 refer to tests and decisions in the step specs; grep -rn "<ID>" test/ finds a test. References such as "§6.4" or "T17.1" are sections and tasks of the step's own spec.

Contents: Stack · Step 1 · Step 2 · Step 3 · Step 4 · Step 5 · Step 6 · E2E runner · Live e2e runner

Stack choices and library versions

Choices from the step spec: Node 24 ESM, strict TypeScript (NodeNext, noUncheckedIndexedAccess, verbatimModuleSyntax), tsx for dev, Biome for lint and format, Vitest with unit and integration projects, Fastify, Kysely with pg and a static migration provider, pg-boss in schema pgboss, pino with redaction, Postgres 17 on port 55432, GitHub numeric IDs as bigint keys (parsed to number, throwing above 2^53), smee-client for the dev tunnel.

Versions in the lockfile (majors): Fastify 5, Kysely 0.29, pg 8, pg-boss 12, pino 10, zod 4, @octokit/app 16, @octokit/rest 22, @octokit/webhooks 14, @octokit/webhooks-methods 6, @octokit/webhooks-examples and -schemas 7, TypeScript 7, Vitest 5, Biome 2, tsx 4, ajv 8, smee-client 5.

Step 1: base setup

Spec: step-1-base-setup.md. Deviations and additions relative to the step spec:

  • pg-boss is v12, not v10. The assumed API held with these differences: queue and job retry delay is retryDelay (seconds), not retryDelaySeconds (our JobDefinition.options.retryDelaySeconds is mapped to it); retention is deleteAfterSeconds (7 days on every queue, except github.webhook with 1 hour since step 6; it is a per-job option, JobDefinition.options.deleteAfterSeconds); work() takes { localConcurrency, batchSize } and a handler over Job[]; the transaction option is db with an executeSql(text, values) adapter (type exported as Db), which is what createPgBossQueue builds from a Kysely transaction. start() installs/migrates its own schema, so the server and worker both call it. Defaults when a job sets no options: retryLimit 5, retryBackoff true, retryDelay 10, expireInSeconds 300. They are applied as queue options in createQueue. Queue options are re-applied with updateQueue on every start, because createQueue does nothing for an existing queue (I-Q-3).
  • singletonKey does not deduplicate on pg-boss v12 standard queues. Since v10 the key is only enforced by queue policies short, singleton, stately, exclusive, key_strict_fifo (or together with singletonSeconds); on our standard queues it is stored and ignored. JobQueue.send(..., { singletonKey }) therefore also sets a deterministic job id (UUIDv5-style SHA-1 of job name and key; singletonJobId in src/queue/queue.ts). pg-boss inserts with ON CONFLICT DO NOTHING, so a second send with the same name and key inserts nothing and send returns null (I-Q-2). The window covers queued, active, completed and failed jobs until they are deleted, 7 days after completion or failure (deleteAfterSeconds; 1 hour for github.webhook). A caller that needs a re-run (for example a check_run.rerequested for the same SHA) must use a different key, such as one that includes an attempt number or a timestamp. A null return means "already enqueued". For webhooks the webhook_deliveries primary key is the first line of defence; if the job send returns null (job exists, delivery row gone) the existing job id is stored and the outcome is duplicate.
  • Empty body returns 400 before signature verification (spec deviation: the spec has no empty-body rule). It is a cheap check that needs no secret and touches no state; GitHub never sends an empty body (U-WH-8). A delivery id over 100 characters also returns 400 (U-WH-7).
  • Permanent failures are retried up to retryLimit; a non-retryable error type is deferred to a later step. Job data validation runs inside the logged try.
  • Kysely 0.29 exports Migrator from kysely/migration.
  • zod 4: z.ZodType<T> works as in the spec; config defaults are applied before validation (string form).
  • Fastify 5.12: disableRequestLogging is deprecated in favour of LogController, which buildServer uses (request logging is off; the receiver logs its own outcome line).
  • githubWebhookJob is built by a factory. The job needs the registry's handler lookup and the registry lists the job, so createRegistry builds it with createGithubWebhookJob(registry) and exposes it as registry.webhookJob (also present in registry.jobs). The receiver sends registry.webhookJob.
  • createPgBossQueue returns PgBossJobQueue (a JobQueue plus the boss instance the worker needs) and takes a supervise flag (false for the server, true for the worker). buildServer is async and returns the instance without listening.
  • Store functions (getActiveRepository, listActiveRepositories, ...) take a Kysely Db or Transaction as their first argument.
  • Extra files not in the layout: src/bin/bootstrap.ts and src/bin/env.ts (shared wiring), scripts/generate-fixtures.ts (how the fixtures were produced), pnpm-workspace.yaml (pnpm 11 build-script allowlist for esbuild), test/helpers/app.ts, test/helpers/stubs.ts.
  • The upstream webhook examples are older than @octokit/webhooks-schemas and contain no merged pull_request.closed; the generator repairs missing required fields with neutral values and marks the closed PR as merged so all fixtures validate (U-FIX-1).

Step 2: provenance

Spec: step-2-provenance.md.

  • Library reality. @octokit/rest 22 includes paginate.iterator (used for commits and comments; the iterator is stopped early after 3 and 10 pages). checks.update takes the check run id and no head_sha, so the PATCH body omits it. Nothing else differed from the spec's assumptions. pg-boss 12 only marks a failed job retry and bumps retry_count when it is next fetched, so I-Q-4 asserts the retry state, not the count.
  • D13 fallback (added). New optional env GITHUB_APP_SLUG (Config.githubAppSlug). When set, a comment authored by <slug>[bot] (type Bot) that starts with the marker is accepted as ours when performed_via_github_app.id does not match. Without the slug the strict app-id check is the only check. Tested in U-MC-9, U-MC-10 and I-PROV-9. HUMAN-TODO has an item to confirm the field on real GitHub.
  • Dedup key carries updated_at and the action. (Step 3 appended :{action}; see T18.) D6 as written would swallow any return to an earlier state within 7 days: closed/reopened after opened, a body edited X to Y to X, or a force-push H1 to H2 to H1 would all hit the key of a completed job, leaving stale provenance. The key is prov:{repo}:{pr}:{head}:{base}:{bodyHash}:{pull_request.updated_at} for all five actions, and pull_request.updated_at is required in the payload schema (a payload without it enqueues nothing). A replayed body has the same updated_at, so replay protection is kept. The cost is one redundant, idempotent job for a title-only edit (the comment comes back unchanged, the check run gets one PATCH).
  • In-lock staleness check. Inside withChangeLock the job publishes only if the stored head SHA equals the live head and the stored pr_updated_at is not newer than the live updated_at. Otherwise two jobs on the same head with different bodies could publish the older body last (I-PROV-10b; the head-moved branch is I-PROV-10c). updated_at has one-second resolution, so two edits inside the same second cannot be ordered. The next event, or a Re-run of the check, refreshes the output; until then it can be one same-second change behind. The second window (a same-second change with an unchanged head and body, for example closed then reopened) is now closed by action in the dedup key. The title-edit plus close case remains: a title-only edit and a close inside one second share updated_at, head and body, and only the action tells them apart.
  • Session host is plain LDH (D14 hardening). validateSessionUrl additionally requires every host label to match [a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?, at least two labels, at most 253 characters (one trailing dot is tolerated and localhost. is rejected), because the hostname is the link text and WHATWG URL accepts ` * _ ~ ! $ & ' { } = in a host. \, ` and * are percent-encoded in the href. Trailer whitespace follows git (space and tab only), not JavaScript \s.
  • Output-size failures are permanent. upsertManagedSection throws PermanentJobError (invalid_output) for a marker in the section or an oversize section or comment, instead of retrying a deterministic failure 8 times.
  • Order inside the lock. The provenance job upserts the managed comment first and then the check run (T14 lists the check run first); the intent job publishes in the other order since step 4 (see "Step 4 deviations and additions"). This is what I-PROV-8 describes ("comment created, then the check-run POST fails") and it keeps the outcome identical: a failure rolls everything back and the retry adopts the existing comment (T8 3b).
  • Not applied snapshot returns early. If upsertChangeFromPull reports applied: false (stored updated_at is newer), the job logs stale and stops before any commit fetch or publish. That is what I-PROV-3 requires; T14 does not say it.
  • parseTrailerBlock also returns blockTooLong (P7 needs a way to reach the block_too_long warning); ProvenanceResult also carries truncated (the renderer needs it and counts cannot express it); persistProvenance also takes repositoryFullName (for the issue URL). When the text was cut from the front (P2) the single remaining paragraph is not treated as a subject.
  • Session URLs must be printable ASCII (no non-ASCII at all, not even in the path), a stricter reading of "all character sets are ASCII". | is percent-encoded as well as ( ) [ ], because the URL is rendered inside a table cell (and see the LDH rule above).
  • Rendering of warnings without a field (unknown_graft_key, block_too_long): - <where>: trailer block ignored (<code>), or unknown Graft key ignored (unknown_graft_key) for an unknown key. When there are no trailers and the PR is truncated, the "no Graft-* trailers found" footer still gets the truncation sentence appended.
  • U-REN-7 uses the agent value octo@bot/@team: the spec's @octo/bot starts with @, which the agent character set rejects (a separate assertion checks that it is rejected and never rendered).
  • I-SEC-3 posts the installation.created body as pull_request twice: unchanged (the receiver ignores it, since the action created is not handled) and with action: "opened" so the handler really runs and logs payload_shape_mismatch.
  • pnpm db:rollback reverts one migration (the latest), as T1 requires; in step 1 it rolled back everything. The test helper migrateDown still goes to zero.
  • Extras: getActiveInstallation (installations store, exported) is the "installation exists and is not deleted or suspended" check used by the handlers and the job; ProvenanceTable.source gains pr_body; changes.pr_updated_at and graft_comment_id are optional on insert so step 1 tests compile unchanged; a continue instead of return lets the worker finish a batch after a permanent error (batch size is 1).

Step 3: intent service

Spec: step-3-intent-service.md.

  • Versions. @anthropic-ai/sdk 0.133.0, picomatch 4.0.7, @types/picomatch 4.0.3 (dev; picomatch ships no types).
  • SDK verified against node_modules (T1). Everything the spec names exists and behaves as assumed: the constructor options apiKey, maxRetries, timeout, fetch (and baseURL, used by the tests); per-request { timeout, maxRetries }; output_config: { effort, format: { type: "json_schema", schema } } on the plain messages.create (effort is low | medium | high | xhigh | max); system text blocks with cache_control; client.beta.messages.create with betas and fallbacks: "default" (server-side-fallback-2026-07-01 is a listed beta); usage.{input_tokens, output_tokens, cache_read_input_tokens, cache_creation_input_tokens} (the cache counters can be null); stop_reason "refusal" and "max_tokens"; response.model; the error classes APIConnectionTimeoutError, APIConnectionError, RateLimitError, InternalServerError, AuthenticationError, PermissionDeniedError, BadRequestError and APIError.status. Detail the spec did not state: the beta call puts betas into the anthropic-beta header and adds ?beta=true to the URL, while fallbacks stays in the JSON body (U-LLM-2 asserts exactly that). A 529 arrives as an InternalServerError-style APIError with status 529 and is retried by the SDK (U-LLM-10). Errors that are not AnthropicErrors are rethrown as programmer errors.
  • pg-boss 12.38 verified (T1). schedule(name, cron, data?, options?), unschedule(name, key?), getSchedules(name?, key?) and the constructor option schedule: boolean exist as named. schedule needs the queue to exist (the queue wrapper creates all queues in start()), calling it again for the same name replaces the cron (unschedule removes it), and the cron expression is only validated by pg-boss at schedule() time: GRAFT_FEEDBACK_POLL_CRON is checked for shape only (5 fields of digits, *, /, ,, -), so a cron that passes the shape but is invalid stops the worker at startup. JobQueue.unschedule takes the job definition, like schedule.
  • The card JSON sent to the verifier escapes < as \u003c (still valid JSON). Without it, a writer that echoes the nonce it saw could put a closing data tag into the card and break out of the data block. The spec says "JSON.stringify of the validated writer output"; the parsed JSON is identical.
  • Injection rule (R15) ignores format characters. Before matching, computeRiskFloor removes Unicode Cf characters and the visible [U+XXXX] form that neutralizeUntrusted writes for them, so ig\u200bnore previous instructions still counts. The spec's patterns are unchanged.
  • Floor reasons. FloorReason.paths holds at most 5 paths as specified, so the check output lists at most 5 per rule (the spec's "up to 50" is an upper bound). The injection reason has no paths and count is the number of matching texts; size and listing reasons have none and count 0 (the renderer takes line and file counts from inputs).
  • Classification order. Lockfile, then binary, then generated, then "no patch from the API" (T9), implemented once in classifyFile (risk-floor.ts) and shared by the diff preparation and rule R10. Files beyond the 300th in priority order are summarised per top-level directory; files at the root fall under ".".
  • InputStats.truncated is true when the listing is truncated, the PR is too large, or any file is partial (truncated, or omitted for a reason other than lockfile or binary).
  • Nonce seam. chooseNonce(canonical, untrustedBlocks, derive?) in diff.ts takes the derivation function as an optional parameter; U-DIFF-10 uses it to force a k=0 collision.
  • A failed escalation keeps what is known. If the fast writer answered and the strong re-write fails, the unavailable result still carries the first-pass class, its model and the instruction flag, so a first pass of sensitive is not lost.
  • Cached cards skip the pending note and the budget. The cache check (step 5) comes before the outdated-section note (D11) and the budget check, so a cache hit makes no extra GitHub write and counts no tokens.
  • Card rows use the injected clock. saveCard writes created_at and updated_at from ctx.clock (not the database default), so the daily budget and the tests follow the injected time.
  • intent.feedback.sweep data is the empty object, validated strictly. The sweep reads owner and repo from repositories.full_name, skips removed repositories, and the poll job re-checks the installation.
  • Feedback summary since applies to the effective vote's reacted_at (the vote that wins first, then the time filter).
  • Reactions with a null user are dropped when read; reactions of any user type other than Bot count (Mannequin and Organization included).
  • Models in the card row are response.model as returned by the API (often a dated id), not the requested alias.
  • Pure generate.ts exports also RETRYABLE (used by the store to rebuild retryable from a stored row) and EFFORTS, MAX_OUTPUT_TOKENS.
  • Test changes outside the spec's list. test/unit/fixtures.test.ts lists the new check_run.requested_action fixture (the generator reuses the upstream example and rewrites it); I-DB-7 now rolls back two migrations (0003 then 0002) because 0003 is the latest; I-CHG-6 uses a replay of opened for its first assertion because the action is now part of the key; U-RISK-13's "removed line does not match" half is tested at the card level (U-RISK-13c), because computeRiskFloor receives only the added lines; U-INJ-2 includes a file whose name tries to close the section from inside a code span, so that removing the <!-- rewrite in codeSpan fails it. New helper files: test/helpers/intent-inputs.ts (pure builders) next to the specified intent-scenario.ts. test/unit/log-hygiene.test.ts (U-LOG-2) is an extra grep-style test that no logger call in src/llm or src/modules/intent mentions prompts, diffs, PR text or model output.
  • T18 follow-ups. Done as specified: provenance re-run key prov-rerun:{repoId}:{pr}:{minute} (I-PROV-11), :{action} in refreshKey, WarningCode and GraftField moved to src/db/schema.ts (fields.ts re-exports them; grep -rn "modules/" src/db/ finds nothing), I-PROV-10c isolated by clearing changes.pr_updated_at before the gate is released, and the README wording in the step 2 section. Mutation check for I-PROV-10c: with the in-lock head check removed from provenance/job.ts the test now fails (the same holds for I-INT-9 and intent/job.ts).
  • Mutation checks (DoD) were run by hand and each made the named tests fail: removing the in-lock head check in intent/job.ts fails I-INT-9; dropping the floor term in combineRisk fails U-RISK-17, U-RISK-18, U-INJ-1 (and U-GEN-4, U-GEN-13, U-GEN-20); adding the PR text to the verifier message fails U-GEN-15; removing the <!-- rewrite in markdown.ts fails U-MD-4, U-MD-9, U-INJ-2 and U-INJ-4.

Step 3 review 1 changes

  • Spend is a ledger (D21 changed). intent_spend (repository_id, change_id, tokens, llm_calls, spent_at) is append-only and saveCard writes one row per model run before the card upsert, so retries, re-runs, in-place regeneration of an unavailable row and a dropped concurrent ok save all count. tokensUsedToday sums the ledger for the UTC day (bounded on both sides), not intent_cards.created_at. The card's token columns still describe only the run that produced the row. Migration 0003_intent (not yet committed) was edited in place; TABLE_NAMES now has 13 tables (I-DB-3 and I-DB-8 updated). I-INT-17, I-INT-18, I-INT-19; I-INT-11 seeds the ledger.
  • Displays are recorded (reaction attribution changed). intent_displays (change_id, card_id, status, shown_at, seq) gets a row whenever what is on screen changes (another card, or the same card in another status), written in the publish lock with ctx.clock.now() taken right before markPublished (not the job start). Reactions are attributed to the display with the greatest shown_at <= created_at, so A, B, A credits later votes to A, and a reaction made while the "card unavailable" notice was shown is dropped. intent_cards.published_at keeps the first display only (the sweep still uses it, and publishedCheckRunForHead); latestPublishedCard (the D11 note) follows the latest display. markPublished takes { id, changeId, status }. I-INT-20, I-FB-6, I-FB-7, I-FB-8, U-FB-7.
  • Retry notice (D9/D8 changed). The retry job is queued before the publish and "Graft retries automatically" is rendered only when send returned an id. The retry key ended with the triggering deliveryId in step 3; since step 4 it ends with a 5-minute bucket (see "Step 4 deviations and additions"). A deduplicated send shows the "Re-run" text. I-INT-21, I-INT-22.
  • Total deadline (D6 changed). LlmRequest.deadlineMs (the remaining job deadline) becomes an AbortSignal.timeout on the SDK request, so SDK retries and their backoff end at the deadline; the abort maps to timeout. The deadline starts at the job start (GenerateOptions.startedAtMs), so the listing time counts. U-LLM-12.
  • Fallback blocks (D5). Only the text blocks after the last fallback block are the answer. U-LLM-11.
  • Feedback button guard. The job data carries the payload's check_run.external_id (externalId, optional); a vote whose card differs from the check run's current external_id is rejected (feedback_rejected). I-FB-9.
  • Nits fixed. Output strings made only of Cf characters are empty (U-OUT-7); R15 patterns use \s+ between words (U-RISK-19); GH-<n> is broken with a word joiner (U-MD-11); intentRepoSettings takes { repositoryId, installationId, owner, repo, baseSha } for step 5 (LivePull.base.sha is optional).
  • Nits not fixed. Paths in R15 (spec change: file names like ai-reviewer.md would raise the floor on ordinary repositories). Pure rename classified no_patch_from_api (needs diff.ts to handle a file without a patch and a spec change to U-DIFF). "Up to 50 paths" in the check output (U-RISK-7 and the stored FloorReason shape fix the cap at 5; carrying a second list is a spec change).
  • I-E2E-3 now waits for both check runs as well as the comment: the comment contains both sections one request before the intent check run exists.
  • Review 1 mutation checks (run by hand in a throwaway copy): dropping the ledger insert in saveCard fails I-INT-17, I-INT-18, I-INT-19; making toJobError return the original error and the worker log and rethrow the raw one fails I-INT-12 and I-E2E-4 (with only one of the two layers undone, U-GHE-4/U-GHE-5 or nothing fails, by design); taking the display time at the job start fails I-INT-20; recording a display only when none exists fails I-FB-6 and I-FB-7 (and I-FB-1, I-FB-2); ignoring the fallback block fails U-LLM-11; removing the abort signal fails U-LLM-12; always showing the retry text fails I-INT-21; removing the external_id guard fails I-FB-9; removing the in-lock head check in provenance/job.ts fails I-PROV-10c.

Step 4: evaluation set

Spec: step-4-evaluation-set.md. (Referred to elsewhere as "Step 4 deviations and additions".)

  • Retry key (T17.1). intent-retry:{changeId}:{headSha}:{inputHash16}:{attempt}:{floor(nowMs / 300000)}, with nowMs from ctx.clock. A pending-only singleton is not available on our standard queues (pg-boss 12), hence the bucket: two triggers for the same inputs within five minutes queue one retry, and a later failure in a new bucket queues its own even when an earlier chain completed. The comment above the send in job.ts says so. I-INT-21 now moves the clock by one bucket for its last step (a different delivery in the same bucket is deduplicated); I-INT-22 covers the new rule.
  • The D11 "outdated" note is a display (T17.2). Migration 0004_intent_display_pending widens intent_displays.status to ok | unavailable | pending (down: deletes pending rows, restores the old CHECK). After the note's comment write, inside the same lock, the job records a pending display of the previous card. attributeReactions already treated every non-ok display as a dropped one, so a reaction made while the note is shown is not credited to any card. latestPublishedCard skips pending displays. markPublished still compares with the last display of any status, so the next ok or unavailable publish after a note adds a row. I-FB-10, I-DB-9.
  • Publish order and display time (T17.3). The intent job now writes the check run first, then the comment, then markPublished with a now taken right after the comment write. A failed check-run call leaves the old comment and no new display, a failed comment call leaves no new display either, and the pg-boss retry finds the saved card and publishes once. This replaces the "comment first" order for the intent job only (the provenance job keeps its order). I-INT-23.
  • 0003 down (T17.4) uses DROP TABLE IF EXISTS for intent_displays and intent_spend. escapeProse (T17.5) keeps the <!-- rewrite with the "unreachable, defence in depth" comment; no behavior change.
  • New exports. modules/intent/index.ts also exports fetchIntentInputs, promptFingerprint, maxRisk, RISK_ORDER, DOCS_GLOBS, TEST_GLOBS, globMatcher and MAX_OUTPUT_TOKENS; modules/provenance/index.ts exports computeProvenance and fetchPrCommits. U-PROMPT-1 also asserts promptFingerprint() equals the pinned hash.
  • No new dependency. zod 4's z.toJSONSchema (draft 2020-12, verified against node_modules) generates the schemas; dates and timestamps are checked with regular expressions instead of format, so that a plain JSON Schema validator needs no format plugin. Refinements JSON Schema cannot express are listed in the case schema's top-level description.
  • Report details the spec left open. status of a row is ok | unavailable | skipped (a case skipped by the budget has status skipped). Report thresholds carry a provisional boolean; report.pass is true only when every result passes (a provisional failure makes it false but exits 1 only with --strict). The baseline comparison also ignores the derived fields thresholds and pass (they depend on the baseline itself) and the per-row cached flag; everything else must be equal. Cached rows keep the cost of the result they reuse, costUsdThisRun counts only the rows that were not cached. A baseline that does not exist is a usage error with --check on the synthetic set, and an explicit --baseline that does not exist is always one.
  • Run options added. RunOptions.git is not part of the harness: the CLI fills report.git itself. maxTokens also gates the pre-run estimate. Budgets and the confirmation apply to --live only (fake mode has no real spend); in-flight cases reserve their estimate so that concurrency cannot overshoot --max-cost-usd.
  • Export details. The export counts cases that already exist toward --sample (so a re-run of the same export writes nothing, and an aborted one resumes with the rest, re-sampled: see "Resumed export" below), and skips them before stage 1. Exported cases are tagged size-s|m|l|xl and path-sensitive|tests|docs|source so that the report's per-tag under-classification shows the strata. --author bot filters on the list response (user.type, --agent-logins) and makes no commit request; --author any fetches commits only to classify. Pull requests with more than 20,000 changed lines or 1,000 files are classified without a file listing (stratum source). source.baseSha falls back to forty zeros if the API omits base.sha. --redact accepts only emails.

Redaction is wider than the spec's §6.4 table (step 4 review 1, finding 1). The assignment rule matches an identifier that contains a secret word (password, passwd, passphrase, secret, credential, api_key, access_key, private_key anywhere in the name; token(s), pwd, auth, authorization as a whole _, -, . or camelCase segment), so DB_PASSWORD, AWS_SECRET_ACCESS_KEY, client_secret, access_token, app.db.password and clientSecret are covered, and tokenize(...) and author are not. It accepts a quote before the operator ("password": "...", 'api_key': '...', escaped JSON), the operators =, ==, ===, :=, => and :, and a quoted value with spaces (up to the matching quote); the name, operator and quotes stay, and a value shorter than 8 characters stays. Further rules: url_credentials (scheme://user:PASSWORD@, only the password), bearer (Authorization: Bearer|Basic|Token ...), <password>...</password> elements, PEM and PGP private key blocks, and the provider tokens GitHub (ghp_, gho_, ghs_, ghu_, ghr_, github_pat_), AWS (AKIA, ASIA), Stripe (sk_live_, rk_live_), Anthropic and OpenAI (sk-), Google (AIza), GitLab (glpat-), npm (npm_), Slack (xox[abprs]-) and JWTs. Every quantifier is bounded, and the assignment scan is linear (step 5, T14): valueSpan looks for a quoted close in a 512-character window, an unquoted value ends at whitespace, a quote, or one of , ; ) } ] &, a run longer than the window is redacted whole up to its real end, and after every candidate (accepted, rejected or empty) the scan resumes past the span it read, so no character is scanned twice per line however adversarial the line is (U-RED-12). The same step 5 pass closes the leak forms found in step 4 review 2: a value on the next lines (a YAML |/> block, an empty password: followed by indented lines that are not key: lines, a \ continuation, an unterminated quote) is redacted too, at most 50 lines, keeping indentation, diff prefixes and the line count; a hunk header or \ No newline line ends that state. New kinds: cli_flag (--password VALUE, -token VALUE when the name is a secret name and the value has 8 or more characters; -pVALUE only on a line that names mysql, mysqldump, mysqladmin or mariadb; the password of -u|--user user:pass), slack_webhook, and redis://:pass@host URLs; encryption_key, master_key and signing_key are secret names; a name is also tested in its flat form so PassWord is caught; names that start with max_, min_, limit_, budget_, num_ or total_ (max_tokens) are left alone. Line count and diff prefixes (+, -, space, @@, \) never change; a hunk header ends an unterminated key block. Intentional over-redaction: any 8+ character value after a secret-looking name is replaced, including a bare identifier (token = someLongVariableName) or PWD=/home/user/project; names ending in length, size, count, type, name, path, file, url, ttl, expires, enabled and similar are left alone, as are placeholders (${VAR}, $VAR, <token>, {{ x }}), env lookups (process.env...), function calls and null/true/false. Not covered: a secret with no secret-looking name and no known prefix (a raw high-entropy string in prose); review show output before sharing a set. --redact-pattern values that match the empty string, or that are not a valid regular expression, are rejected (exit 2).

Further step 4 review 1 changes. Error output never carries pilot paths or label text: schema messages name the field only, unmapped errors print their class name (GRAFT_EVAL_DEBUG=1 adds the message), and a fake-mode generation error echoes its message only for a synthetic set. The baseline comparison also ignores metrics.costUsdThisRun (0 for a run served from the cache). After an unexpected error in one case no worker starts another. The CLI ignores a GRAFT_EVAL_KEY that only .env provides and warns on stderr: .env is for ANTHROPIC_API_KEY and the GitHub App settings, and the case-set key stays in the shell. Resumed export: a re-run does not reproduce the sample of an uninterrupted run. It counts existing cases toward --sample, then draws the remainder (sample - existing) from the fresh candidates with the same seed, and --min-per-stratum applies to the strata of those candidates only. Reproducing the original draw would mean fetching and classifying every already-exported pull request again, which costs requests for no new data; if an exact sample matters, export into a fresh set in one go. Retry key: the 5-minute bucket in the retry singletonKey means two triggers on either side of a bucket boundary queue two retry chains (at most two per boundary, each capped at two attempts); this is accepted, see the comment in src/modules/intent/job.ts. - Fake GitHub API (T8). GET /repos/{o}/{r}/pulls (state, sort, direction, Link paging, and state.listPageSize to force small pages), state.rateLimit with x-ratelimit-* headers and 403 when exhausted, an injectable clock (createFakeGitHubApi({ now })), and base.sha in makePull (optional in FakePull). - Existing tests changed. I-INT-10 (retry key), I-INT-21 (bucket), I-DB-2, I-DB-7 and I-DB-8 (they now roll back and re-apply 0004 too). No assertion was weakened. - Prices and models. Haiku and Sonnet prices are the ones in the spec (PRICING_VERIFIED_AT = 2026-10-06); I did not check them against Anthropic's page (HUMAN-TODO). No live eval was run and the real API was never called. - The synthetic cases are fictional and were adjusted only where the spec's table described behaviour the floor does not produce on its own: none needed a floor change. The floor additionally reports size_large for syn-016 and syn-018 (more than 15 files or 300 lines) and partial_view for syn-025 (the rename has no patch); the table lists the rules that decide the class. Headline values: UC floor 6/30, severe UC floor 1/30, UC final 0, accuracy final 28/30, out-of-scope and flag precision and recall 1, unavailable rate 2/30.

Step 5: policy engine

Spec: step-5-policy-engine.md.

  • Library verification (T1). yaml 2.9.1 (added with pnpm add yaml; it is a CommonJS package with types). Checked against node_modules and by running it: parseAllDocuments returns an array of Document (zero for an empty or comment-only text, which becomes not_a_mapping); LineCounter and error.pos give lines and columns, but linePos is only filled with prettyErrors: true, so the parser uses prettyErrors: false and the LineCounter; node.range is [start, valueEnd, nodeEnd]; uniqueKeys: true reports DUPLICATE_KEY as an error, a tab indent is TAB_AS_INDENT, a bad indent or an open flow collection is BAD_INDENT or similar. maxAliasCount is a toJS option, not a parse option (D6 puts it on the parser): it is passed to toJS, where 0 makes any alias throw "Alias resolution is disabled"; the real defence is that every anchor and alias is rejected by a visit before toJS runs (U-PY-3 spies on Document.prototype.toJS). merge: false makes << an ordinary key whose *alias value is still caught. !custom and !!python/object give a warning (TAG_RESOLVE_FAILED), not an error; !!binary, !!set and even !!str resolve silently and only show in node.tag; so the parser ignores TAG_RESOLVE_FAILED as a warning, reports every other warning as yaml_syntax, and rejects any explicit tag (yaml_tag), a little stricter than D6's "outside the core set". logLevel: "silent" stops toJS from printing a Node warning for a complex mapping key (the schema then reports the stringified key as an unknown key). Octokit: git.getRef({ ref: "heads/main" }) sends GET /git/ref/heads%2Fmain (Octokit percent-encodes the slash; GitHub accepts it; the fake API matches both forms), and data.object.sha is the commit; repos.getContent returns an array for a directory, a base64 content for a file, encoding: "none" above 1 MB, and type: "symlink"/"submodule" for those. That GitHub follows a symlink to an in-repo file and reports the target's path is taken from GitHub's documentation and not verified against a real repository (the check type === "file" && path === "graft.yaml" is correct either way). @octokit/webhooks has types for pull_request_review and push; the handlers validate with their own zod looseObject schemas instead, like the other handlers, and @octokit/webhooks-schemas names the push definition push$event (U-FIX-1 maps it).
  • Schema checks (T3). Structure is the zod schema (with { error: "<code>" } on each leaf and a mapper from zod issues to the catalogue; invalid_union is resolved by looking at whether the raw value is a list). The cross-field rules (exactly one action, non-empty match, approve restrictions, size bounds, unique names, glob validity, the 500-glob cap, empty require) live in crossChecks and always run on the raw value instead of a zod superRefine: zod skips a root refinement when a sibling already has a structural error, which would hide errors. They emit the same codes. policyJsonSchema() additionally writes oneOf (exactly one action) and minProperties: 1 for match, require and size into the generated schema, so editors flag those too; the rules it still cannot express are in its description. too_many_errors counts inside the 50: 49 errors plus the marker. glob_invalid reason 7 is picomatch's $^ answer for a pattern it cannot compile ([z-a]), or a regex above 2,000 characters.
  • Flow-depth pre-scan. [ and { count only where a YAML value can start (the start of a block line, after :, -, ?, ,, [ or {), so brackets inside plain scalars, quotes and comments are text. Any parser failure (for example a stack overflow) is caught and reported as yaml_syntax, so parsePolicyText never throws.
  • Spec examples that the helpers do not produce. E12 as written (Docs/A.md) would match rule 1, because *.md matches A.md with the extension's case; the test uses Docs/A.MD. The spec's rendered examples show infra\-and\-auth, but escapeProse does not escape a hyphen inside a word; the output is infra-and-auth (byte-for-byte tests use the real helper output). The closing (code) of an error line is added after escapeProse, so its parentheses stay literal and the underscore is escaped ((glob\_invalid)), as in the spec's example. EffectiveRisk.raisedBy lists a raise only when it changed the class.
  • Loader. src/policy/load.ts is the one file of src/policy that imports src/github and the error classes of src/queue/errors.ts (PermanentJobError); the CLI never imports it. U-CLI-6 checks the CLI's whole import closure, and that nothing else in src/policy reaches modules, db, config or queue. loadBasePolicy takes an optional log and also shares an in-flight request between concurrent callers. resolveBaseCommit treats a 404 as base_missing and sends other errors through toJobError; a 403 reading graft.yaml is permanent (toJobError).
  • facts_hash is the spec's input plus the rendered title, section and details: the author's name, the labels and the size are shown but not part of the verdict, so a change in them must republish. A repeated evaluation with the same hash makes no GitHub request but still moves the row's facts_read_at and evaluated_at forward (a database write only), so an older job cannot overtake it. rules_matched keeps the PR paths of a paths reason (at most 5) and only the count for a labels reason (a label also appears in the policy).
  • Signals. The intent job emits intent_published whenever a card is on screen after a successful publish (a new card or a cached one); test/helpers/fake-queue.ts needed no change because unit contexts use noSignals.
  • Fixtures. pull_request.labeled clones opened with a hotfix label; the pull_request_review fixtures keep upstream's slimmer pull_request object moved onto the fixture PR's number and head; push is a push to the fixture PR's base branch (master) that modifies graft.yaml. The upstream catalog has no usable push commits, so the generator writes one.
  • Review 1 changes (step 5).
  • Glob matching no longer uses picomatch regexes (blocker). picomatch compiles **/*a*a*/**/*a*a* and similar valid globs to nested overlapping quantifiers; one non-matching path of a few hundred characters took seconds (the §6.2 limits bound each segment, not the interplay across **). src/core/glob.ts now compiles each glob to segments and matches without backtracking over the input: dynamic programming over (pattern part, path segment), where ** costs O(parts x segments) in total, and the classic two-pointer wildcard algorithm per segment, which is polynomial. picomatch stays only inside validateGlob (compile check) and as the oracle in the tests (U-PG-7, 3,000 generated globs x 30 paths x both case modes, plus the repository's real globs, all equal). Decisions: (1) a path over 1,024 characters or 128 segments is not matched; the matcher answers its onLimit option, which the callers set to the strict side (true for sensitive_paths, tightening paths, the policy-file check and the risk floor's R1 to R7 path rules; false for only_paths and approve rules, and for test, docs, lockfile and generated classification, where a match would loosen); (2) each call has a budget of 200,000 work units and 20 ms of wall clock and answers onLimit when it is spent; (3) *** (three or more stars in a row) and ]+ are rejected with glob_invalid detail 7, because picomatch gives them regex meanings ([ab]+ repeats, a/**/*** matches a) that the segment matcher does not model; (4) the quirks that are kept on purpose: a trailing /** matches the directory itself (docs/** matches docs) unless the segment before it ends with a star (*/** needs a second segment), and ** inside a longer segment acts as *. The reviewer suggested 1,024 characters; the 4,096 in the brief would put the worst case at four times the work. The 1,024 cap is the only deliberate behaviour change for ordinary inputs: no path in the corpus is that long. Worst case, measured: about 0.6 ms per path for the worst accepted glob shapes on 1,024-character paths (U-PG-8 runs eleven such cases x 1,000 paths, each under a second).
  • An incomplete file listing may change graft.yaml (major). In step 4 of §9.2 a matched approve rule gives way to the policy_file_changed built-in also when !listingComplete; the verdict is would_require with 1 human and is never would_approve. policyFileChanged stays false (the file was not seen to change), the comment gets a note ("The list of changed files is incomplete..."). U-PE-23 is the reviewer's reproduction (size-only approve rule, 1,001 renames, files empty); U-PE-24 is the same without graft.yaml in the visible part. A require or block match is unaffected (it already needs at least 1 human), the default already requires at least 1, and the effective risk is not raised (raisedBy would claim something unknown).
  • T14 empty values (minor). The empty continuation now starts only for a YAML key line: key: with an optional - list prefix and an optional quote, and nothing else before it on the line. if password:, for token in tokens:, class PasswordForm: and case "password": stay readable (U-RED-15 negatives). An empty value after = (password =) no longer starts it either. The eval baseline is unchanged (pnpm eval:ci reports equal): the synthetic set has no such blocks.
  • Nits. The CLI rejects size > 32768 from lstat before reading (U-CLI-7, with a 3 GB sparse file that could not be read); the push fixture now targets the fixture PR's base (refs/heads/master) so the smoke fan-out finds the PR (U-FIX-3; the integration suite's pushEvent helper keeps main, which is its world's base); I-POL-21 catches mutation 8 on its own (an approving policy plus a PR that edits graft.yaml gives "Would require 1 human"); the loader's missing-blob-SHA failure is a PermanentJobError (U-LOAD-7).
  • Review 2 changes (step 5).
  • Total work is bounded (major). Measured before: a valid 32 KB policy (132 block rules of * + 194 a + b?*) against 1,000 renamed 1,024-character paths took about 6 minutes. Two defences. (a) A cost cap in validateGlob: at most 48 characters other than * per segment and 96 per glob (** segments and stars are free), glob_invalid detail 8 ("too costly"). The worst accepted glob costs about 0.37 ms per 1,024-character path, not 1.3 ms. (b) One shared GlobBudget of 50,000,000 work units (review 3 lowered this to 20,000,000 and made the model structural) per evaluatePolicy call and per computeRiskFloor call (prepareDiff has its own for the file ordering), on top of the 200,000-unit limit per matcher call. Work units are counted, not timed, so the result is the same on every machine and load; wall clock stays only as a coarse backstop (100 ms per call, 3 s per evaluation) that ordinary and crafted inputs do not reach. Each call also pays for splitting the path, so many cheap calls cannot run free. When the budget is spent: a tightening paths key counts as matched and uncertain, sensitive_paths raise the risk, only_paths and approve rules do not match, and the verdict gets limits: ["budget_exhausted"] (rendered as a note); the risk floor adds a rule (see review 3: glob_budget). Measured worst case: the 32 KB policy (161 block rules, 3 hostile globs each, plus 2 hostile sensitive_paths) against 1,000 files with 1,024-character path and previousPath takes about 0.5 s and ends would_block with budget_exhausted; U-PG-12 bounds it at 2 s. The policy-file check uses its own small budget, so a spent shared one cannot fake a change to graft.yaml.
  • / inside [...] (minor). Rejected (glob_invalid detail 6), because the matcher splits at / first while picomatch would read [a/b] as a class. U-PG-7 now covers every allowed punctuation character, more class edge cases ([-a], [a-], [*], [?], [ ], [Z-a], [é-ü]) and the folding characters ß, Kelvin, ı, É. The only accepted difference is case-insensitive matching of Kelvin, ı and ß, where the new matcher matches more than the RegExp engine (strict side).
  • onLimit sides are pinned (minor). U-PE-25 fails if paths or sensitive_paths use the loose side or if only_paths uses the strict one (two extra mutations, both killed).
  • R8 and R9 (minor). A deleted test, a test renamed away, and a shrunk test with an over-long path still raise the floor (onLimit: true for those terms; the "no longer a test" term keeps false). U-RISK-20.
  • path_too_long (nit). An over-long path is no longer reported as a change to graft.yaml: it is a limit reason (limits: ["path_too_long"]), the policy-file check does not match it, and, like an incomplete listing, it makes the built-in apply to approve matches (it may hide a change). U-PE-26.
  • Review 3 changes (step 5): the cost model is structural.
  • Why. Review 2's budget charged one unit per step, but a case-insensitive class cost about 60 units of real work (four range scans and two string allocations per character) and the path was case-folded on every call, so a valid 32 KB policy of 200 class-glob rules against 1,000 maximum-length files took about 36 s, and the 3 s wall-clock check ran only inside a single call's own counter.
  • Cost model (3 lines). (1) Each path is split and case-folded once per job (cached in the GlobBudget, charged one unit per character), and each class is compiled once into a 256-entry table with case folding already applied (code units above 255 are memoised after one charged miss), so a class test costs the same as a literal. (2) Every inner step (one character comparison, class test or DP cell) is exactly 1 unit; per-call setup is charged by size. (3) One GlobBudget per job caps total work at 20,000,000 units and glob x path comparisons at 250,000 (both superseded in review 4: 48,000,000 units, no pair cap), shared by buildFacts's computeRiskFloor and evaluatePolicy of the same policy job (evaluatePolicy(policy, facts, { budget })); when either is spent every later answer is the strict side and exhausted is set.
  • Wall clock. The deadline is now checked on a counter in the shared budget, every 16,384 units, across the whole job, and is set to 10 s: it is a bug guard, not part of the model. The budget runs out in about 0.2 to 0.3 s of matching, so on any machine the fuel binds first and results do not depend on load (U-PG-15 gives deep-equal verdicts for 5 s and 600 s limits). If the guard ever fires the worker logs glob matching hit its wall-clock guard (hook set in bootstrap.ts).
  • Measured, no network (1,000 files, 1,024-character path and previousPath, 200 rules plus 20 sensitive_paths, 32 KB): literal shape about 0.19 s for evaluatePolicy and 0.42 s for computeRiskFloor; class shape (44-entry class) 0.24 s and 0.42 s; mixed (class, two-class two-segment, literal) 0.18 s and 0.44 s. The floor time is mostly its own fixed-glob classification, not the budget. Review 3 measured 32 s and 5 s for the class shape before this change.
  • Floor reason (nit). A spent budget now adds its own floor rule glob_budget ("path matching stopped at its work limit", sensitive) instead of reusing listing_truncated. The rule is only added when the budget runs out, so no synthetic case produces it: the eval schema, the synthetic set and the saved baseline are unchanged (pnpm eval:ci reports equal), and listing_truncated metrics no longer count budget stops.
  • Uncertain hits (nit). A paths key whose answer came from a limit (a path over the caps, a per-call fuel hit or a spent budget) is reported as uncertain: true; a real match stays false. An approve or only_paths key that hit a limit does not match. Reviewers can tell an assumed hit from a real one (U-PE-29).
  • mayChangePolicy terms (nit). evaluatePolicy takes an options seam (budget, policyBudget); U-PE-28 passes a spent shared budget and a spent policy-file budget and expects the 1-human built-in, which kills the mutation that removes both terms.
  • Review 4 changes (step 5): the budget is sized from the legal maximum.
  • Problem. Review 3's 250,000-pair cap and 20 M units were guesses. The shipped example policy plus 1,000 renamed files with a pending card gave a false "Would block: rule 5", and a 41-rule policy added unrelated team requirements. A false block or requirement from the budget is as wrong as a missed one.
  • Derivation. Spec maxima: 500 globs per file, 1,000 listed files, a rename matched on path and previousPath: 500 x 1,000 x 2 = 1,000,000 glob x path pairs for the policy, plus the floor's fixed globs (about 130 x 2,000 = 264,000 pairs). Measured with an unlimited budget on the largest ordinary policy (161 rules, 483 plain globs like area3/part1/**, 7 sensitive_paths, 1,000 renames of 40-character paths, card pending so the floor shares the job): 1,243,900 pairs and 25,547,820 units (floor 4,423,480 + policy 21,124,340), about 0.5 s. EVALUATION_FUEL = 25.5 M x 1.9 = 48,000,000. The pair cap is removed: a pair costs at least 2 units plus its DP cells, so units already bound pairs; pairsUsed stays as a statistic. The derivation is also in the comment on EVALUATION_FUEL.
  • Hostile timings after the change (1,000 files, 1,024-character path and previousPath, 200 rules + 20 sensitive_paths, 32 KB; ms): literal: policy 386, floor 541, one shared budget 591; 44-entry class: 389, 473, 525; class above code 255 ([Ā-ŝ]-style): 458, 461, 533; mixed: 372, 553, 589. The job (shared budget) stays under 0.6 s; two separate budgets under 1 s. To keep the per-character cost low the DP table is now one reusable buffer instead of an allocation per pair. The ordinary maximum above takes about 0.47 s (floor + policy).
  • Memo for code units above 255. Still 4,096 entries per class and now also 100,000 entries in total: when the total is reached every memo is dropped together (and also when the compiled-matcher cache is cleared); a later miss pays its charge again. A miss costs 4 units per range (U-PG-17 pins the exact charge and the global bound).
  • Equivalent mutants. M18 (only_paths with onLimit: true) is equivalent: any limit hit already makes that key not match. M17a (paths with onLimit: false) gives the same verdict but dropped the over-limit file from the reason's list and count; U-PE-29 now asserts the count (2) and both paths, so it is killed.
  • Budget creation. The policy job creates the GlobBudget after its database reads (card and provenance), so the 10 s guard does not include them.
  • Tests. U-PE-30 (largest ordinary policy, 1,000 renames, pending card: no limit hit, correct verdict, more than 1 M pairs and under two thirds of the fuel, which pins the sizing), U-PE-31 (the shipped example is not would_block), U-PG-12/14 (hostile shapes still strict and under the time bound).
  • Review 5 changes (step 5): the cost of an ordinary glob no longer depends on path depth.
  • Problem. The matcher filled a (glob parts + 1) x (segments + 1) table for every pair, so even area3/part1/** cost O(depth). The 48 M fuel was sized on 4-segment paths; on 14-segment Java paths 160 plain 3-glob rules exhausted it and produced a false "Would block: rule 161" and unrelated team requirements. No fixed fuel can cover hundreds of millions of cells at the legal maxima.
  • Design (4 lines). (1) A forward NFA over path segments keeps only the set of live pattern positions (** may stay or skip), so an anchored prefix dies at the first mismatching segment (about 1 segment comparison for area3/part1/** against src/main/...), matching stops as soon as the set is empty, and a trailing ** accepts as soon as it is reached. (2) Prefilters computed once per glob and checked in a few units: the minimum segment count, required star-free literal segments (secrets of **/secrets/**, against a per-path segment set built once, only for floating globs), required literal runs inside parts with stars (part3 of *part3*, one native substring search on the folded path) and the literal tail of the last segment (.md of **/*.md). (3) Charge only the work done: 1 unit per live position and segment, plus the segment match, plus a few units per prefilter. (4) Semantics are unchanged: U-PG-7 still compares against picomatch, now also on 6- to 14-segment paths with several **.
  • Fuel derivation (millions of units, 1,000 renames, 161 rules x 3 globs, floor + policy in one job, pending card; final cards cost about 1 M less):

    Shape 4 segments 15 segments 30 segments
    anchored areaN/partM/** 6.1 6.5 6.7
    floating **/xN/** 5.1 5.5 5.7
    extension **/*.eN 6.1 8.4 9.6
    name part **/*partN*/** 6.1 8.4 9.6
    every glob matches (worst) 7.6 / 14.8 22.4 / 19.9 30.0 / 35.0

    (Review 4's 4-segment figure was 25.5 M with the old matcher; review 5 measured up to 161 M on 30 segments.) The worst ordinary case is 35.0 M; EVALUATION_FUEL = 60,000,000 is 1.7x that. The shipped example policy needs 1.2 to 3.7 M at 4 to 60 segments. - Hostile timings with 60 M (1,000 files with 1,024-character paths, 200 rules + 20 sensitive_paths, ms; eval / floor / shared job): literal that defeats the prefilters 386 / 444 / 483; 44-entry class 439 / 134 / 495; class above code 255 476 / 126 / 522; mixed 406 / 434 / 499; ** chains on 127-segment paths 429 / 55 / 430. All end would_block with budget_exhausted. A literal glob whose literal is missing from the path is now rejected by a prefilter in a few units, so the old literal shape stopped being hostile; the hostile tests use a literal that is present (*a...ab?x* against paths containing it). - Also fixed on the way. compareCodePoints (used to sort the matching paths of every rule) allocated two arrays per comparison; with 161 rules matching 1,000 files that took seconds. It is now allocation-free with the same results. - Tests. U-PE-32 (depths 4, 15 and 30, pending and final cards, five ordinary shapes: no limit hit, correct verdict, under two thirds of the fuel), U-PE-33 (the reviewer's 14-segment Java case: not blocked, no unrelated team requirements), U-PG-7 (deep-path differential), U-PG-18 (the early exit costs a small constant), U-PE-30/31 and U-PG-12/14/15/17 unchanged in intent. - Review 6 changes (step 5). - Blocker: the generation clock wrapped (fixed by removing it). The NFA deduplicated live positions with a module-level stampClock stored in Int32Arrays; past 2^31 ticks (about 2 x 10^9, reached by a long-lived worker, or by a few dozen hostile jobs) every comparison failed and every glob silently stopped matching, process-wide. There is no counter any more: positions are marked with 0/1 flags, and each flag that was set is cleared again in a finally (also when a call stops on a spent budget). Nothing can wrap, go stale or leak between calls. The scratch arrays grow to the largest glob (they were fixed at 128, which a long GRAFT_INTENT_SENSITIVE_PATHS glob could exceed). U-PG-19 runs tens of thousands of deep matches, calls that stop half way on budgets of 1 to 300 units, and asserts matcherScratchClean(), the matches of **/secrets/**, *.ts, infra/** and docs/** before and after, a 100-part glob, and, as a structural guard, that the source has no stampClock or stamp array. Because there is no counter to set near 2^31, the test pins the property that made the counter safe instead (no state survives a call). Audit of the other module-level state: the compiled-matcher caches (cleared past 200 keys), the memo for code units above 255 (per class and global caps, cleared together), the per-job prepared map (on the budget) and the backstop hook hold no fixed-width counter. - Minor: glued ** rejected. /**.ts, a**b, x/**y/z give glob_invalid detail 7 ("a ** glued to other characters is not supported"), because picomatch reads them as "any characters across /" and the matcher as *; the ambiguous form is rejected so a tightening glob can never match less than its author meant. **.ts without a slash is rejected too (it was read as **/**.ts). - Minor: a class's own text. picomatch compiles [ab] to (?:\[ab\]|[ab]), so *[ab] also matches a file named x[Ab] (an attacker could otherwise name a file to evade a secret[ab] glob). It is modelled, not rejected: a part with a class runs a character-level NFA whose class node is "one character of the set" or "the class's source text". Probed on picomatch: the text alternative exists unless the body holds one of - * ? + . (so [a-c], [a.b], [*] do not have it); the matcher follows that rule. U-PG-7 now has paths holding class texts and glued stars (deep and flat), U-PG-20 pins both cases. - Nit: units track time. Every iteration of both NFAs is charged, including when the live set is empty. M23 (removing the empty-set early exit) was a cost-free mutant; now the loop would charge one unit per remaining segment, and U-PG-18 (an anchored glob costs at most 10 units beyond preparation on a 30-segment path) kills it. - Existing tests changed. I-WH-4 (pull_request.labeled is now handled by the policy module, so the test sends assigned for the ignored case), I-E2E-4 (a third publisher, the policy job, also posts a check run, so three first POSTs are made to fail instead of two), U-MAN-1 (events), I-DB-2, I-DB-3 (the new columns), I-DB-7, I-DB-8 and I-DB-9 (they roll back and re-apply 0005 too), U-FIX-1's expected list, and the contexts that build a JobContext by hand (they gained signals). No assertion was weakened. - Not done / left open. Real GitHub behaviour is unverified (symlink path, git/ref encoding, the two new events, comment rendering of the [!IMPORTANT] blocks); see HUMAN-TODO. No Phase 1 feature exists: there is no enforcement code path, and the check's conclusion is a constant.

Step 6: pilots and gate

Spec: step-6-pilots-and-gate.md.

  • Library verification (checked in node_modules, pg-boss 12.38.0).
  • Retention is deleteAfterSeconds, a queue option (createQueue / updateQueue) and also a send option. A job copies the queue's deletion_seconds when it is inserted, so updateQueue changes only jobs created afterwards. Maintenance deletes a job when completed_on + deletion_seconds has passed; failed and cancelled jobs have completed_on too, so one rule covers completed and failed jobs. A job that never ran is bounded by retentionSeconds (14 days by default), which is left alone.
  • Table and column names used by the doctor and the purge: <schema>.job is a partitioned table by queue name (job_common is the default partition; deleting from the parent reaches every queue), with name, state (enum created, retry, active, completed, cancelled, failed), data (jsonb), created_on, completed_on, deletion_seconds; <schema>.schedule holds name and cron; <schema>.queue holds name and deletion_seconds. boss.supervise(name) runs the maintenance for a queue on demand (used by I-Q-6).
  • Octokit: pulls.listRequestedReviewers answers { users, teams }; issues.listEventsForTimeline events are normalized by normalizeEvent (reviewed events carry submitted_at and commit_id, the others created_at; a review request has requested_reviewer or requested_team); the paginate iterator exposes the response headers, which is how a 10-page timeline cap is detected from rel="next".
  • github.webhook retention is 1 hour (the extra task). JobDefinition.options gained deleteAfterSeconds; the queue maps it through resolveJobOptions (default 7 days) and the webhook job sets 3600 (WEBHOOK_JOB_RETENTION_SECONDS). Because a job keeps the value it was inserted with, queue.start() also runs UPDATE <schema>.job SET deletion_seconds = 3600 WHERE name = 'github.webhook' AND deletion_seconds > 3600, so completed webhook jobs that already exist (7 days) are removed at the next maintenance run instead of lingering for a week (I-Q-6). Dedup does not depend on old webhook jobs (see the retention note in architecture.md). The onboarding doc states the same facts, including the two exceptions (a job that has not run yet, up to 14 days; button votes keep the voter login in intent.feedback.record data for 7 days). U-Q-6 pins the retention of every registered job.
  • Dates are strings. src/db/client.ts now parses the Postgres date type (OID 1082) to YYYY-MM-DD; the default would build a Date at local midnight. Only review_time_runs.from_date, to_date and metrics_daily.day are dates.
  • CardResult.costUsd is a top-level optional field and not part of usage: the eval harness copies usage into its report rows (the saved baseline would change). saveCard writes cost_usd from it; an absent value (a result cached before step 6) is stored as NULL. A call that spent no tokens costs 0 whatever its model is (a failed call has a model but no usage), so only a call that used tokens on an unknown model makes the run unpriced (null).
  • effectiveVotes reads the votes of the installations and reduces them in memory with the exported pure pickEffectiveVotes (the feedbackSummary rule), then applies the [since, until) window to the effective vote. This keeps U-GATE-3 a unit test; a DISTINCT ON query would be equivalent. feedback_recorded carries the card's head for a button vote and the change's current head for a reaction poll (the poll does not know which card each reaction belongs to).
  • Reviewer sync. Users of type Bot are not stored; the job throws a PermanentJobError on a 404 (the worker completes it), and syncReviewers is called as it is by pilot sync-reviewers, which counts such a PR as skipped. getChangeByNumber was added to modules/changes.
  • Cron wiring moved from bootstrap.ts into src/app/crons.ts (applyCronSchedules) so that I-ROLL-4 can test it with a real pg-boss.
  • Rollup (§8.4) choices where the spec is silent. Excluded authors are left out of the PR counters (prs_*, merged_*) only; cards, displays, spend, incidents and deliveries count everything. merged_trivial looks at the card of the merge head: an ok card is preferred, the newest first, and it counts when its final class is trivial. The verdict at merge is the one of the merge head with the greatest facts_read_at (ties: the latest created). cost_usd is the sum of the priced rows as a decimal string; rows with NULL are unpriced_runs.
  • Gate choices. "At least 2 installations" counts the installations asked for in the report (each needs 40 eligible votes); a coverage population of 0 PRs is a coverage failure (no data cannot satisfy 98%); the side metrics are summed from metrics_daily for the window, and rollup_incomplete is raised when fewer rollup days exist than window days times repositories. Drops are counted in the order author, bot or agent, not a reviewer, so a vote is dropped once. votes_without_reviewer_sync counts changes with a non-author vote and no pr_reviewers row.
  • Review-time choices. A review that shares the merge's second counts as before the merge (<= for merged_without_review; timeline timestamps have one-second resolution). paused_minutes is the paused time inside [S, merge or close or now]. Only excluded_author and timeline_truncated rows are produced; commits_truncated exists in the table's check constraint but is never written, because commit truncation (more than 250 commits) still classifies the author. A PR whose clock never starts because every request fell in a pause that never ends falls back to ready_for_review, then to creation.
  • Doctor. Without GitHub calls, so the budget check uses GRAFT_LLM_DAILY_TOKEN_BUDGET and intent_spend; the feedback check reports the first of its two warnings; crons warns only for a schedule that is configured but missing in pg-boss. A missing or deleted installation still runs every check (they come back empty).
  • Purge. --confirm is required and checked also for --dry-run, and an installation that was not uninstalled (deleted_at IS NULL) is refused (exit 3) unless --even-if-active is given; a --dry-run deletes nothing and is allowed on an active installation, which the spec's manual smoke test needs. Counts are taken inside the same transaction that deletes, and the pilot_purges row stores them (plus pgboss_jobs).
  • Globs. (1) ALLOWED now accepts combining marks (\p{M}) as the spec's café/** example with a combining accent requires; glob_non_ascii still flags them. (2) The mixed-script check (detail 9) runs right after the character check (detail 2), before the other structural checks; scripts beyond the 14 named are distinguished too (Bengali, Tamil, ..., Coptic: 32 in all) and any other letter shares one Other bucket. (3) The spec's sensitive_paths[0] in U-PY-12 is the 0-based index; locations are 1-based everywhere (sensitive_paths[1]). (4) warnings[].line/column point at the glob's own scalar.
  • Exact-text shortcut (picomatch 4.0.7). lib/picomatch.js test() sets match = input === glob before running the regular expression, where glob is the pattern as it was passed (here the normalized glob, normalizeGlob) and input the path unchanged: a case-sensitive comparison that ignores nocase, with no format (no windows option). src/core/glob.ts stores exact = normalizeGlob(glob) per compiled glob and answers true for an equal path, charging one unit per compared character when the lengths are equal and one unit otherwise (the engine checks lengths first). It matters for a glob that the compiled pattern reads differently from its text, for example ./a, whose leading ./ is dropped. U-PG-7 and U-PG-25 add text-equal paths.
  • Review 1 changes (step 6).
  • Invisible and compatibility lookalikes (major). validateGlob rejects (detail 2) any Default_Ignorable_Code_Point (the combining grapheme joiner U+034F, the variation selectors, zero-width characters, the byte order mark) and any combining mark that does not follow a letter, and rejects (detail 9) a glob whose NFC form changes under NFKC (fullwidth forms, mathematical alphanumerics). Visible accents, written precomposed or as two code points, CJK and Hangul stay valid (U-PG-21, U-CFG-31). This applies to graft.yaml, sensitive_paths and the env globs, which share validateGlob. Deliberately not done: telling two unlisted scripts apart (Ogham and Runic share the Other bucket and may mix); every segment that mixes Other with a listed script is rejected.
  • Paired comparison (major). The shadow-versus-baseline comparison pools only repositories that have both a baseline and a shadow run; the report states how many were left out (baseline only, shadow only) and I-GATE-4 covers both cases. It also shows the unfinished (right-censored) PRs next to each side.
  • Gate hardening (requested by the coordinator, changes spec §8.1.6). (Superseded by review 2: a leave-one-out check replaces the 30% block.) The early FAIL needs at least 6 voters and 2 installations (U-GATE-6 was rewritten for it: with fewer, the verdict is INSUFFICIENT DATA); the 14-day minimum is measured from the first to the last eligible vote (window.dataSpanDays), not from --since; a PASS carries a pass reason (accuracy, lower bound, n). The votes are clustered by PR and reviewer, so the Wilson interval is optimistic: this is stated in the report and not corrected (no bootstrap or design-effect adjustment).
  • Eligibility. A comment-only review no longer makes a reviewer: pr_reviewers.reason = 'reviewed' is written for APPROVED and CHANGES_REQUESTED only. Not configurable (the spec does not ask for it). Previously stored comment-only rows stay (the table is never pruned): clear them with delete from pr_reviewers where reason = 'reviewed' and run pilot sync-reviewers if that matters.
  • Allow-list renames. (Superseded, see the eligibility hardening below: the allow-list matches numeric user IDs, never logins.)
  • Vote-eligibility hardening (security check of the step 6 commit).
    • Reviews need standing. A review makes its author a reviewer only if its author_association is OWNER, MEMBER or COLLABORATOR, its state is APPROVED, CHANGES_REQUESTED or DISMISSED, and the author holds triage-or-higher permission, checked like a request's (security review 1, finding 2): any member of an organisation is a MEMBER of its public repositories and a COLLABORATOR may have read only, so the association alone is not enough (I-REV-6, I-REV-11). One permission call per distinct user per job, shared by requests and reviews.
    • Requested users need permission. A requested user is stored only with triage-or-higher permission (role_name is admin, maintain, write or triage, or the legacy permission is admin or write, which covers custom roles built on write; the legacy field maps triage to read, which is why the role name is read; a custom role built on triage is not eligible, fail closed). The answer must name the same user id as the request. 404 and 403 (forbidden) mean not eligible; rate limits and 5xx fail the job so it retries, storing nothing (I-REV-7, I-REV-8). The manifest is unchanged: Metadata: read covers the endpoint (see github-app.md). The fallback to "review association only" was therefore not needed and is not implemented.
    • Fail-closed is visible (finding 5). Every 403/404 on the permission check logs a warning with the status and the route template only (no login), the sync's info line carries permissionForbidden and permissionNotFound, and the last answer per (change, user) is kept in reviewer_check_denials (ids and status; migration 0007; cleared when a later check passes; purged with the installation). pilot doctor's feedback check WARNs on any 403 in 7 days ("does the App have Metadata: read?") and reports 404s in its OK line (I-REV-12, I-DOC-4).
    • Custom roles are judged by the legacy permission (write/admin pass; a role built on triage or read fails closed) and a role named Triage is not the built-in triage (the match is case-sensitive; I-REV-10).
    • Exclusions by id. changes.author_id (filled from pulls.get on every upsert; null for rows stored before) is compared with the voter's id, so a renamed author who is allow-listed is still dropped (I-GATE-11, U-GATE-16). Residual, not fixed: [bot] logins and GRAFT_AGENT_LOGINS are still matched by login, so a renamed agent account that holds write access and is requested would escape the agent exclusion. It needs an insider's deliberate rename; the fix is a GRAFT_AGENT_IDS setting (skipped: a new configuration surface for Phase 0). Agent accounts should be listed by their current login and re-checked after any rename.
    • Numeric logins. A line of digits is always an ID (GitHub allows all-digit logins, so 1234 alone means id 1234, not the login). A numeric login is written @1234 (login-only, like @alice) and resolve writes 1234:<id>; 1234:<id> is also accepted directly (U-GATE-15, I-ALLOW-1). resolve does not look up bare IDs to confirm their owner: check each ID with the person.
    • Atomic resolve write. src/pilot/atomic-write.ts: temp file .<name>.tmp-<pid>-<rand> in the same directory, fsync, rename, temp removed on failure; the original keeps its mode (0600 for a new file). An empty allow-list is valid, which is why a truncated write must not be possible (U-ATOMIC-1, U-ATOMIC-2, I-ALLOW-1).
    • Legacy rows (strict option). Migration 0007 adds pr_reviewers.verified boolean NOT NULL DEFAULT false: every row stored by the old rules becomes unverified, the gate counts only verified rows, and pilot sync-reviewers re-verifies (it upserts only users who pass the checks, setting verified = true; nothing is deleted). The alternative, trusting old rows until someone cleans them, leaves exactly the bypass this change closes for as long as nobody remembers; the cost of the strict option is a re-sync after deploy, and until then votes are dropped as not_reviewer and the report warns votes_without_reviewer_sync (I-GATE-10, I-REV-9, I-DB-12). The old rows are kept as an audit trail, not deleted. pilot doctor counts a PR as synced only if it has a verified row (I-DOC-4).
    • Allow-list by ID. The allow-list holds user IDs (<id> or <login>:<id>); the login is informational and never matched; a login-only line is an error (line number only) and pilot allow-list resolve rewrites it. The latestLogin rename lookup is gone (I-GATE-9, U-GATE-2, U-GATE-12 to U-GATE-15, I-ALLOW-1). resolve takes --installation <id> because an App JWT is meant for /app endpoints only, so it borrows an installation token (public data only). The report line reads "N user IDs (sha256 ...)".
  • Framed incidents. A culprit named in a fix PR's body is kept only when Graft knows it was merged into the same repository before the fix PR was opened (changes, or a baseline run's merged PRs). Residual risk: someone who can edit the body can still name any merged PR; the report shows incidents by source, incident list is in the weekly ritual and incident remove deletes one.
  • I-PRIV-1 now enqueues the pilot jobs through the handlers and the listener and asserts that jobs exist before searching them.
  • Onboarding mentions installations.account_login and qualifies the 14 days.
  • Skipped nits. votes_without_reviewer_sync only got a clearer wording (it counts PRs with votes and no reviewer row; telling "never synced" from "synced, nobody found" needs a marker).
  • Review 2 changes (step 6).
  • No wall-clock assertions in the default suite (major). Every millisecond bound (U-PY-3, U-PG-4/6/8/9/12/14, U-RISK-21, U-LLM-12, U-RED-12) now goes through expectDuration, which asserts the bound only with GRAFT_PERF=1 (pnpm test:perf) and a 30 s sanity ceiling otherwise. The same tests assert deterministic work: U-PG-4 (under 2,000 units), U-PG-6 (500,000 pairs, under 7 M units, budget not exhausted), U-PG-8 (under 20,000 units per call and no per-call limit hit; measured at most 8,860), U-PG-9 (the call stops once at its own 200,000-unit limit), U-PG-12/14 (budget_exhausted, and the shared budget exhausted); U-PE-30/32 and U-PG-13/15 already did. Removing the required-run, literal-tail or required-literal prefilters of the matcher is still killed (U-PG-8, U-PG-18, U-PE-32), now by units. Unit tests have a 120 s timeout (U-PE-32 no longer depends on the 5 s default).
  • Leave-one-out instead of the 30% block. A PASS (including the n >= 400 point-estimate PASS) must also hold on the votes without the top voter; otherwise INSUFFICIENT DATA with dominant_voter (share, accuracy with and without, lower bound without). The 40% warning stays. A FAIL is never turned into anything else.
  • Breadth counts installations that voted (at least one eligible vote) for the early FAIL.
  • Exact data span. dataSpanDays is the exact first-to-last-vote span (two decimals), and the 14-day rule is span + 1 >= 14: the first day counts as a full day, so 14 days of voting (Monday 10:00 to the second Sunday) are enough. U-GATE-5 changed accordingly (12.99 fails, 13 passes).
  • Compatibility characters, per code point (detail 10). Any code point whose NFKC form differs from itself is rejected with its own message ("contains a compatibility character ..."): fullwidth and mathematical letters, the Kelvin, Angstrom and Ohm signs, CJK compatibility ideographs, compatibility jamo, halfwidth katakana, superscripts and Roman numerals (fail-closed, an error and not a silent miss). Precomposed letters, lone combining accents and Hangul jamo are stable and stay valid.
  • Dismissed reviews count. DISMISSED joins APPROVED and CHANGES_REQUESTED in pr_reviewers (it was a real approval until a push dismissed it; I-REV-2).
  • Existing tests changed. U-CLI-3 (--json now also prints warnings), the registry test (two signal listeners), I-FB-3 (its cards now have intent_displays rows, because the sweep windows on the last display since T13), I-DB-½/3/⅞/9 and step 5's I-DB-10 (they roll back or re-apply 0006 too; I-DB-3 inserts one row into each new table), and U-PG-7 (text-equal paths). After review 1: U-GATE-6 (early FAIL needs 6 voters and 2 installations), the decideGate and I-GATE-1 PASS expectations (a pass reason), I-REV-2 (a comment-only review is not stored) and the incident tests (their culprits are seeded as merged PRs). Step 6's own I-DB-10 shares its ID with step 5's two tests (the spec reused it); its name starts with I-DB-10 0006.
  • Added beyond the spec. U-Q-6 and I-Q-6 (retention), I-PRIV-1 (a sentinel PR title, body, commit message and login never reach the new tables or the new jobs), a sentinel-voter test for the gate report, and the policy checks in .github/workflows/ci.yml. docs/graft-yaml.md documents detail 9, combining marks and the warning. .env.example lists the three new variables.
  • Not done / left open. Real GitHub behaviour is unverified: the shape of timeline events (requested_team, reviewed), issues.listEventsForTimeline on pull requests with the issues: read permission, and rate-limit headers of a long baseline run; check them on the first pilot baseline (HUMAN-TODO). Business-hours time, team expansion and automatic calibration are out of scope by the spec. The smoke test of the spec's DoD was run against a dead local GitHub port only.

E2E runner library facts

Library facts checked against node_modules (@octokit/plugin-rest-endpoint-methods 17): apps.getRepoInstallation (GET /repos/{owner}/{repo}/installation), apps.getInstallation, apps.listWebhookDeliveries, apps.getWebhookDelivery, apps.redeliverWebhookDelivery (POST /app/hook/deliveries/{delivery_id}/attempts), actions.setGithubActionsPermissionsRepository and actions.getGithubActionsPermissionsRepository, repos.createForAuthenticatedUser (POST /user/repos), repos.createFork, repos.mergeUpstream, repos.addCollaborator, git.createBlob|createTree|createCommit|createRef|updateRef|deleteRef|listMatchingRefs, pulls.requestReviewers, reactions.createForIssueComment, issues.addLabels|createLabel|getLabel|listEventsForTimeline all exist under those names. mediaType: { format: "full" } sends Accept: application/vnd.github.v3.full, not ...full+json; the fake world recognises both. octokit.paginate reads response.url, so the fakes now set it.

Live e2e runner deviations and additions

Spec: docs/e2e/live-scenarios-spec.md.

Choices made while implementing docs/e2e/live-scenarios-spec.md, each where the spec was silent or could not hold:

  • Gate window. gate report --since <run day> needs --until after it (the default is the start of today), so s08 passes --until <run day + 2>; b1 passes --to <run day + 1> as specified.
  • Product fixes found by the first review (both reproduced by the runner). (1) The card's task input. The intent job read changes.task_id, which the provenance job writes concurrently; for the same head the card could miss the Graft-Task issue (intent won the race) or use the previous head's (after a rewritten history), and the input hash, hence the cache, varied from run to run. Design choice: the intent job now derives the task from this head's own commits with the shared provenance parser (fetchPrCommits and computeProvenance, through modules/provenance/index.ts), before any lock, so the card has no dependency on provenance ordering and the input hash is a function of the head. Waiting for or scheduling after provenance would have added a retry path and a latency to every card; reading change_provenance only for a matching head would still need a fallback. The extra cost is one pulls.listCommits call per card. A 404 on that call leaves the card without a task (the provenance job reports the failure itself). Tests: I-INT-TASK-1 (intent before and after provenance give the same inputs), I-INT-TASK-2 (a trailer-less rewrite does not use the stale task; the original head hits its cache again). s01 asserts intent_cards.linked_issue_number, and s07 (d) carries Graft-Task again. (2) Reviewer requests. pull_request.review_requested arrives with opened; its sync job could run before the changes row existed and returned skipped, and once the reviewer reviewed GitHub no longer listed them as requested. Design choice: the job now creates the change from pulls.get (API data, not the payload) instead of skipping, reads the request from the API: the webhook only names a user to look at, and the job records requested only if the PR's issue timeline holds a review_requested event for that user id (the timeline keeps it after the reviewer has reviewed, the requested list does not) and the permission endpoint gives them triage or higher, so jobs write only API data and a forged or replayed claim records nothing, and it calls ensureRepository like provenance and intent (a removed repository is skipped; an unknown one gets its row), and the provenance_published signal also enqueues one sync per change (rev-sync-open:{repo}:{pr}). No retry loop was added: an upsert has no failure mode that a retry would fix. Tests: I-REV-REQ-1 (a request before the change row exists, then a COMMENTED-only review: the reviewer stays eligible, which is exactly what the gate counts via pr_reviewers.verified), I-REV-REQ-2 (no permission, nothing recorded), I-REV-REQ-3 (a claim the timeline does not show records nothing), I-REV-REQ-4 (removed and unknown repositories), I-REV-REQ-5 (provenance_published enqueues one sync per change; the listener is now onChangeSignal). s08 requests the reviewer at open again, as the spec orders.
  • s02 team check. Graft renders the team as a code span of its full slug (`@mmornati/platform`). The assertion is: a code span @<owner>/platform is in the policy section, and no @...platform mention remains outside code spans.
  • Token minting (POST /app/installations/{id}/access_tokens) is how an App client authenticates; the write hook lets it through for the app and probe clients only, and only for the run's installation. Every other non-GET of the probe client is refused.
  • Redaction pattern. (token|bearer)\s+\S+ would eat prose such as "token budget"; it is narrowed to a credential-looking word (12 or more characters of [A-Za-z0-9._~+/=-]). The registry and the gh[pousr]_, github_pat_, sk-ant-, JWT, PEM and smee patterns are as specified.
  • Installation scope (review 1, finding 1). The preflight of run, verify and setup refuses (exit 3, check installation_scope) unless the App's installation has repository_selection: "selected" and GET /installation/repositories (installation token) lists exactly the sandbox, by id and name; a protected repository or a source in the list is named as such. s10 selects its delivery by installation id, repository id (list and payload) and PR number, never the number alone.
  • Write guard narrowed. The actor may not write to /repos/{sandbox} itself or to /transfer. The outsider may only react, create pull requests, request reviewers, create blobs, trees and commits, create branches graft-e2e/<runId>/... and move or delete them (in public mode: the same on its verified fork, plus merge-upstream). Writes are sent with redirect: "manual" and any 3xx answer is a GuardRefusal; the preflight requires the repository to answer under the sandbox's own name (sandbox_renamed otherwise, also in setup) and pins its id, setup checks that the created repository is the sandbox before pushing and closes repository creation in a finally, and openPr checks base.repo.id against it.
  • HUMAN-TODO ticking (review 1, finding 4). --update-human-todo ticks only items that are a check (s2-performed-via, s3-model-access, s3-outputs, s5-check-github, s6-redelivery). Items that are a deployment or pilot action (s1-install, s5-events, s6-backfill, s6-retention, s6-baseline-github) keep their box and get "Observed in sandbox on (live e2e run ; assumptions ...); the action above is still to do." s3-outputs needs A04, hence a run with --wait-cron (about 16 more minutes).
  • git isolation. Every git child runs with GIT_CONFIG_GLOBAL=/dev/null, GIT_CONFIG_NOSYSTEM=1 and GIT_TERMINAL_PROMPT=0; clone and push reset credential.helper; the push authenticates through a GIT_ASKPASS script in the 0700 temp directory that prints the token from the child's environment (so no helper, URL, argv or file holds it) and goes to the full_name GitHub returned for the created repository. Children start from childEnv (src/e2e/child-env.ts): no GRAFT_E2E_* variable, and for git no inherited GIT_* variable at all (GIT_CONFIG_COUNT, GIT_ASKPASS, ... could inject configuration or credentials).
  • --only-offline runs the live variant only unless --llm none is given, redacts the child's output and passes it only the three offline variables. A plain pnpm test also runs the none variant, on a subset of scenarios (s01, s05, s06, s07, s08, s09, s10, b1) and asserts that no model was called.
  • s10 counts only pull_request.opened deliveries and github.webhook jobs created since the redelivery, and waits for a 15 s quiet window (scaled by --timeout-scale). b2 logs requests before the GET-only hook, so a refused write is counted. Assertions that could pass vacuously were fixed: s01 asserts a policy hash, s05 and s09 fail without H0 (s05 now needs s01), s07's delivery booleans come from the waiters, s02 compares the team name in the policy title, RUN-2 asserts zero model calls.
  • Public mode now has an integration test (I-LIVE-PUBLIC-1: s08 from a fork, then cleanup in the fork). The worker takes an optional pollingIntervalSeconds (tests use 0.5 s).
  • E2eDeps additions: readDotEnv() (the raw .env, for the token check), rootDir, gitPusher, mutationSpacingMs (the 1,000 ms spacer; tests use 0), pid/isAlive (the run lock), random, target. ops(argv, { baselineToken, requestLog }) records the token client's requests for b2's GET-only assertion. GitPusher is one method, copyAndPush, which does the clone, the drop, the overlay, the commit and the push on disk (binary files never pass through a string map).
  • Preflight scope per command. run and verify need Graft running (installation rows, readyz, no stale queued job, graft.yaml equal to the starter); setup checks the database and migrations but not the App, the marker or the policy before it acts; cleanup needs GitHub only. A queued job counts as stale only when it is due (start_after <= now()) and older than 2 minutes, so a delayed retry is not "a stopped worker". A missing actor token (no variable, gh failing) is exit 2, as U-LIVE-SEC-3 asks; a token that exists but fails GET /user is exit 3.
  • Cleanup also lists git/matching-refs under heads/graft-e2e/<runId>/, so the branches of PRs that were closed by hand are deleted too, and it keeps a ref list for the outsider's fork in public mode.
  • Tests that need the database (U-LIVE-CLI-2, U-LIVE-CLI-3, U-LIVE-PLAN-2, U-LIVE-REP-3, U-LIVE-OPS-1, U-LIVE-BASE-1) are integration tests (test/integration/live-e2e-*.test.ts) because the preflight opens the database; their IDs are unchanged.
  • Fakes. createFakeGitHubApi gains owner, repo, per-issue comments, completed_at on check runs, paged comments and check runs, and response URLs. The world supports base_tree in git/trees (the spec said full trees only; the runner uses base_tree as the real API allows) and a minimal line diff (common prefix and suffix trimmed). Webhook review payloads spell state in lower case, as GitHub does; REST responses keep upper case. Public (fork) mode is covered by I-LIVE-PUBLIC-1.
  • One run directory per run. state.json also holds the evaluated assumptions so report re-renders without a network; verify appends a synthetic verify scenario with the re-run invariants.

LLM gateways and client-side refusal fallback

Step 1 of plan/llm-provider-expansion.md.

  • One wire format, any host. ANTHROPIC_BASE_URL points the existing Messages API client at a gateway or cloud that speaks the same API (OpenRouter, Vercel AI Gateway, Requesty, LiteLLM, Bedrock Mantle). ANTHROPIC_AUTH_TOKEN sends Authorization: Bearer instead of x-api-key; at most one of the two may be set. The client passes null for every credential and base URL it is not given, so the SDK never falls back to reading ANTHROPIC_* from the process environment: the validated config is the only source (U-LLM-13). The log line names the host only, because a URL can carry credentials (U-LLM-15).
  • Model IDs. Still Claude only, since this is the Anthropic wire format, but a gateway's vendor prefix and a version suffix are accepted (anthropic/claude-sonnet-5.5, us.anthropic.claude-sonnet-5-5-v1:0). The pattern (MODEL_ID in src/llm/types.ts) is shared by src/config and src/eval/env.ts. Such IDs have no entry in src/llm/pricing.ts: the service records the run as unpriced (costUsd null), and pnpm eval --live refuses until a price is added.
  • Server-side refusal fallback on the Claude API only. The server-side-fallback-2026-07-01 beta exists on the Claude API alone, so GRAFT_LLM_REFUSAL_FALLBACK is ignored when ANTHROPIC_BASE_URL is set to any host other than api.anthropic.com (isClaudeApi, U-LLM-14).
  • Client-side refusal fallback. GRAFT_LLM_REFUSAL_FALLBACK_MODEL (off by default, so nothing changes without it): on refusal, callValidated repeats the call once on that model, unless the client reports through the new optional LlmClient.serverRefusalFallback(model) that it already fell back server-side, or the fallback is the model that refused. The switch is not one of the two D6 attempts, so a call may run three times (refused, fallback, repaired output), still bounded by the deadline. Each call is priced by the model that answered, and the card names that model (U-GEN-32 to U-GEN-35).
  • Diff filtering: no change. The plan listed "drop lockfiles, generated and vendored files" as new work, but classifyFile already leaves lockfiles, binaries and GENERATED_GLOBS paths (dist/, vendor/, minified, snapshots, protobuf output) out of the model input. Filtering more would not save money: every file the model does not see except lockfiles and binaries raises the floor to logic (R13 partial_view, so that a pull request cannot hide code behind a "generated" path), which moves the PR off the cheap Haiku first pass.

Workload Identity Federation

Step 1, item 5 of plan/llm-provider-expansion.md.

  • A third credential, still explicit. ANTHROPIC_FEDERATION_RULE_ID, ANTHROPIC_ORGANIZATION_ID, ANTHROPIC_SERVICE_ACCOUNT_ID and ANTHROPIC_IDENTITY_TOKEN_FILE (plus the optional ANTHROPIC_WORKSPACE_ID) are validated by Graft (src/llm/federation.ts, shared by src/config and src/eval/env.ts) and passed to the SDK as credentials: oidcFederationProvider({ identityTokenProvider: identityTokenFromFile(path), ... }). The SDK's own environment resolution (federation variables, ANTHROPIC_PROFILE, ~/.config/anthropic) is never used: apiKey and authToken stay null, and credentials is always given when neither is, so the default chain is not consulted (U-LLM-16).
  • Exactly one way to authenticate. The SDK lets an API key shadow federation. Graft refuses the combination at startup instead, so a leftover ANTHROPIC_API_KEY cannot silently win (U-CFG-34). Federation is all-or-nothing; the service account ID is required although the SDK treats it as optional, because the reference lists it as required on the exchange. The identity token path must be absolute. Errors name variables only.
  • Same host for the exchange and the calls. The token is exchanged at ANTHROPIC_BASE_URL (default https://api.anthropic.com), which must be https or loopback with federation, since it receives the identity token. The server-side refusal fallback stays on when the host is the Claude API.
  • Token file re-read on every exchange. Assertions with a jti are single use, so the provider never caches the identity token: the SDK's token cache refreshes the minted token in the 2-minute window before expiry and once after a 401, and each refresh reads the file again (U-LLM-17).
  • Exchange failures are API outcomes. The SDK throws WorkloadIdentityError (an AnthropicError, not an APIError) from inside the request, before any HTTP call, and AnthropicError for an unreadable token file; classify would have rethrown both as programmer errors. A wrapper maps them to FederationError with a failure: 400, 401, 403, a missing access_token and an unreadable or empty file are auth; 429 is rate_limited; 5xx and a network failure (detected in the wrapped fetch, not from the message) are unavailable (U-LLM-18). The message carries the status only, never the token, path or body. The exchange is outside the request's deadline signal, so it has its own 30 s timeout.
  • Logging and redaction. The client log line carries auth: "federation", the rule ID and the host (U-LLM-19). The logger redacts anthropicFederation, organizationId, serviceAccountId, identityTokenFile, identityToken, assertion and access_token. The e2e runner registers the token file's current contents as a secret; later rotations are caught by the JWT pattern.