diff --git a/.agents/skills/work-issue/SKILL.md b/.agents/skills/work-issue/SKILL.md index 7608e0a01..f94a5b3b3 100644 --- a/.agents/skills/work-issue/SKILL.md +++ b/.agents/skills/work-issue/SKILL.md @@ -269,7 +269,10 @@ harnesses or rerun a real failure to obtain green. Keep drafts cheap and batch pushes. A ready PR synchronize event runs fast feedback with a deliberately nonpassing final gate. After the last push, convert to draft and mark ready to request complete candidate confirmation; opening/reopening ready also requests it. After cheap gates independent categories run concurrently; per-PR -cancellation cannot cancel another PR. Measure queue/elapsed time and wasted +cancellation cannot cancel another PR. For a native stack, use +`bun scripts/request-candidate.ts ` after the final push/preflight instead of +a bare ready transition. It freezes the contributing prefix into the request; +missing/stale native request evidence rejects confirmation. Measure queue/elapsed time and wasted cancellations before adding shards. Follow docs/decisions/ci-utility.md. Assess missing main commits before refreshing. Revalidate affected proof after diff --git a/.agents/skills/work-issue/references/ci-and-merge.md b/.agents/skills/work-issue/references/ci-and-merge.md index 24fc8cc31..69828396e 100644 --- a/.agents/skills/work-issue/references/ci-and-merge.md +++ b/.agents/skills/work-issue/references/ci-and-merge.md @@ -57,6 +57,11 @@ fresh candidate; selected job checkout and aggregate checks remain required. ## Read the actual head For stacked PRs, apply [Stacked PR evidence](#stacked-pr-evidence) below; +native requests use `bun scripts/request-candidate.ts ` to freeze the +contributing prefix into the ready event. Bare ready transitions lack that +request evidence. Before native merge, repeat the read-only +`bun scripts/request-candidate.ts --verify ` and retain its prefix identity +alongside actual selected-job receipts. A failed request does not mark ready. the `main` commands in this reference describe an ordinary PR or bottom layer. Record the PR head and query live main independently. Run the comparison @@ -215,10 +220,16 @@ Load `gh-stack` and follow the Run local preflight against the immediate parent ref for each upper layer. For every PR in the intended merge prefix, record its head and declared base branch, independently query that base's live Git ref, and inspect its own -selected jobs/checkouts. A two-parent synthetic checkout must have that recorded -parent tip and PR head as parents. Keep contract head-only receipts separate. -Do not substitute live `main` for an upper layer's base or claim its CI tested -the eventual integrated trunk tree. +selected jobs/checkouts. Ordinary dependency PRs require that recorded parent +tip and PR head as synthetic parents. Native requests instead require the +supported exact checkout shape: either ordered immutable synthetic first +parents through the frozen trunk with each second parent equal to its frozen +member head, or exact own-parent/candidate parents with independently verified +frozen-trunk/member ancestry and immutable member evidence. Every actual merge +tree must be independently reconstructed. Run the read-only prefix verification immediately +before native merge. Keep own-parent contract head-only receipts separate; +neither parent-relative preflight nor final-tree equality replaces native +prefix proof. Actual integrated-tree/main reconciliation remains required. Read back every included PR's release milestone and all issues it advances, along with current heads, bases, checks and review state. Confirm the resolved diff --git a/.github/workflows/contract.yml b/.github/workflows/contract.yml index 0c1740cd2..f7ad794c9 100644 --- a/.github/workflows/contract.yml +++ b/.github/workflows/contract.yml @@ -39,10 +39,14 @@ jobs: - uses: oven-sh/setup-bun@v2 with: bun-version: "1.4.2" + - name: Install native prefix verification dependencies + if: github.event.pull_request.stack != null + run: bun install --frozen-lockfile - name: Match the CONTRACT title and label to the contract doc env: BASE_REF: origin/${{ github.base_ref }} HEAD_SHA: ${{ github.event.pull_request.head.sha }} PR_TITLE: ${{ github.event.pull_request.title }} PR_LABELS: ${{ toJSON(github.event.pull_request.labels.*.name) }} + GH_TOKEN: ${{ github.token }} run: bun scripts/check-contract-pr.ts "$BASE_REF" "$HEAD_SHA" diff --git a/docs/decisions/ci-utility.md b/docs/decisions/ci-utility.md index 33b1f7557..b19e2ee8a 100644 --- a/docs/decisions/ci-utility.md +++ b/docs/decisions/ci-utility.md @@ -570,6 +570,66 @@ Agents must not do so, and this policy does not change protection settings. The read-only [Get a pull request API](https://docs.github.com/en/rest/pulls/pulls#get-a-pull-request) accepts the existing `contents: read` permission; no privileged token/event is added. +### Native contributing-prefix identity (#2141) + +The selected native policy freezes the trunk through the candidate, excluding +strictly higher layers. After the final push and local preflight, run +`bun scripts/request-candidate.ts ` to place a bounded prefix descriptor in +the PR body before requesting its ready event. It records a nonce, repository, +stack, ordered PR numbers, heads, declared parent refs and exact trunk. It is +request context, never authority by itself. Missing descriptors reject native +confirmation with the required action; later live membership is never silently +substituted. Ordinary PR requests keep their existing workflow. + +Final candidate validation and the contract gate independently compare the +descriptor with read-only stack/PR APIs and live refs. Candidate proof also +binds the actual run SHA and immutable checkout's ordered parents and tree. +The supported prefix contains one to six open contributing members. The +2026-10-17 maintainer ruling extends support to two exact checkout shapes: + +- A two-parent synthetic chain ending exactly at the frozen trunk. Each + synthetic commit's second parent is its ordered frozen member head, member + heads contain their predecessors, and every synthetic tree equals a locally + reconstructed `git merge-tree --write-tree` result. +- For an upper candidate, a two-parent merge whose parents are exactly + `[frozen own parent, frozen candidate head]`. Independently compare each + contributing member's immutable commit with local Git, prove ancestry from + the exact frozen trunk through every ordered member head, and reconstruct + the actual merge tree with `git merge-tree --write-tree`. + +The second shape is observed in real upper fixture #2286; it does not encode +lower synthetic commits in its first-parent history. Both shapes require the +same frozen/live prefix authority and exact actual checkout identity. Conflicts, +unavailable inputs, unsupported shapes and contradictory evidence reject; +matching final trees alone never qualify a candidate. The six-member limit +preserves the existing bounded API lookup. Git's +[merge-tree documentation](https://git-scm.com/docs/git-merge-tree) defines the +independent merge operation; support is limited to these verified shapes. + +The contract gate compares the frozen head with its verified own parent; +proof selection always compares the frozen trunk to the requested head, even +when the native event names its immediate own parent. The event/request/head +bindings must agree before planning; live authority is independently checked +at admission/final verification. An upper-only diff never narrows selected +runtime coverage for its contributing lower layers. Categories, +freshness and retention obligations are unchanged. A higher layer may change +only if fresh evidence proves this prefix and tested identity unchanged. A +contributing merge/rebase, head/ref/order change, replaced request or changed +trunk requires a new request even for equal content. Repeat the read-only +`bun scripts/request-candidate.ts --verify ` immediately before native merge +alongside actual-job and merge requirements. This check is not a lock; a failed +request does not mark the candidate ready. + +[GitHub's event metadata](https://docs.github.com/en/pull-requests/how-tos/merge-and-close-pull-requests/optimizing-ci-for-stacked-pull-requests) +and [stack APIs](https://docs.github.com/en/pull-requests/reference/stacked-pull-requests-apis-and-webhooks) +supply request context and independent membership. They do not themselves +freeze every contributing identity. Real ordinary and native hosted receipts +are required before calling an implementation qualified; historical rejected +or cancelled native runs remain unqualified. No privileged event/token, generic +proof cache or native merge queue is introduced. #2111's selected reservation +still covers one exact candidate; this verification never extends it to another +candidate or waives per-layer proof. + ## Historical measurement and upstream guidance The following guidance records the paid-provider investigation which motivated diff --git a/docs/process/evidence/native-merge-chain-replay.json b/docs/process/evidence/native-merge-chain-replay.json new file mode 100644 index 000000000..296e39a51 --- /dev/null +++ b/docs/process/evidence/native-merge-chain-replay.json @@ -0,0 +1,75 @@ +{ + "source_issue": "https://github.com/schlessera/brain-kit/issues/2141", + "scope": "Immutable historical native metadata checkout and independent ORT tree reconstruction; not passing native candidate qualification.", + "git": "git version 2.51.0", + "commits": [ + { + "parents": [ + "3fc6fea5e22304df2810348074c32b1983fbea46", + "c40f533e5f2277b961acb1d48b7b3742dd7ac11c" + ], + "sha": "ff8cecd806cf17987c1fea05ce084c9cf8b8fbef", + "tree": "125491a2dc2407dbc042bc36975b94ed1621467c", + "reconstructed_tree": "125491a2dc2407dbc042bc36975b94ed1621467c", + "reconstruction_exit": 0, + "run": 38902174935, + "attempt": 1, + "event": "pull_request", + "metadata_job": 116976830747, + "actual_checkout_from_job_log": "ff8cecd806cf17987c1fea05ce084c9cf8b8fbef", + "run_head": "c40f533e5f2277b961acb1d48b7b3742dd7ac11c", + "qualification": "Historical metadata/shape evidence only; cancelled upper runs are not passing native proof." + }, + { + "parents": [ + "ff8cecd806cf17987c1fea05ce084c9cf8b8fbef", + "b11c43ff08c9490f5acce54873682333b2a2601a" + ], + "sha": "1262a109997fb6b7861f50e52b8da7c5940df0ed", + "tree": "25c7dc9c39199610343f59dfc52fd4f9c944d792", + "reconstructed_tree": "25c7dc9c39199610343f59dfc52fd4f9c944d792", + "reconstruction_exit": 0, + "run": 38902179343, + "attempt": 1, + "event": "pull_request", + "metadata_job": 116976845098, + "actual_checkout_from_job_log": "1262a109997fb6b7861f50e52b8da7c5940df0ed", + "run_head": "b11c43ff08c9490f5acce54873682333b2a2601a", + "qualification": "Historical metadata/shape evidence only; cancelled upper runs are not passing native proof." + }, + { + "parents": [ + "1262a109997fb6b7861f50e52b8da7c5940df0ed", + "395ff3a0f3981f2da9cebb17aa6a4dc887a7a94a" + ], + "sha": "acbacf7c4d73dc48af92eae90387d19a7fbb9cc1", + "tree": "8bb77e92333ef76ca963938e24e2c0e779070b4c", + "reconstructed_tree": "8bb77e92333ef76ca963938e24e2c0e779070b4c", + "reconstruction_exit": 0, + "run": 38902182348, + "attempt": 1, + "event": "pull_request", + "metadata_job": 116976855103, + "actual_checkout_from_job_log": "acbacf7c4d73dc48af92eae90387d19a7fbb9cc1", + "run_head": "395ff3a0f3981f2da9cebb17aa6a4dc887a7a94a", + "qualification": "Historical metadata/shape evidence only; cancelled upper runs are not passing native proof." + }, + { + "parents": [ + "acbacf7c4d73dc48af92eae90387d19a7fbb9cc1", + "0d658e7ca5a1ef98860ad1b79653782931a1b63c" + ], + "sha": "72caa79f3408a5a56107f0b95cf12e138f7a3a16", + "tree": "f3261f4bcfb381eb1bced8a039aae97b01bda102", + "reconstructed_tree": "f3261f4bcfb381eb1bced8a039aae97b01bda102", + "reconstruction_exit": 0, + "run": 38902186914, + "attempt": 1, + "event": "pull_request", + "metadata_job": 116976871841, + "actual_checkout_from_job_log": "72caa79f3408a5a56107f0b95cf12e138f7a3a16", + "run_head": "0d658e7ca5a1ef98860ad1b79653782931a1b63c", + "qualification": "Historical metadata/shape evidence only; cancelled upper runs are not passing native proof." + } + ] +} diff --git a/docs/process/evidence/native-own-parent-replay.json b/docs/process/evidence/native-own-parent-replay.json new file mode 100644 index 000000000..54c824a50 --- /dev/null +++ b/docs/process/evidence/native-own-parent-replay.json @@ -0,0 +1,75 @@ +{ + "source_issue": "https://github.com/schlessera/brain-kit/issues/2141", + "scope": "Immutable replay of observed own-parent checkout; not new live-prefix or passing full candidate qualification. Original run was superseded and fixtures returned to draft.", + "git": "git version 2.51.0", + "run": 39019059474, + "metadata_job": 117357054877, + "actual_checkout_from_job_log": "7f92440e874e53eae0ef3d0ee0302e0229facf7b", + "prefix": { + "version": 1, + "request": "17670a921e36ca5b8e989596666e69ab", + "repository": "schlessera/brain-kit", + "pr": 2286, + "stack": 2287, + "position": 2, + "trunk": { + "ref": "test/2141-qualification-trunk-07f4f991", + "sha": "41a00e0ab38937d752d320e932ba240bf2a19770" + }, + "members": [ + { + "number": 2285, + "ref": "test/2141-native-lower-07f4f991", + "head": "1d2408a9cffd6db11920828dfc36c0666e88fb75", + "parent": "test/2141-qualification-trunk-07f4f991" + }, + { + "number": 2286, + "ref": "test/2141-native-upper-07f4f991", + "head": "111d118add10b4c79c51663960f339b35e6fd155", + "parent": "test/2141-native-lower-07f4f991" + } + ] + }, + "commits": [ + { + "sha": "7f92440e874e53eae0ef3d0ee0302e0229facf7b", + "tree": "4bcc9f6665ed1dec863d40ee3b7aba1a80ea2ea2", + "parents": [ + "1d2408a9cffd6db11920828dfc36c0666e88fb75", + "111d118add10b4c79c51663960f339b35e6fd155" + ], + "immutable_api_matches_local_git": true + }, + { + "sha": "1d2408a9cffd6db11920828dfc36c0666e88fb75", + "tree": "a108352ee1643c4963d8187e80ff5247b02b01cd", + "parents": [ + "e4997f38f4aa986ab5dc08d515b8faa017ff72ad" + ], + "immutable_api_matches_local_git": true + }, + { + "sha": "111d118add10b4c79c51663960f339b35e6fd155", + "tree": "4bcc9f6665ed1dec863d40ee3b7aba1a80ea2ea2", + "parents": [ + "1d2408a9cffd6db11920828dfc36c0666e88fb75" + ], + "immutable_api_matches_local_git": true + } + ], + "ancestry": [ + { + "ancestor": "41a00e0ab38937d752d320e932ba240bf2a19770", + "descendant": "1d2408a9cffd6db11920828dfc36c0666e88fb75", + "exit": 0 + }, + { + "ancestor": "1d2408a9cffd6db11920828dfc36c0666e88fb75", + "descendant": "111d118add10b4c79c51663960f339b35e6fd155", + "exit": 0 + } + ], + "reconstructed_tree": "4bcc9f6665ed1dec863d40ee3b7aba1a80ea2ea2", + "reconstruction_exit": 0 +} diff --git a/docs/process/github.md b/docs/process/github.md index a47bf0161..585bb6fb2 100644 --- a/docs/process/github.md +++ b/docs/process/github.md @@ -326,7 +326,12 @@ non-interactive operation. These project rules apply alongside it: - The bottom PR targets `main`; each upper PR targets its immediate parent. Run `bun run check:pr --base origin/main` at the bottom and `bun run check:pr --base ` above it, after fetching or verifying - that parent. CI and contract gates already use the PR's declared base. + that parent. Ordinary dependency PRs use their declared base. Native Actions + evaluate against the stack trunk: request confirmation with + `bun scripts/request-candidate.ts ` after preflight, freezing the exact + contributing prefix before the ready event. The contract gate separately + verifies the layer's own parent. Missing/unsupported prefix evidence rejects + confirmation; see the [native identity policy](../decisions/ci-utility.md#native-contributing-prefix-identity-2141). Recheck the diff and affected proof after rebasing or retargeting. - Each PR uses the repository template and carries its own title, labels, release milestone and proof. Replace generated titles/bodies after @@ -359,11 +364,16 @@ non-interactive operation. These project rules apply alongside it: before a PR number. Confirm the resolved membership and inspect all included PRs' current heads, bases, reviews, milestones and selected checks immediately before merging. The installed command has no `--match-head-commit` flag; + repeat `bun scripts/request-candidate.ts --verify ` for the candidate's + frozen contributing prefix alongside the actual selected-job identity checks. do not describe that readback as an atomic head lock or bypass branch rules. If native stacks are unavailable, report the CLI error and use ordinary PRs only after deliberately reconciling their ancestry and bases. -- Upper-layer CI proves a merge with its parent branch, not with live `main`. - Record that actual parent/head/checkout tree. After merging, verify every +- Ordinary upper-layer CI proves its parent/head merge; native CI verifies the + verified checkout: either the frozen synthetic chain from the exact trunk, + or exact own-parent merge with independent frozen-trunk/member ancestry, + immutable member evidence and reconstructed tree. Record + every contributing head and actual synthetic parent/checkout/tree. After merging, verify every included PR's merge result on `main`, the integrated commits/trees, issue states and milestones, and automatic main-push proof. A queued stack is not yet merged. Use the stack-specific guidance in diff --git a/scripts/candidate-proof.ts b/scripts/candidate-proof.ts index d92a28b86..271e9f2a1 100644 --- a/scripts/candidate-proof.ts +++ b/scripts/candidate-proof.ts @@ -2,6 +2,7 @@ import { planFromEnvironment, proofCadence, proofSelections } from "./ci-plan"; import { checkout, type Checkout } from "./proof-inputs"; import { GitHubProofAPI, type API } from "./proof-reuse"; +import { validateNativeCandidate } from "./native-candidate"; export async function validateCandidate(root: string, event: any, env: NodeJS.ProcessEnv, api?: API, actual: Checkout = checkout(root)): Promise { @@ -9,6 +10,11 @@ export async function validateCandidate(root: string, event: any, env: NodeJS.Pr if (cadence === "feedback") throw new Error("Candidate confirmation required: convert this PR to draft, then mark ready for review after the final push."); if (cadence !== "confirmation") return; const pr = event.pull_request; + if (pr?.stack) { + if (!api) throw new Error("Native candidate confirmation requires live read-only GitHub identity verification"); + await validateNativeCandidate(root, event, env, api, actual); + return; + } if (!Number.isSafeInteger(pr?.number) || pr.number < 1 || !pr.base?.ref || !pr.head?.sha || !pr.base?.sha || actual.sha !== env.GITHUB_SHA || JSON.stringify(actual.parents) !== JSON.stringify([pr.base.sha, pr.head.sha])) { throw new Error("Candidate checkout does not match the requested head/base merge identity"); @@ -17,7 +23,7 @@ export async function validateCandidate(root: string, event: any, env: NodeJS.Pr const live = await api.json(`pulls/${pr.number}`); const base = await api.json(`git/ref/heads/${encodeURIComponent(pr.base.ref)}`); const commit = await api.json(`git/commits/${actual.sha}`); - if (live.state !== "open" || live.draft !== false || live.head?.sha !== pr.head.sha || + if (live.stack || live.state !== "open" || live.draft !== false || live.head?.sha !== pr.head.sha || live.base?.ref !== pr.base.ref || live.base?.repo?.full_name !== env.GITHUB_REPOSITORY || base.object?.sha !== pr.base.sha) { throw new Error("Candidate became stale: the live PR head, readiness or base changed; request confirmation again"); } diff --git a/scripts/check-contract-pr.ts b/scripts/check-contract-pr.ts index 20ff91087..d46dd7ff0 100644 --- a/scripts/check-contract-pr.ts +++ b/scripts/check-contract-pr.ts @@ -89,7 +89,7 @@ export function parseLabels(raw: string | undefined): string[] { } if (import.meta.main) { - const [base, head] = process.argv.slice(2); + let [base, head] = process.argv.slice(2); const title = process.env.PR_TITLE; if (!base || !head || title === undefined) { console.error( @@ -98,6 +98,24 @@ if (import.meta.main) { process.exit(2); } + if (process.env.GITHUB_EVENT_PATH) { + const event = await Bun.file(process.env.GITHUB_EVENT_PATH).json(); + if (event.pull_request?.stack) { + try { + if (!process.env.GH_TOKEN || !process.env.GITHUB_REPOSITORY) throw new Error("Native contract comparison requires read-only GitHub identity verification"); + const { GitHubProofAPI } = await import("./proof-reuse"); + const { verifyNativePrefix, ownNativeParent } = await import("./native-candidate"); + const { checkout } = await import("./proof-inputs"); + if (head !== event.pull_request.head?.sha || checkout(ROOT).sha !== head) throw new Error("Native contract checkout differs from the frozen candidate head"); + base = ownNativeParent(await verifyNativePrefix(event, process.env, + new GitHubProofAPI(process.env.GITHUB_REPOSITORY, process.env.GH_TOKEN), true)); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(2); + } + } + } + const changed = changedFiles(ROOT, base, head); // A gate that cannot see the diff must not report success. if (changed === null) process.exit(2); diff --git a/scripts/ci-plan.ts b/scripts/ci-plan.ts index b19b9d79e..7041720cb 100644 --- a/scripts/ci-plan.ts +++ b/scripts/ci-plan.ts @@ -2,6 +2,7 @@ import { appendFileSync, existsSync, readFileSync, readdirSync, readlinkSync, lstatSync, realpathSync } from "node:fs"; import { basename, dirname, join, relative, resolve } from "node:path"; import ts from "typescript"; +import { requestedNativePrefix } from "./native-candidate"; import { closureSelects, discoverBrowserClosures, type BrowserClosures } from "./browser-input-closure"; export interface Workspace { @@ -354,7 +355,16 @@ export function changedFiles(root: string, base: string, head = "HEAD", mergeBas export function planFromEnvironment(root: string, env: NodeJS.ProcessEnv): CheckPlan { if (["api", "schedule", "workflow_dispatch"].includes(env.GITHUB_EVENT_NAME ?? "")) return planChecks(["package.json"], workspaces(root), root); const pr = env.GITHUB_EVENT_NAME === "pull_request"; - const base = pr ? env.BASE_SHA : env.BEFORE_SHA; + let base = pr ? env.BASE_SHA : env.BEFORE_SHA; + if (pr && env.GITHUB_EVENT_PATH) { + const event = JSON.parse(readFileSync(env.GITHUB_EVENT_PATH, "utf8")); + if (event.pull_request?.stack) { + if (event.pull_request.head?.sha !== env.HEAD_SHA) throw new Error("Native planning head differs from the requested event"); + // Every contributing layer remains a proof input. Only the contract gate + // compares the candidate's own parent; planning must retain the trunk. + base = requestedNativePrefix(event, env).trunk.sha; + } + } if (!base || !env.HEAD_SHA) throw new Error("CI requires BASE_SHA/BEFORE_SHA and HEAD_SHA"); return planChecks(changedFiles(root, base, env.HEAD_SHA, pr), workspaces(root), root, pr); } diff --git a/scripts/native-candidate.ts b/scripts/native-candidate.ts new file mode 100644 index 000000000..769ee70b8 --- /dev/null +++ b/scripts/native-candidate.ts @@ -0,0 +1,146 @@ +/** Native confirmation binds an explicit request, not a later live stack. */ +import { randomBytes } from "node:crypto"; +import { checkout, git, type Checkout } from "./proof-inputs"; +import type { API } from "./proof-reuse"; + +interface Member { number: number; ref: string; head: string; parent: string; } +export interface NativePrefix { + version: 1; request: string; repository: string; pr: number; stack: number; position: number; + trunk: { ref: string; sha: string }; members: Member[]; +} +const marker = //g; +const sha = (value: unknown): value is string => typeof value === "string" && /^[a-f0-9]{40}$/.test(value); +const number = (value: unknown): value is number => Number.isSafeInteger(value) && Number(value) > 0; +const ref = (value: unknown): value is string => typeof value === "string" && /^[A-Za-z0-9][A-Za-z0-9._/-]{0,199}$/.test(value) && + !value.includes("..") && !value.includes("//") && !value.endsWith("/") && !value.endsWith(".lock"); +const fail = (reason: string): never => { throw new Error(`Native candidate rejected: ${reason}`); }; + +export function nativeRequest(body: unknown): NativePrefix { + const matches = [...String(body ?? "").matchAll(marker)]; + if (matches.length !== 1 || matches[0]![1]!.length > 16000) fail("freeze the contributing prefix with bun scripts/request-candidate.ts before requesting confirmation"); + let value: NativePrefix; + try { value = JSON.parse(Buffer.from(matches[0]![1]!, "base64url").toString("utf8")); } + catch { return fail("malformed prefix request"); } + if (!value || value.version !== 1 || !/^[a-f0-9]{32}$/.test(value.request) || !/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(value.repository) || + !number(value.pr) || !number(value.stack) || !number(value.position) || !ref(value.trunk?.ref) || !sha(value.trunk?.sha) || + !Array.isArray(value.members) || !value.members.length || value.members.length > 6 || + value.members.some(m => !number(m.number) || !ref(m.ref) || !ref(m.parent) || !sha(m.head)) || + new Set(value.members.map(m => m.number)).size !== value.members.length || + new Set(value.members.map(m => m.ref)).size !== value.members.length || + new Set(value.members.map(m => m.head)).size !== value.members.length || value.members.at(-1)?.number !== value.pr || + value.members.some(m => m.ref === value.trunk.ref) || + value.members.some((m, i) => m.parent !== (i ? value.members[i - 1]!.ref : value.trunk.ref))) fail("invalid or unsupported prefix identity"); + return value; +} + +export function nativeRequestBody(body: string, prefix: NativePrefix): string { + return `${body.replace(marker, "").trimEnd()}\n\n\n`; +} + +/** Freeze only the live contributing prefix; callers put it in the ready event body. */ +export async function freezeNativePrefix(repository: string, pr: number, api: API, allowTopDraft = false): Promise { + const live = await api.json(`pulls/${pr}`); + if (!number(live.stack?.number) || !number(live.stack?.position) || !ref(live.stack?.base?.ref)) fail("missing authoritative native membership"); + const stack = await api.json(`stacks/${live.stack.number}`); + const all: Array<{ number: number; state: string; merged_at?: string }> = stack.pull_requests; + if (stack.number !== live.stack.number || stack.base?.ref !== live.stack.base.ref || !Array.isArray(all) || all.length > 100 || + live.stack.size !== all.length || all.some(p => !number(p.number)) || new Set(all.map(p => p.number)).size !== all.length) fail("missing or contradictory stack API inventory"); + const index = all.findIndex(p => p.number === pr); + if (index < 0 || index + 1 !== live.stack.position) fail("candidate membership/order changed"); + const before = all.slice(0, index + 1); + if (before.some(p => p.state !== "open" && !(p.state === "closed" && Number.isFinite(Date.parse(p.merged_at ?? ""))))) fail("closed unmerged or unknown contributing member"); + const selected = before.filter(p => p.state === "open"); + if (!selected.length || selected.length > 6) fail("supported contributing prefix contains one to six open members"); + const trunk = { ref: stack.base.ref as string, sha: (await api.json(`git/ref/heads/${encodeURIComponent(stack.base.ref)}`)).object?.sha }; + if (!sha(trunk.sha) || live.stack.base.sha !== trunk.sha) fail("stack trunk is stale or unavailable; refresh the stack before requesting confirmation"); + const members: Member[] = []; + for (const entry of selected) { + const member = entry.number === pr ? live : await api.json(`pulls/${entry.number}`); + const parent = members.at(-1); + if (member.number !== entry.number || member.state !== "open" || (member.draft !== false && !(allowTopDraft && entry.number === pr && member.draft === true)) || + member.stack?.number !== stack.number || member.stack?.position !== all.findIndex(p => p.number === entry.number) + 1 || + member.stack?.base?.ref !== trunk.ref || member.stack?.base?.sha !== trunk.sha || + member.head?.repo?.full_name !== repository || member.base?.repo?.full_name !== repository || !ref(member.head?.ref) || !sha(member.head?.sha) || + member.base?.ref !== (parent?.ref ?? trunk.ref) || member.base?.sha !== (parent?.head ?? trunk.sha)) fail("contributing PR readiness, repository, parent or membership changed"); + if ((await api.json(`git/ref/heads/${encodeURIComponent(member.head.ref)}`)).object?.sha !== member.head.sha) fail("contributing live head changed"); + members.push({ number: entry.number, ref: member.head.ref, head: member.head.sha, parent: member.base.ref }); + } + // Validate our own generated shape too; this rejects duplicate heads/refs. + return nativeRequest(nativeRequestBody("", { version: 1, request: randomBytes(16).toString("hex"), repository, pr, + stack: stack.number, position: index + 1, trunk, members })); +} + +/** Bind request/event inputs before planning; live authority is checked separately. */ +export function requestedNativePrefix(event: any, env: NodeJS.ProcessEnv): NativePrefix { + const requested = nativeRequest(event.pull_request?.body); + const pr = event.pull_request; + if (requested.repository !== env.GITHUB_REPOSITORY || requested.pr !== pr?.number || requested.stack !== pr.stack?.number || + requested.position !== pr.stack?.position || requested.trunk.ref !== pr.stack?.base?.ref || requested.trunk.sha !== pr.stack?.base?.sha || + requested.members.at(-1)?.head !== pr.head?.sha) fail("request does not bind the triggering event"); + const parent = requested.members.at(-1)!; + if (!number(pr.stack.size) || pr.stack.size < requested.position || + !((pr.base?.ref === requested.trunk.ref && pr.base?.sha === requested.trunk.sha) || + (pr.base?.ref === parent.parent && pr.base?.sha === ownNativeParent(requested))) || + (pr.head?.ref && pr.head.ref !== parent.ref) || + (pr.head?.repo?.full_name && pr.head.repo.full_name !== requested.repository)) fail("contradictory event parent or repository evidence"); + return requested; +} + +/** Contract checks may inspect a draft; candidate proof always requires readiness. */ +export async function verifyNativePrefix(event: any, env: NodeJS.ProcessEnv, api: API, allowTopDraft = false): Promise { + const requested = requestedNativePrefix(event, env); + const live = await freezeNativePrefix(requested.repository, requested.pr, api, allowTopDraft); + if (JSON.stringify({ ...live, request: requested.request }) !== JSON.stringify(requested)) fail("contributing prefix changed; request confirmation again"); + const current = await api.json(`pulls/${requested.pr}`); + if (JSON.stringify(nativeRequest(current.body)) !== JSON.stringify(requested)) fail("request was replaced or revoked"); + return requested; +} + +export function ownNativeParent(prefix: NativePrefix): string { + return prefix.members.at(-2)?.head ?? prefix.trunk.sha; +} + +/** Compare local objects with independently obtained immutable API evidence. */ +async function immutableCheckout(root: string, id: string, api: API): Promise { + const local = checkout(root, id), immutable = await api.json(`git/commits/${id}`); + if (local.sha !== immutable.sha || local.tree !== immutable.tree?.sha || + JSON.stringify(local.parents) !== JSON.stringify(immutable.parents?.map((p: { sha: string }) => p.sha))) fail("immutable checkout identity disagrees with local Git"); + return local; +} + +/** Admit only verified synthetic-chain or exact own-parent ORT checkouts. */ +export async function validateNativeCandidate(root: string, event: any, env: NodeJS.ProcessEnv, api: API, actual: Checkout): Promise { + if (actual.sha !== env.GITHUB_SHA) fail("checkout differs from the requested run identity"); + const prefix = await verifyNativePrefix(event, env, api); + const candidate = prefix.members.at(-1)!; + const ownParents = [ownNativeParent(prefix), candidate.head]; + if (prefix.members.length > 1 && JSON.stringify(actual.parents) === JSON.stringify(ownParents)) { + const local = await immutableCheckout(root, actual.sha, api); + if (JSON.stringify(local) !== JSON.stringify(actual)) fail("actual own-parent checkout identity differs from local Git"); + // The checkout alone contains no synthetic history for lower contributors. + // Independently bind every frozen member, starting at the exact frozen trunk. + let predecessor = prefix.trunk.sha; + for (const member of prefix.members) { + await immutableCheckout(root, member.head, api); + git(root, "merge-base", "--is-ancestor", predecessor, member.head); + predecessor = member.head; + } + const tree = git(root, "merge-tree", "--write-tree", ownParents[0]!, candidate.head).trim(); + if (!sha(tree) || tree !== local.tree) fail("own-parent tree differs from the independently reconstructed ORT merge"); + console.log(`BRAIN_NATIVE_CANDIDATE_RECEIPT ${JSON.stringify({ prefix, checkout: actual })}`); + return; + } + let next = actual.sha; + for (let index = prefix.members.length - 1; index >= 0; index--) { + const member = prefix.members[index]!; + const local = await immutableCheckout(root, next, api); + if (local.parents.length !== 2 || local.parents[1] !== member.head || + (index === prefix.members.length - 1 && JSON.stringify(local) !== JSON.stringify(actual))) fail("immutable synthetic checkout/parents do not match the ordered prefix"); + if (index) git(root, "merge-base", "--is-ancestor", prefix.members[index - 1]!.head, member.head); + const tree = git(root, "merge-tree", "--write-tree", local.parents[0]!, member.head).trim(); + if (!sha(tree) || tree !== local.tree) fail("synthetic tree differs from the independently reconstructed ORT prefix merge"); + next = local.parents[0]!; + } + if (next !== prefix.trunk.sha) fail("synthetic chain does not terminate at the exact frozen trunk"); + console.log(`BRAIN_NATIVE_CANDIDATE_RECEIPT ${JSON.stringify({ prefix, checkout: actual })}`); +} diff --git a/scripts/request-candidate.ts b/scripts/request-candidate.ts new file mode 100644 index 000000000..16bf6cab8 --- /dev/null +++ b/scripts/request-candidate.ts @@ -0,0 +1,56 @@ +/** Freeze an explicit native contributing-prefix request before the ready event. */ +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { scanText } from "./check-leakage"; +import { freezeNativePrefix, nativeRequest, nativeRequestBody, verifyNativePrefix } from "./native-candidate"; +import type { API } from "./proof-reuse"; + +function gh(...args: string[]): string { + const result = Bun.spawnSync(["gh", ...args], { stdout: "pipe", stderr: "pipe" }); + if (result.exitCode !== 0) throw new Error(new TextDecoder().decode(result.stderr)); + return new TextDecoder().decode(result.stdout).trim(); +} + +if (import.meta.main) { + let temporary: string | undefined; + try { + const args = process.argv.slice(2), verify = args[0] === "--verify"; + if (verify) args.shift(); + if (args.length !== 1 || !/^[1-9]\d*$/.test(args[0]!)) throw new Error("usage: bun scripts/request-candidate.ts [--verify] "); + const pr = Number(args[0]); + if (!Number.isSafeInteger(pr)) throw new Error("Invalid PR number"); + const repository = gh("repo", "view", "--json", "nameWithOwner", "--jq", ".nameWithOwner"); + if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repository)) throw new Error("Invalid repository identity"); + let requests = 0; + const api: API = { async json(path) { + if (++requests > 32) throw new Error("Native request API budget exceeded"); + return JSON.parse(gh("api", `repos/${repository}/${path}`)); + }, async log() { throw new Error("Unexpected log request"); } }; + const before = await api.json(`pulls/${pr}`); + if (before.state !== "open" || !before.stack) throw new Error("An open native stacked PR is required"); + if (verify) { + const prefix = await verifyNativePrefix({ pull_request: before }, { GITHUB_REPOSITORY: repository }, api); + console.log(JSON.stringify({ verified: true, prefix })); + process.exit(0); + } + if (!before.draft) gh("pr", "ready", String(pr), "--repo", repository, "--undo"); + const prefix = await freezeNativePrefix(repository, pr, api, true); + const current = await api.json(`pulls/${pr}`); + if (current.head?.sha !== prefix.members.at(-1)?.head || current.draft !== true) throw new Error("Candidate changed while freezing request"); + const body = nativeRequestBody(String(current.body ?? ""), prefix); + if (scanText("body", body, { allowUploadImages: true }).length) throw new Error("Native request body failed the public leakage gate"); + temporary = mkdtempSync(join(tmpdir(), "odysseus-native-request-")); + const file = join(temporary, "body.md"); writeFileSync(file, body); + gh("pr", "edit", String(pr), "--repo", repository, "--body-file", file); + // Verify a second authoritative snapshot before spending candidate proof. + const checked = await freezeNativePrefix(repository, pr, api, true); + if (JSON.stringify({ ...checked, request: prefix.request }) !== JSON.stringify(prefix) || + JSON.stringify(nativeRequest((await api.json(`pulls/${pr}`)).body)) !== JSON.stringify(prefix)) throw new Error("Native request became stale before confirmation"); + gh("pr", "ready", String(pr), "--repo", repository); + console.log(JSON.stringify({ requested: true, prefix })); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; + } finally { if (temporary) rmSync(temporary, { recursive: true, force: true }); } +} diff --git a/tests/ci-plan.test.ts b/tests/ci-plan.test.ts index 2f4b59f9a..129ec5714 100644 --- a/tests/ci-plan.test.ts +++ b/tests/ci-plan.test.ts @@ -2,6 +2,7 @@ import { afterEach, describe, expect, test } from "bun:test"; import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync, chmodSync, symlinkSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; +import { nativeRequestBody, type NativePrefix } from "../scripts/native-candidate"; import { changedFiles, FAST_TESTS, planChecks, planFromEnvironment, workspaces, proofSelections, rootConsumers, ROOT_CONSUMERS, unitShards, selectBrowserClosures, proofCadence } from "../scripts/ci-plan"; const scratch: string[] = []; @@ -186,6 +187,7 @@ describe("affected CI checks", () => { test("the executable writes affected job outputs and keeps drafts on cheap gates", async () => { const f = fixture(); f.write("scripts/ci-plan.ts", readFileSync(join(import.meta.dir, "../scripts/ci-plan.ts"), "utf8").replace('"typescript"', JSON.stringify(import.meta.resolve("typescript")))); + f.write("scripts/native-candidate.ts", `export * from ${JSON.stringify(resolve(import.meta.dir, "../scripts/native-candidate.ts"))};\n`); f.write("scripts/browser-input-closure.ts", `export * from ${JSON.stringify(resolve(import.meta.dir, "../scripts/browser-input-closure.ts"))};\n`); f.write("packages/core/src/note.ts", "export const note = 2;\n"); f.git("add", "."); f.git("commit", "-qm", "planner fixture"); @@ -340,3 +342,39 @@ test("browser closure composition preserves exact consumer inventories and bound expect(unitShards(selectBrowserClosures(base, { browser: closed, layout: closed, endurance: closed, captures: closed }))).toEqual([1, 2, 3, 4]); expect(selected.local.runtime).toBe(plan.local.runtime); }); + +test("native upper planning retains frozen-trunk proof while contract diff stays own-parent scoped", async () => { + const f = fixture(); + f.write("scripts/lower-tooling.ts", "export const lower = true;\n"); + f.git("add", "."); f.git("commit", "-qm", "lower tooling"); const lower = f.git("rev-parse", "HEAD"); + f.write("docs/raft.md", "The upper crossing.\n");f.git("add", ".");f.git("commit", "-qm", "upper docs"); const head = f.git("rev-parse", "HEAD"); + const prefix: NativePrefix = { version: 1, request: "1".repeat(32), repository: "fixture/brain-kit", pr: 2, stack: 7, position: 2, + trunk: { ref: "main", sha: f.base }, members: [{ number: 1, ref: "lower", head: lower, parent: "main" }, { number: 2, ref: "upper", head, parent: "lower" }] }; + const event = { action: "ready_for_review", pull_request: { number: 2, head: { sha: head }, base: { ref: "lower", sha: lower }, + stack: { number: 7, position: 2, size: 2, base: prefix.trunk }, body: nativeRequestBody("", prefix) } }; + const path = join(f.dir, "native-event.json"); writeFileSync(path, JSON.stringify(event)); + const env = { GITHUB_EVENT_NAME: "pull_request", GITHUB_EVENT_PATH: path, BASE_SHA: lower, HEAD_SHA: head, GITHUB_REPOSITORY: prefix.repository }; + const plan = planFromEnvironment(f.dir, env); + expect(plan.changed, "NATIVE_PREFIX_PLANNING_RETAINS_LOWER_TOOLING").toEqual(["docs/raft.md", "scripts/lower-tooling.ts"]); + expect(plan.global).toBe(true); + expect(Object.values(proofSelections(plan)).every(Boolean)).toBe(true); + // Exercise the actual metadata executable and its job-dispatch outputs too. + f.write("scripts/ci-plan.ts", readFileSync(join(import.meta.dir, "../scripts/ci-plan.ts"), "utf8").replace('"typescript"', JSON.stringify(import.meta.resolve("typescript")))); + for (const name of ["browser-input-closure", "native-candidate"]) f.write(`scripts/${name}.ts`, `export * from ${JSON.stringify(resolve(import.meta.dir, `../scripts/${name}.ts`))};\n`); + const output = join(f.dir, "outputs"), savedPlan = join(f.dir, "plan.json"); + const child = Bun.spawn([process.execPath, join(f.dir, "scripts/ci-plan.ts")], { cwd: f.dir, stdout: "pipe", stderr: "pipe", + env: { ...process.env, ...env, GITHUB_ACTIONS: "true", PR_DRAFT: "false", GITHUB_OUTPUT: output, CI_PLAN_PATH: savedPlan } }); + const [code, err] = await Promise.all([child.exited, new Response(child.stderr).text()]); + expect(code, err).toBe(0); + expect(JSON.parse(readFileSync(savedPlan, "utf8")).changed).toEqual(plan.changed); + for (const category of Object.keys(proofSelections(plan))) expect(readFileSync(output, "utf8"), `NATIVE_PREFIX_EXECUTABLE_SELECTS_${category}`).toContain(`${category}=true\n`); + expect(() => planFromEnvironment(f.dir, { ...env, HEAD_SHA: lower })).toThrow("Native planning head"); + const original = JSON.stringify(event); + event.pull_request.body = "missing request"; writeFileSync(path, JSON.stringify(event)); + expect(() => planFromEnvironment(f.dir, env)).toThrow("freeze the contributing prefix"); + writeFileSync(path, original); + // Ordinary PRs still plan exactly their own declared base/head range. + delete (event.pull_request as Partial).stack; + writeFileSync(path, JSON.stringify(event)); + expect(planFromEnvironment(f.dir, env).changed).toEqual(["docs/raft.md"]); +}); diff --git a/tests/ci-workflows.test.ts b/tests/ci-workflows.test.ts index bfca6c27e..79f823fde 100644 --- a/tests/ci-workflows.test.ts +++ b/tests/ci-workflows.test.ts @@ -147,7 +147,7 @@ describe("the authoritative jobs retain their important phases", () => { const checkout = job.steps.find(s => s.uses === "actions/checkout@v7"); expect(checkout?.with?.ref).toBe("${{ github.event.pull_request.head.sha }}"); expect(checkout?.with?.["fetch-depth"]).toBe(0); - const command = job.steps.find(s => s.run)!; + const command = job.steps.find(s => s.run?.startsWith("bun scripts/check-contract-pr.ts"))!; expect(command.run).toBe('bun scripts/check-contract-pr.ts "$BASE_REF" "$HEAD_SHA"'); expect(command.env?.PR_TITLE).toBe("${{ github.event.pull_request.title }}"); expect(command.env?.PR_LABELS).toBe("${{ toJSON(github.event.pull_request.labels.*.name) }}"); @@ -224,7 +224,7 @@ describe("contributions execute without privileged credentials", () => { } test("a fork's hostile title stays inert in the emitted contract step", () => { const adapter = workflows.find(w => w.file === "contract.yml")!.workflow; - const steps = Object.values(adapter.jobs).flatMap((job) => job.steps).filter((step) => step.run); + const steps = Object.values(adapter.jobs).flatMap((job) => job.steps).filter((step) => step.run?.startsWith("bun scripts/check-contract-pr.ts")); expect(steps.length).toBe(1); const step = steps[0]!; const scratch = mkdtempSync(join(tmpdir(), "fork-contract-")); @@ -240,6 +240,7 @@ describe("contributions execute without privileged credentials", () => { }); // The values the runner would place in the step's env. const valueOf = (expression: string): string => { + if (expression === "github.token") return "offline-fixture-token"; const labels = /^toJSON\(github\.event\.pull_request\.labels\.\*\.name\)$/.exec(expression); const pr = (context.event as Obj).pull_request as Obj; if (labels) return JSON.stringify((pr.labels as Obj[]).map((l) => l.name)); diff --git a/tests/native-candidate.test.ts b/tests/native-candidate.test.ts new file mode 100644 index 000000000..58cab6c68 --- /dev/null +++ b/tests/native-candidate.test.ts @@ -0,0 +1,269 @@ +import { afterEach, expect, test } from "bun:test"; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync, readFileSync, symlinkSync, chmodSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { validateCandidate } from "../scripts/candidate-proof"; +import type { API } from "../scripts/proof-reuse"; +import { nativeRequestBody, ownNativeParent, verifyNativePrefix, type NativePrefix } from "../scripts/native-candidate"; +import { changedFiles, judge } from "../scripts/check-contract-pr"; + +const dirs: string[] = []; +afterEach(() => { for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true }); }); +function fixture() { + const root = mkdtempSync(join(tmpdir(), "odysseus-native-candidate-")); dirs.push(root); + function git(...args: string[]) { + const result = Bun.spawnSync(["git", ...args], { cwd: root, stdout: "pipe", stderr: "pipe", env: { + ...process.env, GIT_AUTHOR_NAME: "Odysseus", GIT_AUTHOR_EMAIL: "odysseus@example.invalid", + GIT_COMMITTER_NAME: "Odysseus", GIT_COMMITTER_EMAIL: "odysseus@example.invalid", + } }); + if (result.exitCode !== 0) throw new Error(new TextDecoder().decode(result.stderr)); + return new TextDecoder().decode(result.stdout).trim(); + } + git("init", "-q"); + writeFileSync(join(root, "README.md"), "Odysseus sails.\n"); git("add", "."); + const trunk = git("commit-tree", git("write-tree"), "-m", "trunk"); + mkdirSync(join(root, "docs")); writeFileSync(join(root, "docs/integration-contract.md"), "The crossing contract.\n"); git("add", "."); + const lower = git("commit-tree", git("write-tree"), "-p", trunk, "-m", "lower"); + const synthetic = git("commit-tree", git("write-tree"), "-p", trunk, "-p", lower, "-m", "lower candidate"); + writeFileSync(join(root, "raft.md"), "The raft manifest.\n"); git("add", "."); + const upper = git("commit-tree", git("write-tree"), "-p", lower, "-m", "upper"); + const sha = git("commit-tree", git("write-tree"), "-p", synthetic, "-p", upper, "-m", "upper candidate"); + git("update-ref", "HEAD", sha); + const actual = { sha, tree: git("rev-parse", `${sha}^{tree}`), parents: [synthetic, upper] }; + const prefix: NativePrefix = { version: 1, request: "1".repeat(32), repository: "fixture/brain-kit", pr: 2, stack: 7, position: 2, + trunk: { ref: "main", sha: trunk }, members: [ + { number: 1, ref: "lower", head: lower, parent: "main" }, + { number: 2, ref: "upper", head: upper, parent: "lower" }, + ] }; + const body = `Native request\n`; + const stack = { number: 7, position: 2, size: 2, base: { ref: "main", sha: trunk } }; + const event = { action: "ready_for_review", pull_request: { number: 2, draft: false, body, + head: { sha: upper }, base: { ref: "main", sha: trunk }, stack } }; + const env = { GITHUB_EVENT_NAME: "pull_request", GITHUB_SHA: sha, GITHUB_REPOSITORY: "fixture/brain-kit" }; + const replies: Record = { + "stacks/7": { number: 7, base: { ref: "main" }, pull_requests: [{ number: 1, state: "open" }, { number: 2, state: "open" }] }, + "pulls/1": { number: 1, state: "open", draft: false, head: { ref: "lower", sha: lower, repo: { full_name: env.GITHUB_REPOSITORY } }, base: { ref: "main", sha: trunk, repo: { full_name: env.GITHUB_REPOSITORY } }, stack: { ...stack, position: 1 } }, + "pulls/2": { number: 2, state: "open", draft: false, body, head: { ref: "upper", sha: upper, repo: { full_name: env.GITHUB_REPOSITORY } }, base: { ref: "lower", sha: lower, repo: { full_name: env.GITHUB_REPOSITORY } }, stack: structuredClone(stack) }, + "git/ref/heads/main": { object: { sha: trunk } }, "git/ref/heads/lower": { object: { sha: lower } }, "git/ref/heads/upper": { object: { sha: upper } }, + }; + for (const id of [sha, synthetic, trunk, lower, upper]) { + const [commit, tree, ...parents] = git("show", "-s", "--format=%H %T %P", id).split(" "); + replies[`git/commits/${id}`] = { sha: commit, tree: { sha: tree }, parents: parents.map(sha => ({ sha })) }; + } + const api: API = { async json(path) { if (!(path in replies)) throw new Error(`Missing fixture API ${path}`); return structuredClone(replies[path]); }, async log() { throw new Error("Unexpected log"); } }; + return { root, git, event, env, actual, prefix, replies, api }; +} + +test("native candidate accepts the verified ordered synthetic chain rather than the declared branch tip", async () => { + const f = fixture(); + await validateCandidate(f.root, f.event, f.env, f.api, f.actual); +}); + +function ownParentFixture() { + const f = fixture(); + const parents = [ownNativeParent(f.prefix), f.prefix.members.at(-1)!.head]; + const sha = f.git("commit-tree", f.actual.tree, "-p", parents[0]!, "-p", parents[1]!, "-m", "own-parent candidate"); + f.actual = { ...f.actual, sha, parents }; f.env.GITHUB_SHA = sha; + f.git("update-ref", "HEAD", sha); + f.replies[`git/commits/${sha}`] = { sha, tree: { sha: f.actual.tree }, parents: parents.map(sha => ({ sha })) }; + f.event.pull_request.base = { ref: "lower", sha: parents[0]! }; + return f; +} + +test("native own-parent checkout accepts an independently verified complete frozen prefix", async () => { + const f = ownParentFixture(); + let error: unknown; + try { await validateCandidate(f.root, f.event, f.env, f.api, f.actual); } catch (caught) { error = caught; } + expect(error, "NATIVE_OWN_PARENT_VERIFIED_PREFIX_ACCEPTED").toBeUndefined(); +}); + +test("native own-parent checkout rejects stale authority and contradictory immutable evidence", async () => { + const changes: Array<(f: ReturnType) => void> = [ + f => { f.replies["git/ref/heads/main"].object.sha = "a".repeat(40); }, + f => { f.replies["git/ref/heads/lower"].object.sha = "a".repeat(40); }, + f => { f.replies["git/ref/heads/upper"].object.sha = "a".repeat(40); }, + f => { f.replies["stacks/7"].pull_requests.reverse(); }, + f => { f.replies["pulls/1"].draft = true; }, + f => { f.replies[`git/commits/${f.actual.sha}`].parents.reverse(); }, + f => { f.replies[`git/commits/${f.prefix.members[0]!.head}`].tree.sha = "a".repeat(40); }, + f => { delete f.replies[`git/commits/${f.prefix.members[1]!.head}`]; }, + f => { f.env.GITHUB_SHA = "a".repeat(40); }, + f => { f.actual.parents.reverse(); }, + ]; + for (const change of changes) { + const f = ownParentFixture(); change(f); + await expect(validateCandidate(f.root, f.event, f.env, f.api, f.actual), "NATIVE_OWN_PARENT_REJECTS_CONTRADICTION").rejects.toThrow(); + } +}); + +test("native own-parent checkout rejects an exact-parent merge with an unreconstructed tree", async () => { + const f = ownParentFixture(); + const tree = f.git("rev-parse", `${f.prefix.trunk.sha}^{tree}`); + const sha = f.git("commit-tree", tree, "-p", f.actual.parents[0]!, "-p", f.actual.parents[1]!, "-m", "wrong tree"); + f.env.GITHUB_SHA = sha; + f.replies[`git/commits/${sha}`] = { sha, tree: { sha: tree }, parents: f.actual.parents.map(sha => ({ sha })) }; + await expect(validateCandidate(f.root, f.event, f.env, f.api, { ...f.actual, sha, tree }), "NATIVE_OWN_PARENT_RECONSTRUCTS_TREE").rejects.toThrow(); +}); + +test.each([0, 1])("native own-parent checkout requires ancestry edge %s from the exact frozen trunk", async breakAt => { + const f = ownParentFixture(); + // Keep content, live APIs and exact actual parents consistent while removing one ancestry edge. + for (let i = breakAt; i < f.prefix.members.length; i++) { + const member = f.prefix.members[i]!, old = member.head; + const parent = i === breakAt ? (i ? ["-p", f.prefix.trunk.sha] : []) : ["-p", f.prefix.members[i - 1]!.head]; + member.head = f.git("commit-tree", f.git("rev-parse", `${old}^{tree}`), ...parent, "-m", `disconnected member ${i}`); + f.replies[`pulls/${member.number}`].head.sha = member.head; + f.replies[`git/ref/heads/${member.ref}`].object.sha = member.head; + if (i) f.replies[`pulls/${member.number}`].base.sha = f.prefix.members[i - 1]!.head; + const [, tree, ...parents] = f.git("show", "-s", "--format=%H %T %P", member.head).split(" "); + f.replies[`git/commits/${member.head}`] = { sha: member.head, tree: { sha: tree }, parents: parents.map(sha => ({ sha })) }; + } + f.event.pull_request.head.sha = f.prefix.members[1]!.head; + f.event.pull_request.base.sha = ownNativeParent(f.prefix); + f.event.pull_request.body = nativeRequestBody("", f.prefix); + f.replies["pulls/2"].body = f.event.pull_request.body; + const parents = [ownNativeParent(f.prefix), f.prefix.members[1]!.head]; + const sha = f.git("commit-tree", f.actual.tree, "-p", parents[0]!, "-p", parents[1]!, "-m", "disconnected candidate"); + f.env.GITHUB_SHA = sha; + f.replies[`git/commits/${sha}`] = { sha, tree: { sha: f.actual.tree }, parents: parents.map(sha => ({ sha })) }; + await expect(validateCandidate(f.root, f.event, f.env, f.api, { ...f.actual, sha, parents }), "NATIVE_OWN_PARENT_REQUIRES_EVERY_ANCESTRY_EDGE").rejects.toThrow(); +}); + +test("strictly higher-layer changes do not change the frozen contributing prefix", async () => { + const f = fixture(); + f.replies["stacks/7"].pull_requests.push({ number: 3, state: "open" }); + f.replies["pulls/2"].stack.size = 3; + await validateCandidate(f.root, f.event, f.env, f.api, f.actual); +}); + +test("native proof rejects stale or missing request, membership, refs, readiness and immutable evidence", async () => { + const changes: Array<(f: ReturnType) => void> = [ + f => { f.event.pull_request.body = "missing request"; }, + f => { f.event.pull_request.stack.position = 1; }, + f => { f.event.pull_request.base.sha = "a".repeat(40); }, + f => { f.replies["stacks/7"].pull_requests.reverse(); }, + f => { f.replies["stacks/7"].pull_requests.shift(); }, + f => { f.replies["stacks/7"].pull_requests[0].number = 99; }, + f => { f.replies["pulls/1"].draft = true; }, + f => { f.replies["pulls/1"].base.ref = "other"; }, + f => { f.replies["pulls/1"].head.repo.full_name = "foreign/brain-kit"; }, + f => { f.replies["git/ref/heads/main"].object.sha = "a".repeat(40); }, + f => { f.replies["git/ref/heads/lower"].object.sha = "a".repeat(40); }, + f => { f.replies["pulls/2"].head.sha = "a".repeat(40); }, + f => { f.replies["pulls/2"].body = nativeRequestBody("", { ...f.prefix, request: "2".repeat(32) }); }, + f => { f.replies[`git/commits/${f.actual.sha}`].tree.sha = "a".repeat(40); }, + f => { f.replies[`git/commits/${f.actual.parents[0]}`].parents.reverse(); }, + f => { f.env.GITHUB_SHA = "a".repeat(40); }, + f => { delete f.replies["stacks/7"]; }, + ]; + for (const change of changes) { + const f = fixture(); change(f); + await expect(validateCandidate(f.root, f.event, f.env, f.api, f.actual)).rejects.toThrow(); + } +}); + +test("emitted native contract gate uses the verified own parent for inherited and own contract edits", async () => { + for (const ownContract of [false, true]) { + const f = fixture(); + let head = f.event.pull_request.head.sha; + if (ownContract) { + writeFileSync(join(f.root, "docs/integration-contract.md"), "The raft adds its own contract.\n"); f.git("add", "."); + head = f.git("commit-tree", f.git("write-tree"), "-p", f.prefix.members[0]!.head, "-m", "own contract"); + f.prefix.members[1]!.head = head; f.event.pull_request.head.sha = head; + f.event.pull_request.body = nativeRequestBody("", f.prefix); + f.replies["pulls/2"].head.sha = head; f.replies["pulls/2"].body = f.event.pull_request.body; + f.replies["git/ref/heads/upper"].object.sha = head; + } + f.git("update-ref", "HEAD", head); + const scripts = join(f.root, "scripts"); mkdirSync(scripts); + const source = resolve(import.meta.dir, "../scripts"); + writeFileSync(join(scripts, "check-contract-pr.ts"), readFileSync(join(source, "check-contract-pr.ts"))); + for (const file of ["proof-reuse.ts", "proof-inputs.ts", "native-candidate.ts"]) symlinkSync(join(source, file), join(scripts, file)); + const eventPath = join(f.root, "event.json"), preload = join(f.root, "api.ts"); + writeFileSync(eventPath, JSON.stringify(f.event)); + writeFileSync(preload, `const replies = ${JSON.stringify(f.replies)}; globalThis.fetch = async input => { + const key=String(input).replace("https://api.github.com/repos/fixture/brain-kit/", ""); + if (!(key in replies)) throw new Error("Unexpected offline API " + input); return Response.json(replies[key]); + };`); + const child = Bun.spawn([process.execPath, "--preload", preload, join(scripts, "check-contract-pr.ts"), f.prefix.trunk.sha, head], { + cwd: f.root, stdout: "pipe", stderr: "pipe", env: { ...process.env, GITHUB_EVENT_PATH: eventPath, GITHUB_REPOSITORY: f.env.GITHUB_REPOSITORY, + GH_TOKEN: "offline-fixture-token", PR_TITLE: "fix(core): repair the raft", PR_LABELS: "[]" }, + }); + const [code, out, err] = await Promise.all([child.exited, new Response(child.stdout).text(), new Response(child.stderr).text()]); + expect(code, err).toBe(ownContract ? 1 : 0); + if (ownContract) expect(err).toContain("docs/integration-contract.md changed, but the title does not start with"); + else expect(out).toContain("unchanged; not a contract change"); + } +}); + +test("native request executable freezes before ready, verifies read-only, and refuses changed contributors", async () => { + for (const mode of ["request", "verify", "changed"] as const) { + const f = fixture(), bin = join(f.root, "bin"), state = join(f.root, "api-state.json"), calls = join(f.root, "calls.jsonl"); + mkdirSync(bin); writeFileSync(state, JSON.stringify(f.replies)); + const gh = join(bin, "gh"); + writeFileSync(gh, `#!${process.execPath} + import { readFileSync, writeFileSync, appendFileSync } from "node:fs"; + const args=process.argv.slice(2), state=${JSON.stringify(state)}, calls=${JSON.stringify(calls)}; + const replies=JSON.parse(readFileSync(state,"utf8"));appendFileSync(calls,JSON.stringify(args)+"\\n"); + if(args[0]==="repo") console.log("fixture/brain-kit"); + else if(args[0]==="api") {const key=args[1].replace("repos/fixture/brain-kit/","");if(!(key in replies))throw new Error("Unexpected offline API "+key);console.log(JSON.stringify(replies[key]));} + else if(args[0]==="pr"&&args[1]==="ready") replies["pulls/2"].draft=args.includes("--undo"); + else if(args[0]==="pr"&&args[1]==="edit") {replies["pulls/2"].body=readFileSync(args[args.indexOf("--body-file")+1],"utf8"); + if(${JSON.stringify(mode)}==="changed")replies["git/ref/heads/lower"].object.sha="a".repeat(40);} + else throw new Error("Unexpected offline command "+JSON.stringify(args)); + writeFileSync(state,JSON.stringify(replies)); + `); chmodSync(gh, 0o755); + const child = Bun.spawn([process.execPath, resolve(import.meta.dir, "../scripts/request-candidate.ts"), ...(mode === "verify" ? ["--verify"] : []), "2"], { + cwd: f.root, stdout: "pipe", stderr: "pipe", env: { ...process.env, PATH: `${bin}:${process.env.PATH ?? ""}` }, + }); + const [code, out, err] = await Promise.all([child.exited, new Response(child.stdout).text(), new Response(child.stderr).text()]); + expect(code, err).toBe(mode === "changed" ? 1 : 0); + const issued = readFileSync(calls, "utf8").trim().split("\n").map(line => JSON.parse(line) as string[]); + const final = JSON.parse(readFileSync(state, "utf8")); + if (mode === "verify") { + expect(issued.every(args => ["repo", "api"].includes(args[0]!)), "VERIFY_NEVER_MUTATES_PR").toBe(true); + expect(JSON.parse(out).verified).toBe(true); + } else { + const ready = issued.filter(args => args[0] === "pr" && args[1] === "ready"); + expect(ready[0]).toContain("--undo"); + expect(final["pulls/2"].draft).toBe(mode === "changed"); + if (mode === "changed") expect(ready).toHaveLength(1); + else { + expect(issued.at(-1)?.slice(0, 3)).toEqual(["pr", "ready", "2"]); + expect(JSON.parse(out).prefix.members).toEqual(f.prefix.members); + } + } + } +}); + +test("matching content trees cannot authorize a foreign or truncated synthetic chain", async () => { + const f = fixture(); + const wrong = f.git("commit-tree", f.actual.tree, "-p", f.prefix.trunk.sha, "-p", f.prefix.members[1]!.head, "-m", "missing lower synthetic"); + const actual = { ...f.actual, sha: wrong, parents: [f.prefix.trunk.sha, f.prefix.members[1]!.head] }; + f.env.GITHUB_SHA = wrong; + f.replies[`git/commits/${wrong}`] = { sha: wrong, tree: { sha: actual.tree }, parents: actual.parents.map(sha => ({ sha })) }; + await expect(validateCandidate(f.root, f.event, f.env, f.api, actual)).rejects.toThrow(); +}); + +test("a complete same-tree synthetic chain must terminate at the exact frozen trunk", async () => { + const f = fixture(); + const extra = f.git("commit-tree", f.git("rev-parse", `${f.prefix.trunk.sha}^{tree}`), "-p", f.prefix.trunk.sha, "-m", "foreign trunk"); + const lower = f.git("commit-tree", f.git("rev-parse", `${f.prefix.members[0]!.head}^{tree}`), "-p", extra, "-p", f.prefix.members[0]!.head, "-m", "foreign lower"); + const top = f.git("commit-tree", f.actual.tree, "-p", lower, "-p", f.prefix.members[1]!.head, "-m", "foreign top"); + for (const id of [top, lower]) { + const [sha, tree, ...parents] = f.git("show", "-s", "--format=%H %T %P", id).split(" "); + f.replies[`git/commits/${id}`] = { sha, tree: { sha: tree }, parents: parents.map(sha => ({ sha })) }; + } + f.env.GITHUB_SHA = top; + await expect(validateCandidate(f.root, f.event, f.env, f.api, { ...f.actual, sha: top, parents: [lower, f.prefix.members[1]!.head] })).rejects.toThrow("exact frozen trunk"); +}); + +test("native contract comparison excludes inherited contract changes but catches its own", async () => { + const f = fixture(); + const verified = await verifyNativePrefix(f.event, f.env, f.api, true); + const own = changedFiles(f.root, ownNativeParent(verified), f.event.pull_request.head.sha)!; + expect(own).toEqual(["raft.md"]); + expect(judge("fix(core): repair the raft", [], own).ok).toBe(true); + expect(judge("fix(core): repair the raft", [], changedFiles(f.root, f.prefix.trunk.sha, f.event.pull_request.head.sha)!).ok).toBe(false); + expect(judge("fix(core): repair the raft", [], ["docs/integration-contract.md"]).ok).toBe(false); +});