src/job-claims.ts
375 lines and 21 functions in DrDustinEdwards/capsid, at commit daa875b on 2026-10-24. This file on GitHub.
Functions
| Function | Lines | Complexity | Covered | CRAP | Mutation |
|---|---|---|---|---|---|
| parseClaim | 95 to 117 | 10 | 100% | WatchCRAP 10 | No data: mutation testing has not run on this repo yet |
| <anonymous:113> | 113 to 113 | 2 | 100% | OkCRAP 2 | No data: mutation testing has not run on this repo yet |
| parsedValue | 133 to 142 | 4 | 80% | OkCRAP 4.1 | No data: mutation testing has not run on this repo yet |
| rawJson | 144 to 155 | 2 | 100% | OkCRAP 2 | No data: mutation testing has not run on this repo yet |
| listColumn | 161 to 161 | 2 | 100% | OkCRAP 2 | No data: mutation testing has not run on this repo yet |
| claimRow | 169 to 209 | 17 | 100% | WatchCRAP 17 | No data: mutation testing has not run on this repo yet |
| claimStatement | 213 to 247 | 1 | 100% | OkCRAP 1 | No data: mutation testing has not run on this repo yet |
| agreementOf | 273 to 277 | 4 | 100% | OkCRAP 4 | No data: mutation testing has not run on this repo yet |
| workerEvaluatorId | 279 to 279 | 2 | 100% | OkCRAP 2 | No data: mutation testing has not run on this repo yet |
| urlsOf | 282 to 286 | 3 | 100% | OkCRAP 3 | No data: mutation testing has not run on this repo yet |
| <anonymous:285> | 285 to 285 | 1 | 100% | OkCRAP 1 | No data: mutation testing has not run on this repo yet |
| json | 288 to 288 | 2 | 100% | OkCRAP 2 | No data: mutation testing has not run on this repo yet |
| evaluationRows | 299 to 378 | 22 | 100% | WatchCRAP 22 | No data: mutation testing has not run on this repo yet |
| read | 305 to 305 | 1 | 100% | OkCRAP 1 | No data: mutation testing has not run on this repo yet |
| row | 306 to 318 | 2 | 100% | OkCRAP 2 | No data: mutation testing has not run on this repo yet |
| unread | 319 to 319 | 1 | 100% | OkCRAP 1 | No data: mutation testing has not run on this repo yet |
| <anonymous:319> | 319 to 319 | 1 | 100% | OkCRAP 1 | No data: mutation testing has not run on this repo yet |
| countLabel | 321 to 321 | 2 | 100% | OkCRAP 2 | No data: mutation testing has not run on this repo yet |
| <anonymous:329> | 329 to 329 | 1 | 100% | OkCRAP 1 | No data: mutation testing has not run on this repo yet |
| evaluationStatements | 382 to 404 | 1 | 100% | OkCRAP 1 | No data: mutation testing has not run on this repo yet |
| <anonymous:383> | 383 to 402 | 1 | 100% | OkCRAP 1 | No data: mutation testing has not run on this repo yet |
Imports
It imports src/job-outcomes.ts, src/limits.ts, src/ops-types/claims.ts. Imported by src/jobs-holder.ts, src/jobs-seat.ts, src/tools/jobs.ts.
Source
import { z } from "zod";
import type { EvidenceVerdict, JobEvidence } from "./job-outcomes";
import { bounded } from "./limits";
import type { JobClaimRow as StoredClaimRow } from "./ops-types/claims";
// WHAT THE AGENT SAID, kept apart from what the Worker verified (migrations/0023).
//
// job_outcomes stores GitHub's number in place of the driver's wherever GitHub could
// answer, so the driver's own account was overwritten at the moment it was recorded.
// This module records that account first, in job_claims, from the arguments exactly as
// they arrived, and then one job_evaluations row per check with the claim and the
// verified value side by side. Both are written in the transition's own batch
// (holderTransition, src/jobs-holder.ts), so a claim exists exactly when the
// transition it describes does.
//
// NULL IS NOT FALSE, on 0011's reasoning: a field the agent did not state is NULL,
// never 0 and never false. "Nobody said" and "said none" are different facts.
const CLAIM_TESTS_RESULTS = ["pass", "fail", "partial", "not_run"] as const;
const CLAIM_DEPLOY_STATES = ["none", "pending", "deployed", "verified", "failed"] as const;
// Bounds on what one claim may carry. A claim is stored whole, so each list and each
// string is bounded where it arrives rather than truncated where it is written.
export const MAX_CLAIM_PRS = 10;
const MAX_CLAIM_URL = 512;
const MAX_CLAIM_FILES = 500;
const MAX_CLAIM_PATH = 512;
const MAX_CLAIM_VERSION = 128;
const count = z.number().int().nonnegative();
const version = bounded(MAX_CLAIM_VERSION);
// A usage figure the agent read off its own session. zod's number() already refuses NaN
// and Infinity, which JSON cannot carry but a caller object can; the claim test pins that,
// so neither can reach SUM().
const usageAmount = z.number().nonnegative();
// Strict at every level: an unknown key is refused, never dropped. A dropped key is a
// claim the agent made and the record does not have, which is the loss this table
// exists to prevent.
export const claimSchema = z
.object({
prs_opened: z.array(bounded(MAX_CLAIM_URL)).max(MAX_CLAIM_PRS).optional().describe("Pull request URLs this job opened."),
prs_merged: z.array(bounded(MAX_CLAIM_URL)).max(MAX_CLAIM_PRS).optional().describe("Pull request URLs this job says are merged."),
tests: z
.object({
run: count.optional(),
passed: count.optional(),
failed: count.optional(),
result: z.enum(CLAIM_TESTS_RESULTS).optional(),
})
.strict()
.optional()
.describe("Tests the agent ran, and their result."),
deploy_state: z.enum(CLAIM_DEPLOY_STATES).optional(),
files_touched: z.array(bounded(MAX_CLAIM_PATH)).max(MAX_CLAIM_FILES).optional().describe("Repo-relative paths the agent touched."),
usage: z
.object({
cost_usd: usageAmount.optional(),
active_seconds: usageAmount.optional(),
tokens: z
.object({
input: count.optional(),
output: count.optional(),
cache_read: count.optional(),
cache_creation: count.optional(),
})
.strict()
.optional(),
})
.strict()
.optional()
.describe(
"What this session used, as the session reports it: cost_usd, active_seconds and tokens {input, output, cache_read, cache_creation}. This session's own total, not a running total across earlier claims on the job. Self-reported, recorded and never used to authorize anything. Telemetry (docs/telemetry.md) stays the measured source; the claims aggregate shows both side by side."
),
versions: z
.object({
model_id: version.optional(),
client_name: version.optional(),
client_version: version.optional(),
permission_mode: version.optional(),
})
.strict()
.optional()
.describe("Self-reported, recorded and never used to authorize anything."),
})
.strict();
export type JobClaim = z.infer<typeof claimSchema>;
// A claim arrives as an object or as a JSON string, for the reason evidence does
// (parseEvidence, src/job-outcomes.ts). Unlike evidence, a field that does not fit is
// refused rather than dropped, because the claim is the record.
export type ClaimInput = JobClaim | string | undefined;
export function parseClaim(input: unknown): { claim: JobClaim | undefined } | { error: string } {
if (input === undefined || input === null) return { claim: undefined };
let value: unknown = input;
if (typeof input === "string") {
const text = input.trim();
// Blank is nothing sent, as for evidence.
if (text.length === 0) return { claim: undefined };
try {
value = JSON.parse(text);
} catch {
return { error: `claim was sent as a string that is not JSON: ${text.slice(0, 120)}. Nothing was written.` };
}
}
if (value === null || typeof value !== "object" || Array.isArray(value)) {
return { error: "claim must be an object (or a JSON string of one). Nothing was written." };
}
const parsed = claimSchema.safeParse(value);
if (!parsed.success) {
const problems = parsed.error.issues.map((issue) => `claim${issue.path.length ? "." + issue.path.map(String).join(".") : ""}: ${issue.message}`);
return { error: `claim was refused, not trimmed: ${problems.join("; ")}. Nothing was written.` };
}
return { claim: parsed.data };
}
// The claim-bearing arguments exactly as sent. Recorded whole in job_claims.raw so the
// structured columns can always be checked against what was actually said.
export interface ClaimRaw {
evidence?: unknown;
claim?: unknown;
result_summary?: string;
reason?: string;
result_ref?: string | null;
command?: string;
}
// evidence and claim may have arrived as JSON strings; raw keeps the parsed value, so
// the column is one JSON document rather than JSON with JSON inside it. A string that
// does not parse was refused before this runs, so it is kept as sent, not dropped.
function parsedValue(value: unknown): unknown {
if (typeof value !== "string") return value;
const text = value.trim();
if (text.length === 0) return undefined;
try {
return JSON.parse(text);
} catch {
return value;
}
}
export function rawJson(raw: ClaimRaw): string {
// JSON.stringify omits a key whose value is undefined, which is the rule: a key the
// caller did not send is absent, not null.
return JSON.stringify({
evidence: parsedValue(raw.evidence),
claim: parsedValue(raw.claim),
result_summary: raw.result_summary,
reason: raw.reason,
result_ref: raw.result_ref ?? undefined,
command: raw.command,
});
}
// The row as inserted: the stored row (src/ops-types/claims.ts) before D1 gives it an id.
export type JobClaimRow = Omit<StoredClaimRow, "id">;
type ClaimAction = JobClaimRow["action"];
const listColumn = (list: readonly string[] | undefined): string | null => (list === undefined ? null : JSON.stringify([...list]));
/**
* The job_claims row for one complete, fail or block call. Pure, and built from the
* arguments before verifyEvidence runs, so nothing GitHub says can reach it: commits,
* files_changed and tests_added are the driver's numbers as sent. An empty list is
* stated ("said none") and stored as [] and 0; an absent one is NULL.
*/
export function claimRow(input: {
job_id: string;
action: ClaimAction;
agent: string;
namespace: string;
raw: ClaimRaw;
claim?: JobClaim;
evidence?: JobEvidence;
capsid_sha?: string | null;
now: Date;
}): JobClaimRow {
const { claim, evidence } = input;
const opened = claim?.prs_opened ?? evidence?.prs;
const merged = claim?.prs_merged;
return {
job_id: input.job_id,
action: input.action,
agent: input.agent,
namespace: input.namespace,
raw: rawJson(input.raw),
prs_opened_urls: listColumn(opened),
prs_merged_urls: listColumn(merged),
prs_opened: opened === undefined ? null : opened.length,
prs_merged: merged === undefined ? null : merged.length,
commits: evidence?.commits ?? null,
files_changed: evidence?.files_changed ?? null,
tests_added: evidence?.tests_added ?? null,
tests_run: claim?.tests?.run ?? null,
tests_passed: claim?.tests?.passed ?? null,
tests_failed: claim?.tests?.failed ?? null,
tests_result: claim?.tests?.result ?? null,
deploy_state: claim?.deploy_state ?? null,
files_touched: listColumn(claim?.files_touched),
model_id: claim?.versions?.model_id ?? null,
client_name: claim?.versions?.client_name ?? null,
client_version: claim?.versions?.client_version ?? null,
permission_mode: claim?.versions?.permission_mode ?? null,
capsid_sha: input.capsid_sha ?? null,
recorded_at: input.now.toISOString(),
};
}
// Append-only: migrations/0023's triggers refuse UPDATE and DELETE, so this INSERT is
// the only statement that ever touches the table from code.
export function claimStatement(db: D1Database, row: JobClaimRow): D1PreparedStatement {
return db
.prepare(
`INSERT INTO job_claims (job_id, action, agent, namespace, raw, prs_opened_urls, prs_merged_urls, prs_opened,
prs_merged, commits, files_changed, tests_added, tests_run, tests_passed, tests_failed, tests_result,
deploy_state, files_touched, model_id, client_name, client_version, permission_mode, capsid_sha, recorded_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22, ?23, ?24)`
)
.bind(
row.job_id,
row.action,
row.agent,
row.namespace,
row.raw,
row.prs_opened_urls,
row.prs_merged_urls,
row.prs_opened,
row.prs_merged,
row.commits,
row.files_changed,
row.tests_added,
row.tests_run,
row.tests_passed,
row.tests_failed,
row.tests_result,
row.deploy_state,
row.files_touched,
row.model_id,
row.client_name,
row.client_version,
row.permission_mode,
row.capsid_sha,
row.recorded_at
);
}
// EVALUATIONS. The worker's own checks, named for OpenTelemetry's
// gen_ai.evaluation.result event. hidden_tests and scope_respected are in the table's
// vocabulary for a later evaluator (a model, a human); the worker writes none of them.
export const WORKER_EVALUATIONS = ["pr_merged", "prs_opened", "commits", "files_changed", "ci_green"] as const;
type WorkerEvaluation = (typeof WORKER_EVALUATIONS)[number];
export type Agreement = "agree" | "disagree" | "unclaimed" | "unchecked";
type ScoreLabel = "pass" | "fail" | "unknown";
export interface JobEvaluationRow {
job_id: string;
name: WorkerEvaluation;
score_value: number | null;
score_label: ScoreLabel;
claimed: string | null;
verified: string | null;
agreement: Agreement;
evaluator: "worker";
evaluator_id: string;
explanation: string | null;
recorded_at: string;
}
/** Computed once, at write time, from the two values the row stores. */
export function agreementOf(claimed: number | null, verified: number | null): Agreement {
if (claimed === null) return "unclaimed";
if (verified === null) return "unchecked";
return claimed === verified ? "agree" : "disagree";
}
export const workerEvaluatorId = (buildSha: string | null | undefined) => `capsid@${buildSha ?? "unknown"}`;
// The column holds a JSON array the row builder wrote, or NULL.
function urlsOf(column: string | null): string[] | null {
if (column === null) return null;
const parsed = JSON.parse(column) as unknown;
return Array.isArray(parsed) ? parsed.filter((u): u is string => typeof u === "string") : null;
}
const json = (value: number | null): string | null => (value === null ? null : JSON.stringify(value));
/**
* One row per worker check, from the claim row and what verifyEvidence read off
* GitHub. Pure. A value the Worker could not read is NULL with label unknown, and an
* unstated claim is NULL with agreement unclaimed: neither is ever a 0 or a false.
*
* Only the pull requests in evidence.prs, and a result_ref that is one, are read (verifyEvidence), so a URL the claim
* names that evidence does not is unread, and a check over it is unchecked rather than
* guessed.
*/
export function evaluationRows(
claim: JobClaimRow,
verdict: Pick<EvidenceVerdict, "github">,
ctx: { evaluator_id: string; now: Date }
): JobEvaluationRow[] {
const merged = verdict.github?.merged ?? {};
const read = (url: string) => Object.hasOwn(merged, url);
const row = (name: WorkerEvaluation, claimed: number | null, verified: number | null, label: ScoreLabel, explanation: string | null): JobEvaluationRow => ({
job_id: claim.job_id,
name,
score_value: verified,
score_label: verified === null ? "unknown" : label,
claimed: json(claimed),
verified: json(verified),
agreement: agreementOf(claimed, verified),
evaluator: "worker",
evaluator_id: ctx.evaluator_id,
explanation,
recorded_at: ctx.now.toISOString(),
});
const unread = (urls: string[]) => urls.filter((u) => !read(u)).length;
// A count passes when GitHub's number does not contradict the driver's.
const countLabel = (claimed: number | null, verified: number | null): ScoreLabel => (agreementOf(claimed, verified) === "disagree" ? "fail" : "pass");
// pr_merged: the pull requests the claim says are merged, else the ones evidence
// named. Verified is how many of them GitHub reads as merged, known only when every
// one was read.
const mergedClaim = urlsOf(claim.prs_merged_urls);
const mergedSet = mergedClaim ?? urlsOf(claim.prs_opened_urls) ?? [];
const mergedMissing = unread(mergedSet);
const mergedVerified = mergedSet.length > 0 && mergedMissing === 0 ? mergedSet.filter((u) => merged[u]).length : null;
const prMerged = row(
"pr_merged",
claim.prs_merged,
mergedVerified,
mergedVerified !== null && mergedVerified === mergedSet.length ? "pass" : "fail",
mergedSet.length === 0
? "no pull request was named, so there was nothing to check"
: mergedMissing > 0
? `${mergedMissing} of ${mergedSet.length} named pull requests could not be read (only evidence.prs and a pull request result_ref are read)`
: null
);
// prs_opened: every pull request the claim names exists on GitHub. Verified is the
// count, known only when every one was read.
const openedSet = urlsOf(claim.prs_opened_urls) ?? [];
const openedMissing = unread(openedSet);
const openedVerified = openedSet.length > 0 && openedMissing === 0 ? openedSet.length : null;
const prsOpened = row(
"prs_opened",
claim.prs_opened,
openedVerified,
countLabel(claim.prs_opened, openedVerified),
openedSet.length === 0
? "no pull request was named, so there was nothing to check"
: openedMissing > 0
? `${openedMissing} of ${openedSet.length} named pull requests could not be read (only evidence.prs and a pull request result_ref are read)`
: null
);
// commits and files_changed: GitHub's sum over every named pull request, known only
// when every one was read, against the driver's number as sent.
const sums = "GitHub's sum is known only when every pull request in evidence.prs and result_ref was read";
const commitsVerified = verdict.github?.commits ?? null;
const filesVerified = verdict.github?.files_changed ?? null;
const commits = row("commits", claim.commits, commitsVerified, countLabel(claim.commits, commitsVerified), commitsVerified === null ? sums : null);
const files = row("files_changed", claim.files_changed, filesVerified, countLabel(claim.files_changed, filesVerified), filesVerified === null ? sums : null);
// ci_green: no claim field says anything about CI, so the claim is NULL (unclaimed).
const ciVerified = verdict.github?.ci_green ?? null;
const ci = row(
"ci_green",
null,
ciVerified,
ciVerified === 1 ? "pass" : "fail",
ciVerified === null ? "CI on the last named pull request's head was not read, or had not finished" : null
);
return [prMerged, prsOpened, commits, files, ci];
}
// claim_id is the claim row this batch inserted. Its id is not known before the batch
// runs, and the batch is one transaction, so the newest claim for the job is that row.
export function evaluationStatements(db: D1Database, rows: readonly JobEvaluationRow[]): D1PreparedStatement[] {
return rows.map((row) =>
db
.prepare(
`INSERT INTO job_evaluations (job_id, claim_id, name, score_value, score_label, claimed, verified, agreement,
evaluator, evaluator_id, explanation, recorded_at)
VALUES (?1, (SELECT MAX(id) FROM job_claims WHERE job_id = ?1), ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)`
)
.bind(
row.job_id,
row.name,
row.score_value,
row.score_label,
row.claimed,
row.verified,
row.agreement,
row.evaluator,
row.evaluator_id,
row.explanation,
row.recorded_at
)
);
}