Decisions, library versions and deviations¶
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), notretryDelaySeconds(ourJobDefinition.options.retryDelaySecondsis mapped to it); retention isdeleteAfterSeconds(7 days on every queue, exceptgithub.webhookwith 1 hour since step 6; it is a per-job option,JobDefinition.options.deleteAfterSeconds);work()takes{ localConcurrency, batchSize }and a handler overJob[]; the transaction option isdbwith anexecuteSql(text, values)adapter (type exported asDb), which is whatcreatePgBossQueuebuilds 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 increateQueue. Queue options are re-applied withupdateQueueon every start, becausecreateQueuedoes nothing for an existing queue (I-Q-3). singletonKeydoes not deduplicate on pg-boss v12 standard queues. Since v10 the key is only enforced by queue policiesshort,singleton,stately,exclusive,key_strict_fifo(or together withsingletonSeconds); on ourstandardqueues it is stored and ignored.JobQueue.send(..., { singletonKey })therefore also sets a deterministic jobid(UUIDv5-style SHA-1 of job name and key;singletonJobIdinsrc/queue/queue.ts). pg-boss inserts withON CONFLICT DO NOTHING, so a second send with the same name and key inserts nothing andsendreturnsnull(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 forgithub.webhook). A caller that needs a re-run (for example acheck_run.rerequestedfor the same SHA) must use a different key, such as one that includes an attempt number or a timestamp. Anullreturn means "already enqueued". For webhooks thewebhook_deliveriesprimary key is the first line of defence; if the job send returnsnull(job exists, delivery row gone) the existing job id is stored and the outcome isduplicate.- 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 loggedtry. - Kysely 0.29 exports
Migratorfromkysely/migration. - zod 4:
z.ZodType<T>works as in the spec; config defaults are applied before validation (string form). - Fastify 5.12:
disableRequestLoggingis deprecated in favour ofLogController, whichbuildServeruses (request logging is off; the receiver logs its own outcome line). githubWebhookJobis built by a factory. The job needs the registry's handler lookup and the registry lists the job, socreateRegistrybuilds it withcreateGithubWebhookJob(registry)and exposes it asregistry.webhookJob(also present inregistry.jobs). The receiver sendsregistry.webhookJob.createPgBossQueuereturnsPgBossJobQueue(aJobQueueplus thebossinstance the worker needs) and takes asuperviseflag (false for the server, true for the worker).buildServerisasyncand returns the instance without listening.- Store functions (
getActiveRepository,listActiveRepositories, ...) take a KyselyDborTransactionas their first argument. - Extra files not in the layout:
src/bin/bootstrap.tsandsrc/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-schemasand contain no mergedpull_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/rest22 includespaginate.iterator(used for commits and comments; the iterator is stopped early after 3 and 10 pages).checks.updatetakes the check run id and nohead_sha, so the PATCH body omits it. Nothing else differed from the spec's assumptions. pg-boss 12 only marks a failed jobretryand bumpsretry_countwhen it is next fetched, so I-Q-4 asserts theretrystate, not the count. - D13 fallback (added). New optional env
GITHUB_APP_SLUG(Config.githubAppSlug). When set, a comment authored by<slug>[bot](typeBot) that starts with the marker is accepted as ours whenperformed_via_github_app.iddoes 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_atand the action. (Step 3 appended:{action}; see T18.) D6 as written would swallow any return to an earlier state within 7 days:closed/reopenedafteropened, 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 isprov:{repo}:{pr}:{head}:{base}:{bodyHash}:{pull_request.updated_at}for all five actions, andpull_request.updated_atis required in the payload schema (a payload without it enqueues nothing). A replayed body has the sameupdated_at, so replay protection is kept. The cost is one redundant, idempotent job for a title-only edit (the comment comes backunchanged, the check run gets one PATCH). - In-lock staleness check. Inside
withChangeLockthe job publishes only if the stored head SHA equals the live head and the storedpr_updated_atis not newer than the liveupdated_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_athas 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 exampleclosedthenreopened) is now closed byactionin the dedup key. The title-edit plus close case remains: a title-only edit and a close inside one second shareupdated_at, head and body, and only the action tells them apart. - Session host is plain LDH (D14 hardening).
validateSessionUrladditionally 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 andlocalhost.is rejected), because the hostname is the link text and WHATWGURLaccepts` * _ ~ ! $ & ' { } =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.
upsertManagedSectionthrowsPermanentJobError(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
upsertChangeFromPullreportsapplied: false(storedupdated_atis newer), the job logsstaleand stops before any commit fetch or publish. That is what I-PROV-3 requires; T14 does not say it. parseTrailerBlockalso returnsblockTooLong(P7 needs a way to reach theblock_too_longwarning);ProvenanceResultalso carriestruncated(the renderer needs it andcountscannot express it);persistProvenancealso takesrepositoryFullName(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>), orunknown Graft key ignored (unknown_graft_key)for an unknown key. When there are no trailers and the PR is truncated, the "noGraft-*trailers found" footer still gets the truncation sentence appended. - U-REN-7 uses the agent value
octo@bot/@team: the spec's@octo/botstarts with@, which the agent character set rejects (a separate assertion checks that it is rejected and never rendered). - I-SEC-3 posts the
installation.createdbody aspull_requesttwice: unchanged (the receiver ignores it, since the actioncreatedis not handled) and withaction: "opened"so the handler really runs and logspayload_shape_mismatch. pnpm db:rollbackreverts one migration (the latest), as T1 requires; in step 1 it rolled back everything. The test helpermigrateDownstill 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.sourcegainspr_body;changes.pr_updated_atandgraft_comment_idare optional on insert so step 1 tests compile unchanged; acontinueinstead ofreturnlets 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/sdk0.133.0,picomatch4.0.7,@types/picomatch4.0.3 (dev; picomatch ships no types). - SDK verified against
node_modules(T1). Everything the spec names exists and behaves as assumed: the constructor optionsapiKey,maxRetries,timeout,fetch(andbaseURL, used by the tests); per-request{ timeout, maxRetries };output_config: { effort, format: { type: "json_schema", schema } }on the plainmessages.create(effort islow | medium | high | xhigh | max); system text blocks withcache_control;client.beta.messages.createwithbetasandfallbacks: "default"(server-side-fallback-2026-07-01is a listed beta);usage.{input_tokens, output_tokens, cache_read_input_tokens, cache_creation_input_tokens}(the cache counters can benull);stop_reason"refusal"and"max_tokens";response.model; the error classesAPIConnectionTimeoutError,APIConnectionError,RateLimitError,InternalServerError,AuthenticationError,PermissionDeniedError,BadRequestErrorandAPIError.status. Detail the spec did not state: the beta call putsbetasinto theanthropic-betaheader and adds?beta=trueto the URL, whilefallbacksstays in the JSON body (U-LLM-2 asserts exactly that). A 529 arrives as anInternalServerError-styleAPIErrorwith status 529 and is retried by the SDK (U-LLM-10). Errors that are notAnthropicErrors 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 optionschedule: booleanexist as named.scheduleneeds the queue to exist (the queue wrapper creates all queues instart()), calling it again for the same name replaces the cron (unscheduleremoves it), and the cron expression is only validated by pg-boss atschedule()time:GRAFT_FEEDBACK_POLL_CRONis checked for shape only (5 fields of digits,*,/,,,-), so a cron that passes the shape but is invalid stops the worker at startup.JobQueue.unscheduletakes the job definition, likeschedule. - 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.stringifyof the validated writer output"; the parsed JSON is identical. - Injection rule (R15) ignores format characters. Before matching,
computeRiskFloorremoves UnicodeCfcharacters and the visible[U+XXXX]form thatneutralizeUntrustedwrites for them, soig\u200bnore previous instructionsstill counts. The spec's patterns are unchanged. - Floor reasons.
FloorReason.pathsholds 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 andcountis the number of matching texts; size and listing reasons have none andcount0 (the renderer takes line and file counts frominputs). - 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.truncatedis 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?)indiff.tstakes 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
sensitiveis 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.
saveCardwritescreated_atandupdated_atfromctx.clock(not the database default), so the daily budget and the tests follow the injected time. intent.feedback.sweepdata is the empty object, validated strictly. The sweep reads owner and repo fromrepositories.full_name, skips removed repositories, and the poll job re-checks the installation.- Feedback summary
sinceapplies to the effective vote'sreacted_at(the vote that wins first, then the time filter). - Reactions with a
nulluser are dropped when read; reactions of any user type other thanBotcount (MannequinandOrganizationincluded). - Models in the card row are
response.modelas returned by the API (often a dated id), not the requested alias. - Pure
generate.tsexports alsoRETRYABLE(used by the store to rebuildretryablefrom a stored row) andEFFORTS,MAX_OUTPUT_TOKENS. - Test changes outside the spec's list.
test/unit/fixtures.test.tslists the newcheck_run.requested_actionfixture (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 ofopenedfor 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), becausecomputeRiskFloorreceives 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 incodeSpanfails it. New helper files:test/helpers/intent-inputs.ts(pure builders) next to the specifiedintent-scenario.ts.test/unit/log-hygiene.test.ts(U-LOG-2) is an extra grep-style test that no logger call insrc/llmorsrc/modules/intentmentions 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}inrefreshKey,WarningCodeandGraftFieldmoved tosrc/db/schema.ts(fields.tsre-exports them;grep -rn "modules/" src/db/finds nothing), I-PROV-10c isolated by clearingchanges.pr_updated_atbefore 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 fromprovenance/job.tsthe test now fails (the same holds for I-INT-9 andintent/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.tsfails I-INT-9; dropping the floor term incombineRiskfails 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 inmarkdown.tsfails 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 andsaveCardwrites one row per model run before the card upsert, so retries, re-runs, in-place regeneration of anunavailablerow and a dropped concurrentoksave all count.tokensUsedTodaysums the ledger for the UTC day (bounded on both sides), notintent_cards.created_at. The card's token columns still describe only the run that produced the row. Migration0003_intent(not yet committed) was edited in place;TABLE_NAMESnow 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 withctx.clock.now()taken right beforemarkPublished(not the job start). Reactions are attributed to the display with the greatestshown_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_atkeeps the first display only (the sweep still uses it, andpublishedCheckRunForHead);latestPublishedCard(the D11 note) follows the latest display.markPublishedtakes{ 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
sendreturned an id. The retry key ended with the triggeringdeliveryIdin 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 anAbortSignal.timeouton the SDK request, so SDK retries and their backoff end at the deadline; the abort maps totimeout. 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
fallbackblock 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 currentexternal_idis rejected (feedback_rejected). I-FB-9. - Nits fixed. Output strings made only of
Cfcharacters 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);intentRepoSettingstakes{ repositoryId, installationId, owner, repo, baseSha }for step 5 (LivePull.base.shais optional). - Nits not fixed. Paths in R15 (spec change: file names like
ai-reviewer.mdwould raise the floor on ordinary repositories). Pure rename classifiedno_patch_from_api(needsdiff.tsto 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 storedFloorReasonshape 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
saveCardfails I-INT-17, I-INT-18, I-INT-19; makingtoJobErrorreturn 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 theexternal_idguard fails I-FB-9; removing the in-lock head check inprovenance/job.tsfails 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)}, withnowMsfromctx.clock. A pending-only singleton is not available on ourstandardqueues (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 injob.tssays 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_pendingwidensintent_displays.statustook | unavailable | pending(down: deletespendingrows, restores the old CHECK). After the note's comment write, inside the same lock, the job records apendingdisplay of the previous card.attributeReactionsalready treated every non-okdisplay as a dropped one, so a reaction made while the note is shown is not credited to any card.latestPublishedCardskipspendingdisplays.markPublishedstill compares with the last display of any status, so the nextokorunavailablepublish 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
markPublishedwith anowtaken 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. 0003down (T17.4) usesDROP TABLE IF EXISTSforintent_displaysandintent_spend.escapeProse(T17.5) keeps the<!--rewrite with the "unreachable, defence in depth" comment; no behavior change.- New exports.
modules/intent/index.tsalso exportsfetchIntentInputs,promptFingerprint,maxRisk,RISK_ORDER,DOCS_GLOBS,TEST_GLOBS,globMatcherandMAX_OUTPUT_TOKENS;modules/provenance/index.tsexportscomputeProvenanceandfetchPrCommits. U-PROMPT-1 also assertspromptFingerprint()equals the pinned hash. - No new dependency. zod 4's
z.toJSONSchema(draft 2020-12, verified againstnode_modules) generates the schemas; dates and timestamps are checked with regular expressions instead offormat, so that a plain JSON Schema validator needs no format plugin. Refinements JSON Schema cannot express are listed in the case schema's top-leveldescription. - Report details the spec left open.
statusof a row isok | unavailable | skipped(a case skipped by the budget has statusskipped). Report thresholds carry aprovisionalboolean;report.passis 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 fieldsthresholdsandpass(they depend on the baseline itself) and the per-rowcachedflag; everything else must be equal. Cached rows keep the cost of the result they reuse,costUsdThisRuncounts only the rows that were not cached. A baseline that does not exist is a usage error with--checkon the synthetic set, and an explicit--baselinethat does not exist is always one. - Run options added.
RunOptions.gitis not part of the harness: the CLI fillsreport.gititself.maxTokensalso gates the pre-run estimate. Budgets and the confirmation apply to--liveonly (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 taggedsize-s|m|l|xlandpath-sensitive|tests|docs|sourceso that the report's per-tag under-classification shows the strata.--author botfilters on the list response (user.type,--agent-logins) and makes no commit request;--author anyfetches commits only to classify. Pull requests with more than 20,000 changed lines or 1,000 files are classified without a file listing (stratumsource).source.baseShafalls back to forty zeros if the API omitsbase.sha.--redactaccepts onlyemails.
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).
yaml2.9.1 (added withpnpm add yaml; it is a CommonJS package with types). Checked againstnode_modulesand by running it:parseAllDocumentsreturns an array ofDocument(zero for an empty or comment-only text, which becomesnot_a_mapping);LineCounteranderror.posgive lines and columns, butlinePosis only filled withprettyErrors: true, so the parser usesprettyErrors: falseand theLineCounter;node.rangeis[start, valueEnd, nodeEnd];uniqueKeys: truereportsDUPLICATE_KEYas an error, a tab indent isTAB_AS_INDENT, a bad indent or an open flow collection isBAD_INDENTor similar.maxAliasCountis atoJSoption, not a parse option (D6 puts it on the parser): it is passed totoJS, where0makes any alias throw "Alias resolution is disabled"; the real defence is that every anchor and alias is rejected by avisitbeforetoJSruns (U-PY-3 spies onDocument.prototype.toJS).merge: falsemakes<<an ordinary key whose*aliasvalue is still caught.!customand!!python/objectgive a warning (TAG_RESOLVE_FAILED), not an error;!!binary,!!setand even!!strresolve silently and only show innode.tag; so the parser ignoresTAG_RESOLVE_FAILEDas a warning, reports every other warning asyaml_syntax, and rejects any explicit tag (yaml_tag), a little stricter than D6's "outside the core set".logLevel: "silent"stopstoJSfrom 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" })sendsGET /git/ref/heads%2Fmain(Octokit percent-encodes the slash; GitHub accepts it; the fake API matches both forms), anddata.object.shais the commit;repos.getContentreturns an array for a directory, a base64contentfor a file,encoding: "none"above 1 MB, andtype: "symlink"/"submodule"for those. That GitHub follows a symlink to an in-repo file and reports the target'spathis taken from GitHub's documentation and not verified against a real repository (the checktype === "file" && path === "graft.yaml"is correct either way).@octokit/webhookshas types forpull_request_reviewandpush; the handlers validate with their own zodlooseObjectschemas instead, like the other handlers, and@octokit/webhooks-schemasnames the push definitionpush$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_unionis resolved by looking at whether the raw value is a list). The cross-field rules (exactly one action, non-emptymatch, approve restrictions, size bounds, unique names, glob validity, the 500-glob cap, emptyrequire) live incrossChecksand always run on the raw value instead of a zodsuperRefine: zod skips a root refinement when a sibling already has a structural error, which would hide errors. They emit the same codes.policyJsonSchema()additionally writesoneOf(exactly one action) andminProperties: 1formatch,requireandsizeinto the generated schema, so editors flag those too; the rules it still cannot express are in itsdescription.too_many_errorscounts inside the 50: 49 errors plus the marker.glob_invalidreason 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 asyaml_syntax, soparsePolicyTextnever throws. - Spec examples that the helpers do not produce. E12 as written (
Docs/A.md) would match rule 1, because*.mdmatchesA.mdwith the extension's case; the test usesDocs/A.MD. The spec's rendered examples showinfra\-and\-auth, butescapeProsedoes not escape a hyphen inside a word; the output isinfra-and-auth(byte-for-byte tests use the real helper output). The closing(code)of an error line is added afterescapeProse, so its parentheses stay literal and the underscore is escaped ((glob\_invalid)), as in the spec's example.EffectiveRisk.raisedBylists a raise only when it changed the class. - Loader.
src/policy/load.tsis the one file ofsrc/policythat importssrc/githuband the error classes ofsrc/queue/errors.ts(PermanentJobError); the CLI never imports it. U-CLI-6 checks the CLI's whole import closure, and that nothing else insrc/policyreachesmodules,db,configorqueue.loadBasePolicytakes an optionallogand also shares an in-flight request between concurrent callers.resolveBaseCommittreats a 404 asbase_missingand sends other errors throughtoJobError; a 403 readinggraft.yamlis permanent (toJobError). facts_hashis 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'sfacts_read_atandevaluated_atforward (a database write only), so an older job cannot overtake it.rules_matchedkeeps the PR paths of apathsreason (at most 5) and only the count for alabelsreason (a label also appears in the policy).- Signals. The intent job emits
intent_publishedwhenever a card is on screen after a successful publish (a new card or a cached one);test/helpers/fake-queue.tsneeded no change because unit contexts usenoSignals. - Fixtures.
pull_request.labeledclonesopenedwith ahotfixlabel; thepull_request_reviewfixtures keep upstream's slimmerpull_requestobject moved onto the fixture PR's number and head;pushis a push to the fixture PR's base branch (master) that modifiesgraft.yaml. The upstream catalog has no usablepushcommits, 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.tsnow 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 insidevalidateGlob(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 itsonLimitoption, which the callers set to the strict side (trueforsensitive_paths, tighteningpaths, the policy-file check and the risk floor's R1 to R7 path rules;falseforonly_pathsand 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 answersonLimitwhen it is spent; (3)***(three or more stars in a row) and]+are rejected withglob_invaliddetail 7, because picomatch gives them regex meanings ([ab]+repeats,a/**/***matchesa) that the segment matcher does not model; (4) the quirks that are kept on purpose: a trailing/**matches the directory itself (docs/**matchesdocs) 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 thepolicy_file_changedbuilt-in also when!listingComplete; the verdict iswould_requirewith 1 human and is neverwould_approve.policyFileChangedstaysfalse(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,filesempty); U-PE-24 is the same withoutgraft.yamlin the visible part. Arequireorblockmatch is unaffected (it already needs at least 1 human), the default already requires at least 1, and the effective risk is not raised (raisedBywould claim something unknown). - T14 empty values (minor). The
emptycontinuation 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:andcase "password":stay readable (U-RED-15 negatives). An empty value after=(password =) no longer starts it either. The eval baseline is unchanged (pnpm eval:cireports equal): the synthetic set has no such blocks. - Nits. The CLI rejects
size > 32768fromlstatbefore reading (U-CLI-7, with a 3 GB sparse file that could not be read); thepushfixture now targets the fixture PR's base (refs/heads/master) so the smoke fan-out finds the PR (U-FIX-3; the integration suite'spushEventhelper keepsmain, which is its world's base); I-POL-21 catches mutation 8 on its own (an approving policy plus a PR that editsgraft.yamlgives "Would require 1 human"); the loader's missing-blob-SHA failure is aPermanentJobError(U-LOAD-7). - Review 2 changes (step 5).
- Total work is bounded (major). Measured before: a valid 32 KB policy (132 block rules of
*+ 194a+b?*) against 1,000 renamed 1,024-character paths took about 6 minutes. Two defences. (a) A cost cap invalidateGlob: at most 48 characters other than*per segment and 96 per glob (**segments and stars are free),glob_invaliddetail 8 ("too costly"). The worst accepted glob costs about 0.37 ms per 1,024-character path, not 1.3 ms. (b) One sharedGlobBudgetof 50,000,000 work units (review 3 lowered this to 20,000,000 and made the model structural) perevaluatePolicycall and percomputeRiskFloorcall (prepareDiffhas 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 tighteningpathskey counts as matched and uncertain,sensitive_pathsraise the risk,only_pathsand approve rules do not match, and the verdict getslimits: ["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 hostilesensitive_paths) against 1,000 files with 1,024-characterpathandpreviousPathtakes about 0.5 s and endswould_blockwithbudget_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 tograft.yaml. /inside[...](minor). Rejected (glob_invaliddetail 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).onLimitsides are pinned (minor). U-PE-25 fails ifpathsorsensitive_pathsuse the loose side or ifonly_pathsuses 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: truefor those terms; the "no longer a test" term keepsfalse). U-RISK-20. path_too_long(nit). An over-long path is no longer reported as a change tograft.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) OneGlobBudgetper 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 bybuildFacts'scomputeRiskFloorandevaluatePolicyof the same policy job (evaluatePolicy(policy, facts, { budget })); when either is spent every later answer is the strict side andexhaustedis 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 inbootstrap.ts). - Measured, no network (1,000 files, 1,024-character
pathandpreviousPath, 200 rules plus 20sensitive_paths, 32 KB): literal shape about 0.19 s forevaluatePolicyand 0.42 s forcomputeRiskFloor; 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 reusinglisting_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:cireports equal), andlisting_truncatedmetrics no longer count budget stops. - Uncertain hits (nit). A
pathskey whose answer came from a limit (a path over the caps, a per-call fuel hit or a spent budget) is reported asuncertain: true; a real match staysfalse. An approve oronly_pathskey that hit a limit does not match. Reviewers can tell an assumed hit from a real one (U-PE-29). mayChangePolicyterms (nit).evaluatePolicytakes 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
pathandpreviousPath: 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 likearea3/part1/**, 7sensitive_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;pairsUsedstays as a statistic. The derivation is also in the comment onEVALUATION_FUEL. - Hostile timings after the change (1,000 files, 1,024-character
pathandpreviousPath, 200 rules + 20sensitive_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_pathswithonLimit: true) is equivalent: any limit hit already makes that key not match. M17a (pathswithonLimit: 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
GlobBudgetafter 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 forarea3/part1/**againstsrc/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 (secretsof**/secrets/**, against a per-path segment set built once, only for floating globs), required literal runs inside parts with stars (part3of*part3*, one native substring search on the folded path) and the literal tail of the last segment (.mdof**/*.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 **/*.eN6.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 endwould_blockwithbudget_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-levelstampClockstored inInt32Arrays; 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 afinally(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 longGRAFT_INTENT_SENSITIVE_PATHSglob 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 assertsmatcherScratchClean(), the matches of**/secrets/**,*.ts,infra/**anddocs/**before and after, a 100-part glob, and, as a structural guard, that the source has nostampClockor 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-jobpreparedmap (on the budget) and the backstop hook hold no fixed-width counter. - Minor: glued**rejected./**.ts,a**b,x/**y/zgiveglob_invaliddetail 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.**.tswithout 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 namedx[Ab](an attacker could otherwise name a file to evade asecret[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.labeledis now handled by the policy module, so the test sendsassignedfor 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-apply0005too), U-FIX-1's expected list, and the contexts that build aJobContextby hand (they gainedsignals). No assertion was weakened. - Not done / left open. Real GitHub behaviour is unverified (symlink path,git/refencoding, 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 asendoption. A job copies the queue'sdeletion_secondswhen it is inserted, soupdateQueuechanges only jobs created afterwards. Maintenance deletes a job whencompleted_on + deletion_secondshas passed; failed and cancelled jobs havecompleted_ontoo, so one rule covers completed and failed jobs. A job that never ran is bounded byretentionSeconds(14 days by default), which is left alone. - Table and column names used by the doctor and the purge:
<schema>.jobis a partitioned table by queue name (job_commonis the default partition; deleting from the parent reaches every queue), withname,state(enumcreated,retry,active,completed,cancelled,failed),data(jsonb),created_on,completed_on,deletion_seconds;<schema>.scheduleholdsnameandcron;<schema>.queueholdsnameanddeletion_seconds.boss.supervise(name)runs the maintenance for a queue on demand (used by I-Q-6). - Octokit:
pulls.listRequestedReviewersanswers{ users, teams };issues.listEventsForTimelineevents are normalized bynormalizeEvent(reviewedevents carrysubmitted_atandcommit_id, the otherscreated_at; a review request hasrequested_reviewerorrequested_team); the paginate iterator exposes the response headers, which is how a 10-page timeline cap is detected fromrel="next". github.webhookretention is 1 hour (the extra task).JobDefinition.optionsgaineddeleteAfterSeconds; the queue maps it throughresolveJobOptions(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 runsUPDATE <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 inintent.feedback.recorddata for 7 days). U-Q-6 pins the retention of every registered job.- Dates are strings.
src/db/client.tsnow parses the Postgresdatetype (OID 1082) toYYYY-MM-DD; the default would build aDateat local midnight. Onlyreview_time_runs.from_date,to_dateandmetrics_daily.dayare dates. CardResult.costUsdis a top-level optional field and not part ofusage: the eval harness copiesusageinto its report rows (the saved baseline would change).saveCardwritescost_usdfrom it; an absent value (a result cached before step 6) is stored asNULL. 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).effectiveVotesreads the votes of the installations and reduces them in memory with the exported purepickEffectiveVotes(thefeedbackSummaryrule), then applies the[since, until)window to the effective vote. This keeps U-GATE-3 a unit test; aDISTINCT ONquery would be equivalent.feedback_recordedcarries 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
Botare not stored; the job throws aPermanentJobErroron a 404 (the worker completes it), andsyncReviewersis called as it is bypilot sync-reviewers, which counts such a PR as skipped.getChangeByNumberwas added tomodules/changes. - Cron wiring moved from
bootstrap.tsintosrc/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_triviallooks at the card of the merge head: anokcard is preferred, the newest first, and it counts when its final class istrivial. The verdict at merge is the one of the merge head with the greatestfacts_read_at(ties: the latest created).cost_usdis the sum of the priced rows as a decimal string; rows withNULLareunpriced_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_dailyfor the window, androllup_incompleteis 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_synccounts changes with a non-author vote and nopr_reviewersrow. - Review-time choices. A review that shares the merge's second counts as before the merge (
<=formerged_without_review; timeline timestamps have one-second resolution).paused_minutesis the paused time inside[S, merge or close or now]. Onlyexcluded_authorandtimeline_truncatedrows are produced;commits_truncatedexists 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 toready_for_review, then to creation. - Doctor. Without GitHub calls, so the budget check uses
GRAFT_LLM_DAILY_TOKEN_BUDGETandintent_spend; thefeedbackcheck reports the first of its two warnings;cronswarns 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.
--confirmis 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-activeis given; a--dry-rundeletes 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 thepilot_purgesrow stores them (pluspgboss_jobs). - Globs. (1)
ALLOWEDnow accepts combining marks (\p{M}) as the spec'scafé/**example with a combining accent requires;glob_non_asciistill 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 oneOtherbucket. (3) The spec'ssensitive_paths[0]in U-PY-12 is the 0-based index; locations are 1-based everywhere (sensitive_paths[1]). (4)warnings[].line/columnpoint at the glob's own scalar. - Exact-text shortcut (picomatch 4.0.7).
lib/picomatch.jstest()setsmatch = input === globbefore running the regular expression, whereglobis the pattern as it was passed (here the normalized glob,normalizeGlob) andinputthe path unchanged: a case-sensitive comparison that ignoresnocase, with noformat(nowindowsoption).src/core/glob.tsstoresexact = normalizeGlob(glob)per compiled glob and answerstruefor 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).
validateGlobrejects (detail 2) anyDefault_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 tograft.yaml,sensitive_pathsand the env globs, which sharevalidateGlob. Deliberately not done: telling two unlisted scripts apart (Ogham and Runic share theOtherbucket and may mix); every segment that mixesOtherwith 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 apassreason (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 forAPPROVEDandCHANGES_REQUESTEDonly. Not configurable (the spec does not ask for it). Previously stored comment-only rows stay (the table is never pruned): clear them withdelete from pr_reviewers where reason = 'reviewed'and runpilot sync-reviewersif 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_associationisOWNER,MEMBERorCOLLABORATOR, its state isAPPROVED,CHANGES_REQUESTEDorDISMISSED, and the author holds triage-or-higher permission, checked like a request's (security review 1, finding 2): any member of an organisation is aMEMBERof its public repositories and aCOLLABORATORmay 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_nameisadmin,maintain,writeortriage, or the legacypermissionisadminorwrite, 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: readcovers 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
permissionForbiddenandpermissionNotFound, and the last answer per (change, user) is kept inreviewer_check_denials(ids and status; migration 0007; cleared when a later check passes; purged with the installation).pilot doctor'sfeedbackcheck 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/adminpass; a role built on triage or read fails closed) and a role namedTriageis not the built-intriage(the match is case-sensitive; I-REV-10). - Exclusions by id.
changes.author_id(filled frompulls.geton 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 andGRAFT_AGENT_LOGINSare 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 aGRAFT_AGENT_IDSsetting (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
1234alone means id 1234, not the login). A numeric login is written@1234(login-only, like@alice) andresolvewrites1234:<id>;1234:<id>is also accepted directly (U-GATE-15, I-ALLOW-1).resolvedoes not look up bare IDs to confirm their owner: check each ID with the person. - Atomic
resolvewrite.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 onlyverifiedrows, andpilot sync-reviewersre-verifies (it upserts only users who pass the checks, settingverified = 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 asnot_reviewerand the report warnsvotes_without_reviewer_sync(I-GATE-10, I-REV-9, I-DB-12). The old rows are kept as an audit trail, not deleted.pilot doctorcounts 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) andpilot allow-list resolverewrites it. ThelatestLoginrename lookup is gone (I-GATE-9, U-GATE-2, U-GATE-12 to U-GATE-15, I-ALLOW-1).resolvetakes--installation <id>because an App JWT is meant for/appendpoints only, so it borrows an installation token (public data only). The report line reads "N user IDs (sha256 ...)".
- Reviews need standing. A review makes its author a reviewer only if its
- 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 listis in the weekly ritual andincident removedeletes 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_loginand qualifies the 14 days. - Skipped nits.
votes_without_reviewer_synconly 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 withGRAFT_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 budgetexhausted); 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.
dataSpanDaysis the exact first-to-last-vote span (two decimals), and the 14-day rule isspan + 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.
DISMISSEDjoinsAPPROVEDandCHANGES_REQUESTEDinpr_reviewers(it was a real approval until a push dismissed it; I-REV-2). - Existing tests changed. U-CLI-3 (
--jsonnow also printswarnings), the registry test (two signal listeners), I-FB-3 (its cards now haveintent_displaysrows, 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-apply0006too; 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), thedecideGateand I-GATE-1 PASS expectations (apassreason), I-REV-2 (a comment-only review is not stored) and the incident tests (their culprits are seeded as merged PRs). Step 6's ownI-DB-10shares its ID with step 5's two tests (the spec reused it); its name starts withI-DB-10 0006. - Added beyond the spec.
U-Q-6andI-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.mddocuments detail 9, combining marks and the warning..env.examplelists the three new variables. - Not done / left open. Real GitHub behaviour is unverified: the shape of timeline events (
requested_team,reviewed),issues.listEventsForTimelineon pull requests with theissues: readpermission, 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--untilafter 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 theGraft-Taskissue (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 (fetchPrCommitsandcomputeProvenance, throughmodules/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; readingchange_provenanceonly for a matching head would still need a fallback. The extra cost is onepulls.listCommitscall 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 assertsintent_cards.linked_issue_number, and s07 (d) carriesGraft-Taskagain. (2) Reviewer requests.pull_request.review_requestedarrives withopened; its sync job could run before thechangesrow existed and returnedskipped, and once the reviewer reviewed GitHub no longer listed them as requested. Design choice: the job now creates the change frompulls.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 recordsrequestedonly if the PR's issue timeline holds areview_requestedevent 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 callsensureRepositorylike provenance and intent (a removed repository is skipped; an unknown one gets its row), and theprovenance_publishedsignal 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 viapr_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_publishedenqueues one sync per change; the listener is nowonChangeSignal). 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>/platformis in the policy section, and no@...platformmention 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 theappandprobeclients only, and only for the run's installation. Every other non-GET of theprobeclient 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 thegh[pousr]_,github_pat_,sk-ant-, JWT, PEM and smee patterns are as specified. - Installation scope (review 1, finding 1). The preflight of
run,verifyandsetuprefuses (exit 3, checkinstallation_scope) unless the App's installation hasrepository_selection: "selected"andGET /installation/repositories(installation token) lists exactly the sandbox, by id and name; a protected repository or asourcein 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 branchesgraft-e2e/<runId>/...and move or delete them (in public mode: the same on its verified fork, plusmerge-upstream). Writes are sent withredirect: "manual"and any 3xx answer is aGuardRefusal; the preflight requires the repository to answer under the sandbox's own name (sandbox_renamedotherwise, also insetup) and pins its id,setupchecks that the created repository is the sandbox before pushing and closes repository creation in afinally, andopenPrchecksbase.repo.idagainst it. - HUMAN-TODO ticking (review 1, finding 4).
--update-human-todoticks 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-outputsneeds 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=1andGIT_TERMINAL_PROMPT=0; clone and push resetcredential.helper; the push authenticates through aGIT_ASKPASSscript 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 thefull_nameGitHub returned for the created repository. Children start fromchildEnv(src/e2e/child-env.ts): noGRAFT_E2E_*variable, and forgitno inheritedGIT_*variable at all (GIT_CONFIG_COUNT,GIT_ASKPASS, ... could inject configuration or credentials). --only-offlineruns the live variant only unless--llm noneis given, redacts the child's output and passes it only the three offline variables. A plainpnpm testalso 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.openeddeliveries andgithub.webhookjobs 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). E2eDepsadditions: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.GitPusheris 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.
runandverifyneed Graft running (installation rows,readyz, no stale queued job,graft.yamlequal to the starter);setupchecks the database and migrations but not the App, the marker or the policy before it acts;cleanupneeds 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,ghfailing) is exit 2, as U-LIVE-SEC-3 asks; a token that exists but failsGET /useris exit 3. - Cleanup also lists
git/matching-refsunderheads/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.
createFakeGitHubApigainsowner,repo, per-issue comments,completed_aton check runs, paged comments and check runs, and response URLs. The world supportsbase_treeingit/trees(the spec said full trees only; the runner usesbase_treeas the real API allows) and a minimal line diff (common prefix and suffix trimmed). Webhook review payloads spellstatein 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.jsonalso holds the evaluated assumptions soreportre-renders without a network;verifyappends a syntheticverifyscenario 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_URLpoints 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_TOKENsendsAuthorization: Bearerinstead ofx-api-key; at most one of the two may be set. The client passesnullfor every credential and base URL it is not given, so the SDK never falls back to readingANTHROPIC_*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_IDinsrc/llm/types.ts) is shared bysrc/configandsrc/eval/env.ts. Such IDs have no entry insrc/llm/pricing.ts: the service records the run as unpriced (costUsdnull), andpnpm eval --liverefuses until a price is added. - Server-side refusal fallback on the Claude API only. The
server-side-fallback-2026-07-01beta exists on the Claude API alone, soGRAFT_LLM_REFUSAL_FALLBACKis ignored whenANTHROPIC_BASE_URLis set to any host other thanapi.anthropic.com(isClaudeApi, U-LLM-14). - Client-side refusal fallback.
GRAFT_LLM_REFUSAL_FALLBACK_MODEL(off by default, so nothing changes without it): onrefusal,callValidatedrepeats the call once on that model, unless the client reports through the new optionalLlmClient.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
classifyFilealready leaves lockfiles, binaries andGENERATED_GLOBSpaths (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 tologic(R13partial_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_IDandANTHROPIC_IDENTITY_TOKEN_FILE(plus the optionalANTHROPIC_WORKSPACE_ID) are validated by Graft (src/llm/federation.ts, shared bysrc/configandsrc/eval/env.ts) and passed to the SDK ascredentials: oidcFederationProvider({ identityTokenProvider: identityTokenFromFile(path), ... }). The SDK's own environment resolution (federation variables,ANTHROPIC_PROFILE,~/.config/anthropic) is never used:apiKeyandauthTokenstaynull, andcredentialsis 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_KEYcannot 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(defaulthttps://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
jtiare 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(anAnthropicError, not anAPIError) from inside the request, before any HTTP call, andAnthropicErrorfor an unreadable token file;classifywould have rethrown both as programmer errors. A wrapper maps them toFederationErrorwith a failure: 400, 401, 403, a missingaccess_tokenand an unreadable or empty file areauth; 429 israte_limited; 5xx and a network failure (detected in the wrappedfetch, not from the message) areunavailable(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 redactsanthropicFederation,organizationId,serviceAccountId,identityTokenFile,identityToken,assertionandaccess_token. The e2e runner registers the token file's current contents as a secret; later rotations are caught by the JWT pattern.