From cc4c6602edad203ac77317b8564d9c7c3c5a0790 Mon Sep 17 00:00:00 2001 From: aliferous3 Date: Mon, 19 Oct 2026 21:18:38 +0400 Subject: [PATCH 1/4] feat: add crash-safe ownership accumulation engine (B2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the ACCUMULATE half of the checkpoint architecture on the B1 substrate: preflight -> HEAD probe -> pinned Commit validation -> guarded init -> versioned scope membership (depth, scope_path_hash) -> pinned history(path:) pages -> grouped (period x work_key) deltas -> own_checkpoint_page CAS, one scope at a time until resolve-ready. Exactly-once is owned by the DB CAS — a committed page replayed with the old cursor returns 'stale' and never double-counts. Slices are bounded (~40s wall clock, ~300 measured GraphQL points, 500-point rate reserve) and a soft per-repo lease inside history_cursor serializes workers; both boundaries stop only at committed page boundaries. ACCUMULATE only: p_identities is always [], no RESOLVE pass, no promotion, no retained snapshots, no identity staging, no production wiring — the single entrypoint stays behind the hard-off OWNERSHIP_INGEST gate. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- lib/ownership/accumulate-runtime.mjs | 160 ++++++ lib/ownership/accumulate.mjs | 592 ++++++++++++++++++++++ lib/ownership/page-deltas.mjs | 72 +++ lib/ownership/queries.mjs | 100 +++- scripts/ownership-engine-proofs.mjs | 315 ++++++++++++ scripts/ownership-pg-db.mjs | 93 ++++ scripts/ownership-work-projection.mjs | 109 ++++ scripts/recovery-drill.mjs | 7 +- test/ownership-accumulate.test.mjs | 683 ++++++++++++++++++++++++++ test/ownership-schema.test.mjs | 35 +- 10 files changed, 2159 insertions(+), 7 deletions(-) create mode 100644 lib/ownership/accumulate-runtime.mjs create mode 100644 lib/ownership/accumulate.mjs create mode 100644 lib/ownership/page-deltas.mjs create mode 100644 scripts/ownership-engine-proofs.mjs create mode 100644 scripts/ownership-pg-db.mjs create mode 100644 scripts/ownership-work-projection.mjs create mode 100644 test/ownership-accumulate.test.mjs diff --git a/lib/ownership/accumulate-runtime.mjs b/lib/ownership/accumulate-runtime.mjs new file mode 100644 index 0000000..3905bc6 --- /dev/null +++ b/lib/ownership/accumulate-runtime.mjs @@ -0,0 +1,160 @@ +// Production wiring for the ACCUMULATE engine (Phase 1C-B2). +// +// This module adapts the repo's real infrastructure — supabase-js and the +// installation Octokit — to the small transport/db surfaces the engine +// consumes. The pure engine lives in accumulate.mjs and stays free of +// infra imports so unit tests can drive it with in-memory doubles. +// +// Feature gate: runOwnershipAccumulate is the ONLY callable production +// entrypoint and it passes through the OWNERSHIP_INGEST_REPOS hard-off +// gate before ANY GitHub request or ownership write. Nothing schedules it +// — no cron, no webhook, no UI — so production ownership tables receive +// zero rows until a later phase deliberately enables the gate. + +import { supabase } from '../db.mjs' +import { getInstallationOctokit, withBackoff } from '../github.mjs' +import { assertOwnershipIngestAllowed } from './gate.mjs' +import { runAccumulateSlice, SLICE_BUDGET_MS } from './accumulate.mjs' + +// ── supabase adapter ────────────────────────────────────────────────────── +// Guarded sync-state writes use PostgREST equality predicates that mirror +// the B1 CAS: building_version and history_cursor are match predicates +// (null -> IS NULL, absent -> no predicate) so a racing update can never +// slip through. +export function makeSupabaseOwnershipDb(client) { + return { + async loadRepo(repositoryId) { + const { data } = await client + .from('repositories') + .select('id, user_id, owner_login, name, installation_id') + .eq('id', repositoryId) + .maybeSingle() + return data ?? null + }, + async ensureSyncRow(userId, repositoryId) { + const { error } = await client + .from('ownership_sync_state') + .upsert( + { user_id: userId, repository_id: repositoryId, phase: 'pending' }, + { onConflict: 'user_id,repository_id', ignoreDuplicates: true }, + ) + if (error) throw error + }, + async loadSync(userId, repositoryId) { + const { data } = await client + .from('ownership_sync_state') + .select('*') + .eq('user_id', userId) + .eq('repository_id', repositoryId) + .maybeSingle() + return data ?? null + }, + async rpc(fn, args) { + const { data, error } = await client.rpc(fn, args) + if (error) throw error + return data + }, + async upsertScopes(rows) { + if (!rows.length) return [] + const { data, error } = await client + .from('ownership_scopes') + .upsert(rows, { onConflict: 'repository_id,scope_path_hash' }) + .select('id, scope_path_hash, depth') + if (error) throw error + return data ?? [] + }, + async insertMembership(rows) { + if (!rows.length) return + const { error } = await client + .from('ownership_scope_versions') + .upsert(rows, { + onConflict: 'scope_id,calculation_version', + ignoreDuplicates: true, + }) + if (error) throw error + }, + async membershipFor(userId, repositoryId, version) { + const { data } = await client + .from('ownership_scope_versions') + .select('scope_id, excluded') + .eq('user_id', userId) + .eq('repository_id', repositoryId) + .eq('calculation_version', version) + return data ?? [] + }, + async loadScope(scopeId) { + const { data } = await client + .from('ownership_scopes') + .select('id, display_path, scope_type') + .eq('id', scopeId) + .maybeSingle() + return data ?? null + }, + async casSync({ userId, repositoryId, match = {}, patch }) { + let q = client + .from('ownership_sync_state') + .update(patch) + .eq('user_id', userId) + .eq('repository_id', repositoryId) + if ('building_version' in match) { + q = match.building_version == null + ? q.is('building_version', null) + : q.eq('building_version', match.building_version) + } + if ('history_cursor' in match) { + q = match.history_cursor == null + ? q.is('history_cursor', null) + : q.eq('history_cursor', match.history_cursor) + } + const { data, error } = await q.select('user_id') + if (error) throw error + return (data?.length ?? 0) > 0 + }, + } +} + +// ── octokit transport ───────────────────────────────────────────────────── +// GraphQL goes through withBackoff so secondary/transient errors get the +// same retry treatment as the activity pipeline; RateLimitError and +// GitHubAuthError propagate unchanged for the engine's closed taxonomy. +export function makeOctokitTransport(octokit) { + return { + graphql: (query, vars) => + withBackoff(() => octokit.graphql(query, vars), { tag: 'ownership.graphql' }), + async getTree({ owner, repo, treeSha }) { + const res = await withBackoff( + () => + octokit.rest.git.getTree({ + owner, + repo, + tree_sha: treeSha, + recursive: 'true', + }), + { tag: 'ownership.tree' }, + ) + const d = res?.data ?? {} + return { tree: Array.isArray(d.tree) ? d.tree : [], truncated: d.truncated === true } + }, + } +} + +// ── gated production entrypoint ─────────────────────────────────────────── +// Dormant until a later phase wires a caller. The hard-off gate throws +// before a repo row is even read — no GitHub request, no DB write, no +// feature state change when the gate is closed. +export async function runOwnershipAccumulate(repositoryId, { budgetMs = SLICE_BUDGET_MS, env } = {}) { + assertOwnershipIngestAllowed(repositoryId, env) + const db = makeSupabaseOwnershipDb(supabase) + const repo = await db.loadRepo(repositoryId) + if (!repo || !repo.installation_id) { + return { status: 'error', code: 'OWNERSHIP_INTERNAL_ERROR' } + } + const octokit = await getInstallationOctokit(repo.installation_id) + return runAccumulateSlice({ + db, + transport: makeOctokitTransport(octokit), + repositoryId, + budgetMs, + env, + }) +} diff --git a/lib/ownership/accumulate.mjs b/lib/ownership/accumulate.mjs new file mode 100644 index 0000000..e599d27 --- /dev/null +++ b/lib/ownership/accumulate.mjs @@ -0,0 +1,592 @@ +// ACCUMULATE checkpoint engine — Phase 1C-B2. +// +// Implements the crash-safe history walk from the checkpoint architecture: +// one scope at a time, every page pinned to the build's immutable commit +// OID, every page committed through own_checkpoint_page's compare-and-set +// so a replayed page can never double-count. Application code holds NO +// idempotency state — the DB CAS is the exactly-once authority (§5.5). +// +// This tranche is ACCUMULATE ONLY. p_identities is always [] — RESOLVE, +// COMMIT, promotion, and retained snapshots are B3 territory. +// +// PRIVACY (§9): node ids and logins exist in memory only long enough to +// derive pseudonymous work_keys — they are never persisted, logged, or +// placed in errors. All thrown/loggable values use the closed ownership +// error taxonomy; provider payload text never crosses the boundary. +// +// The engine is transport/db agnostic for testability: +// db.loadRepo(repositoryId) +// db.ensureSyncRow(userId, repositoryId) +// db.loadSync(userId, repositoryId) -> sync-state row | null +// db.rpc(fn, namedArgs) -> scalar (throws on error) +// db.upsertScopes(rows) -> [{id, scope_path_hash, depth}] +// db.insertMembership(rows) +// db.membershipFor(userId, repositoryId, version) -> [{scope_id, excluded}] +// db.loadScope(scopeId) -> {display_path, scope_type} | null +// db.casSync({userId, repositoryId, match, patch}) -> bool +// match = optional {building_version, history_cursor} equality +// predicates; a null value means "IS NULL", an absent key means +// "no predicate". patch is a plain column→value map. +// transport.graphql(query, vars) -> response data +// transport.getTree({owner, repo, treeSha}) -> {tree, truncated} + +import crypto from 'node:crypto' +import { RateLimitError, GitHubAuthError } from '../github.mjs' +import { assertOwnershipIngestAllowed } from './gate.mjs' +import { deriveScopeCandidates } from './scopes.mjs' +import { reducePageDeltas } from './page-deltas.mjs' +import { + ownershipHeadProbeQuery, + pinnedObjectQuery, + pinnedHistoryPageQuery, + assertOwnershipQuerySafe, + assertOwnershipResponseSafe, +} from './queries.mjs' + +// Bounded-slice model (§15): ~40s wall clock, ~300 measured GraphQL points +// per invocation, 500-point rate-limit reserve, ~90s soft lease. +export const SLICE_BUDGET_MS = 40_000 +export const SLICE_COST_BUDGET = 300 +export const RATE_LIMIT_RESERVE = 500 +export const LEASE_TTL_MS = 90_000 + +const OID_RE = /^[0-9a-f]{40}$/ +const UUID_RE = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i + +// Closed internal status vocabulary — the caller may act on these; they are +// never persisted verbatim and never carry provider text. +export const SLICE_STATUS = Object.freeze({ + RESOLVE_READY: 'resolve-ready', // scope finished ACCUMULATE — B2 handoff + SCOPES_DRAINED: 'scopes-drained', // nothing pending — B3 promote territory + SLICE_BUDGET: 'slice-budget', // paused cleanly at a page boundary + RATE_LIMITED: 'rate-limited', + LOCKED: 'locked', // another live worker holds the lease + BLOCKED: 'blocked', // a build appeared between preflight and init + STALE: 'stale', // build vanished/version changed mid-flight + ABANDONED: 'abandoned', // invalid durable state — canonical abandon ran + ERROR: 'error', +}) + +// ── transport + error classification ────────────────────────────────────── + +// Every GraphQL call goes through here: the query text is re-audited at the +// trust boundary and the response is scanned for contract-forbidden keys +// before anything else may touch it. +async function gql(transport, query, vars) { + const data = await transport.graphql(assertOwnershipQuerySafe(query), vars) + return assertOwnershipResponseSafe(data) +} + +function classifyFailure(e) { + if (e instanceof RateLimitError) return { status: SLICE_STATUS.RATE_LIMITED } + if (e instanceof GitHubAuthError) { + return { status: SLICE_STATUS.ERROR, code: 'GITHUB_ACCESS_REVOKED' } + } + return { status: SLICE_STATUS.ERROR, code: 'OWNERSHIP_HISTORY_FAILED' } +} + +async function markError(db, st, code) { + // phase='error' leaves build state for operator inspection until the + // documented sweep/abandon path runs (§7). + await db.casSync({ + userId: st.user_id, + repositoryId: st.repository_id, + match: { building_version: st.building_version }, + patch: { phase: 'error', error: code }, + }) +} + +// ── lease (§14) ─────────────────────────────────────────────────────────── +// Soft per-repo worker lease inside history_cursor — no separate lease +// table. The page-level CAS remains the real correctness barrier; the +// lease only bounds duplicate work. A live foreign lease wins the slice; +// an expired or absent one is taken. +async function claimLease(db, st, workerId, now) { + const lease = st.history_cursor?.lease + if (lease?.owner && lease.owner !== workerId) { + if (Number.isFinite(Date.parse(lease.expires ?? '')) && Date.parse(lease.expires) > now()) { + return null + } + } + const cursor = { + ...st.history_cursor, + lease: { owner: workerId, expires: new Date(now() + LEASE_TTL_MS).toISOString() }, + } + const ok = await db.casSync({ + userId: st.user_id, + repositoryId: st.repository_id, + match: { building_version: st.building_version, history_cursor: st.history_cursor }, + patch: { history_cursor: cursor }, + }) + return ok ? { ...st, history_cursor: cursor } : null +} + +// ── §5.6b resumability ──────────────────────────────────────────────────── +// A build is resumable ONLY by internal consistency — never reconstructed +// heuristically. Shape, membership, scope/pass legality, and pending-set +// membership must all hold, otherwise the caller abandons (§5.8). +export async function isResumable(db, st) { + const V = st?.building_version + if (V == null || st.phase !== 'history') return false + const hc = st.history_cursor + if (!hc || typeof hc !== 'object' || Array.isArray(hc)) return false + if (typeof hc.pinned_oid !== 'string' || !OID_RE.test(hc.pinned_oid)) return false + const cur = hc.current + if (cur != null) { + if ( + typeof cur !== 'object' || + !UUID_RE.test(typeof cur.scope_id === 'string' ? cur.scope_id : '') || + (cur.pass !== 'accumulate' && cur.pass !== 'resolve') || + (cur.cursor !== null && typeof cur.cursor !== 'string') + ) { + return false + } + } + const members = await db.membershipFor(st.user_id, st.repository_id, V) + const byId = new Map(members.map((m) => [m.scope_id, m])) + if (cur && (!byId.has(cur.scope_id) || byId.get(cur.scope_id).excluded === true)) { + return false + } + if (st.pending_scopes != null) { + if ( + !Array.isArray(st.pending_scopes) || + !st.pending_scopes.every( + (id) => + UUID_RE.test(typeof id === 'string' ? id : '') && + byId.has(id) && + byId.get(id).excluded !== true, + ) + ) { + return false + } + } + return true +} + +// ── build start (§5.4, split preflight → pin → init) ────────────────────── + +async function startBuild({ db, transport, repo, st }) { + const userId = repo.user_id + const repositoryId = repo.id + + // Step 1: preflight decides whether a build may begin. It NEVER reserves + // one — the CAS inside own_build_init is the guard. + const pre = await db.rpc('own_build_preflight', { + p_user: userId, + p_repository: repositoryId, + }) + if (pre !== 'ready') return { status: pre } // 'blocked' | 'stale' + + // Step 2: the ONLY mutable-branch read in the engine — resolve HEAD once + // to discover the immutable revision to pin. + let probe + try { + probe = await gql(transport, ownershipHeadProbeQuery(), { + owner: repo.owner_login, + name: repo.name, + }) + } catch (e) { + const r = classifyFailure(e) + if (r.code) { + await db.casSync({ + userId, repositoryId, + match: { building_version: null }, + patch: { error: r.code }, + }) + } + return r + } + const headOid = probe?.repository?.defaultBranchRef?.target?.oid + if (typeof headOid !== 'string' || !OID_RE.test(headOid)) { + await db.casSync({ + userId, repositoryId, + match: { building_version: null }, + patch: { error: 'OWNERSHIP_HISTORY_FAILED' }, + }) + return { status: SLICE_STATUS.ERROR, code: 'OWNERSHIP_HISTORY_FAILED' } + } + + // Step 3: the candidate must resolve as a Commit with an unchanged OID — + // a pin established against anything else would silently mix revisions. + let obj + try { + obj = await gql(transport, pinnedObjectQuery(), { + owner: repo.owner_login, + name: repo.name, + oid: headOid, + }) + } catch (e) { + const r = classifyFailure(e) + if (r.code) { + await db.casSync({ + userId, repositoryId, + match: { building_version: null }, + patch: { error: r.code }, + }) + } + return r + } + const pinned = obj?.repository?.object + if (pinned == null || pinned.__typename !== 'Commit' || pinned.oid !== headOid) { + await db.casSync({ + userId, repositoryId, + match: { building_version: null }, + patch: { error: 'OWNERSHIP_HISTORY_FAILED' }, + }) + return { status: SLICE_STATUS.ERROR, code: 'OWNERSHIP_HISTORY_FAILED' } + } + + // Step 4: guarded init — detects a concurrent build/abandon that landed + // between preflight and now. Never write around 'blocked'/'stale'. + const version = (st?.active_version ?? 0) + 1 + const init = await db.rpc('own_build_init', { + p_user: userId, + p_repository: repositoryId, + p_new_version: version, + p_pinned_oid: headOid, + }) + if (init !== 'initialized') return { status: init } // 'blocked' | 'stale' + return { status: 'started' } +} + +// ── scope membership + selection (§5.3) ─────────────────────────────────── +// Membership comes from deriveScopeCandidates on the PINNED tree — bounded +// (≤500 dir scopes), filtered (vendor/generated/binary/submodule), and +// ordered (depth, scope_path_hash). Overflow paths are a count only and are +// never persisted or logged. +async function prepareScopes({ db, transport, repo, st }) { + const userId = st.user_id + const repositoryId = repo.id + const V = st.building_version + const pinnedOid = st.history_cursor.pinned_oid + + // Re-resolve the pinned object — both re-verifies the pin before heavy + // work and yields the pinned commit's tree for scope enumeration. + let data + try { + data = await gql(transport, pinnedObjectQuery(), { + owner: repo.owner_login, + name: repo.name, + oid: pinnedOid, + }) + } catch (e) { + const r = classifyFailure(e) + if (r.code) await markError(db, st, r.code) + return { ok: false, out: r } + } + const obj = data?.repository?.object + const treeOid = obj?.tree?.oid + if ( + obj == null || + obj.__typename !== 'Commit' || + obj.oid !== pinnedOid || + typeof treeOid !== 'string' || + !OID_RE.test(treeOid) + ) { + await markError(db, st, 'OWNERSHIP_HISTORY_FAILED') + return { ok: false, out: { status: SLICE_STATUS.ERROR, code: 'OWNERSHIP_HISTORY_FAILED' } } + } + + let tree + try { + tree = await transport.getTree({ owner: repo.owner_login, repo: repo.name, treeSha: treeOid }) + } catch (e) { + const r = classifyFailure(e) + if (r.code) await markError(db, st, r.code) + return { ok: false, out: r } + } + if (tree.truncated === true) { + // enumeration cannot be trusted — fail closed rather than persist a + // partial scope universe + await markError(db, st, 'OWNERSHIP_HISTORY_FAILED') + return { ok: false, out: { status: SLICE_STATUS.ERROR, code: 'OWNERSHIP_HISTORY_FAILED' } } + } + + const { scopes } = deriveScopeCandidates(tree.tree) + // excluded is deliberately absent — the column default is false and the + // upsert must not reset base-scope exclusion state on re-derivation; + // exclusion decisions are version-scoped (ownership_scope_versions) + const upserted = await db.upsertScopes( + scopes.map((c) => ({ + user_id: userId, + repository_id: repositoryId, + scope_path_hash: c.scope_path_hash, + display_path: c.display_path, + scope_type: c.scope_type, + depth: c.depth, + })), + ) + await db.insertMembership( + upserted.map((s) => ({ + user_id: userId, + repository_id: repositoryId, + scope_id: s.id, + calculation_version: V, + excluded: false, + })), + ) + // deterministic processing order: depth, scope_path_hash — re-derivable + // from membership alone + const ordered = [...upserted] + .sort((a, b) => + a.depth !== b.depth + ? a.depth - b.depth + : a.scope_path_hash < b.scope_path_hash + ? -1 + : a.scope_path_hash > b.scope_path_hash ? 1 : 0, + ) + .map((s) => s.id) + + const begun = await beginScope(db, st, ordered[0], ordered) + if (!begun) return { ok: false, out: { status: SLICE_STATUS.STALE } } + return { ok: true, sync: begun } +} + +// Guarded "first pending scope enters ACCUMULATE" write — the CAS inside +// casSync rejects any racing/advance state (§5.2). +async function beginScope(db, st, scopeId, pendingIds) { + const cursor = { + ...st.history_cursor, + current: { scope_id: scopeId, pass: 'accumulate', cursor: null }, + } + const patch = { history_cursor: cursor } + if (pendingIds) patch.pending_scopes = pendingIds + const ok = await db.casSync({ + userId: st.user_id, + repositoryId: st.repository_id, + match: { building_version: st.building_version, history_cursor: st.history_cursor }, + patch, + }) + return ok + ? { ...st, history_cursor: cursor, pending_scopes: pendingIds ?? st.pending_scopes } + : null +} + +// ── page loop (§5.5) ────────────────────────────────────────────────────── +// fetch page -> reduce to grouped deltas -> own_checkpoint_page CAS. +// 'applied' advances; 'stale' re-reads the canonical row and follows it — +// never a blind retry; a response lost AFTER a committed page replays to +// 'stale' and cannot double-count. +async function accumulateScope({ db, transport, repo, st, now, deadline, costBudget }) { + const userId = st.user_id + const repositoryId = repo.id + const V = st.building_version + const pinnedOid = st.history_cursor.pinned_oid + const cur = st.history_cursor.current + + const scope = await db.loadScope(cur.scope_id) + if (!scope) { + // the base scope vanished while V membership references it — invalid + // ephemeral state; the canonical remedy is abandon (§5.6) + await db.rpc('own_abandon_build', { + p_user: userId, + p_repository: repositoryId, + p_version: V, + }) + return { status: SLICE_STATUS.ABANDONED } + } + const path = scope.scope_type === 'repo' ? null : scope.display_path + + let cursor = cur.cursor ?? null + let pages = 0 + let cost = 0 + let remaining = null + + for (;;) { + if (now() >= deadline || cost >= costBudget) { + return { status: SLICE_STATUS.SLICE_BUDGET, pages, cost, remaining } + } + if (remaining != null && remaining < RATE_LIMIT_RESERVE) { + return { status: SLICE_STATUS.RATE_LIMITED, pages, cost, remaining } + } + + const vars = { owner: repo.owner_login, name: repo.name, oid: pinnedOid, path, cursor } + let data + try { + data = await gql(transport, pinnedHistoryPageQuery(), vars) + } catch (e) { + const r = classifyFailure(e) + if (r.code) await markError(db, st, r.code) + return { ...r, pages, cost } + } + + let history = data?.repository?.object + if (history == null || history.__typename !== 'Commit' || history.oid !== pinnedOid) { + // pinned boundary unusable — retry the identical page exactly once, + // then fail closed. NEVER repin mid-build (§10). + try { + const retry = await gql(transport, pinnedHistoryPageQuery(), vars) + history = retry?.repository?.object + } catch (e) { + history = null + } + if (history == null || history.__typename !== 'Commit' || history.oid !== pinnedOid || !history.history) { + await markError(db, st, 'OWNERSHIP_HISTORY_FAILED') + return { status: SLICE_STATUS.ERROR, code: 'OWNERSHIP_HISTORY_FAILED', pages, cost } + } + } + const h = history.history + if ( + h == null || + typeof h.pageInfo?.hasNextPage !== 'boolean' || + (h.pageInfo.hasNextPage && typeof h.pageInfo.endCursor !== 'string') || + !Array.isArray(h.nodes) + ) { + await markError(db, st, 'OWNERSHIP_HISTORY_FAILED') + return { status: SLICE_STATUS.ERROR, code: 'OWNERSHIP_HISTORY_FAILED', pages, cost } + } + + let deltas + try { + deltas = reducePageDeltas(repositoryId, h.nodes) + } catch { + // malformed provider identity — page wedges at the durable cursor, + // nothing commits (fail-closed per §5.5) + await markError(db, st, 'OWNERSHIP_HISTORY_FAILED') + return { status: SLICE_STATUS.ERROR, code: 'OWNERSHIP_HISTORY_FAILED', pages, cost } + } + + let r + try { + r = await db.rpc('own_checkpoint_page', { + p_user: userId, + p_repository: repositoryId, + p_version: V, + p_scope: cur.scope_id, + p_pass: 'accumulate', + p_expected_cursor: cursor, + p_next_cursor: h.pageInfo.endCursor ?? null, + p_deltas: deltas, + p_identities: [], + p_last_page: h.pageInfo.hasNextPage !== true, + }) + } catch { + // own_checkpoint_page raised (bounds, drift, malformed payload) — + // the closed internal-error taxonomy, never raw SQL text + await markError(db, st, 'OWNERSHIP_INTERNAL_ERROR') + return { status: SLICE_STATUS.ERROR, code: 'OWNERSHIP_INTERNAL_ERROR', pages, cost } + } + + if (r === 'stale') { + // Do NOT retry blindly — read the canonical row and follow it. + const fresh = await db.loadSync(userId, repositoryId) + const c = fresh?.history_cursor?.current + if (fresh?.building_version !== V || !c || c.scope_id !== cur.scope_id) { + return { status: SLICE_STATUS.STALE, pages, cost } + } + if (c.pass === 'resolve') { + // the winner already finished this scope's ACCUMULATE + return { status: SLICE_STATUS.RESOLVE_READY, pages, cost } + } + if (c.pass !== 'accumulate') return { status: SLICE_STATUS.STALE, pages, cost } + cursor = c.cursor ?? null // adopt the durable cursor; our page already landed + continue + } + + pages += 1 + cost += data.rateLimit?.cost ?? 0 + if (data.rateLimit?.remaining != null) remaining = data.rateLimit.remaining + if (h.pageInfo.hasNextPage !== true) { + // last page committed — B1 flipped the scope to pass='resolve'; + // B2 stops here for this scope (RESOLVE is B3) + return { status: SLICE_STATUS.RESOLVE_READY, pages, cost, remaining } + } + cursor = h.pageInfo.endCursor + } +} + +// ── the slice entrypoint ────────────────────────────────────────────────── + +export async function runAccumulateSlice({ + db, + transport, + repositoryId, + budgetMs = SLICE_BUDGET_MS, + costBudget = SLICE_COST_BUDGET, + workerId = crypto.randomUUID(), + now = () => Date.now(), + env, +}) { + // The hard-off gate runs before any GitHub request or ownership write. + assertOwnershipIngestAllowed(repositoryId, env) + + const deadline = now() + budgetMs + const repo = await db.loadRepo(repositoryId) + if (!repo) return { status: SLICE_STATUS.ERROR, code: 'OWNERSHIP_INTERNAL_ERROR' } + const userId = repo.user_id + + await db.ensureSyncRow(userId, repositoryId) + let st = await db.loadSync(userId, repositoryId) + if (!st) return { status: SLICE_STATUS.ERROR, code: 'OWNERSHIP_INTERNAL_ERROR' } + + if (st.building_version == null) { + const start = await startBuild({ db, transport, repo, st }) + if (start.status !== 'started') return start + st = await db.loadSync(userId, repositoryId) + if (st?.building_version == null) return { status: SLICE_STATUS.STALE } + } + + const leased = await claimLease(db, st, workerId, now) + if (!leased) return { status: SLICE_STATUS.LOCKED } + st = leased + + try { + // §5.6b — a live build must be internally consistent to resume; anything + // else takes the documented fail-closed abandon path. + if (!(await isResumable(db, st))) { + await db.rpc('own_abandon_build', { + p_user: userId, + p_repository: repositoryId, + p_version: st.building_version, + }) + return { status: SLICE_STATUS.ABANDONED } + } + + // Dispatch on the durable position — current=null means either fresh + // membership prep (pending_scopes null) or the next pending scope. + let cur = st.history_cursor.current ?? null + if (cur == null) { + if (st.pending_scopes == null) { + const prep = await prepareScopes({ db, transport, repo, st }) + if (!prep.ok) return prep.out + st = prep.sync + } else if (st.pending_scopes.length === 0) { + return { status: SLICE_STATUS.SCOPES_DRAINED } + } else { + const begun = await beginScope(db, st, st.pending_scopes[0], st.pending_scopes) + if (!begun) return { status: SLICE_STATUS.STALE } + st = begun + } + cur = st.history_cursor.current + } + if (cur.pass === 'resolve') return { status: SLICE_STATUS.RESOLVE_READY } + + return await accumulateScope({ db, transport, repo, st, now, deadline, costBudget }) + } finally { + // a finished slice must never pin the lease — a dead worker is covered + // by the TTL, a clean exit frees the next invocation immediately + await releaseLease(db, st, workerId) + } +} + +// Best-effort lease release at slice exit. Re-loads the row so the release +// never clobbers cursor fields the checkpoint advanced during the slice. +async function releaseLease(db, st, workerId) { + try { + const fresh = await db.loadSync(st.user_id, st.repository_id) + if (!fresh || fresh.history_cursor?.lease?.owner !== workerId) return + const { lease: _dropped, ...rest } = fresh.history_cursor + await db.casSync({ + userId: st.user_id, + repositoryId: st.repository_id, + match: { + building_version: fresh.building_version, + history_cursor: fresh.history_cursor, + }, + patch: { history_cursor: rest }, + }) + } catch { + // best-effort only — the TTL covers an unreleased lease + } +} diff --git a/lib/ownership/page-deltas.mjs b/lib/ownership/page-deltas.mjs new file mode 100644 index 0000000..f7cee65 --- /dev/null +++ b/lib/ownership/page-deltas.mjs @@ -0,0 +1,72 @@ +// ACCUMULATE page delta reduction — pure, no I/O. +// +// Folds one pinned-history GraphQL page (≤100 nodes) into grouped counter +// deltas at (period_start × work_key) grain — exactly the shape +// own_checkpoint_page's p_deltas expects. Mathematically equivalent to +// reducing each node individually: touches sum, first/last_seen merge by +// min/max, and is_bot comes from the canonical classifier. +// +// HARD PRIVACY CONTRACT (§6.4 + §9 of the spec): the returned payload may +// contain ONLY {period_start, work_key, is_bot, touches, first_seen_at, +// last_seen_at}. github_node_id and display_login exist transiently in +// memory just long enough to derive the pseudonymous key — they must +// never appear in a returned delta, a log line, or a thrown value. +// Malformed provider identity raises OwnershipIdentityError and aborts +// the page: the checkpoint then never commits and the walk wedges at a +// durable cursor — the documented fail-closed path (§5.5). + +import { + classifyAuthorIdentity, + OwnershipIdentityError, + UNLINKED_KEY, +} from './contributors.mjs' +import { monthStartUtc } from './history-reducer.mjs' + +// nodes: [{ committedDate, author: { user: { id, login } | null } | null }] +// repositoryId: the repository-uuid boundary the contributor keys are +// scoped to (same id the canonical reducer receives). +export function reducePageDeltas(repositoryId, nodes) { + const groups = new Map() + for (const node of Array.isArray(nodes) ? nodes : []) { + if (!node || node.committedDate == null) { + throw new TypeError('history node missing committedDate') + } + const period = monthStartUtc(node.committedDate) + if (node.author != null && typeof node.author !== 'object') { + throw new OwnershipIdentityError() + } + // classifyAuthorIdentity returns the literal 'unlinked' bucket for + // author.user == null, the 64-hex repository-scoped key otherwise, + // and throws OwnershipIdentityError on malformed provider identity. + const identity = classifyAuthorIdentity(node.author?.user, repositoryId) + + const groupKey = `${period}${identity.contributor_key}` + let g = groups.get(groupKey) + if (!g) { + g = { + period_start: period, + work_key: identity.contributor_key, + is_bot: identity.is_bot, // first classification wins — canonical rule + touches: 0, + first_seen_at: node.committedDate, + last_seen_at: node.committedDate, + } + groups.set(groupKey, g) + } + g.touches += 1 + if (node.committedDate < g.first_seen_at) g.first_seen_at = node.committedDate + if (node.committedDate > g.last_seen_at) g.last_seen_at = node.committedDate + } + + // Deterministic output order: identical pages always serialize to the + // identical payload — keeps checkpoints byte-stable and tests exact. + return [...groups.values()].sort((a, b) => + a.period_start === b.period_start + ? a.work_key < b.work_key ? -1 : a.work_key > b.work_key ? 1 : 0 + : a.period_start < b.period_start ? -1 : 1, + ) +} + +// Exported for callers that need the exact domain the DB enforces — +// 'unlinked' plus 64-hex keys only; 'other' is never a counter key. +export { UNLINKED_KEY } diff --git a/lib/ownership/queries.mjs b/lib/ownership/queries.mjs index 8caee6e..f10238f 100644 --- a/lib/ownership/queries.mjs +++ b/lib/ownership/queries.mjs @@ -98,6 +98,66 @@ export function batchedBlameQuery(paths) { }` } +// ── pinned-revision queries (Phase 1C-B, checkpoint architecture §10) ──── +// Every ACCUMULATE build page is anchored at the build's immutable +// pinned_oid via object(oid:) — NEVER defaultBranchRef, which resolves at +// query time and drifts mid-build. historyPathQuery above remains the +// pre-checkpoint reference shape only; the checkpoint engine must use the +// pinned forms exclusively. + +// HEAD probe — the ONLY place the mutable branch tip may be read: a fresh +// build resolves it exactly once to pin the immutable revision. +export function ownershipHeadProbeQuery() { + return `query ($owner: String!, $name: String!) { + repository(owner: $owner, name: $name) { + defaultBranchRef { target { oid } } + } + ${RATE_LIMIT} +}` +} + +// Pin validation — resolves the candidate oid once at build start: the +// object must be a Commit and its returned oid must equal the probe's. +// Also captures the pinned commit's tree oid so scope enumeration is +// revision-pinned too (B0.5 measured form). +export function pinnedObjectQuery() { + return `query ($owner: String!, $name: String!, $oid: GitObjectID!) { + repository(owner: $owner, name: $name) { + object(oid: $oid) { + __typename + ... on Commit { oid tree { oid } } + } + } + ${RATE_LIMIT} +}` +} + +// The ACCUMULATE history page — the empirically proven B0.5 shape: +// Repository.object(oid: $pinnedOid) -> Commit -> history(path:, after:). +// Nodes carry ONLY what the reducer needs (committedDate + linked +// identity); no node oid, no message/email/name, no whole-commit stats, +// no files — a page's worth of deltas is derivable from these alone. +export function pinnedHistoryPageQuery() { + return `query ($owner: String!, $name: String!, $oid: GitObjectID!, $path: String, $cursor: String) { + repository(owner: $owner, name: $name) { + object(oid: $oid) { + __typename + ... on Commit { + oid + history(first: 100, path: $path, after: $cursor) { + pageInfo { hasNextPage endCursor } + nodes { + committedDate + author { ${AUTHOR_USER_FIELDS} } + } + } + } + } + } + ${RATE_LIMIT} +}` +} + // Static audit — throws when a built query widened past the metadata // contract. Also verifies every user-selection is exactly id+login so a // stray name/email field cannot hide inside an author block. @@ -116,6 +176,44 @@ export function assertOwnershipQuerySafe(query) { return query } +// Raised when a GraphQL RESPONSE carries a key outside the metadata +// contract — a privacy tripwire at the trust boundary, not a data error. +export class OwnershipResponseContractError extends Error { + constructor() { + // deliberately carries no field name, payload, or response fragment + super('ownership response violates the metadata contract') + this.name = 'OwnershipResponseContractError' + } +} + +// Recursively scan a response payload for keys that may never appear. Any +// hit throws — the caller treats it as a closed-contract failure, never a +// retryable data shape. Deliberately walks EVERY nested object/array. +export function assertOwnershipResponseSafe(payload) { + const walk = (v) => { + if (!v || typeof v !== 'object') return + if (Array.isArray(v)) { + for (const item of v) walk(item) + return + } + for (const [k, val] of Object.entries(v)) { + if (FORBIDDEN_OWNERSHIP_FIELDS.includes(k)) { + throw new OwnershipResponseContractError() + } + walk(val) + } + } + walk(payload) + return payload +} + // Every builder output passes the same audit — run at module load so the // constant query shapes are provably within contract before any use. -assertOwnershipQuerySafe(historyPathQuery()) +for (const q of [ + historyPathQuery(), + ownershipHeadProbeQuery(), + pinnedObjectQuery(), + pinnedHistoryPageQuery(), +]) { + assertOwnershipQuerySafe(q) +} diff --git a/scripts/ownership-engine-proofs.mjs b/scripts/ownership-engine-proofs.mjs new file mode 100644 index 0000000..7826932 --- /dev/null +++ b/scripts/ownership-engine-proofs.mjs @@ -0,0 +1,315 @@ +// ACCUMULATE engine proofs — the Phase 1C-B2 engine running against the +// REAL migration-013 substrate on real PostgreSQL. Invoked by the recovery +// drill on drill_src after seeding; every scenario uses its own throwaway +// tenant. +// +// The transport is synthetic (no GitHub) but the persistence path is fully +// real: own_build_preflight, own_build_init, own_checkpoint_page CAS, the +// lease CAS, membership/scopes upserts, and the abandon path all execute +// against actual constraints and triggers. +// +// E1 end-to-end fresh build -> accumulate -> resolve-ready handoff +// E2 differential: work-state projects EXACTLY to the canonical reducer +// E3 restart-between-every-page resume equivalence +// E4 committed-page replay with a stale cursor -> 'stale', no double-count +// E5 wrong expected cursor / wrong version -> 'stale', no writes +// E6 checkpoint transport/DB failure -> no partial delta, phase='error' +// E7 pinned-object vanished -> one retry -> fail closed, pin preserved +// E8 lease: live foreign wins, expired is taken, released on exit +// E9 invalid durable state -> canonical abandon +// E10 privacy: zero identity fields anywhere in persisted ownership state + +import crypto from 'node:crypto' +import { runAccumulateSlice, SLICE_STATUS } from '../lib/ownership/accumulate.mjs' +import { reduceHistoryToMonthly } from '../lib/ownership/history-reducer.mjs' +import { makePgOwnershipDb } from './ownership-pg-db.mjs' +import { projectWorkToMonthly } from './ownership-work-projection.mjs' + +const OID = 'a'.repeat(40) +const node = (date, user) => ({ committedDate: date, author: { user: user ?? null } }) +const nd = (id, login, date) => node(date, { id: `U_${id}`, login }) + +function syntheticNodes() { + const nodes = [] + const months = ['2025-10', '2025-11', '2025-12'] + for (let m = 0; m < months.length; m++) { + for (let c = 0; c < 30; c++) { + const touches = c < 5 ? 40 - m : c === 24 || c === 25 || c === 26 ? 7 : 2 + ((c + m) % 9) + for (let t = 0; t < touches; t++) { + nodes.push(nd(`pg${m}_${c}`, `engineuser${m}_${c}`, `${months[m]}-${String(1 + (t % 27)).padStart(2, '0')}T12:00:00Z`)) + } + } + for (let t = 0; t < 5; t++) nodes.push(nd(`pgbot${m}`, `ci[bot]`, `${months[m]}-05T08:00:00Z`)) + for (let t = 0; t < 9; t++) nodes.push(node(`${months[m]}-15T09:00:00Z`, null)) + } + return nodes +} + +function makeTransport({ nodes = [], tree = [], pinnedOid = OID, pageSize = 100, remaining = 5000, cost = 1 } = {}) { + const calls = [] + const rl = () => ({ cost, remaining, resetAt: '2026-01-01T00:00:00Z' }) + return { + calls, + async graphql(query, vars) { + calls.push({ query, vars }) + if (query.includes('defaultBranchRef')) { + return { repository: { defaultBranchRef: { target: { oid: pinnedOid } } }, rateLimit: rl() } + } + if (query.includes('tree {')) { + return { + repository: { object: { __typename: 'Commit', oid: vars.oid, tree: { oid: 'b'.repeat(40) } } }, + rateLimit: rl(), + } + } + if (vars.oid !== pinnedOid) return { repository: { object: null }, rateLimit: rl() } + const start = vars.cursor == null ? 0 : Number(vars.cursor.split(':')[1]) + const end = Math.min(start + pageSize, nodes.length) + return { + repository: { + object: { + __typename: 'Commit', oid: vars.oid, + history: { + pageInfo: { hasNextPage: end < nodes.length, endCursor: `idx:${end}` }, + nodes: nodes.slice(start, end), + }, + }, + }, + rateLimit: rl(), + } + }, + async getTree() { return { tree, truncated: false } }, + } +} + +export async function engineProofs(sql) { + const problems = [] + const check = (ok, msg) => { if (!ok) problems.push(msg) } + const tenantIds = [] + let seq = 0 + async function tenant() { + const uid = crypto.randomUUID() + const repoId = crypto.randomUUID() + const gid = 81000000 + ++seq + await sql`insert into users (id, github_user_id, github_login) values (${uid}, ${gid}, ${'eng-' + seq})` + await sql`insert into repositories (id, user_id, github_repo_id, owner_login, name, full_name) + values (${repoId}, ${uid}, ${gid}, 'acme', ${'engr' + seq}, ${'acme/engr' + seq})` + tenantIds.push(uid) + return { uid, repoId } + } + const env = (t) => ({ OWNERSHIP_INGEST_ENABLED: 'true', OWNERSHIP_CANARY_REPOSITORY_IDS: t.repoId }) + const run = (t, transport, opts = {}) => + runAccumulateSlice({ db: makePgOwnershipDb(sql), transport, repositoryId: t.repoId, env: env(t), ...opts }) + const syncRow = (t) => + sql`select * from ownership_sync_state where repository_id = ${t.repoId}`.then((r) => r[0] ?? null) + const workRows = (t) => + sql`select * from ownership_history_work where repository_id = ${t.repoId}`.then((r) => r) + + async function drive(t, transport, opts = {}, cap = 500) { + for (let i = 0; i < cap; i++) { + const r = await run(t, transport, opts) + if (r.status !== SLICE_STATUS.SLICE_BUDGET && r.status !== SLICE_STATUS.BLOCKED) return r + } + return { status: 'never-terminated' } + } + + // ── E1: end-to-end fresh build on the real CAS substrate ──────────────── + { + const t = await tenant() + const nodes = syntheticNodes() + const transport = makeTransport({ nodes }) + const r = await drive(t, transport) + check(r.status === SLICE_STATUS.RESOLVE_READY, `E1: expected resolve-ready, got ${r.status}`) + const st = await syncRow(t) + check(st?.building_version === 1, 'E1: building_version must be 1') + check(st?.phase === 'history', 'E1: phase must remain history for B3') + check(st?.history_cursor?.pinned_oid === OID, 'E1: pinned_oid must be the immutable pin') + check(st?.history_cursor?.current?.pass === 'resolve', 'E1: scope must rest at resolve') + check(st?.history_cursor?.current?.cursor === null, 'E1: resolve cursor must be fresh') + check(typeof st?.history_cursor?.lease === 'undefined' || st?.history_cursor?.lease === null, + 'E1: lease must be released at slice end') + const work = await workRows(t) + check(work.length > 0, 'E1: history_work must hold accumulated counters') + const bw = await sql`select count(*)::int c from ownership_contributor_build_work where repository_id = ${t.repoId}` + const cc = await sql`select count(*)::int c from ownership_contributors where repository_id = ${t.repoId}` + const sn = await sql`select count(*)::int c from ownership_snapshots where repository_id = ${t.repoId}` + check(bw[0].c === 0, 'E1: contributor_build_work must stay empty in B2') + check(cc[0].c === 0, 'E1: no retained contributors may be created in B2') + check(sn[0].c === 0, 'E1: no snapshots may be materialized in B2') + const pages = transport.calls.filter((c) => c.query.includes('history(first')) + check(pages.length >= 2, `E1: expected multiple pinned pages, got ${pages.length}`) + check(pages.every((c) => c.vars.oid === OID && c.query.includes('object(oid: $oid)') + && !c.query.includes('defaultBranchRef')), + 'E1: every history page must anchor at object(oid:$oid), never HEAD') + // pending_scopes = the deterministic (depth, scope_path_hash) ordering + const members = await sql`select s.id, s.depth, s.scope_path_hash + from ownership_scope_versions v join ownership_scopes s on s.id = v.scope_id + where v.repository_id = ${t.repoId} and v.calculation_version = 1 + order by s.depth, s.scope_path_hash` + check(JSON.stringify(st?.pending_scopes) === JSON.stringify(members.map((m) => m.id)), + 'E1: pending_scopes must equal membership ordered by (depth, scope_path_hash)') + } + + // ── E2: differential — work state projects exactly to the reducer ──────── + { + const nodes = syntheticNodes() + for (const pageSize of [1, 37, 100, 5000]) { + const tt = await tenant() + const expected = reduceHistoryToMonthly({ repositoryId: tt.repoId, scope: { id: 'repo' }, nodes }) + const r = await drive(tt, makeTransport({ nodes, pageSize })) + check(r.status === SLICE_STATUS.RESOLVE_READY, `E2[${pageSize}]: expected resolve-ready, got ${r.status}`) + const work = await workRows(tt) + const projected = projectWorkToMonthly(work) + check(JSON.stringify(projected.months) === JSON.stringify(expected.months), + `E2[${pageSize}]: projected months must equal the reducer's months`) + const expectedKeys = [...expected.contributor_candidates.map((c) => c.contributor_key)].sort() + check(JSON.stringify([...projected.represented].sort()) === JSON.stringify(expectedKeys), + `E2[${pageSize}]: represented key set must equal the reducer's`) + for (const c of expected.contributor_candidates) { + if (!/^[0-9a-f]{64}$/.test(c.contributor_key)) continue + const b = projected.candidateBounds.get(c.contributor_key) + check(b?.first_seen_at?.slice(0, 19) === String(c.first_seen_at).slice(0, 19) + && b?.last_seen_at?.slice(0, 19) === String(c.last_seen_at).slice(0, 19) + && b?.is_bot === c.is_bot, + `E2[${pageSize}]: candidate bounds must match for ${c.contributor_key.slice(0, 8)}`) + } + } + } + + // ── E3: restart between every page — identical accumulation ────────────── + // Both runs share one tenant so repo-scoped work_keys are comparable: + // walk to completion, canonical-abandon the version, rebuild the SAME + // node stream — the restarted counters must be identical. + { + const t = await tenant() + const nodes = syntheticNodes().slice(0, 220) + await drive(t, makeTransport({ nodes })) + const p1 = projectWorkToMonthly(await workRows(t)) + await sql`select own_abandon_build(${t.uid}, ${t.repoId}, 1)` + const r = await drive(t, makeTransport({ nodes, pageSize: 10 }), { costBudget: 1 }) + check(r.status === SLICE_STATUS.RESOLVE_READY, `E3: restarted walk must finish, got ${r.status}`) + const p2 = projectWorkToMonthly(await workRows(t)) + check(JSON.stringify(p1.months) === JSON.stringify(p2.months), + 'E3: restarted accumulation must equal the single-shot result') + } + + // ── E4/E5: stale replay + wrong-version on the real CAS ────────────────── + { + const t = await tenant() + const transport = makeTransport({ nodes: syntheticNodes().slice(0, 60), pageSize: 20 }) + await run(t, transport, { costBudget: 1 }) // exactly one committed page + const before = await workRows(t) + const st = await syncRow(t) + const scopeId = st.history_cursor.current.scope_id + const r1 = await sql`select own_checkpoint_page(${t.uid}, ${t.repoId}, 1, ${scopeId}, + 'accumulate', null, 'idx:20', '[]'::jsonb, '[]'::jsonb, false)` + check(r1[0].own_checkpoint_page === 'stale', 'E4: replayed page with stale cursor must return stale') + const after = await workRows(t) + check(JSON.stringify(after) === JSON.stringify(before), 'E4: stale replay must not double-count') + const r2 = await sql`select own_checkpoint_page(${t.uid}, ${t.repoId}, 99, ${scopeId}, + 'accumulate', 'idx:20', 'idx:40', '[]'::jsonb, '[]'::jsonb, false)` + check(r2[0].own_checkpoint_page === 'stale', 'E5: wrong version must return stale') + } + + // ── E6: checkpoint failure mid-walk — no partial delta ─────────────────── + { + const t = await tenant() + const db = makePgOwnershipDb(sql) + let n = 0 + const orig = db.rpc.bind(db) + db.rpc = async (fn, args) => { + if (fn === 'own_checkpoint_page' && n++ === 1) throw new Error('simulated tx abort') + return orig(fn, args) + } + const r = await runAccumulateSlice({ + db, transport: makeTransport({ nodes: syntheticNodes(), pageSize: 50 }), + repositoryId: t.repoId, env: env(t), + }) + check(r.status === SLICE_STATUS.ERROR && r.code === 'OWNERSHIP_INTERNAL_ERROR', + `E6: expected error/OWNERSHIP_INTERNAL_ERROR, got ${r.status}/${r.code ?? 'none'}`) + const st = await syncRow(t) + check(st?.phase === 'error', 'E6: phase must be error after checkpoint failure') + const work = await workRows(t) + check(work.length > 0, 'E6: page-1 counters must remain committed') + check(st.history_cursor.current.cursor === 'idx:50', + 'E6: durable cursor must rest at the last COMMITTED page') + } + + // ── E7: pinned object vanished — retry once, fail closed, pin preserved ── + { + const t = await tenant() + const transport = makeTransport({ nodes: syntheticNodes().slice(0, 10) }) + let historyCalls = 0 + const orig = transport.graphql + transport.graphql = async (q, v) => { + if (q.includes('history(first')) { + historyCalls++ + return { repository: { object: null }, rateLimit: { cost: 1, remaining: 5000, resetAt: 'x' } } + } + return orig(q, v) + } + const r = await run(t, transport) + check(r.status === SLICE_STATUS.ERROR && r.code === 'OWNERSHIP_HISTORY_FAILED', + `E7: expected fail-closed error, got ${r.status}/${r.code ?? 'none'}`) + check(historyCalls === 2, `E7: exactly one retry expected, got ${historyCalls}`) + const st = await syncRow(t) + check(st?.phase === 'error' && st?.history_cursor?.pinned_oid === OID, + 'E7: pin must be preserved and phase must be error — never repin mid-build') + } + + // ── E8: lease CAS on real Postgres ──────────────────────────────────────── + { + const t = await tenant() + await run(t, makeTransport({ nodes: [] }), { budgetMs: 0 }) + const st = await syncRow(t) + const cur = { ...st.history_cursor, lease: { owner: 'foreign', expires: new Date(Date.now() + 60000).toISOString() } } + await sql`update ownership_sync_state set history_cursor = ${sql.json(cur)} + where repository_id = ${t.repoId}` + const r1 = await run(t, makeTransport(), { workerId: 'other' }) + check(r1.status === SLICE_STATUS.LOCKED, `E8: live foreign lease must win, got ${r1.status}`) + const cur2 = { ...cur, lease: { owner: 'foreign', expires: new Date(Date.now() - 1000).toISOString() } } + await sql`update ownership_sync_state set history_cursor = ${sql.json(cur2)} + where repository_id = ${t.repoId}` + const r2 = await run(t, makeTransport(), { workerId: 'other', budgetMs: 0 }) + check(r2.status !== SLICE_STATUS.LOCKED, `E8: expired lease must be taken, got ${r2.status}`) + } + + // ── E9: invalid durable state -> canonical abandon ─────────────────────── + { + const t = await tenant() + await run(t, makeTransport({ nodes: [] }), { budgetMs: 0 }) + const st = await syncRow(t) + const bogus = { ...st.history_cursor, current: { scope_id: crypto.randomUUID(), pass: 'accumulate', cursor: null } } + await sql`update ownership_sync_state set history_cursor = ${sql.json(bogus)} + where repository_id = ${t.repoId}` + const r = await run(t, makeTransport()) + check(r.status === SLICE_STATUS.ABANDONED, `E9: expected abandoned, got ${r.status}`) + const after = await syncRow(t) + check(after?.building_version === null && after?.phase === 'pending', + 'E9: abandon must clear the building version') + check((await workRows(t)).length === 0, 'E9: abandon must purge ephemeral counters') + } + + // ── E10: no identity field anywhere in persisted ownership state ───────── + { + const t = await tenant() + const nodes = syntheticNodes() + await drive(t, makeTransport({ nodes })) + const st = await syncRow(t) + const cursorText = JSON.stringify(st?.history_cursor ?? {}) + for (const needle of ['engineuser', 'pgbot', 'U_pg', 'github_node_id', 'display_login', 'login']) { + check(!cursorText.includes(needle), `E10: history_cursor leaked ${needle}`) + } + const leaked = await sql`select count(*)::int c from ownership_history_work + where repository_id = ${t.repoId} and (work_key like 'U_%' or work_key like '%login%')` + check(leaked[0].c === 0, 'E10: work_key must be opaque — no plaintext identity') + const bw = await sql`select count(*)::int c from ownership_contributor_build_work where repository_id = ${t.repoId}` + check(bw[0].c === 0, 'E10: identity staging must be empty during ACCUMULATE') + } + + // every proof tenant self-destructs — user deletion cascades through + // repositories into every ownership table, leaving the seeded fixture + // untouched for the backup/restore leg + await sql`delete from users where id = any(${tenantIds})` + + return problems +} diff --git a/scripts/ownership-pg-db.mjs b/scripts/ownership-pg-db.mjs new file mode 100644 index 0000000..8b01adf --- /dev/null +++ b/scripts/ownership-pg-db.mjs @@ -0,0 +1,93 @@ +// Direct-Postgres ownership db adapter for the ACCUMULATE engine. +// +// Used by the recovery-drill / proof harness to run the real engine against +// the real own_* functions — no PostgREST. jsonb columns receive +// sql.json()-encoded values; guarded updates use IS NULL / equality +// predicates identical to the supabase adapter's match semantics. + +export function makePgOwnershipDb(sql) { + return { + async loadRepo(repositoryId) { + const rows = await sql` + select id, user_id, owner_login, name, installation_id + from repositories where id = ${repositoryId}` + return rows[0] ?? null + }, + async ensureSyncRow(userId, repositoryId) { + await sql` + insert into ownership_sync_state (user_id, repository_id, phase) + values (${userId}, ${repositoryId}, 'pending') + on conflict (user_id, repository_id) do nothing` + }, + async loadSync(userId, repositoryId) { + const rows = await sql` + select * from ownership_sync_state + where user_id = ${userId} and repository_id = ${repositoryId}` + return rows[0] ?? null + }, + async rpc(fn, args) { + const keys = Object.keys(args) + const params = keys.map((k) => args[k]) + const placeholders = keys.map((_, i) => `$${i + 1}`).join(',') + const rows = await sql.unsafe(`select ${fn}(${placeholders}) as r`, params) + return rows[0]?.r + }, + async upsertScopes(rows) { + if (!rows.length) return [] + return sql` + insert into ownership_scopes ${sql(rows)} + on conflict (repository_id, scope_path_hash) + do update set depth = excluded.depth + returning id, scope_path_hash, depth` + }, + async insertMembership(rows) { + if (!rows.length) return + await sql` + insert into ownership_scope_versions ${sql(rows)} + on conflict (scope_id, calculation_version) do nothing` + }, + async membershipFor(userId, repositoryId, version) { + return sql` + select scope_id, excluded from ownership_scope_versions + where user_id = ${userId} and repository_id = ${repositoryId} + and calculation_version = ${version}` + }, + async loadScope(scopeId) { + const rows = await sql` + select id, display_path, scope_type from ownership_scopes where id = ${scopeId}` + return rows[0] ?? null + }, + async casSync({ userId, repositoryId, match = {}, patch }) { + // jsonb-typed values go through sql.json so the driver encodes them + // structurally; scalar columns pass through as plain params + const sets = Object.fromEntries( + Object.entries(patch).map(([k, v]) => [ + k, + v !== null && typeof v === 'object' ? sql.json(v) : v, + ]), + ) + const conds = [] + if ('building_version' in match) { + conds.push( + match.building_version == null + ? sql`building_version is null` + : sql`building_version = ${match.building_version}`, + ) + } + if ('history_cursor' in match) { + conds.push( + match.history_cursor == null + ? sql`history_cursor is null` + : sql`history_cursor = ${sql.json(match.history_cursor)}`, + ) + } + if (!conds.length) throw new Error('casSync requires a match predicate') + const where = conds.reduce((a, c) => sql`${a} and ${c}`) + const rows = await sql` + update ownership_sync_state set ${sql(sets)} + where user_id = ${userId} and repository_id = ${repositoryId} and ${where} + returning user_id` + return rows.length > 0 + }, + } +} diff --git a/scripts/ownership-work-projection.mjs b/scripts/ownership-work-projection.mjs new file mode 100644 index 0000000..465b5ad --- /dev/null +++ b/scripts/ownership-work-projection.mjs @@ -0,0 +1,109 @@ +// Test-side projection of accumulated ownership_history_work rows into the +// canonical reduceHistoryToMonthly output shape — the pure comparison the +// checkpoint architecture's differential harness requires (§11: "Where an +// exact finalized result cannot yet exist because RESOLVE/COMMIT are +// intentionally later, a pure projection from work state ... compare it to +// the canonical reducer"). +// +// TEST TOOLING ONLY — nothing here is production state. The fold is +// reimplemented deliberately: drift between this projection and the +// canonical reducer is exactly what the differential catches. +// +// rows: ownership_history_work rows {period_start, work_key, is_bot, +// touch_commits, first_seen_at, last_seen_at} +// returns { months, represented, candidateBounds } where months matches +// reduceHistoryToMonthly's month entries and represented is the +// individually-represented linked-key union. + +import { TOP_LINKED_PER_PERIOD, shareOf } from '../lib/ownership/history-reducer.mjs' +import { OTHER_KEY, UNLINKED_KEY } from '../lib/ownership/contributors.mjs' + +const isHexKey = (s) => /^[0-9a-f]{64}$/.test(s) + +// pg drivers return timestamptz columns as Date objects — normalize to ISO +// strings so periods group correctly and reducer comparisons are exact. +const iso = (v) => (v instanceof Date ? v.toISOString() : v) + +export function projectWorkToMonthly(rows) { + const byPeriod = new Map() + const bounds = new Map() // work_key -> {first,last,is_bot} + for (const r of rows) { + const period = iso(r.period_start) + const first = iso(r.first_seen_at) + const last = iso(r.last_seen_at) + let p = byPeriod.get(period) + if (!p) { + p = { total: 0, unlinked: 0, linked: [], human: new Set() } + byPeriod.set(period, p) + } + p.total += r.touch_commits + if (r.work_key === UNLINKED_KEY) { + p.unlinked += r.touch_commits + continue + } + p.linked.push({ ...r, period_start: period, first_seen_at: first, last_seen_at: last }) + if (!r.is_bot) p.human.add(r.work_key) + const b = bounds.get(r.work_key) + bounds.set(r.work_key, { + is_bot: r.is_bot, + first_seen_at: b ? (b.first_seen_at < first ? b.first_seen_at : first) : first, + last_seen_at: b ? (b.last_seen_at > last ? b.last_seen_at : last) : last, + }) + } + + const months = [] + const represented = new Set() + for (const [period, m] of [...byPeriod.entries()].sort(([a], [b]) => (a < b ? -1 : 1))) { + const ranked = [...m.linked].sort((a, b) => + b.touch_commits !== a.touch_commits + ? b.touch_commits - a.touch_commits + : a.work_key < b.work_key ? -1 : a.work_key > b.work_key ? 1 : 0) + const top = ranked.slice(0, TOP_LINKED_PER_PERIOD) + const overflow = ranked.slice(TOP_LINKED_PER_PERIOD) + const contributors = top.map((c) => { + represented.add(c.work_key) + return { + contributor_key: c.work_key, + identity_kind: 'linked', + is_bot: c.is_bot, + touch_commits: c.touch_commits, + touch_share: shareOf(c.touch_commits, m.total), + } + }) + if (m.unlinked > 0) { + represented.add(UNLINKED_KEY) + contributors.push({ + contributor_key: UNLINKED_KEY, + identity_kind: 'unlinked', + is_bot: false, + touch_commits: m.unlinked, + touch_share: shareOf(m.unlinked, m.total), + }) + } + if (overflow.length > 0) { + represented.add(OTHER_KEY) + const t = overflow.reduce((n, c) => n + c.touch_commits, 0) + contributors.push({ + contributor_key: OTHER_KEY, + identity_kind: 'aggregate', + is_bot: false, + touch_commits: t, + touch_share: shareOf(t, m.total), + }) + } + months.push({ + period_start: period, + totals: { + total_touch_commits: m.total, + human_contributor_count: m.human.size, + unlinked_touch_share: shareOf(m.unlinked, m.total), + }, + contributors, + }) + } + + const candidateBounds = new Map( + [...represented].filter(isHexKey).map((k) => [k, bounds.get(k)]), + ) + return { months, represented, candidateBounds } +} diff --git a/scripts/recovery-drill.mjs b/scripts/recovery-drill.mjs index c4f8b4d..1e527a5 100644 --- a/scripts/recovery-drill.mjs +++ b/scripts/recovery-drill.mjs @@ -23,6 +23,7 @@ import { loadOperatorEnv, pgEnv, isLocalHost } from './operator-env.mjs' import { runPgTool } from './pg-tools.mjs' import { BACKUP_TABLES, EPHEMERAL_TABLES } from './backup-scope.mjs' import { checkpointingProofs } from './ownership-checkpoint-proofs.mjs' +import { engineProofs } from './ownership-engine-proofs.mjs' const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') const BACKED_UP = BACKUP_TABLES @@ -802,8 +803,9 @@ async function main() { Object.assign(ids, seeded) const oProblems = await ownershipProofs(src, seeded) const cProblems = oProblems.length ? [] : await checkpointingProofs(src) + const eProblems = cProblems.length ? [] : await engineProofs(src) await src.end() - const schemaProblems = [...oProblems, ...cProblems] + const schemaProblems = [...oProblems, ...cProblems, ...eProblems] if (schemaProblems.length) { for (const p of schemaProblems) console.error(` FAIL ${p}`) fail(`${schemaProblems.length} ownership schema proof(s) failed`) @@ -816,6 +818,9 @@ async function main() { console.log(' isolation (M–O), canonical abandon (P–Q), restart staging (R–T), exact-set') console.log(' promotion (U–W), retired-version lifecycle gate (X–AA), checkpoint CAS/replay,') console.log(' stale/orphan sweep, work-key domain and privacy shape guards') + console.log('drill: engine proofs passed — pinned end-to-end ACCUMULATE, reducer differential,') + console.log(' restart equivalence, stale/replay CAS, checkpoint-failure, pin-loss fail-closed,') + console.log(' lease CAS, canonical abandon, and zero-identity persistence') // same backup path the operator uses const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'dl-drill-')) diff --git a/test/ownership-accumulate.test.mjs b/test/ownership-accumulate.test.mjs new file mode 100644 index 0000000..db53d61 --- /dev/null +++ b/test/ownership-accumulate.test.mjs @@ -0,0 +1,683 @@ +// Phase 1C-B2 — ACCUMULATE engine unit tests. +// +// The engine is driven against an in-memory db adapter that implements the +// SAME compare-and-set semantics as own_checkpoint_page/own_build_init +// (the real functions are proven on PostgreSQL in the drill). The +// differential harness proves: every GraphQL page partition of one node +// stream accumulates to work-state that projects to exactly the canonical +// reducer's result. + +import test from 'node:test' +import assert from 'node:assert/strict' +import crypto from 'node:crypto' +import { readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { dirname, join } from 'node:path' + +import { reduceHistoryToMonthly } from '../lib/ownership/history-reducer.mjs' +import { contributorKey, UNLINKED_KEY } from '../lib/ownership/contributors.mjs' +import { reducePageDeltas } from '../lib/ownership/page-deltas.mjs' +import { + runAccumulateSlice, + isResumable, + SLICE_STATUS, + RATE_LIMIT_RESERVE, +} from '../lib/ownership/accumulate.mjs' +import { + pinnedHistoryPageQuery, + pinnedObjectQuery, + ownershipHeadProbeQuery, + assertOwnershipQuerySafe, + assertOwnershipResponseSafe, + OwnershipResponseContractError, +} from '../lib/ownership/queries.mjs' +import { RateLimitError, GitHubAuthError } from '../lib/github.mjs' +import { projectWorkToMonthly } from '../scripts/ownership-work-projection.mjs' + +const HERE = dirname(fileURLToPath(import.meta.url)) +const REPO = '11111111-1111-1111-1111-111111111111' +const USER = '22222222-2222-2222-2222-222222222222' +const OID = 'a'.repeat(40) +const ENV = { OWNERSHIP_INGEST_ENABLED: 'true', OWNERSHIP_CANARY_REPOSITORY_IDS: REPO } + +const isKey = (s) => /^[0-9a-f]{64}$/.test(s) +const node = (date, user) => ({ committedDate: date, author: { user: user ?? null } }) +const nd = (id, login, date) => node(date, { id: `U_${id}`, login }) + +// ── in-memory db adapter ───────────────────────────────────────────────── +// Faithful CAS/upsert semantics for the ACCUMULATE surface: the checkpoint +// compares (building_version, scope, pass, expected_cursor) BEFORE any +// write and commits the page + cursor atomically — a replay with a stale +// expected cursor is 'stale' and writes nothing. +function makeMemDb({ repoRow } = {}) { + const repo = repoRow ?? { + id: REPO, user_id: USER, owner_login: 'acme', name: 'widget', installation_id: 7, + } + const state = { + repo, + sync: null, + scopes: new Map(), // scopeId -> row + scopeIds: new Map(), // hash -> scopeId + membership: new Map(), // `${scopeId}|${v}` -> row + work: new Map(), // `${v}|${scope}|${period}|${key}` -> row + calls: { checkpoint: 0, checkpointArgs: [] }, + failNext: new Map(), // fn -> error to throw once + } + const key = (v, s, p, k) => `${v}|${s}|${p}|${k}` + + const db = { + _state: state, + failOnce(fn, err) { state.failNext.set(fn, err) }, + workRows() { return [...state.work.values()].map((r) => ({ ...r })) }, + + async loadRepo() { return state.repo }, + async ensureSyncRow() { + state.sync ??= { + user_id: USER, repository_id: REPO, phase: 'pending', error: null, + active_version: null, building_version: null, + history_cursor: null, pending_scopes: null, + updated_at: new Date().toISOString(), + } + }, + async loadSync() { return state.sync ? { ...state.sync } : null }, + async rpc(fn, args) { + const err = state.failNext.get(fn) + if (err) { state.failNext.delete(fn); throw err } + switch (fn) { + case 'own_build_preflight': + if (!state.sync) return 'stale' + return state.sync.building_version != null ? 'blocked' : 'ready' + case 'own_build_init': { + if (!state.sync) return 'stale' + if (state.sync.building_version != null) return 'blocked' + state.sync.building_version = args.p_new_version + state.sync.phase = 'history' + state.sync.error = null + state.sync.pending_scopes = null + state.sync.history_cursor = { + pinned_oid: args.p_pinned_oid, + current: null, + build_started_at: new Date().toISOString(), + } + return 'initialized' + } + case 'own_abandon_build': { + if (!state.sync) return 'stale' + const V = args.p_version + if (state.sync.building_version !== V) return 'no-active-build' + for (const k of [...state.work.keys()]) if (k.startsWith(`${V}|`)) state.work.delete(k) + for (const k of [...state.membership.keys()]) if (k.endsWith(`|${V}`)) state.membership.delete(k) + state.sync.building_version = null + state.sync.phase = 'pending' + state.sync.history_cursor = null + state.sync.pending_scopes = null + return 'abandoned' + } + case 'own_checkpoint_page': { + state.calls.checkpoint += 1 + state.calls.checkpointArgs.push(structuredClone(args)) + const st = state.sync + if (!st || st.building_version !== args.p_version || st.phase !== 'history') return 'stale' + const cur = st.history_cursor?.current + if (!cur || cur.scope_id !== args.p_scope || cur.pass !== args.p_pass) return 'stale' + if ((cur.cursor ?? null) !== (args.p_expected_cursor ?? null)) return 'stale' + // additive upsert — same row exists => sums merge + for (const d of args.p_deltas) { + const k = key(args.p_version, args.p_scope, d.period_start, d.work_key) + const prev = state.work.get(k) + state.work.set(k, prev + ? { + ...prev, + touch_commits: prev.touch_commits + d.touches, + first_seen_at: prev.first_seen_at < d.first_seen_at ? prev.first_seen_at : d.first_seen_at, + last_seen_at: prev.last_seen_at > d.last_seen_at ? prev.last_seen_at : d.last_seen_at, + } + : { + calculation_version: args.p_version, scope_id: args.p_scope, + period_start: d.period_start, work_key: d.work_key, + is_bot: d.is_bot, touch_commits: d.touches, + first_seen_at: d.first_seen_at, last_seen_at: d.last_seen_at, + }) + } + if (args.p_last_page) { + st.history_cursor.current = { scope_id: args.p_scope, pass: 'resolve', cursor: null } + } else { + st.history_cursor.current = { ...cur, cursor: args.p_next_cursor } + } + return 'applied' + } + default: + throw new Error(`mem db: unhandled rpc ${fn}`) + } + }, + async upsertScopes(rows) { + const out = [] + for (const r of rows) { + let id = state.scopeIds.get(r.scope_path_hash) + if (!id) { + id = crypto.randomUUID() + state.scopeIds.set(r.scope_path_hash, id) + state.scopes.set(id, { ...r, id }) + } + out.push({ id, scope_path_hash: r.scope_path_hash, depth: r.depth }) + } + return out + }, + async insertMembership(rows) { + for (const r of rows) { + state.membership.set(`${r.scope_id}|${r.calculation_version}`, r) + } + }, + async membershipFor(_u, _r, version) { + return [...state.membership.values()].filter((m) => m.calculation_version === version) + }, + async loadScope(scopeId) { return state.scopes.get(scopeId) ?? null }, + async casSync({ userId, repositoryId, match = {}, patch }) { + const st = state.sync + if (!st) return false + if ('building_version' in match) { + if ((match.building_version ?? null) !== (st.building_version ?? null)) return false + } + if ('history_cursor' in match) { + const a = JSON.stringify(match.history_cursor ?? null) + const b = JSON.stringify(st.history_cursor ?? null) + if (a !== b) return false + } + Object.assign(st, structuredClone(patch)) + return true + }, + } + return db +} + +// ── fake transport ──────────────────────────────────────────────────────── +// Serves the three audited queries off a synthetic node stream partitioned +// by opaque index cursors — the same stream a real history(path:) walk +// would produce. calls[] records every query+vars for static assertions. +function makeTransport({ nodes = [], tree = [], pinnedOid = OID, pageSize = 100, remaining = 5000, cost = 1 } = {}) { + const calls = [] + const rl = () => ({ cost, remaining, resetAt: '2026-01-01T00:00:00Z' }) + return { + calls, + async graphql(query, vars) { + calls.push({ query, vars }) + if (query.includes('defaultBranchRef')) { + return { repository: { defaultBranchRef: { target: { oid: pinnedOid } } }, rateLimit: rl() } + } + if (query.includes('tree {')) { + return { + repository: { object: { __typename: 'Commit', oid: vars.oid, tree: { oid: 'b'.repeat(40) } } }, + rateLimit: rl(), + } + } + // pinned history page — object(oid:) mismatch yields a null object + if (vars.oid !== pinnedOid) return { repository: { object: null }, rateLimit: rl() } + const start = vars.cursor == null ? 0 : Number(vars.cursor.split(':')[1]) + const end = Math.min(start + pageSize, nodes.length) + return { + repository: { + object: { + __typename: 'Commit', oid: vars.oid, + history: { + pageInfo: { hasNextPage: end < nodes.length, endCursor: `idx:${end}` }, + nodes: nodes.slice(start, end), + }, + }, + }, + rateLimit: rl(), + } + }, + async getTree() { return { tree, truncated: false } }, + } +} + +const run = (db, transport, opts = {}) => + runAccumulateSlice({ db, transport, repositoryId: REPO, env: ENV, ...opts }) + +// drive slices until a terminal state (budget = 1 page per slice => resume) +async function driveToTerminal(db, transport, opts = {}) { + const seen = [] + for (let i = 0; i < 500; i++) { + const r = await run(db, transport, opts) + seen.push(r.status) + if (r.status === SLICE_STATUS.RESOLVE_READY || r.status === SLICE_STATUS.SCOPES_DRAINED) { + return { status: r.status, history: seen, last: r } + } + if ([SLICE_STATUS.ERROR, SLICE_STATUS.ABANDONED, SLICE_STATUS.STALE, SLICE_STATUS.LOCKED].includes(r.status)) { + return { status: r.status, history: seen, last: r } + } + } + throw new Error('did not reach a terminal state') +} + +// a deterministic synthetic stream: multiple months, ties at the Top-25 +// boundary, bots, unlinked nodes, heavy repeat contributors +function syntheticNodes() { + const nodes = [] + const months = ['2025-06', '2025-07', '2025-08'] + for (let m = 0; m < months.length; m++) { + for (let c = 0; c < 30; c++) { + const touches = c < 5 ? 40 - m : c === 24 || c === 25 || c === 26 ? 7 : 2 + ((c + m) % 9) + for (let t = 0; t < touches; t++) { + nodes.push(nd(`c${c}`, `user${c}`, `${months[m]}-${String(1 + (t % 27)).padStart(2, '0')}T12:00:00Z`)) + } + } + for (let t = 0; t < 5; t++) nodes.push(nd(`bot${m}`, `deploy[bot]`, `${months[m]}-05T08:00:00Z`)) + for (let t = 0; t < 9; t++) nodes.push(node(`${months[m]}-15T09:00:00Z`, null)) + } + return nodes +} + +// ══ pure page-delta reducer ══════════════════════════════════════════════ + +test('page-delta reduction groups (period x work_key) with correct bounds', () => { + const nodes = [ + nd('a1', 'alice', '2025-06-03T10:00:00Z'), + nd('a1', 'alice', '2025-06-20T08:00:00Z'), + nd('a1', 'alice', '2025-06-01T09:00:00Z'), + nd('b2', 'bot[bot]', '2025-06-05T00:00:00Z'), + node('2025-06-07T00:00:00Z', null), + nd('a1', 'alice', '2025-07-02T00:00:00Z'), + ] + const d = reducePageDeltas(REPO, nodes) + assert.equal(d.length, 4) + const aliceJun = d.find((x) => x.work_key === contributorKey(REPO, 'U_a1') && x.period_start === '2025-06-01T00:00:00.000Z') + assert.equal(aliceJun.touches, 3) + assert.equal(aliceJun.is_bot, false) + assert.equal(aliceJun.first_seen_at, '2025-06-01T09:00:00Z') + assert.equal(aliceJun.last_seen_at, '2025-06-20T08:00:00Z') + const bot = d.find((x) => x.work_key === contributorKey(REPO, 'U_b2')) + assert.equal(bot.is_bot, true) + const unl = d.find((x) => x.work_key === UNLINKED_KEY) + assert.equal(unl.touches, 1) + const aliceJul = d.find((x) => x.work_key === contributorKey(REPO, 'U_a1') && x.period_start === '2025-07-01T00:00:00.000Z') + assert.equal(aliceJul.touches, 1) +}) + +test('page-delta keys are pseudonymous and deterministic', () => { + const d = reducePageDeltas(REPO, [nd('a1', 'alice', '2025-06-03T10:00:00Z')]) + assert.equal(d[0].work_key, contributorKey(REPO, 'U_a1')) + assert.ok(isKey(d[0].work_key)) +}) + +test('checkpoint delta payload contains no plaintext identity (hard guard)', () => { + const nodes = [nd('a1', 'alice-pii', '2025-06-03T10:00:00Z'), nd('b2', 'bob-secret', '2025-06-04T10:00:00Z')] + const payload = JSON.stringify(reducePageDeltas(REPO, nodes)) + for (const forbidden of ['U_a1', 'U_b2', 'alice-pii', 'bob-secret', 'github_node_id', 'display_login', 'login', 'email', 'message', 'oid']) { + assert.ok(!payload.includes(forbidden), `delta payload leaked ${forbidden}`) + } + const fields = new Set(Object.keys(JSON.parse(payload)[0]).sort()) + assert.deepEqual([...fields], ['first_seen_at', 'is_bot', 'last_seen_at', 'period_start', 'touches', 'work_key']) +}) + +test('malformed nodes fail closed', () => { + assert.throws(() => reducePageDeltas(REPO, [{ author: {} }]), TypeError) + assert.throws(() => reducePageDeltas(REPO, [node('2025-06-01T00:00:00Z', { id: 42, login: 'x' })])) + assert.throws(() => reducePageDeltas(REPO, [node('2025-06-01T00:00:00Z', { id: 'U_x', login: 5 })])) +}) + +// ══ pinned query contract ════════════════════════════════════════════════ + +test('every history page is anchored at object(oid:$oid) with Commit verification', () => { + const q = pinnedHistoryPageQuery() + assert.ok(q.includes('object(oid: $oid)')) + assert.ok(q.includes('__typename')) + assert.ok(q.includes('oid')) + assert.ok(q.includes('history(first: 100, path: $path, after: $cursor)')) + assert.ok(!q.includes('defaultBranchRef')) + assert.ok(!q.includes('expression:')) + assertOwnershipQuerySafe(q) +}) + +test('pin/object queries pass the field audit', () => { + assertOwnershipQuerySafe(pinnedObjectQuery()) + assertOwnershipQuerySafe(ownershipHeadProbeQuery()) +}) + +test('accumulate engine never queries defaultBranchRef for build pages', () => { + const src = readFileSync(join(HERE, '..', 'lib', 'ownership', 'accumulate.mjs'), 'utf8') + assert.ok(src.includes('pinnedHistoryPageQuery')) + assert.ok(!src.includes('historyPathQuery')) + // the only defaultBranchRef reference is inside the HEAD probe call site + assert.equal(src.split('ownershipHeadProbeQuery').length - 1 >= 2, true) + const q = pinnedHistoryPageQuery() + assert.ok(!/defaultBranchRef|expression\s*:/.test(q)) +}) + +test('response contract tripwire rejects forbidden keys', () => { + assert.throws( + () => assertOwnershipResponseSafe({ repository: { object: { message: 'x' } } }), + OwnershipResponseContractError, + ) + assert.throws( + () => assertOwnershipResponseSafe({ a: [{ b: [{ email: 'x@y.z' }] }] }), + OwnershipResponseContractError, + ) + assertOwnershipResponseSafe({ repository: { object: { __typename: 'Commit', oid: OID } } }) +}) + +// ══ build start / resume flow ════════════════════════════════════════════ + +test('fresh build: preflight -> pin -> init -> prep -> accumulate -> resolve-ready', async () => { + const db = makeMemDb() + const t = makeTransport({ nodes: syntheticNodes(), pageSize: 100 }) + const r = await driveToTerminal(db, t) + assert.equal(r.status, SLICE_STATUS.RESOLVE_READY) + const sync = await db.loadSync() + assert.equal(sync.building_version, 1) + assert.equal(sync.phase, 'history') + assert.equal(sync.history_cursor.pinned_oid, OID) + assert.equal(sync.history_cursor.current.pass, 'resolve') + // every history page used the pinned oid — no defaultBranchRef pages + const pages = t.calls.filter((c) => c.query.includes('history(first')) + assert.ok(pages.length >= 1) + for (const c of pages) { + assert.equal(c.vars.oid, OID) + assert.ok(c.query.includes('object(oid: $oid)')) + assert.ok(!c.query.includes('defaultBranchRef')) + } +}) + +test('scope ordering: repo scope first, pending ordered by depth+hash', async () => { + const db = makeMemDb() + const tree = [ + { path: 'src/a.ts', type: 'blob' }, { path: 'src/lib/b.ts', type: 'blob' }, + { path: 'docs/x.md', type: 'blob' }, { path: 'node_modules/pkg/i.js', type: 'blob' }, + { path: '.gitmodules', type: 'blob' }, + ] + const t = makeTransport({ nodes: [], tree }) + // first slice stops at membership+first-scope; inspect the durable state + const r = await run(db, t, { budgetMs: 0 }) + // with zero budget the loop exits before the first page — state must still + // show repo scope current at accumulate + const sync = await db.loadSync() + assert.ok([SLICE_STATUS.SLICE_BUDGET, SLICE_STATUS.RESOLVE_READY].includes(r.status)) + const cur = sync.history_cursor?.current + assert.ok(cur) + assert.equal(cur.pass, 'accumulate') + const repoScope = await db.loadScope(cur.scope_id) + assert.equal(repoScope.scope_type, 'repo') + // membership is exactly the candidate set — vendor paths never persisted + const members = await db.membershipFor(USER, REPO, 1) + const paths = [...db._state.scopes.values()].map((s) => s.display_path) + assert.ok(!paths.includes('node_modules')) + assert.ok(members.length >= 2) + // pending order = (depth, scope_path_hash) + const ordered = [...db._state.scopes.values()] + .sort((a, b) => (a.depth !== b.depth ? a.depth - b.depth : a.scope_path_hash < b.scope_path_hash ? -1 : 1)) + .map((s) => s.id) + assert.deepEqual(sync.pending_scopes, ordered) +}) + +test('resume: second slice picks up the durable cursor with no double-count', async () => { + const nodes = syntheticNodes() + const db1 = makeMemDb() + const t1 = makeTransport({ nodes }) + await driveToTerminal(db1, t1) + + const db2 = makeMemDb() + const t2 = makeTransport({ nodes }) + const first = await run(db2, t2, { costBudget: 1 }) // one page per slice + assert.equal(first.status, SLICE_STATUS.SLICE_BUDGET) + const r = await driveToTerminal(db2, t2, { costBudget: 1 }) + assert.equal(r.status, SLICE_STATUS.RESOLVE_READY) + + const p1 = projectWorkToMonthly(db1.workRows()) + const p2 = projectWorkToMonthly(db2.workRows()) + assert.deepEqual(p2.months, p1.months) +}) + +// ══ differential reducer equivalence ═════════════════════════════════════ + +test('differential: every page partition accumulates to the reducer result', async () => { + const nodes = syntheticNodes() + const expected = reduceHistoryToMonthly({ repositoryId: REPO, scope: { id: 's' }, nodes }) + for (const pageSize of [1000, 1, 7, 100, 333]) { + const db = makeMemDb() + const t = makeTransport({ nodes, pageSize }) + const r = await driveToTerminal(db, t) + assert.equal(r.status, SLICE_STATUS.RESOLVE_READY, `pageSize=${pageSize}`) + const projected = projectWorkToMonthly(db.workRows()) + assert.deepEqual(projected.months, expected.months, `pageSize=${pageSize} months`) + const expectedKeys = new Set(expected.contributor_candidates.map((c) => c.contributor_key)) + assert.deepEqual([...projected.represented].sort(), [...expectedKeys].sort(), `pageSize=${pageSize} keys`) + // candidate first/last-seen bounds match the reducer's seen bookkeeping + for (const c of expected.contributor_candidates) { + if (!isKey(c.contributor_key)) continue + const b = projected.candidateBounds.get(c.contributor_key) + assert.equal(b.first_seen_at, c.first_seen_at, `${c.contributor_key} first`) + assert.equal(b.last_seen_at, c.last_seen_at, `${c.contributor_key} last`) + assert.equal(b.is_bot, c.is_bot) + } + } +}) + +test('differential: adversarial partitions of an identical stream agree', async () => { + const nodes = syntheticNodes() + const runs = [] + for (const pageSize of [1, 3, 50, 100, 10000]) { + const db = makeMemDb() + const r = await driveToTerminal(db, makeTransport({ nodes, pageSize })) + assert.equal(r.status, SLICE_STATUS.RESOLVE_READY) + runs.push(projectWorkToMonthly(db.workRows()).months) + } + for (const m of runs.slice(1)) assert.deepEqual(m, runs[0]) +}) + +// ══ crash / replay matrix ════════════════════════════════════════════════ + +test('A: failure before the first request writes nothing', async () => { + const db = makeMemDb() + const t = makeTransport() + t.graphql = () => Promise.reject(new Error('network gone')) + const r = await run(db, t) + assert.equal(r.status, SLICE_STATUS.ERROR) + assert.equal(db.workRows().length, 0) + assert.equal((await db.loadSync()).building_version ?? null, null) +}) + +test('B: die between fetch and checkpoint — no counters, no cursor move', async () => { + const db = makeMemDb() + db.failOnce('own_checkpoint_page', new Error('process died')) + const r = await run(db, makeTransport({ nodes: syntheticNodes() })) + assert.equal(r.status, SLICE_STATUS.ERROR) + assert.equal(r.code, 'OWNERSHIP_INTERNAL_ERROR') + assert.equal(db.workRows().length, 0) + const cur = (await db.loadSync()).history_cursor.current + assert.equal(cur.pass, 'accumulate') + assert.equal(cur.cursor, null) +}) + +test('C+D: committed page replayed with the old cursor -> stale, no double-count', async () => { + const db = makeMemDb() + await run(db, makeTransport({ nodes: syntheticNodes().slice(0, 30), pageSize: 10 }), { costBudget: 1 }) + const before = db.workRows() + // replay the committed page with the now-stale expected cursor + const lastArgs = db._state.calls.checkpointArgs.at(-1) + const r = await db.rpc('own_checkpoint_page', { + ...lastArgs, + p_expected_cursor: lastArgs.p_expected_cursor, // old cursor + }) + assert.equal(r, 'stale') + assert.deepEqual(db.workRows(), before) +}) + +test('E+F: wrong expected cursor / wrong version write nothing', async () => { + const db = makeMemDb() + const t = makeTransport({ nodes: syntheticNodes().slice(0, 10) }) + await run(db, t, { costBudget: 1 }) + const sync = await db.loadSync() + const args = db._state.calls.checkpointArgs.at(-1) + const r1 = await db.rpc('own_checkpoint_page', { ...args, p_expected_cursor: 'bogus' }) + assert.equal(r1, 'stale') + const r2 = await db.rpc('own_checkpoint_page', { ...args, p_version: 99 }) + assert.equal(r2, 'stale') + assert.equal(sync.phase, 'history') +}) + +test('G+H: restart between every page completes identically', async () => { + const nodes = syntheticNodes().slice(0, 250) + const db1 = makeMemDb() + await driveToTerminal(db1, makeTransport({ nodes })) + const db2 = makeMemDb() + // fresh workerId + tiny budget each slice = a restart between pages + const r = await driveToTerminal(db2, makeTransport({ nodes, pageSize: 25 }), { + costBudget: 1, + get workerId() { return crypto.randomUUID() }, + }) + assert.equal(r.status, SLICE_STATUS.RESOLVE_READY) + assert.deepEqual(projectWorkToMonthly(db2.workRows()).months, projectWorkToMonthly(db1.workRows()).months) +}) + +test('I: rate-limit error before the page commits writes nothing', async () => { + const db = makeMemDb() + const t = makeTransport({ nodes: syntheticNodes() }) + let hits = 0 + const orig = t.graphql + t.graphql = async (q, v) => { + if (q.includes('history(first') && hits++ === 0) throw new RateLimitError('rate limit') + return orig(q, v) + } + const r = await run(db, t) + assert.equal(r.status, SLICE_STATUS.RATE_LIMITED) + assert.equal(db.workRows().length, 0) + const cur = (await db.loadSync()).history_cursor.current + assert.equal(cur.cursor, null) +}) + +test('J: rate-limit reserve stops the slice after a committed page; next slice resumes', async () => { + const nodes = syntheticNodes() + const db = makeMemDb() + const t = makeTransport({ nodes, pageSize: 100, remaining: RATE_LIMIT_RESERVE - 1 }) + const r = await run(db, t) + assert.equal(r.status, SLICE_STATUS.RATE_LIMITED) + assert.ok(db.workRows().length > 0) // page 1 committed before the reserve check + // next invocation with headroom resumes from the durable cursor + const t2 = makeTransport({ nodes, pageSize: 100 }) + const r2 = await driveToTerminal(db, t2) + assert.equal(r2.status, SLICE_STATUS.RESOLVE_READY) +}) + +test('K: checkpoint error leaves no partial delta and no cursor move', async () => { + const db = makeMemDb() + let calls = 0 + const origRpc = db.rpc.bind(db) + db.rpc = async (fn, args) => { + if (fn === 'own_checkpoint_page' && calls++ === 1) throw new Error('tx aborted') + return origRpc(fn, args) + } + const r = await run(db, makeTransport({ nodes: syntheticNodes(), pageSize: 50 })) + assert.equal(r.status, SLICE_STATUS.ERROR) + // exactly one page's worth of counters (first applied); the second did not + const keys = new Set(db.workRows().map((w) => w.work_key)) + assert.ok(keys.size > 0) + const sync = await db.loadSync() + assert.equal(sync.phase, 'error') + assert.equal(sync.error, 'OWNERSHIP_INTERNAL_ERROR') +}) + +// ══ pinned-object failure ════════════════════════════════════════════════ + +test('pinned object null -> retry once -> fail closed, never repin', async () => { + const db = makeMemDb() + const nodes = syntheticNodes().slice(0, 5) + const t = makeTransport({ nodes }) + let historyCalls = 0 + const orig = t.graphql + t.graphql = async (q, v) => { + if (q.includes('history(first')) { + historyCalls++ + return { repository: { object: null }, rateLimit: { cost: 1, remaining: 5000, resetAt: 'x' } } + } + return orig(q, v) + } + const r = await run(db, t) + assert.equal(r.status, SLICE_STATUS.ERROR) + assert.equal(r.code, 'OWNERSHIP_HISTORY_FAILED') + assert.equal(historyCalls, 2) // exactly one retry + const sync = await db.loadSync() + assert.equal(sync.phase, 'error') + assert.equal(sync.history_cursor.pinned_oid, OID) // still the original pin +}) + +// ══ lease / concurrency ══════════════════════════════════════════════════ + +test('live foreign lease wins the slice; expired lease is taken', async () => { + const db = makeMemDb() + const t = makeTransport({ nodes: syntheticNodes().slice(0, 3) }) + await run(db, t, { workerId: 'w1', budgetMs: 0 }) + // force the lease to look live-foreign + const sync = await db.loadSync() + sync.history_cursor.lease = { owner: 'other-worker', expires: new Date(Date.now() + 60000).toISOString() } + const r = await run(db, t, { workerId: 'w2' }) + assert.equal(r.status, SLICE_STATUS.LOCKED) + // expired lease -> takeover + const sync2 = await db.loadSync() + sync2.history_cursor.lease = { owner: 'other-worker', expires: new Date(Date.now() - 1000).toISOString() } + const r2 = await run(db, t, { workerId: 'w2', budgetMs: 0 }) + assert.notEqual(r2.status, SLICE_STATUS.LOCKED) +}) + +// ══ §5.6b resumability validation ════════════════════════════════════════ + +test('invalid durable state takes the fail-closed abandon path', async () => { + const db = makeMemDb() + const t = makeTransport({ nodes: [] }) + await run(db, t, { budgetMs: 0 }) + // corrupt the cursor: unknown scope id as current + const sync = await db.loadSync() + sync.history_cursor.current = { scope_id: '33333333-3333-3333-3333-333333333333', pass: 'accumulate', cursor: null } + const r = await run(db, t) + assert.equal(r.status, SLICE_STATUS.ABANDONED) + const after = await db.loadSync() + assert.equal(after.building_version, null) + assert.equal(after.phase, 'pending') +}) + +test('isResumable rejects malformed cursor shapes', async () => { + const db = makeMemDb() + const base = { + user_id: USER, repository_id: REPO, phase: 'history', building_version: 1, + history_cursor: { pinned_oid: OID, current: null }, + pending_scopes: null, + } + assert.equal(await isResumable(db, { ...base, history_cursor: { pinned_oid: 'zzz', current: null } }), false) + assert.equal(await isResumable(db, { ...base, phase: 'error' }), false) + assert.equal(await isResumable(db, { ...base, history_cursor: { pinned_oid: OID, current: { scope_id: 'x', pass: 'accumulate', cursor: null } } }), false) + assert.equal(await isResumable(db, { ...base, history_cursor: { pinned_oid: OID, current: { scope_id: crypto.randomUUID(), pass: 'bogus', cursor: null } } }), false) + assert.equal(await isResumable(db, base), true) +}) + +test('resolve pass is a B2 terminal handoff — no resolve work is attempted', async () => { + const db = makeMemDb() + await driveToTerminal(db, makeTransport({ nodes: syntheticNodes().slice(0, 3) })) + const checkpoints = db._state.calls.checkpointArgs + for (const c of checkpoints) { + assert.deepEqual(c.p_identities, []) + assert.equal(c.p_pass, 'accumulate') + } + // invoking again returns the same handoff, unchanged state + const r = await run(db, makeTransport()) + assert.equal(r.status, SLICE_STATUS.RESOLVE_READY) +}) + +test('gate refuses when the repo is not enabled', async () => { + const db = makeMemDb() + await assert.rejects( + () => runAccumulateSlice({ db, transport: makeTransport(), repositoryId: REPO, env: {} }), + (e) => e.name === 'OwnershipDisabledError', + ) +}) + +test('lease is transient — released on slice exit, carries no provider data', async () => { + const db = makeMemDb() + // inject a live foreign lease to capture its persisted shape + await run(db, makeTransport({ nodes: syntheticNodes().slice(0, 3) }), { budgetMs: 0 }) + const sync = await db.loadSync() + assert.equal(sync.history_cursor.lease, undefined) // released on exit + sync.history_cursor.lease = { owner: 'other', expires: new Date(Date.now() + 60000).toISOString() } + const r = await run(db, makeTransport(), { workerId: 'w2' }) + assert.equal(r.status, SLICE_STATUS.LOCKED) + const held = (await db.loadSync()).history_cursor.lease + assert.deepEqual(Object.keys(held).sort(), ['expires', 'owner']) +}) diff --git a/test/ownership-schema.test.mjs b/test/ownership-schema.test.mjs index 50f6b7b..a1e538e 100644 --- a/test/ownership-schema.test.mjs +++ b/test/ownership-schema.test.mjs @@ -232,8 +232,18 @@ const OWNERSHIP_IDENTIFIERS = /ownership_scopes|ownership_contributors|ownership // Phase 1C-A: the pure ingestion core may now exist under lib/ownership/, // but it must remain unwired — no caller, no DB write, no runtime import. +// Phase 1C-B2 adds the ACCUMULATE engine: exactly two runtime modules may +// touch infrastructure — accumulate.mjs (the engine; imports ../github.mjs +// for error classes only) and accumulate-runtime.mjs (the supabase/octokit +// adapter + dormant gated entrypoint). Every other lib/ownership file is +// pure logic and must stay dependency-free. const OWNERSHIP_CORE_DIR = 'lib/ownership/' const isOwnershipCore = (rel) => rel.startsWith(OWNERSHIP_CORE_DIR) +const OWNERSHIP_ENGINE_MODULES = new Set([ + 'lib/ownership/accumulate.mjs', + 'lib/ownership/accumulate-runtime.mjs', +]) +const isOwnershipEngine = (rel) => OWNERSHIP_ENGINE_MODULES.has(rel) function sourceFiles(dir) { return readdirSync(path.join(ROOT, dir), { recursive: true }) @@ -256,17 +266,29 @@ test('ownership core stays inside lib/ownership — nothing else references it', } }) -test('ownership core is pure — no db, octokit, supabase, fs, or runner imports', () => { +test('ownership core is pure — engine modules may import only the sanctioned adapters', () => { for (const rel of sourceFiles('lib').filter(isOwnershipCore)) { const text = src(rel) - for (const m of text.matchAll(/(?:import|from|require)\s*\(?\s*['"]([^'"]+)['"]/g)) { + // real module specifiers only — .from('table') PostgREST calls in the + // engine adapter are not imports + for (const m of text.matchAll(/(?:import\s+(?:type\s+)?[\w*{}\s,]+\s+from|import|require\s*\()\s*['"]([^'"]+)['"]/g)) { const spec = m[1] assert.ok( spec.startsWith('node:') || spec.startsWith('./') || spec.startsWith('../'), `${rel} imports external module ${spec} — the core must stay dependency-free`, ) - assert.doesNotMatch(spec, /db|github|sync|octokit|supabase|analytics|sessions|require-user/i, - `${rel} must not import runtime infrastructure`) + if (isOwnershipEngine(rel)) { + // the B2 engine surface: only the shared github module (error + // classes / installation client) and the db client adapter + assert.doesNotMatch(spec, /sync|octokit|supabase|analytics|sessions|require-user/i, + `${rel} may not import ${spec} — engine may reach only ../github.mjs and ../db.mjs`) + if (rel === 'lib/ownership/accumulate.mjs') { + assert.ok(!spec.includes('db'), 'accumulate.mjs is transport/db agnostic — adapters are injected') + } + } else { + assert.doesNotMatch(spec, /db|github|sync|octokit|supabase|analytics|sessions|require-user/i, + `${rel} must not import runtime infrastructure`) + } } assert.doesNotMatch(text, /process\.env\.(?!OWNERSHIP_INGEST_ENABLED|OWNERSHIP_CANARY_REPOSITORY_IDS)[A-Z_]+/, `${rel} may only read the two documented gate env vars`) @@ -291,8 +313,11 @@ test('no ownership table write exists anywhere in lib/api/src', () => { } } } - // the pure core itself must not construct persistence calls either + // the pure core itself must not construct persistence calls — B2's two + // engine modules are the ONLY ownership-core files allowed to write, and + // only through the adapter surface (rpc + guarded updates) for (const rel of sourceFiles('lib').filter(isOwnershipCore)) { + if (isOwnershipEngine(rel)) continue const text = src(rel) assert.doesNotMatch(text, writerRe, `${rel} contains a persistence-shaped call`) } From 23dab519391e1ffc7e75894f08505b2cfd8bccbd Mon Sep 17 00:00:00 2001 From: aliferous3 Date: Mon, 19 Oct 2026 21:44:49 +0400 Subject: [PATCH 2/4] fix: close same-page is_bot drift and unify slice GraphQL accounting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two B2 correctness gaps before merge review: - page-deltas: first-observation-wins is_bot collapsed contradictory classifications for one work_key inside a page, hiding drift from the stored-row DB guard. A page-wide frozen work_key -> is_bot map now fails closed via OwnershipIdentityError on any conflicting observation (any order, any month); the DB guard remains the cross-page barrier. - accumulate: GraphQL telemetry was only counted inside the history page loop. One shared per-slice budget now records cost/remaining/resetAt for every request — HEAD probe, pin validation, scope-prep pinned revalidation, history pages, and the identical retry — and gates each subsequent request on cost budget, 500-point reserve, and wall clock. The retry is budget-gated too and its telemetry replaces the failed shape's. Coverage: page-reducer drift matrix (same/reverse/cross-month), engine-level no-checkpoint/no-cursor-move proof, cross-page stored-row rejection, per-request cost accumulation, retry double-cost + authoritative remaining, reserve-stop, and cost-budget-stop — plus real Postgres proofs E11/E12 in the recovery drill. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- lib/ownership/accumulate.mjs | 152 +++++++++----- lib/ownership/page-deltas.mjs | 15 ++ scripts/ownership-engine-proofs.mjs | 83 +++++++- test/ownership-accumulate.test.mjs | 307 ++++++++++++++++++++++++++-- 4 files changed, 481 insertions(+), 76 deletions(-) diff --git a/lib/ownership/accumulate.mjs b/lib/ownership/accumulate.mjs index e599d27..9d29ad2 100644 --- a/lib/ownership/accumulate.mjs +++ b/lib/ownership/accumulate.mjs @@ -68,13 +68,56 @@ export const SLICE_STATUS = Object.freeze({ ERROR: 'error', }) +// ── slice budget ────────────────────────────────────────────────────────── +// ONE auditable per-slice budget shared through build start, pin +// validation, scope preparation, and history paging (§15). Every GraphQL +// response — HEAD probe, pinned-object validations, history pages, and the +// identical pinned-history retry — records its measured cost/remaining +// here. No persistence: the durable checkpoint stays authoritative. +function makeBudget({ now, budgetMs, costBudget }) { + return { + deadline: now() + budgetMs, + costBudget, + cost: 0, + remaining: null, + resetAt: null, + } +} + +// Every returned rateLimit block contributes — never assume cost=1. The +// latest response's remaining/resetAt is authoritative (a retry's values +// replace the failed-shape response's). +function recordRl(data, budget) { + const rl = data?.rateLimit + if (rl) { + if (Number.isFinite(rl.cost)) budget.cost += rl.cost + if (rl.remaining != null) budget.remaining = rl.remaining + if (rl.resetAt) budget.resetAt = rl.resetAt + } +} + +// Enforced BEFORE each subsequent request: a just-completed request may +// legitimately drop remaining below the reserve — what is forbidden is +// knowingly issuing the NEXT request afterwards. +function budgetStop(budget, now) { + if (now() >= budget.deadline || budget.cost >= budget.costBudget) { + return SLICE_STATUS.SLICE_BUDGET + } + if (budget.remaining != null && budget.remaining < RATE_LIMIT_RESERVE) { + return SLICE_STATUS.RATE_LIMITED + } + return null +} + // ── transport + error classification ────────────────────────────────────── // Every GraphQL call goes through here: the query text is re-audited at the -// trust boundary and the response is scanned for contract-forbidden keys -// before anything else may touch it. -async function gql(transport, query, vars) { +// trust boundary, its telemetry feeds the shared slice budget FIRST (the +// request consumed quota even if the response is then rejected), and the +// response is scanned for contract-forbidden keys before use. +async function gql(transport, query, vars, budget) { const data = await transport.graphql(assertOwnershipQuerySafe(query), vars) + recordRl(data, budget) return assertOwnershipResponseSafe(data) } @@ -166,7 +209,7 @@ export async function isResumable(db, st) { // ── build start (§5.4, split preflight → pin → init) ────────────────────── -async function startBuild({ db, transport, repo, st }) { +async function startBuild({ db, transport, repo, st, budget, now }) { const userId = repo.user_id const repositoryId = repo.id @@ -179,13 +222,18 @@ async function startBuild({ db, transport, repo, st }) { if (pre !== 'ready') return { status: pre } // 'blocked' | 'stale' // Step 2: the ONLY mutable-branch read in the engine — resolve HEAD once - // to discover the immutable revision to pin. + // to discover the immutable revision to pin. The shared budget gates + // EVERY request, not just history pages. + { + const stop = budgetStop(budget, now) + if (stop) return { status: stop } + } let probe try { probe = await gql(transport, ownershipHeadProbeQuery(), { owner: repo.owner_login, name: repo.name, - }) + }, budget) } catch (e) { const r = classifyFailure(e) if (r.code) { @@ -209,13 +257,17 @@ async function startBuild({ db, transport, repo, st }) { // Step 3: the candidate must resolve as a Commit with an unchanged OID — // a pin established against anything else would silently mix revisions. + { + const stop = budgetStop(budget, now) + if (stop) return { status: stop } + } let obj try { obj = await gql(transport, pinnedObjectQuery(), { owner: repo.owner_login, name: repo.name, oid: headOid, - }) + }, budget) } catch (e) { const r = classifyFailure(e) if (r.code) { @@ -255,7 +307,7 @@ async function startBuild({ db, transport, repo, st }) { // (≤500 dir scopes), filtered (vendor/generated/binary/submodule), and // ordered (depth, scope_path_hash). Overflow paths are a count only and are // never persisted or logged. -async function prepareScopes({ db, transport, repo, st }) { +async function prepareScopes({ db, transport, repo, st, budget, now }) { const userId = st.user_id const repositoryId = repo.id const V = st.building_version @@ -263,13 +315,17 @@ async function prepareScopes({ db, transport, repo, st }) { // Re-resolve the pinned object — both re-verifies the pin before heavy // work and yields the pinned commit's tree for scope enumeration. + { + const stop = budgetStop(budget, now) + if (stop) return { ok: false, out: { status: stop } } + } let data try { data = await gql(transport, pinnedObjectQuery(), { owner: repo.owner_login, name: repo.name, oid: pinnedOid, - }) + }, budget) } catch (e) { const r = classifyFailure(e) if (r.code) await markError(db, st, r.code) @@ -288,6 +344,10 @@ async function prepareScopes({ db, transport, repo, st }) { return { ok: false, out: { status: SLICE_STATUS.ERROR, code: 'OWNERSHIP_HISTORY_FAILED' } } } + { + const stop = budgetStop(budget, now) + if (stop) return { ok: false, out: { status: stop } } + } let tree try { tree = await transport.getTree({ owner: repo.owner_login, repo: repo.name, treeSha: treeOid }) @@ -368,7 +428,7 @@ async function beginScope(db, st, scopeId, pendingIds) { // 'applied' advances; 'stale' re-reads the canonical row and follows it — // never a blind retry; a response lost AFTER a committed page replays to // 'stale' and cannot double-count. -async function accumulateScope({ db, transport, repo, st, now, deadline, costBudget }) { +async function accumulateScope({ db, transport, repo, st, budget, now }) { const userId = st.user_id const repositoryId = repo.id const V = st.building_version @@ -390,40 +450,38 @@ async function accumulateScope({ db, transport, repo, st, now, deadline, costBud let cursor = cur.cursor ?? null let pages = 0 - let cost = 0 - let remaining = null for (;;) { - if (now() >= deadline || cost >= costBudget) { - return { status: SLICE_STATUS.SLICE_BUDGET, pages, cost, remaining } - } - if (remaining != null && remaining < RATE_LIMIT_RESERVE) { - return { status: SLICE_STATUS.RATE_LIMITED, pages, cost, remaining } - } + const stop = budgetStop(budget, now) + if (stop) return { status: stop, pages } const vars = { owner: repo.owner_login, name: repo.name, oid: pinnedOid, path, cursor } let data try { - data = await gql(transport, pinnedHistoryPageQuery(), vars) + data = await gql(transport, pinnedHistoryPageQuery(), vars, budget) } catch (e) { const r = classifyFailure(e) if (r.code) await markError(db, st, r.code) - return { ...r, pages, cost } + return { ...r, pages } } let history = data?.repository?.object if (history == null || history.__typename !== 'Commit' || history.oid !== pinnedOid) { // pinned boundary unusable — retry the identical page exactly once, - // then fail closed. NEVER repin mid-build (§10). + // then fail closed. NEVER repin mid-build (§10). The retry is still a + // GraphQL request — it obeys the shared budget like any other; its own + // telemetry is counted and its remaining becomes authoritative. + const retryStop = budgetStop(budget, now) + if (retryStop) return { status: retryStop, pages } try { - const retry = await gql(transport, pinnedHistoryPageQuery(), vars) + const retry = await gql(transport, pinnedHistoryPageQuery(), vars, budget) history = retry?.repository?.object } catch (e) { history = null } if (history == null || history.__typename !== 'Commit' || history.oid !== pinnedOid || !history.history) { await markError(db, st, 'OWNERSHIP_HISTORY_FAILED') - return { status: SLICE_STATUS.ERROR, code: 'OWNERSHIP_HISTORY_FAILED', pages, cost } + return { status: SLICE_STATUS.ERROR, code: 'OWNERSHIP_HISTORY_FAILED', pages } } } const h = history.history @@ -434,7 +492,7 @@ async function accumulateScope({ db, transport, repo, st, now, deadline, costBud !Array.isArray(h.nodes) ) { await markError(db, st, 'OWNERSHIP_HISTORY_FAILED') - return { status: SLICE_STATUS.ERROR, code: 'OWNERSHIP_HISTORY_FAILED', pages, cost } + return { status: SLICE_STATUS.ERROR, code: 'OWNERSHIP_HISTORY_FAILED', pages } } let deltas @@ -444,7 +502,7 @@ async function accumulateScope({ db, transport, repo, st, now, deadline, costBud // malformed provider identity — page wedges at the durable cursor, // nothing commits (fail-closed per §5.5) await markError(db, st, 'OWNERSHIP_HISTORY_FAILED') - return { status: SLICE_STATUS.ERROR, code: 'OWNERSHIP_HISTORY_FAILED', pages, cost } + return { status: SLICE_STATUS.ERROR, code: 'OWNERSHIP_HISTORY_FAILED', pages } } let r @@ -465,7 +523,7 @@ async function accumulateScope({ db, transport, repo, st, now, deadline, costBud // own_checkpoint_page raised (bounds, drift, malformed payload) — // the closed internal-error taxonomy, never raw SQL text await markError(db, st, 'OWNERSHIP_INTERNAL_ERROR') - return { status: SLICE_STATUS.ERROR, code: 'OWNERSHIP_INTERNAL_ERROR', pages, cost } + return { status: SLICE_STATUS.ERROR, code: 'OWNERSHIP_INTERNAL_ERROR', pages } } if (r === 'stale') { @@ -473,24 +531,22 @@ async function accumulateScope({ db, transport, repo, st, now, deadline, costBud const fresh = await db.loadSync(userId, repositoryId) const c = fresh?.history_cursor?.current if (fresh?.building_version !== V || !c || c.scope_id !== cur.scope_id) { - return { status: SLICE_STATUS.STALE, pages, cost } + return { status: SLICE_STATUS.STALE, pages } } if (c.pass === 'resolve') { // the winner already finished this scope's ACCUMULATE - return { status: SLICE_STATUS.RESOLVE_READY, pages, cost } + return { status: SLICE_STATUS.RESOLVE_READY, pages } } - if (c.pass !== 'accumulate') return { status: SLICE_STATUS.STALE, pages, cost } + if (c.pass !== 'accumulate') return { status: SLICE_STATUS.STALE, pages } cursor = c.cursor ?? null // adopt the durable cursor; our page already landed continue } pages += 1 - cost += data.rateLimit?.cost ?? 0 - if (data.rateLimit?.remaining != null) remaining = data.rateLimit.remaining if (h.pageInfo.hasNextPage !== true) { // last page committed — B1 flipped the scope to pass='resolve'; // B2 stops here for this scope (RESOLVE is B3) - return { status: SLICE_STATUS.RESOLVE_READY, pages, cost, remaining } + return { status: SLICE_STATUS.RESOLVE_READY, pages } } cursor = h.pageInfo.endCursor } @@ -511,24 +567,28 @@ export async function runAccumulateSlice({ // The hard-off gate runs before any GitHub request or ownership write. assertOwnershipIngestAllowed(repositoryId, env) - const deadline = now() + budgetMs + // One auditable budget state shared through build start, pin validation, + // scope preparation, and history paging (§15). + const budget = makeBudget({ now, budgetMs, costBudget }) + const done = (r) => ({ cost: budget.cost, remaining: budget.remaining, ...r }) + const repo = await db.loadRepo(repositoryId) - if (!repo) return { status: SLICE_STATUS.ERROR, code: 'OWNERSHIP_INTERNAL_ERROR' } + if (!repo) return done({ status: SLICE_STATUS.ERROR, code: 'OWNERSHIP_INTERNAL_ERROR' }) const userId = repo.user_id await db.ensureSyncRow(userId, repositoryId) let st = await db.loadSync(userId, repositoryId) - if (!st) return { status: SLICE_STATUS.ERROR, code: 'OWNERSHIP_INTERNAL_ERROR' } + if (!st) return done({ status: SLICE_STATUS.ERROR, code: 'OWNERSHIP_INTERNAL_ERROR' }) if (st.building_version == null) { - const start = await startBuild({ db, transport, repo, st }) - if (start.status !== 'started') return start + const start = await startBuild({ db, transport, repo, st, budget, now }) + if (start.status !== 'started') return done(start) st = await db.loadSync(userId, repositoryId) - if (st?.building_version == null) return { status: SLICE_STATUS.STALE } + if (st?.building_version == null) return done({ status: SLICE_STATUS.STALE }) } const leased = await claimLease(db, st, workerId, now) - if (!leased) return { status: SLICE_STATUS.LOCKED } + if (!leased) return done({ status: SLICE_STATUS.LOCKED }) st = leased try { @@ -540,7 +600,7 @@ export async function runAccumulateSlice({ p_repository: repositoryId, p_version: st.building_version, }) - return { status: SLICE_STATUS.ABANDONED } + return done({ status: SLICE_STATUS.ABANDONED }) } // Dispatch on the durable position — current=null means either fresh @@ -548,21 +608,21 @@ export async function runAccumulateSlice({ let cur = st.history_cursor.current ?? null if (cur == null) { if (st.pending_scopes == null) { - const prep = await prepareScopes({ db, transport, repo, st }) - if (!prep.ok) return prep.out + const prep = await prepareScopes({ db, transport, repo, st, budget, now }) + if (!prep.ok) return done(prep.out) st = prep.sync } else if (st.pending_scopes.length === 0) { - return { status: SLICE_STATUS.SCOPES_DRAINED } + return done({ status: SLICE_STATUS.SCOPES_DRAINED }) } else { const begun = await beginScope(db, st, st.pending_scopes[0], st.pending_scopes) - if (!begun) return { status: SLICE_STATUS.STALE } + if (!begun) return done({ status: SLICE_STATUS.STALE }) st = begun } cur = st.history_cursor.current } - if (cur.pass === 'resolve') return { status: SLICE_STATUS.RESOLVE_READY } + if (cur.pass === 'resolve') return done({ status: SLICE_STATUS.RESOLVE_READY }) - return await accumulateScope({ db, transport, repo, st, now, deadline, costBudget }) + return done(await accumulateScope({ db, transport, repo, st, budget, now })) } finally { // a finished slice must never pin the lease — a dead worker is covered // by the TTL, a clean exit frees the next invocation immediately diff --git a/lib/ownership/page-deltas.mjs b/lib/ownership/page-deltas.mjs index f7cee65..de7586b 100644 --- a/lib/ownership/page-deltas.mjs +++ b/lib/ownership/page-deltas.mjs @@ -27,6 +27,16 @@ import { monthStartUtc } from './history-reducer.mjs' // scoped to (same id the canonical reducer receives). export function reducePageDeltas(repositoryId, nodes) { const groups = new Map() + // Page-wide frozen classification (work_key -> is_bot). Contributor keys + // derive from the node ID, so two logins for the same ID map to the same + // key — e.g. 'alice' then 'alice[bot]' — and would otherwise collapse + // into one grouped delta carrying whichever classification came first, + // hiding the drift from the DB guard. The FIRST observation freezes the + // classification for the whole page; ANY conflicting later observation + // (any order, any month, same page) is provider-contract drift and fails + // closed via OwnershipIdentityError — no login or node id is exposed. + // B1's stored-row is_bot check remains the cross-page barrier. + const classifications = new Map() for (const node of Array.isArray(nodes) ? nodes : []) { if (!node || node.committedDate == null) { throw new TypeError('history node missing committedDate') @@ -39,6 +49,11 @@ export function reducePageDeltas(repositoryId, nodes) { // author.user == null, the 64-hex repository-scoped key otherwise, // and throws OwnershipIdentityError on malformed provider identity. const identity = classifyAuthorIdentity(node.author?.user, repositoryId) + if (identity.identity_kind === 'linked') { + const frozen = classifications.get(identity.contributor_key) + if (frozen === undefined) classifications.set(identity.contributor_key, identity.is_bot) + else if (frozen !== identity.is_bot) throw new OwnershipIdentityError() + } const groupKey = `${period}${identity.contributor_key}` let g = groups.get(groupKey) diff --git a/scripts/ownership-engine-proofs.mjs b/scripts/ownership-engine-proofs.mjs index 7826932..74f3d40 100644 --- a/scripts/ownership-engine-proofs.mjs +++ b/scripts/ownership-engine-proofs.mjs @@ -18,6 +18,8 @@ // E8 lease: live foreign wins, expired is taken, released on exit // E9 invalid durable state -> canonical abandon // E10 privacy: zero identity fields anywhere in persisted ownership state +// E11 same-page is_bot drift -> app guard fails closed before the RPC +// E12 cross-page is_bot drift -> the real stored-row guard rejects page 2 import crypto from 'node:crypto' import { runAccumulateSlice, SLICE_STATUS } from '../lib/ownership/accumulate.mjs' @@ -47,21 +49,23 @@ function syntheticNodes() { function makeTransport({ nodes = [], tree = [], pinnedOid = OID, pageSize = 100, remaining = 5000, cost = 1 } = {}) { const calls = [] - const rl = () => ({ cost, remaining, resetAt: '2026-01-01T00:00:00Z' }) + const pick = (v, q, vars) => (typeof v === 'function' ? v(q, vars, calls.length) : v) + const rl = (q, vars) => ({ cost: pick(cost, q, vars), remaining: pick(remaining, q, vars), resetAt: '2026-01-01T00:00:00Z' }) return { calls, async graphql(query, vars) { calls.push({ query, vars }) + const meta = rl(query, vars) if (query.includes('defaultBranchRef')) { - return { repository: { defaultBranchRef: { target: { oid: pinnedOid } } }, rateLimit: rl() } + return { repository: { defaultBranchRef: { target: { oid: pinnedOid } } }, rateLimit: meta } } if (query.includes('tree {')) { return { repository: { object: { __typename: 'Commit', oid: vars.oid, tree: { oid: 'b'.repeat(40) } } }, - rateLimit: rl(), + rateLimit: meta, } } - if (vars.oid !== pinnedOid) return { repository: { object: null }, rateLimit: rl() } + if (vars.oid !== pinnedOid) return { repository: { object: null }, rateLimit: meta } const start = vars.cursor == null ? 0 : Number(vars.cursor.split(':')[1]) const end = Math.min(start + pageSize, nodes.length) return { @@ -74,13 +78,17 @@ function makeTransport({ nodes = [], tree = [], pinnedOid = OID, pageSize = 100, }, }, }, - rateLimit: rl(), + rateLimit: meta, } }, async getTree() { return { tree, truncated: false } }, } } +// build-start calls are telemetry-free; history pages cost 1 each — keeps +// "one committed page per slice" semantics under the unified slice budget +const PAGE_COST = (q) => (q.includes('history(first') ? 1 : 0) + export async function engineProofs(sql) { const problems = [] const check = (ok, msg) => { if (!ok) problems.push(msg) } @@ -185,7 +193,7 @@ export async function engineProofs(sql) { await drive(t, makeTransport({ nodes })) const p1 = projectWorkToMonthly(await workRows(t)) await sql`select own_abandon_build(${t.uid}, ${t.repoId}, 1)` - const r = await drive(t, makeTransport({ nodes, pageSize: 10 }), { costBudget: 1 }) + const r = await drive(t, makeTransport({ nodes, pageSize: 10, cost: PAGE_COST }), { costBudget: 1 }) check(r.status === SLICE_STATUS.RESOLVE_READY, `E3: restarted walk must finish, got ${r.status}`) const p2 = projectWorkToMonthly(await workRows(t)) check(JSON.stringify(p1.months) === JSON.stringify(p2.months), @@ -195,7 +203,7 @@ export async function engineProofs(sql) { // ── E4/E5: stale replay + wrong-version on the real CAS ────────────────── { const t = await tenant() - const transport = makeTransport({ nodes: syntheticNodes().slice(0, 60), pageSize: 20 }) + const transport = makeTransport({ nodes: syntheticNodes().slice(0, 60), pageSize: 20, cost: PAGE_COST }) await run(t, transport, { costBudget: 1 }) // exactly one committed page const before = await workRows(t) const st = await syncRow(t) @@ -259,7 +267,9 @@ export async function engineProofs(sql) { // ── E8: lease CAS on real Postgres ──────────────────────────────────────── { const t = await tenant() - await run(t, makeTransport({ nodes: [] }), { budgetMs: 0 }) + // budget stops the slice mid-build (build initialized, prep not yet run) + // so a live history_cursor exists for the lease injection + await run(t, makeTransport({ nodes: [] }), { costBudget: 3 }) const st = await syncRow(t) const cur = { ...st.history_cursor, lease: { owner: 'foreign', expires: new Date(Date.now() + 60000).toISOString() } } await sql`update ownership_sync_state set history_cursor = ${sql.json(cur)} @@ -276,7 +286,7 @@ export async function engineProofs(sql) { // ── E9: invalid durable state -> canonical abandon ─────────────────────── { const t = await tenant() - await run(t, makeTransport({ nodes: [] }), { budgetMs: 0 }) + await run(t, makeTransport({ nodes: [] }), { costBudget: 3 }) const st = await syncRow(t) const bogus = { ...st.history_cursor, current: { scope_id: crypto.randomUUID(), pass: 'accumulate', cursor: null } } await sql`update ownership_sync_state set history_cursor = ${sql.json(bogus)} @@ -306,6 +316,61 @@ export async function engineProofs(sql) { check(bw[0].c === 0, 'E10: identity staging must be empty during ACCUMULATE') } + // ── E11: same-page is_bot drift — the app-side page guard fails closed ─── + // 'person' then 'person[bot]' share one node ID -> one work_key; the + // frozen page-wide classification map throws before own_checkpoint_page. + { + const t = await tenant() + const transport = makeTransport({ + nodes: [ + nd('drift1', 'person', '2025-10-03T10:00:00Z'), + nd('drift1', 'person[bot]', '2025-10-04T10:00:00Z'), + ], + }) + const r = await run(t, transport) + check(r.status === SLICE_STATUS.ERROR && r.code === 'OWNERSHIP_HISTORY_FAILED', + `E11: expected error/OWNERSHIP_HISTORY_FAILED, got ${r.status}/${r.code ?? 'none'}`) + const st = await syncRow(t) + check(st?.phase === 'error' && st?.error === 'OWNERSHIP_HISTORY_FAILED', + 'E11: closed taxonomy only — no provider text in the error column') + check(st?.history_cursor?.current?.cursor === null, + 'E11: durable cursor must not advance past the drifting page') + check((await workRows(t)).length === 0, + 'E11: no page counters may commit') + const serialized = JSON.stringify({ e: st?.error, c: st?.history_cursor }) + check(!serialized.includes('person') && !serialized.includes('U_drift1'), + 'E11: persisted state must carry no login or node id') + } + + // ── E12: cross-page drift — the real stored-row guard rejects page 2 ───── + // Page 1 freezes is_bot=false; page 2 arrives with is_bot=true for the + // same (version, scope, period, work_key). The application-side page + // guard cannot see across pages — PostgreSQL's stored-row check is the + // complementary barrier and must reject without moving the cursor. + { + const t = await tenant() + const transport = makeTransport({ + nodes: [ + nd('drift2', 'person', '2025-10-03T10:00:00Z'), + nd('drift2', 'person[bot]', '2025-10-04T10:00:00Z'), + ], + pageSize: 1, + cost: PAGE_COST, + }) + const r1 = await run(t, transport, { costBudget: 1 }) + check(r1.status === SLICE_STATUS.SLICE_BUDGET, + `E12: page 1 must commit then stop, got ${r1.status}`) + const r2 = await run(t, transport) + check(r2.status === SLICE_STATUS.ERROR && r2.code === 'OWNERSHIP_INTERNAL_ERROR', + `E12: stored-row drift guard must fail closed, got ${r2.status}/${r2.code ?? 'none'}`) + const st = await syncRow(t) + check(st?.history_cursor?.current?.cursor === 'idx:1', + 'E12: durable cursor must rest at the last good page') + const work = await workRows(t) + check(work.length === 1 && work[0].is_bot === false && work[0].touch_commits === 1, + 'E12: page-1 counters stay; the drifting page writes nothing') + } + // every proof tenant self-destructs — user deletion cascades through // repositories into every ownership table, leaving the seeded fixture // untouched for the backup/restore leg diff --git a/test/ownership-accumulate.test.mjs b/test/ownership-accumulate.test.mjs index db53d61..d6973c2 100644 --- a/test/ownership-accumulate.test.mjs +++ b/test/ownership-accumulate.test.mjs @@ -15,7 +15,11 @@ import { fileURLToPath } from 'node:url' import { dirname, join } from 'node:path' import { reduceHistoryToMonthly } from '../lib/ownership/history-reducer.mjs' -import { contributorKey, UNLINKED_KEY } from '../lib/ownership/contributors.mjs' +import { + contributorKey, + UNLINKED_KEY, + OwnershipIdentityError, +} from '../lib/ownership/contributors.mjs' import { reducePageDeltas } from '../lib/ownership/page-deltas.mjs' import { runAccumulateSlice, @@ -121,10 +125,14 @@ function makeMemDb({ repoRow } = {}) { const cur = st.history_cursor?.current if (!cur || cur.scope_id !== args.p_scope || cur.pass !== args.p_pass) return 'stale' if ((cur.cursor ?? null) !== (args.p_expected_cursor ?? null)) return 'stale' - // additive upsert — same row exists => sums merge + // additive upsert — same row exists => sums merge; mirrors the + // real function's stored-row is_bot drift guard (fail closed) for (const d of args.p_deltas) { const k = key(args.p_version, args.p_scope, d.period_start, d.work_key) const prev = state.work.get(k) + if (prev && prev.is_bot !== d.is_bot) { + throw new Error('is_bot classification drift') + } state.work.set(k, prev ? { ...prev, @@ -194,24 +202,28 @@ function makeMemDb({ repoRow } = {}) { // Serves the three audited queries off a synthetic node stream partitioned // by opaque index cursors — the same stream a real history(path:) walk // would produce. calls[] records every query+vars for static assertions. +// cost/remaining accept a constant or (query, vars, callIndex) -> value so +// tests can assign distinct telemetry per request type. function makeTransport({ nodes = [], tree = [], pinnedOid = OID, pageSize = 100, remaining = 5000, cost = 1 } = {}) { const calls = [] - const rl = () => ({ cost, remaining, resetAt: '2026-01-01T00:00:00Z' }) + const pick = (v, q, vars) => (typeof v === 'function' ? v(q, vars, calls.length) : v) + const rl = (q, vars) => ({ cost: pick(cost, q, vars), remaining: pick(remaining, q, vars), resetAt: '2026-01-01T00:00:00Z' }) return { calls, async graphql(query, vars) { calls.push({ query, vars }) + const meta = rl(query, vars) if (query.includes('defaultBranchRef')) { - return { repository: { defaultBranchRef: { target: { oid: pinnedOid } } }, rateLimit: rl() } + return { repository: { defaultBranchRef: { target: { oid: pinnedOid } } }, rateLimit: meta } } if (query.includes('tree {')) { return { repository: { object: { __typename: 'Commit', oid: vars.oid, tree: { oid: 'b'.repeat(40) } } }, - rateLimit: rl(), + rateLimit: meta, } } // pinned history page — object(oid:) mismatch yields a null object - if (vars.oid !== pinnedOid) return { repository: { object: null }, rateLimit: rl() } + if (vars.oid !== pinnedOid) return { repository: { object: null }, rateLimit: meta } const start = vars.cursor == null ? 0 : Number(vars.cursor.split(':')[1]) const end = Math.min(start + pageSize, nodes.length) return { @@ -224,13 +236,21 @@ function makeTransport({ nodes = [], tree = [], pinnedOid = OID, pageSize = 100, }, }, }, - rateLimit: rl(), + rateLimit: meta, } }, async getTree() { return { tree, truncated: false } }, } } +// request-type predicates for per-query telemetry +const isProbe = (q) => q.includes('defaultBranchRef') +const isHistory = (q) => q.includes('history(first') +const isObject = (q) => !isProbe(q) && !isHistory(q) +// build-start calls are telemetry-free; history pages cost `n` each — keeps +// "one page per slice" semantics under the unified slice budget +const pageCosts = (n = 1) => (q) => (isHistory(q) ? n : 0) + const run = (db, transport, opts = {}) => runAccumulateSlice({ db, transport, repositoryId: REPO, env: ENV, ...opts }) @@ -385,11 +405,10 @@ test('scope ordering: repo scope first, pending ordered by depth+hash', async () { path: 'docs/x.md', type: 'blob' }, { path: 'node_modules/pkg/i.js', type: 'blob' }, { path: '.gitmodules', type: 'blob' }, ] - const t = makeTransport({ nodes: [], tree }) - // first slice stops at membership+first-scope; inspect the durable state - const r = await run(db, t, { budgetMs: 0 }) - // with zero budget the loop exits before the first page — state must still - // show repo scope current at accumulate + const t = makeTransport({ nodes: syntheticNodes(), tree }) + // budget covers build-start + scope prep (3 requests) + exactly one page — + // the loop must stop before page 2 with the repo scope current + const r = await run(db, t, { costBudget: 4 }) const sync = await db.loadSync() assert.ok([SLICE_STATUS.SLICE_BUDGET, SLICE_STATUS.RESOLVE_READY].includes(r.status)) const cur = sync.history_cursor?.current @@ -416,7 +435,7 @@ test('resume: second slice picks up the durable cursor with no double-count', as await driveToTerminal(db1, t1) const db2 = makeMemDb() - const t2 = makeTransport({ nodes }) + const t2 = makeTransport({ nodes, cost: pageCosts(1) }) const first = await run(db2, t2, { costBudget: 1 }) // one page per slice assert.equal(first.status, SLICE_STATUS.SLICE_BUDGET) const r = await driveToTerminal(db2, t2, { costBudget: 1 }) @@ -490,7 +509,7 @@ test('B: die between fetch and checkpoint — no counters, no cursor move', asyn test('C+D: committed page replayed with the old cursor -> stale, no double-count', async () => { const db = makeMemDb() - await run(db, makeTransport({ nodes: syntheticNodes().slice(0, 30), pageSize: 10 }), { costBudget: 1 }) + await run(db, makeTransport({ nodes: syntheticNodes().slice(0, 30), pageSize: 10, cost: pageCosts(1) }), { costBudget: 1 }) const before = db.workRows() // replay the committed page with the now-stale expected cursor const lastArgs = db._state.calls.checkpointArgs.at(-1) @@ -504,7 +523,7 @@ test('C+D: committed page replayed with the old cursor -> stale, no double-count test('E+F: wrong expected cursor / wrong version write nothing', async () => { const db = makeMemDb() - const t = makeTransport({ nodes: syntheticNodes().slice(0, 10) }) + const t = makeTransport({ nodes: syntheticNodes().slice(0, 10), cost: pageCosts(1) }) await run(db, t, { costBudget: 1 }) const sync = await db.loadSync() const args = db._state.calls.checkpointArgs.at(-1) @@ -521,7 +540,7 @@ test('G+H: restart between every page completes identically', async () => { await driveToTerminal(db1, makeTransport({ nodes })) const db2 = makeMemDb() // fresh workerId + tiny budget each slice = a restart between pages - const r = await driveToTerminal(db2, makeTransport({ nodes, pageSize: 25 }), { + const r = await driveToTerminal(db2, makeTransport({ nodes, pageSize: 25, cost: pageCosts(1) }), { costBudget: 1, get workerId() { return crypto.randomUUID() }, }) @@ -548,7 +567,12 @@ test('I: rate-limit error before the page commits writes nothing', async () => { test('J: rate-limit reserve stops the slice after a committed page; next slice resumes', async () => { const nodes = syntheticNodes() const db = makeMemDb() - const t = makeTransport({ nodes, pageSize: 100, remaining: RATE_LIMIT_RESERVE - 1 }) + // only history-page telemetry dips below the reserve — build-start + // requests keep their headroom so the slice reaches the page loop + const t = makeTransport({ + nodes, pageSize: 100, + remaining: (q) => (isHistory(q) ? RATE_LIMIT_RESERVE - 1 : 5000), + }) const r = await run(db, t) assert.equal(r.status, SLICE_STATUS.RATE_LIMITED) assert.ok(db.workRows().length > 0) // page 1 committed before the reserve check @@ -605,7 +629,7 @@ test('pinned object null -> retry once -> fail closed, never repin', async () => test('live foreign lease wins the slice; expired lease is taken', async () => { const db = makeMemDb() const t = makeTransport({ nodes: syntheticNodes().slice(0, 3) }) - await run(db, t, { workerId: 'w1', budgetMs: 0 }) + await run(db, t, { workerId: 'w1' }) // force the lease to look live-foreign const sync = await db.loadSync() sync.history_cursor.lease = { owner: 'other-worker', expires: new Date(Date.now() + 60000).toISOString() } @@ -614,7 +638,7 @@ test('live foreign lease wins the slice; expired lease is taken', async () => { // expired lease -> takeover const sync2 = await db.loadSync() sync2.history_cursor.lease = { owner: 'other-worker', expires: new Date(Date.now() - 1000).toISOString() } - const r2 = await run(db, t, { workerId: 'w2', budgetMs: 0 }) + const r2 = await run(db, t, { workerId: 'w2' }) assert.notEqual(r2.status, SLICE_STATUS.LOCKED) }) @@ -623,7 +647,7 @@ test('live foreign lease wins the slice; expired lease is taken', async () => { test('invalid durable state takes the fail-closed abandon path', async () => { const db = makeMemDb() const t = makeTransport({ nodes: [] }) - await run(db, t, { budgetMs: 0 }) + await run(db, t) // corrupt the cursor: unknown scope id as current const sync = await db.loadSync() sync.history_cursor.current = { scope_id: '33333333-3333-3333-3333-333333333333', pass: 'accumulate', cursor: null } @@ -672,7 +696,7 @@ test('gate refuses when the repo is not enabled', async () => { test('lease is transient — released on slice exit, carries no provider data', async () => { const db = makeMemDb() // inject a live foreign lease to capture its persisted shape - await run(db, makeTransport({ nodes: syntheticNodes().slice(0, 3) }), { budgetMs: 0 }) + await run(db, makeTransport({ nodes: syntheticNodes().slice(0, 3) })) const sync = await db.loadSync() assert.equal(sync.history_cursor.lease, undefined) // released on exit sync.history_cursor.lease = { owner: 'other', expires: new Date(Date.now() + 60000).toISOString() } @@ -681,3 +705,244 @@ test('lease is transient — released on slice exit, carries no provider data', const held = (await db.loadSync()).history_cursor.lease assert.deepEqual(Object.keys(held).sort(), ['expires', 'owner']) }) + +// ══ same-page is_bot classification drift ════════════════════════════════ +// One GraphQL page may observe the same node ID under contradictory logins +// (e.g. 'person' vs 'person[bot]'). Contributor keys derive from the node +// ID, so both map to one work_key — collapsing them would hide the drift +// from the DB guard. reducePageDeltas freezes the first classification +// page-wide; any conflict fails closed before own_checkpoint_page runs. + +test('A: same key, same month, same classification groups and sums', () => { + const d = reducePageDeltas(REPO, [ + nd('a1', 'alice', '2025-06-03T10:00:00Z'), + nd('a1', 'alice', '2025-06-17T08:00:00Z'), + nd('a1', 'alice', '2025-06-09T12:00:00Z'), + ]) + assert.equal(d.length, 1) + assert.equal(d[0].work_key, contributorKey(REPO, 'U_a1')) + assert.equal(d[0].touches, 3) + assert.equal(d[0].is_bot, false) +}) + +test('B: same key, same month, classification flip fails closed', () => { + assert.throws( + () => reducePageDeltas(REPO, [ + nd('a1', 'person', '2025-06-03T10:00:00Z'), + nd('a1', 'person[bot]', '2025-06-04T10:00:00Z'), + ]), + (e) => { + assert.equal(e.name, 'OwnershipIdentityError') + // closed error only — no login or node id in the message + assert.ok(!e.message.includes('person')) + assert.ok(!e.message.includes('a1')) + assert.ok(!e.message.includes('U_')) + return true + }, + ) +}) + +test('C: reverse-order flip fails identically — no first-observation bias', () => { + assert.throws( + () => reducePageDeltas(REPO, [ + nd('a1', 'person[bot]', '2025-06-03T10:00:00Z'), + nd('a1', 'person', '2025-06-04T10:00:00Z'), + ]), + OwnershipIdentityError, + ) +}) + +test('D: cross-month drift inside one page fails before checkpoint', () => { + assert.throws( + () => reducePageDeltas(REPO, [ + nd('a1', 'person', '2025-06-03T10:00:00Z'), + nd('a1', 'person[bot]', '2025-07-02T10:00:00Z'), + ]), + OwnershipIdentityError, + ) +}) + +test('unlinked observations never participate in drift checking', () => { + const d = reducePageDeltas(REPO, [ + node('2025-06-01T00:00:00Z', null), + nd('a1', 'alice', '2025-06-03T10:00:00Z'), + node('2025-06-02T00:00:00Z', null), + ]) + assert.equal(d.length, 2) + const unl = d.find((x) => x.work_key === UNLINKED_KEY) + assert.equal(unl.is_bot, false) + assert.equal(unl.touches, 2) +}) + +test('B-engine: same-page drift — no checkpoint, no cursor move, closed error', async () => { + const db = makeMemDb() + const t = makeTransport({ + nodes: [ + nd('a1', 'person', '2025-06-03T10:00:00Z'), + nd('a1', 'person[bot]', '2025-06-04T10:00:00Z'), + ], + }) + const r = await run(db, t) + assert.equal(r.status, SLICE_STATUS.ERROR) + assert.equal(r.code, 'OWNERSHIP_HISTORY_FAILED') + assert.equal(db._state.calls.checkpoint, 0) // own_checkpoint_page never called + assert.equal(db.workRows().length, 0) // no partial counters + const sync = await db.loadSync() + assert.equal(sync.phase, 'error') + assert.equal(sync.error, 'OWNERSHIP_HISTORY_FAILED') + assert.equal(sync.history_cursor.current.cursor, null) // cursor unmoved + const persisted = JSON.stringify({ phase: sync.phase, error: sync.error, cursor: sync.history_cursor }) + for (const needle of ['person', 'U_a1', 'a1', 'login', 'node']) { + assert.ok(!persisted.includes(needle), `error state leaked ${needle}`) + } +}) + +test('E: cross-page drift — DB rejects page 2, cursor stays at page 1', async () => { + // page 1 freezes is_bot=false; page 2 sends is_bot=true for the SAME row — + // the stored-row drift guard (app-side page guard + DB guard complement) + const db = makeMemDb() + const t = makeTransport({ + nodes: [ + nd('a1', 'person', '2025-06-03T10:00:00Z'), + nd('a1', 'person[bot]', '2025-06-04T10:00:00Z'), + ], + pageSize: 1, + cost: pageCosts(1), + }) + const r1 = await run(db, t, { costBudget: 1 }) + assert.equal(r1.status, SLICE_STATUS.SLICE_BUDGET) // page 1 committed + const r2 = await run(db, t) + assert.equal(r2.status, SLICE_STATUS.ERROR) + assert.equal(r2.code, 'OWNERSHIP_INTERNAL_ERROR') + const sync = await db.loadSync() + assert.equal(sync.phase, 'error') + assert.equal(sync.error, 'OWNERSHIP_INTERNAL_ERROR') + assert.equal(sync.history_cursor.current.cursor, 'idx:1') // unmoved + const rows = db.workRows() + assert.equal(rows.length, 1) + assert.equal(rows[0].is_bot, false) + assert.equal(rows[0].touch_commits, 1) +}) + +// ══ unified slice-budget accounting (F–L) ════════════════════════════════ +// ONE budget state covers the HEAD probe, pin validation, scope-prep pin +// revalidation, history pages, and the identical retry. Returned rateLimit +// cost/remaining/resetAt are authoritative — never assume cost=1. + +test('F: HEAD probe cost is counted in the slice total', async () => { + const db = makeMemDb() + const t = makeTransport({ + nodes: syntheticNodes().slice(0, 2), + cost: (q) => (isProbe(q) ? 7 : 0), + }) + const r = await run(db, t) + assert.equal(r.status, SLICE_STATUS.RESOLVE_READY) + assert.equal(r.cost, 7) +}) + +test('G: HEAD + pin-validation costs are cumulative', async () => { + const db = makeMemDb() + let objectCalls = 0 + const t = makeTransport({ + nodes: syntheticNodes().slice(0, 2), + cost: (q) => { + if (isProbe(q)) return 7 + if (isObject(q)) return objectCalls++ === 0 ? 5 : 0 // pin validation only + return 0 + }, + }) + const r = await run(db, t) + assert.equal(r.status, SLICE_STATUS.RESOLVE_READY) + assert.equal(r.cost, 12) // 7 (probe) + 5 (pin) +}) + +test('H: scope-preparation pinned-object revalidation is counted', async () => { + const db = makeMemDb() + let objectCalls = 0 + const t = makeTransport({ + nodes: syntheticNodes().slice(0, 2), + cost: (q) => { + if (isObject(q)) return objectCalls++ === 0 ? 0 : 3 // revalidation only + return 0 + }, + }) + const r = await run(db, t) + assert.equal(r.status, SLICE_STATUS.RESOLVE_READY) + assert.equal(r.cost, 3) +}) + +test('I: history page costs are counted per page', async () => { + const db = makeMemDb() + const t = makeTransport({ + nodes: syntheticNodes().slice(0, 30), + pageSize: 10, + cost: pageCosts(4), + }) + const r = await run(db, t) + assert.equal(r.status, SLICE_STATUS.RESOLVE_READY) + assert.equal(r.cost, 12) // 3 pages x 4 +}) + +test('J: identical retry counts both responses; retry remaining is authoritative', async () => { + const db = makeMemDb() + const t = makeTransport({ nodes: syntheticNodes().slice(0, 3) }) + let historyCalls = 0 + const orig = t.graphql + t.graphql = async (q, v) => { + if (isHistory(q)) { + historyCalls++ + if (historyCalls === 1) { + // first response consumed quota but the pinned object was unusable + return { repository: { object: null }, rateLimit: { cost: 7, remaining: 4900, resetAt: 'x1' } } + } + const ok = await orig(q, v) + return { ...ok, rateLimit: { cost: 11, remaining: 4700, resetAt: 'x2' } } + } + return orig(q, v) + } + const r = await run(db, t) + assert.equal(historyCalls, 2) + // probe(1) + pin(1) + scope-prep revalidation(1) + first page(7) + retry(11) + assert.equal(r.cost, 21) + assert.equal(r.remaining, 4700) // retry telemetry wins, not the failed shape's +}) + +test('K: a completed response below the reserve stops the NEXT request', async () => { + const db = makeMemDb() + const t = makeTransport({ + nodes: syntheticNodes().slice(0, 3), + remaining: (q) => (isProbe(q) ? RATE_LIMIT_RESERVE - 1 : 5000), + }) + const r = await run(db, t) + assert.equal(r.status, SLICE_STATUS.RATE_LIMITED) + assert.equal(t.calls.length, 1) // the HEAD probe ran — pin validation never issued + assert.equal((await db.loadSync()).building_version, null) +}) + +test('K2: page response below the reserve commits, then the next page is never issued', async () => { + const db = makeMemDb() + const t = makeTransport({ + nodes: syntheticNodes().slice(0, 20), + pageSize: 10, + remaining: (q) => (isHistory(q) ? RATE_LIMIT_RESERVE - 1 : 5000), + }) + const r = await run(db, t) + assert.equal(r.status, SLICE_STATUS.RATE_LIMITED) + const pages = t.calls.filter((c) => isHistory(c.query)) + assert.equal(pages.length, 1) // page 1 committed; page 2 never requested + assert.ok(db.workRows().length > 0) +}) + +test('L: build-start/pin cost consumption prevents history paging', async () => { + const db = makeMemDb() + const t = makeTransport({ nodes: syntheticNodes(), cost: () => 3 }) + const r = await run(db, t, { costBudget: 5 }) + // probe(3) + pin(3) = 6 >= 5 — the scope-prep revalidation and every + // history page are blocked; the initialized build resumes safely next slice + assert.equal(r.status, SLICE_STATUS.SLICE_BUDGET) + assert.equal(r.cost, 6) + const pages = t.calls.filter((c) => isHistory(c.query)) + assert.equal(pages.length, 0) + assert.equal((await db.loadSync()).building_version, 1) +}) + From 774d26527e17f854a4964f2d3f175bae77daad23 Mon Sep 17 00:00:00 2001 From: aliferous3 Date: Mon, 19 Oct 2026 23:19:01 +0400 Subject: [PATCH 3/4] fix: reconcile ambiguous checkpoint outcomes against durable state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A thrown own_checkpoint_page call does not prove rollback — Postgres may have applied the deltas, advanced the durable cursor, and committed while the response was lost. Treating every RPC exception as "not committed" could double-apply a page or fail a healthy build. The engine now reconciles the sync row before deciding: a proven commit is adopted; a row still exactly at the attempted position is retried once with the identical payload (the B1 CAS makes 'applied' and 'stale' both safe); a canonical transition elsewhere is followed, never overwritten. Error marking is now position-aware: phase='error' can land only while the durable row matches the exact attempted position (version, full history_cursor, cursor, lease), so a stale worker cannot poison a newer cursor or the ACCUMULATE->RESOLVE handoff. Proven by unit tests UA-UG and real-Postgres proofs E13-E15 in the PG17 recovery drill. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- lib/ownership/accumulate.mjs | 285 ++++++++++++++++++++++++---- scripts/ownership-engine-proofs.mjs | 186 +++++++++++++++++- scripts/recovery-drill.mjs | 3 +- test/ownership-accumulate.test.mjs | 234 +++++++++++++++++++++-- 4 files changed, 649 insertions(+), 59 deletions(-) diff --git a/lib/ownership/accumulate.mjs b/lib/ownership/accumulate.mjs index 9d29ad2..3ee3194 100644 --- a/lib/ownership/accumulate.mjs +++ b/lib/ownership/accumulate.mjs @@ -129,15 +129,67 @@ function classifyFailure(e) { return { status: SLICE_STATUS.ERROR, code: 'OWNERSHIP_HISTORY_FAILED' } } -async function markError(db, st, code) { - // phase='error' leaves build state for operator inspection until the - // documented sweep/abandon path runs (§7). - await db.casSync({ - userId: st.user_id, - repositoryId: st.repository_id, - match: { building_version: st.building_version }, - patch: { phase: 'error', error: code }, - }) +// The durable position a worker attempted work at: the slice-entry +// history_cursor (pin, build_started_at, this worker's lease) with +// `current` pinned to the attempted (scope, pass, cursor). Checkpoints only +// ever rewrite history_cursor.current, so sibling keys still match — while +// any other worker's takeover changes the lease and fails the match. +function attemptedHistoryCursor(st, current) { + return { ...(st?.history_cursor ?? {}), current: current ?? null } +} + +// Position-aware error marking. phase='error' leaves build state for +// operator inspection until the documented sweep/abandon path runs (§7), +// but the write may land ONLY while durable state is still exactly at the +// position the worker believes failed: a stale worker must never set +// phase='error' over another worker's committed progress (§5.5). +// Returns whether the CAS landed; a thrown read/update counts as not landed. +async function markErrorAt(db, st, current, code) { + try { + return await db.casSync({ + userId: st.user_id, + repositoryId: st.repository_id, + match: { + building_version: st.building_version, + history_cursor: attemptedHistoryCursor(st, current), + }, + patch: { phase: 'error', error: code }, + }) + } catch { + return false + } +} + +// When the error CAS cannot land the row has moved under us — a stale +// worker defers to durable state instead of marking stale knowledge over +// live state. Maps the canonical row to the closed slice vocabulary. +async function followDurableState(db, st, fallback) { + let fresh + try { + fresh = await db.loadSync(st.user_id, st.repository_id) + } catch { + fresh = null + } + if (fresh?.building_version !== st.building_version) { + return { status: SLICE_STATUS.STALE } + } + if (fresh.phase === 'error') { + return { status: SLICE_STATUS.ERROR, code: fresh.error ?? 'OWNERSHIP_INTERNAL_ERROR' } + } + if (fresh.phase !== 'history') return { status: SLICE_STATUS.STALE } + if (fresh.history_cursor?.current?.pass === 'resolve') { + return { status: SLICE_STATUS.RESOLVE_READY } + } + return fallback +} + +// Closed error path for in-build failures: mark if-and-only-if the durable +// row is still exactly at the attempted position; otherwise follow it. +async function failAtPosition(db, st, current, code) { + if (await markErrorAt(db, st, current, code)) { + return { status: SLICE_STATUS.ERROR, code } + } + return followDurableState(db, st, { status: SLICE_STATUS.ERROR, code }) } // ── lease (§14) ─────────────────────────────────────────────────────────── @@ -328,7 +380,7 @@ async function prepareScopes({ db, transport, repo, st, budget, now }) { }, budget) } catch (e) { const r = classifyFailure(e) - if (r.code) await markError(db, st, r.code) + if (r.code) return { ok: false, out: await failAtPosition(db, st, null, r.code) } return { ok: false, out: r } } const obj = data?.repository?.object @@ -340,8 +392,10 @@ async function prepareScopes({ db, transport, repo, st, budget, now }) { typeof treeOid !== 'string' || !OID_RE.test(treeOid) ) { - await markError(db, st, 'OWNERSHIP_HISTORY_FAILED') - return { ok: false, out: { status: SLICE_STATUS.ERROR, code: 'OWNERSHIP_HISTORY_FAILED' } } + return { + ok: false, + out: await failAtPosition(db, st, null, 'OWNERSHIP_HISTORY_FAILED'), + } } { @@ -353,14 +407,16 @@ async function prepareScopes({ db, transport, repo, st, budget, now }) { tree = await transport.getTree({ owner: repo.owner_login, repo: repo.name, treeSha: treeOid }) } catch (e) { const r = classifyFailure(e) - if (r.code) await markError(db, st, r.code) + if (r.code) return { ok: false, out: await failAtPosition(db, st, null, r.code) } return { ok: false, out: r } } if (tree.truncated === true) { // enumeration cannot be trusted — fail closed rather than persist a // partial scope universe - await markError(db, st, 'OWNERSHIP_HISTORY_FAILED') - return { ok: false, out: { status: SLICE_STATUS.ERROR, code: 'OWNERSHIP_HISTORY_FAILED' } } + return { + ok: false, + out: await failAtPosition(db, st, null, 'OWNERSHIP_HISTORY_FAILED'), + } } const { scopes } = deriveScopeCandidates(tree.tree) @@ -423,11 +479,131 @@ async function beginScope(db, st, scopeId, pendingIds) { : null } +// ── checkpoint unknown-outcome reconciliation (§5.5) ────────────────────── +// A thrown own_checkpoint_page call does NOT prove rollback: PostgreSQL may +// have applied the deltas, advanced the durable cursor, and COMMITTED while +// the PostgREST/HTTP response was lost. The sync row is authoritative — a +// thrown checkpoint is an UNKNOWN COMMIT OUTCOME until durable state is +// reconciled. The B1 CAS makes the resolution safe in both directions: +// if the first call truly never landed, an identical retry applies the +// page; if it landed and only the response was lost, the same retry is a +// no-op 'stale'. +// +// An attempt is identified by: +// {userId, repositoryId, buildingVersion, scopeId, pass, +// expectedCursor, nextCursor, lastPage} +// +// classifyCheckpointState maps a freshly-read sync row to: +// committed — durable state proves the attempted transition landed +// retry — durable state sits exactly at the attempted position +// ambiguous — at the attempted position, but nextCursor collides with +// expectedCursor so landed/not-landed are indistinguishable +// (retry would risk a double-apply — fail closed instead) +// follow — a legal canonical transition moved state elsewhere: defer +function classifyCheckpointState(st, attempt) { + if (st?.building_version !== attempt.buildingVersion) { + return { kind: 'follow', status: SLICE_STATUS.STALE } + } + if (st.phase === 'error') { + // another worker already failed the build at a CAS-guarded position + return { kind: 'follow', status: SLICE_STATUS.ERROR, code: st.error ?? 'OWNERSHIP_INTERNAL_ERROR' } + } + if (st.phase !== 'history') return { kind: 'follow', status: SLICE_STATUS.STALE } + const c = st.history_cursor?.current + if (!c || c.scope_id !== attempt.scopeId) { + return { kind: 'follow', status: SLICE_STATUS.STALE } + } + if (c.pass === 'resolve') { + // The B1 final-page commit flips accumulate -> resolve at a fresh + // cursor ({pass:'resolve', accumulate_done:true, cursor:null}) — that + // exact shape proves OUR attempt landed; any other resolve shape is + // still the canonical handoff for this scope. + return attempt.lastPage && c.accumulate_done === true && (c.cursor ?? null) === null + ? { kind: 'committed' } + : { kind: 'follow', status: SLICE_STATUS.RESOLVE_READY } + } + if (c.pass !== attempt.pass) return { kind: 'follow', status: SLICE_STATUS.STALE } + const cur = c.cursor ?? null + const samePoint = (attempt.nextCursor ?? null) === (attempt.expectedCursor ?? null) + if (!attempt.lastPage && !samePoint && cur === (attempt.nextCursor ?? null)) { + return { kind: 'committed' } + } + if (cur === (attempt.expectedCursor ?? null)) { + // a last-page commit always disambiguates via the pass flip, so + // accumulate@expected can only mean "not landed" — retry is safe; + // for a non-final page it is safe only when nextCursor is distinct + return attempt.lastPage || !samePoint ? { kind: 'retry' } : { kind: 'ambiguous' } + } + return { kind: 'follow', status: SLICE_STATUS.STALE } +} + +// Drive one thrown checkpoint call to a canonical outcome. Returns either +// {kind:'applied'} — the page is durably committed (proven by the durable +// read or by the retry) and the caller treats it like a normal 'applied' — +// or {kind:'return', out} — a canonical slice outcome the caller returns. +async function reconcileCheckpoint({ db, st, attempt, args }) { + const attemptedCurrent = { + scope_id: attempt.scopeId, + pass: attempt.pass, + cursor: attempt.expectedCursor, + } + const reload = async () => { + try { + return await db.loadSync(attempt.userId, attempt.repositoryId) + } catch { + return null + } + } + const done = (out) => ({ kind: 'return', out }) + + const first = classifyCheckpointState(await reload(), attempt) + if (first.kind === 'committed') return { kind: 'applied' } + if (first.kind === 'ambiguous') { + // cannot distinguish landed from unlanded — the error CAS is still + // position-aware, so a racing commit elsewhere cannot be poisoned + return done(await failAtPosition(db, st, attemptedCurrent, 'OWNERSHIP_INTERNAL_ERROR')) + } + if (first.kind !== 'retry') { + // canonical state moved elsewhere — defer, write nothing + return done({ status: first.status, ...(first.code ? { code: first.code } : {}) }) + } + + // durable state sits exactly at the attempted position: retry the exact + // same call once — identical expected/next cursor, deltas, identities + // and last-page flag. 'applied' means the first call truly never landed; + // 'stale' means it committed and its response was lost. Both are safe. + let r2 + try { + r2 = await db.rpc('own_checkpoint_page', args) + } catch { + // the retry threw too — one final durable read decides + const last = classifyCheckpointState(await reload(), attempt) + if (last.kind === 'committed') return { kind: 'applied' } + if (last.kind === 'retry' || last.kind === 'ambiguous') { + // still exactly at the attempted position after two attempts: the + // checkpoint genuinely failed to make durable progress — and ONLY a + // worker still at this position may mark it, so a racing transition + // can never be overwritten + return done(await failAtPosition(db, st, attemptedCurrent, 'OWNERSHIP_INTERNAL_ERROR')) + } + return done({ status: last.status, ...(last.code ? { code: last.code } : {}) }) + } + if (r2 === 'stale') { + // 'stale' is never an error — re-read and follow durable state: our + // page may have landed on the first call (lost response), or another + // worker may have moved the build + const second = classifyCheckpointState(await reload(), attempt) + if (second.kind === 'committed') return { kind: 'applied' } + return done({ status: second.status ?? SLICE_STATUS.STALE, ...(second.code ? { code: second.code } : {}) }) + } + return { kind: 'applied' } // 'applied' — the first call truly failed +} + // ── page loop (§5.5) ────────────────────────────────────────────────────── // fetch page -> reduce to grouped deltas -> own_checkpoint_page CAS. // 'applied' advances; 'stale' re-reads the canonical row and follows it — -// never a blind retry; a response lost AFTER a committed page replays to -// 'stale' and cannot double-count. +// never a blind retry; a thrown call is an UNKNOWN COMMIT OUTCOME and goes +// through reconcileCheckpoint before any error is considered. async function accumulateScope({ db, transport, repo, st, budget, now }) { const userId = st.user_id const repositoryId = repo.id @@ -456,12 +632,13 @@ async function accumulateScope({ db, transport, repo, st, budget, now }) { if (stop) return { status: stop, pages } const vars = { owner: repo.owner_login, name: repo.name, oid: pinnedOid, path, cursor } + const pageCurrent = () => ({ scope_id: cur.scope_id, pass: 'accumulate', cursor }) let data try { data = await gql(transport, pinnedHistoryPageQuery(), vars, budget) } catch (e) { const r = classifyFailure(e) - if (r.code) await markError(db, st, r.code) + if (r.code) return { ...(await failAtPosition(db, st, pageCurrent(), r.code)), pages } return { ...r, pages } } @@ -480,8 +657,8 @@ async function accumulateScope({ db, transport, repo, st, budget, now }) { history = null } if (history == null || history.__typename !== 'Commit' || history.oid !== pinnedOid || !history.history) { - await markError(db, st, 'OWNERSHIP_HISTORY_FAILED') - return { status: SLICE_STATUS.ERROR, code: 'OWNERSHIP_HISTORY_FAILED', pages } + const out = await failAtPosition(db, st, pageCurrent(), 'OWNERSHIP_HISTORY_FAILED') + return { ...out, pages } } } const h = history.history @@ -491,8 +668,8 @@ async function accumulateScope({ db, transport, repo, st, budget, now }) { (h.pageInfo.hasNextPage && typeof h.pageInfo.endCursor !== 'string') || !Array.isArray(h.nodes) ) { - await markError(db, st, 'OWNERSHIP_HISTORY_FAILED') - return { status: SLICE_STATUS.ERROR, code: 'OWNERSHIP_HISTORY_FAILED', pages } + const out = await failAtPosition(db, st, pageCurrent(), 'OWNERSHIP_HISTORY_FAILED') + return { ...out, pages } } let deltas @@ -501,36 +678,62 @@ async function accumulateScope({ db, transport, repo, st, budget, now }) { } catch { // malformed provider identity — page wedges at the durable cursor, // nothing commits (fail-closed per §5.5) - await markError(db, st, 'OWNERSHIP_HISTORY_FAILED') - return { status: SLICE_STATUS.ERROR, code: 'OWNERSHIP_HISTORY_FAILED', pages } + const out = await failAtPosition(db, st, pageCurrent(), 'OWNERSHIP_HISTORY_FAILED') + return { ...out, pages } + } + + const lastPage = h.pageInfo.hasNextPage !== true + const args = { + p_user: userId, + p_repository: repositoryId, + p_version: V, + p_scope: cur.scope_id, + p_pass: 'accumulate', + p_expected_cursor: cursor, + p_next_cursor: h.pageInfo.endCursor ?? null, + p_deltas: deltas, + p_identities: [], + p_last_page: lastPage, + } + // enough to identify the attempted transition for unknown-outcome + // reconciliation if the RPC throws + const attempt = { + userId, + repositoryId, + buildingVersion: V, + scopeId: cur.scope_id, + pass: 'accumulate', + expectedCursor: cursor, + nextCursor: args.p_next_cursor, + lastPage, } let r try { - r = await db.rpc('own_checkpoint_page', { - p_user: userId, - p_repository: repositoryId, - p_version: V, - p_scope: cur.scope_id, - p_pass: 'accumulate', - p_expected_cursor: cursor, - p_next_cursor: h.pageInfo.endCursor ?? null, - p_deltas: deltas, - p_identities: [], - p_last_page: h.pageInfo.hasNextPage !== true, - }) + r = await db.rpc('own_checkpoint_page', args) } catch { - // own_checkpoint_page raised (bounds, drift, malformed payload) — - // the closed internal-error taxonomy, never raw SQL text - await markError(db, st, 'OWNERSHIP_INTERNAL_ERROR') - return { status: SLICE_STATUS.ERROR, code: 'OWNERSHIP_INTERNAL_ERROR', pages } + // UNKNOWN COMMIT OUTCOME: the transaction may have committed and only + // the response was lost — the durable sync row decides (§5.5), never + // the exception itself + const rec = await reconcileCheckpoint({ db, st, attempt, args }) + if (rec.kind === 'return') return { ...rec.out, pages } + r = 'applied' // committed — proven by the durable read or the retry } if (r === 'stale') { // Do NOT retry blindly — read the canonical row and follow it. const fresh = await db.loadSync(userId, repositoryId) const c = fresh?.history_cursor?.current - if (fresh?.building_version !== V || !c || c.scope_id !== cur.scope_id) { + if (fresh?.building_version !== V) return { status: SLICE_STATUS.STALE, pages } + if (fresh.phase === 'error') { + // the build already failed canonically — mirror it, write nothing + return { + status: SLICE_STATUS.ERROR, + code: fresh.error ?? 'OWNERSHIP_INTERNAL_ERROR', + pages, + } + } + if (fresh.phase !== 'history' || !c || c.scope_id !== cur.scope_id) { return { status: SLICE_STATUS.STALE, pages } } if (c.pass === 'resolve') { diff --git a/scripts/ownership-engine-proofs.mjs b/scripts/ownership-engine-proofs.mjs index 74f3d40..231a824 100644 --- a/scripts/ownership-engine-proofs.mjs +++ b/scripts/ownership-engine-proofs.mjs @@ -13,13 +13,17 @@ // E3 restart-between-every-page resume equivalence // E4 committed-page replay with a stale cursor -> 'stale', no double-count // E5 wrong expected cursor / wrong version -> 'stale', no writes -// E6 checkpoint transport/DB failure -> no partial delta, phase='error' +// E6 persistent checkpoint failure -> retried once, no partial delta, +// position-aware phase='error' // E7 pinned-object vanished -> one retry -> fail closed, pin preserved // E8 lease: live foreign wins, expired is taken, released on exit // E9 invalid durable state -> canonical abandon // E10 privacy: zero identity fields anywhere in persisted ownership state // E11 same-page is_bot drift -> app guard fails closed before the RPC // E12 cross-page is_bot drift -> the real stored-row guard rejects page 2 +// E13 checkpoint commits but response is lost -> reconcile adopts it +// E14 final page commits but response is lost -> reconcile -> resolve-ready +// E15 stale worker cannot poison a cursor another worker advanced (real CAS) import crypto from 'node:crypto' import { runAccumulateSlice, SLICE_STATUS } from '../lib/ownership/accumulate.mjs' @@ -218,14 +222,20 @@ export async function engineProofs(sql) { check(r2[0].own_checkpoint_page === 'stale', 'E5: wrong version must return stale') } - // ── E6: checkpoint failure mid-walk — no partial delta ─────────────────── + // ── E6: persistent checkpoint failure mid-walk — retried once, then the + // closed error path; no partial delta, no cursor move ──────────────────── { const t = await tenant() const db = makePgOwnershipDb(sql) - let n = 0 + let attempts = 0 const orig = db.rpc.bind(db) db.rpc = async (fn, args) => { - if (fn === 'own_checkpoint_page' && n++ === 1) throw new Error('simulated tx abort') + // every attempt at the second page dies before commit — durable state + // never leaves the attempted position, so the retried call fails too + if (fn === 'own_checkpoint_page' && args.p_expected_cursor === 'idx:50') { + attempts += 1 + throw new Error('simulated tx abort') + } return orig(fn, args) } const r = await runAccumulateSlice({ @@ -234,6 +244,7 @@ export async function engineProofs(sql) { }) check(r.status === SLICE_STATUS.ERROR && r.code === 'OWNERSHIP_INTERNAL_ERROR', `E6: expected error/OWNERSHIP_INTERNAL_ERROR, got ${r.status}/${r.code ?? 'none'}`) + check(attempts === 2, `E6: exactly one identical retry expected, got ${attempts}`) const st = await syncRow(t) check(st?.phase === 'error', 'E6: phase must be error after checkpoint failure') const work = await workRows(t) @@ -371,6 +382,173 @@ export async function engineProofs(sql) { 'E12: page-1 counters stay; the drifting page writes nothing') } + // ── E13: unknown commit outcome — page COMMITTED, response lost ────────── + // The adapter lets the REAL own_checkpoint_page commit, then throws as if + // the PostgREST response were lost. The engine must reconcile the durable + // row, adopt the commit, and never replay the page. + { + const t = await tenant() + const nodes = syntheticNodes().slice(0, 120) + const db = makePgOwnershipDb(sql) + let checkpointCalls = 0 + let thrown = false + const orig = db.rpc.bind(db) + db.rpc = async (fn, args) => { + const r = await orig(fn, args) // real Postgres transaction commits + if (fn === 'own_checkpoint_page') { + checkpointCalls += 1 + if (!thrown && r === 'applied' && args.p_last_page === false) { + thrown = true + throw new Error('simulated lost response after commit') + } + } + return r + } + const r = await runAccumulateSlice({ + db, transport: makeTransport({ nodes, pageSize: 50 }), + repositoryId: t.repoId, env: env(t), + }) + check(r.status === SLICE_STATUS.RESOLVE_READY, + `E13: expected resolve-ready, got ${r.status}/${r.code ?? 'none'}`) + check(checkpointCalls === 3, + `E13: exactly one call per page — no replay — expected 3, got ${checkpointCalls}`) + const st = await syncRow(t) + check(st?.phase === 'history' && st?.error === null, + 'E13: phase must stay history with error null after a lost response') + check(st?.history_cursor?.current?.pass === 'resolve', + 'E13: the scope must reach the resolve handoff') + // touch counters exist exactly once — the accumulated state projects + // identically to a never-interrupted run of the canonical reducer + const expected = reduceHistoryToMonthly({ repositoryId: t.repoId, scope: { id: 'repo' }, nodes }) + const projected = projectWorkToMonthly(await workRows(t)) + check(JSON.stringify(projected.months) === JSON.stringify(expected.months), + 'E13: counters must equal the single-pass reducer result — no double-apply') + // resumable: a fresh slice hands off at the canonical position + const again = await runAccumulateSlice({ + db: makePgOwnershipDb(sql), transport: makeTransport(), + repositoryId: t.repoId, env: env(t), + }) + check(again.status === SLICE_STATUS.RESOLVE_READY, + `E13: build must remain resumable, got ${again.status}`) + } + + // ── E14: unknown commit outcome — FINAL page committed, response lost ──── + // The commit flips accumulate -> resolve; the thrown adapter leaves the + // engine to discover the handoff from durable state alone. + { + const t = await tenant() + const nodes = syntheticNodes().slice(0, 60) + const db = makePgOwnershipDb(sql) + let checkpointCalls = 0 + const orig = db.rpc.bind(db) + db.rpc = async (fn, args) => { + const r = await orig(fn, args) // real commit — pass flips to resolve + if (fn === 'own_checkpoint_page') { + checkpointCalls += 1 + if (args.p_last_page === true) { + throw new Error('simulated lost response after final commit') + } + } + return r + } + const r = await runAccumulateSlice({ + db, transport: makeTransport({ nodes, pageSize: 50 }), + repositoryId: t.repoId, env: env(t), + }) + check(r.status === SLICE_STATUS.RESOLVE_READY, + `E14: expected resolve-ready, got ${r.status}/${r.code ?? 'none'}`) + check(checkpointCalls === 2, + `E14: one call per page — no duplicate state — expected 2, got ${checkpointCalls}`) + const st = await syncRow(t) + check(st?.phase === 'history' && st?.error === null, + 'E14: phase must stay history with error null') + const cur = st?.history_cursor?.current + check(cur?.pass === 'resolve' && cur?.accumulate_done === true && (cur?.cursor ?? null) === null, + 'E14: durable state must show the B1 resolve handoff') + const expected = reduceHistoryToMonthly({ repositoryId: t.repoId, scope: { id: 'repo' }, nodes }) + const projected = projectWorkToMonthly(await workRows(t)) + check(JSON.stringify(projected.months) === JSON.stringify(expected.months), + 'E14: counters must equal the single-pass reducer result') + } + + // ── E15: a stale worker cannot poison a cursor another worker advanced ──── + // Worker A's checkpoint request fails while worker B advances the durable + // cursor through the REAL CAS. A must defer to canonical state, and its + // position-aware error write must be structurally unable to land. + { + const t = await tenant() + const nodes = syntheticNodes().slice(0, 60) + const first = await runAccumulateSlice({ + db: makePgOwnershipDb(sql), + transport: makeTransport({ nodes, pageSize: 20, cost: PAGE_COST }), + repositoryId: t.repoId, env: env(t), costBudget: 1, + }) + check(first.status === SLICE_STATUS.SLICE_BUDGET, + `E15: page 1 must commit then stop, got ${first.status}`) + const st1 = await syncRow(t) + const scopeId = st1.history_cursor.current.scope_id + check(st1.history_cursor.current.cursor === 'idx:20', + 'E15: durable cursor must rest at idx:20') + + const db = makePgOwnershipDb(sql) + const orig = db.rpc.bind(db) + let fired = false + db.rpc = async (fn, args) => { + if (fn !== 'own_checkpoint_page' || fired) return orig(fn, args) + fired = true + // B takes over mid-flight and advances idx:20 -> idx:60 for real + for (const [from, to] of [['idx:20', 'idx:40'], ['idx:40', 'idx:60']]) { + await orig('own_checkpoint_page', { + p_user: t.uid, p_repository: t.repoId, p_version: 1, p_scope: scopeId, + p_pass: 'accumulate', p_expected_cursor: from, p_next_cursor: to, + p_deltas: [], p_identities: [], p_last_page: false, + }) + } + throw new Error('A: request lost in flight') + } + const r = await runAccumulateSlice({ + db, transport: makeTransport({ nodes, pageSize: 20 }), + repositoryId: t.repoId, env: env(t), + }) + check(r.status === SLICE_STATUS.STALE, + `E15: A must defer to durable state with 'stale', got ${r.status}/${r.code ?? 'none'}`) + const st2 = await syncRow(t) + check(st2?.phase === 'history' && st2?.error === null, + 'E15: no error may be written over B\'s progress') + check(st2?.history_cursor?.current?.cursor === 'idx:60', + 'E15: B\'s advanced cursor remains authoritative') + // the real position-aware CAS: the CURRENT position matches, A's stale + // position cannot — jsonb equality on the full history_cursor + const current = st2.history_cursor + const dbCas = makePgOwnershipDb(sql) + check(await dbCas.casSync({ + userId: t.uid, repositoryId: t.repoId, + match: { building_version: 1, history_cursor: current }, + patch: { error: null }, + }) === true, 'E15: positive control — matching position CAS must land') + check(await dbCas.casSync({ + userId: t.uid, repositoryId: t.repoId, + match: { + building_version: 1, + history_cursor: { + ...current, + current: { scope_id: scopeId, pass: 'accumulate', cursor: 'idx:20' }, + }, + }, + patch: { phase: 'error', error: 'OWNERSHIP_INTERNAL_ERROR' }, + }) === false, 'E15: stale-position error write must be rejected by the CAS') + const st3 = await syncRow(t) + check(st3?.phase === 'history' && st3?.error === null, + 'E15: durable state untouched by the stale write') + // resumable: the next slice continues from B's frontier to the handoff + const done = await runAccumulateSlice({ + db: makePgOwnershipDb(sql), transport: makeTransport({ nodes, pageSize: 20 }), + repositoryId: t.repoId, env: env(t), + }) + check(done.status === SLICE_STATUS.RESOLVE_READY, + `E15: build must resume to resolve-ready, got ${done.status}`) + } + // every proof tenant self-destructs — user deletion cascades through // repositories into every ownership table, leaving the seeded fixture // untouched for the backup/restore leg diff --git a/scripts/recovery-drill.mjs b/scripts/recovery-drill.mjs index 1e527a5..4bdad3e 100644 --- a/scripts/recovery-drill.mjs +++ b/scripts/recovery-drill.mjs @@ -820,7 +820,8 @@ async function main() { console.log(' stale/orphan sweep, work-key domain and privacy shape guards') console.log('drill: engine proofs passed — pinned end-to-end ACCUMULATE, reducer differential,') console.log(' restart equivalence, stale/replay CAS, checkpoint-failure, pin-loss fail-closed,') - console.log(' lease CAS, canonical abandon, and zero-identity persistence') + console.log(' lease CAS, canonical abandon, zero-identity persistence, lost-response') + console.log(' reconciliation (commit-adopt, final-page handoff, stale-worker no-poison)') // same backup path the operator uses const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'dl-drill-')) diff --git a/test/ownership-accumulate.test.mjs b/test/ownership-accumulate.test.mjs index d6973c2..21f0a1c 100644 --- a/test/ownership-accumulate.test.mjs +++ b/test/ownership-accumulate.test.mjs @@ -147,8 +147,15 @@ function makeMemDb({ repoRow } = {}) { first_seen_at: d.first_seen_at, last_seen_at: d.last_seen_at, }) } + // mirrors the real function's last-page merge: the B1 contract + // flips to resolve at a fresh cursor carrying accumulate_done if (args.p_last_page) { - st.history_cursor.current = { scope_id: args.p_scope, pass: 'resolve', cursor: null } + st.history_cursor.current = { + ...cur, + pass: 'resolve', + accumulate_done: true, + cursor: null, + } } else { st.history_cursor.current = { ...cur, cursor: args.p_next_cursor } } @@ -495,16 +502,24 @@ test('A: failure before the first request writes nothing', async () => { assert.equal((await db.loadSync()).building_version ?? null, null) }) -test('B: die between fetch and checkpoint — no counters, no cursor move', async () => { +test('B: checkpoint throws before commit — the identical call is retried once', async () => { const db = makeMemDb() db.failOnce('own_checkpoint_page', new Error('process died')) - const r = await run(db, makeTransport({ nodes: syntheticNodes() })) - assert.equal(r.status, SLICE_STATUS.ERROR) - assert.equal(r.code, 'OWNERSHIP_INTERNAL_ERROR') - assert.equal(db.workRows().length, 0) - const cur = (await db.loadSync()).history_cursor.current - assert.equal(cur.pass, 'accumulate') - assert.equal(cur.cursor, null) + const attempts = [] + const orig = db.rpc.bind(db) + db.rpc = async (fn, args) => { + if (fn === 'own_checkpoint_page') attempts.push(structuredClone(args)) + return orig(fn, args) + } + const r = await run(db, makeTransport({ nodes: syntheticNodes().slice(0, 3) })) + // reconciliation saw the expected cursor unchanged -> one retry -> applied + assert.equal(r.status, SLICE_STATUS.RESOLVE_READY) + assert.equal(attempts.length, 2) + assert.deepEqual(attempts[1], attempts[0]) // identical payload on retry + assert.ok(db.workRows().length > 0) + const sync = await db.loadSync() + assert.equal(sync.phase, 'history') + assert.equal(sync.error, null) }) test('C+D: committed page replayed with the old cursor -> stale, no double-count', async () => { @@ -582,22 +597,30 @@ test('J: rate-limit reserve stops the slice after a committed page; next slice r assert.equal(r2.status, SLICE_STATUS.RESOLVE_READY) }) -test('K: checkpoint error leaves no partial delta and no cursor move', async () => { +test('K: both checkpoint attempts fail before commit — then (and only then) error', async () => { const db = makeMemDb() - let calls = 0 const origRpc = db.rpc.bind(db) + let attempts = 0 db.rpc = async (fn, args) => { - if (fn === 'own_checkpoint_page' && calls++ === 1) throw new Error('tx aborted') + // every page-2 attempt dies before commit — the retry cannot help; + // durable state never leaves the attempted position + if (fn === 'own_checkpoint_page' && args.p_expected_cursor === 'idx:50') { + attempts += 1 + throw new Error('tx aborted') + } return origRpc(fn, args) } const r = await run(db, makeTransport({ nodes: syntheticNodes(), pageSize: 50 })) assert.equal(r.status, SLICE_STATUS.ERROR) - // exactly one page's worth of counters (first applied); the second did not + assert.equal(r.code, 'OWNERSHIP_INTERNAL_ERROR') + assert.equal(attempts, 2) // exactly one retry, then the closed error path + // page-1 counters stay committed; the failed page wrote nothing const keys = new Set(db.workRows().map((w) => w.work_key)) assert.ok(keys.size > 0) const sync = await db.loadSync() assert.equal(sync.phase, 'error') assert.equal(sync.error, 'OWNERSHIP_INTERNAL_ERROR') + assert.equal(sync.history_cursor.current.cursor, 'idx:50') // unmoved }) // ══ pinned-object failure ════════════════════════════════════════════════ @@ -946,3 +969,188 @@ test('L: build-start/pin cost consumption prevents history paging', async () => assert.equal((await db.loadSync()).building_version, 1) }) + +// ══ checkpoint unknown-outcome reconciliation ═══════════════════════════ +// A thrown own_checkpoint_page call may have COMMITTED — only the response +// was lost. The engine must reconcile the durable sync row before deciding: +// adopt a proven commit, retry the identical call at the expected position, +// and never let a stale worker mark phase='error' over newer state. +// (spec C is covered by crash-matrix test B; spec E by crash-matrix test K) + +test('UA: page commits then response is lost — reconcile adopts it, no replay', async () => { + const nodes = syntheticNodes().slice(0, 30) + const db = makeMemDb() + const attempts = [] + let thrown = false + const orig = db.rpc.bind(db) + db.rpc = async (fn, args) => { + if (fn !== 'own_checkpoint_page') return orig(fn, args) + attempts.push(structuredClone(args)) + const r = await orig(fn, args) // the in-memory commit LANDS + if (!thrown) { thrown = true; throw new Error('response lost after commit') } + return r + } + const r = await run(db, makeTransport({ nodes, pageSize: 10 })) + assert.equal(r.status, SLICE_STATUS.RESOLVE_READY) + // 3 pages -> exactly 3 checkpoint calls: reconciliation recognized the + // commit on the first durable read and never retried or replayed + assert.equal(attempts.length, 3) + const sync = await db.loadSync() + assert.equal(sync.phase, 'history') + assert.equal(sync.error, null) + assert.equal(sync.history_cursor.current.pass, 'resolve') + // counted exactly once — the full stream projects to the reducer result + const expected = reduceHistoryToMonthly({ repositoryId: REPO, scope: { id: 's' }, nodes }) + assert.deepEqual(projectWorkToMonthly(db.workRows()).months, expected.months) +}) + +test('UB: final page commits then response is lost — reconcile returns resolve-ready', async () => { + const nodes = syntheticNodes().slice(0, 15) + const db = makeMemDb() + const attempts = [] + const orig = db.rpc.bind(db) + db.rpc = async (fn, args) => { + if (fn !== 'own_checkpoint_page') return orig(fn, args) + attempts.push(structuredClone(args)) + const r = await orig(fn, args) // commit lands — B1 flips pass -> resolve + if (args.p_last_page === true) throw new Error('response lost after commit') + return r + } + const r = await run(db, makeTransport({ nodes, pageSize: 10 })) + assert.equal(r.status, SLICE_STATUS.RESOLVE_READY) + assert.equal(attempts.length, 2) // one call per page — no replay + const cur = (await db.loadSync()).history_cursor.current + assert.equal(cur.pass, 'resolve') + assert.equal(cur.cursor, null) + assert.equal(cur.accumulate_done, true) + const sync = await db.loadSync() + assert.equal(sync.phase, 'history') + assert.equal(sync.error, null) + const expected = reduceHistoryToMonthly({ repositoryId: REPO, scope: { id: 's' }, nodes }) + assert.deepEqual(projectWorkToMonthly(db.workRows()).months, expected.months) +}) + +test('UD: commit landed, reconcile read raced it, retry returns stale', async () => { + const nodes = syntheticNodes().slice(0, 30) + const db = makeMemDb() + const attempts = [] + let preCommit = null + const orig = db.rpc.bind(db) + db.rpc = async (fn, args) => { + if (fn !== 'own_checkpoint_page') return orig(fn, args) + attempts.push(structuredClone(args)) + if (attempts.length === 1) { + preCommit = structuredClone(await db.loadSync()) // durable row BEFORE commit + await orig(fn, args) // commit lands + throw new Error('response lost') + } + return orig(fn, args) + } + const origLoad = db.loadSync.bind(db) + db.loadSync = async () => { + // the first reconcile read still shows the pre-commit position, so the + // engine retries — and the real CAS answers 'stale' for the replay + if (preCommit) { const p = preCommit; preCommit = null; return p } + return origLoad() + } + const r = await run(db, makeTransport({ nodes, pageSize: 10 })) + assert.equal(r.status, SLICE_STATUS.RESOLVE_READY) + // commit+throw, identical retry -> stale, then the remaining 2 pages + assert.equal(attempts.length, 4) + assert.deepEqual(attempts[1], attempts[0]) + const sync = await db.loadSync() + assert.equal(sync.phase, 'history') + assert.equal(sync.error, null) + const expected = reduceHistoryToMonthly({ repositoryId: REPO, scope: { id: 's' }, nodes }) + assert.deepEqual(projectWorkToMonthly(db.workRows()).months, expected.months) +}) + +test('UF: a stalled worker cannot poison a cursor another worker advanced', async () => { + const nodes = syntheticNodes().slice(0, 60) + const db = makeMemDb() + const t1 = makeTransport({ nodes, pageSize: 10, cost: pageCosts(1) }) + const first = await run(db, t1, { costBudget: 1, workerId: 'A-slice-1' }) + assert.equal(first.status, SLICE_STATUS.SLICE_BUDGET) // page 1 -> idx:10 + const st1 = await db.loadSync() + const scopeId = st1.history_cursor.current.scope_id + assert.equal(st1.history_cursor.current.cursor, 'idx:10') + + // Worker A's next slice: its checkpoint request never reaches the DB — + // while it is in flight worker B commits two further pages through the + // real CAS + const orig = db.rpc.bind(db) + let fired = false + db.rpc = async (fn, args) => { + if (fn !== 'own_checkpoint_page' || fired) return orig(fn, args) + fired = true + for (const [from, to] of [['idx:10', 'idx:20'], ['idx:20', 'idx:30']]) { + await orig(fn, { + p_user: USER, p_repository: REPO, p_version: 1, p_scope: scopeId, + p_pass: 'accumulate', p_expected_cursor: from, p_next_cursor: to, + p_deltas: [], p_identities: [], p_last_page: false, + }) + } + throw new Error('A: request lost in flight') + } + const r = await run(db, makeTransport({ nodes, pageSize: 10 }), { workerId: 'A-stale' }) + // A defers to the canonical row — never an error from stale knowledge + assert.equal(r.status, SLICE_STATUS.STALE) + const sync = await db.loadSync() + assert.equal(sync.phase, 'history') + assert.equal(sync.error, null) + assert.equal(sync.history_cursor.current.cursor, 'idx:30') + assert.equal(await isResumable(db, sync), true) + // position-awareness proof: an error write pinned to A's attempted + // position cannot land on the advanced row — while the same write at the + // CURRENT position would + const current = (await db.loadSync()).history_cursor + assert.equal(await db.casSync({ + userId: USER, repositoryId: REPO, + match: { building_version: 1, history_cursor: current }, + patch: {}, + }), true) + const poisoned = await db.casSync({ + userId: USER, repositoryId: REPO, + match: { + building_version: 1, + history_cursor: { ...current, current: { scope_id: scopeId, pass: 'accumulate', cursor: 'idx:10' } }, + }, + patch: { phase: 'error', error: 'OWNERSHIP_INTERNAL_ERROR' }, + }) + assert.equal(poisoned, false) + assert.equal((await db.loadSync()).phase, 'history') + // B's frontier is authoritative and resumable + const done = await driveToTerminal(db, makeTransport({ nodes, pageSize: 10 })) + assert.equal(done.status, SLICE_STATUS.RESOLVE_READY) +}) + +test('UG: a stalled worker cannot poison the ACCUMULATE -> RESOLVE transition', async () => { + const nodes = syntheticNodes().slice(0, 20) + const db = makeMemDb() + const t1 = makeTransport({ nodes, pageSize: 10, cost: pageCosts(1) }) + const first = await run(db, t1, { costBudget: 1, workerId: 'A-slice-1' }) + assert.equal(first.status, SLICE_STATUS.SLICE_BUDGET) // page 1 -> idx:10 + + // A attempts the final page; the call commits under another worker's + // takeover (pass flips to resolve) before A's failure is handled + let calls = 0 + const orig = db.rpc.bind(db) + db.rpc = async (fn, args) => { + if (fn !== 'own_checkpoint_page') return orig(fn, args) + calls += 1 + const r = await orig(fn, args) // the final-page commit lands + throw new Error('A: response lost') + } + const r = await run(db, makeTransport({ nodes, pageSize: 10 }), { workerId: 'A-stale' }) + assert.equal(r.status, SLICE_STATUS.RESOLVE_READY) + assert.equal(calls, 1) // committed once, never replayed + const sync = await db.loadSync() + assert.equal(sync.phase, 'history') // no error overwrite on the handoff + assert.equal(sync.error, null) + const cur = sync.history_cursor.current + assert.equal(cur.pass, 'resolve') + assert.equal(cur.accumulate_done, true) + // no duplicate counters — the stream still projects to the reducer result + const expected = reduceHistoryToMonthly({ repositoryId: REPO, scope: { id: 's' }, nodes }) + assert.deepEqual(projectWorkToMonthly(db.workRows()).months, expected.months) +}) From d245ae709b245492f3656ddfa1606fcbde42b782 Mon Sep 17 00:00:00 2001 From: aliferous3 Date: Mon, 19 Oct 2026 23:24:14 +0400 Subject: [PATCH 4/4] fix: require cursor progression on continuing history pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A continuing page (hasNextPage=true) that returns the request cursor — or an empty endCursor — would checkpoint the same boundary and re-page the identical slice forever, double-counting deltas on every commit. Exactly- once assumes each accepted next cursor is a strictly new page boundary. Validate progression before own_checkpoint_page: a continuing page must return a non-empty endCursor different from the request cursor. Violations fail closed through the position-aware error path with OWNERSHIP_HISTORY_FAILED — no deltas persist, the durable cursor stays at the last valid boundary. Final pages are exempt: B1 flips the pass to resolve regardless of the terminal cursor value. Covered by unit tests CA-CE and real-Postgres proof E16 in the PG17 recovery drill. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- lib/ownership/accumulate.mjs | 19 +++++ scripts/ownership-engine-proofs.mjs | 60 +++++++++++++++ scripts/recovery-drill.mjs | 3 +- test/ownership-accumulate.test.mjs | 111 ++++++++++++++++++++++++++++ 4 files changed, 192 insertions(+), 1 deletion(-) diff --git a/lib/ownership/accumulate.mjs b/lib/ownership/accumulate.mjs index 3ee3194..d782645 100644 --- a/lib/ownership/accumulate.mjs +++ b/lib/ownership/accumulate.mjs @@ -672,6 +672,25 @@ async function accumulateScope({ db, transport, repo, st, budget, now }) { return { ...out, pages } } + // Cursor progression (§5.5 exactly-once): a CONTINUING page must advance + // the boundary — its endCursor has to be a non-empty opaque string that + // differs from the request cursor. An identical or empty endCursor would + // checkpoint the same position and re-page the identical slice forever, + // double-counting its deltas on every commit. The rule applies only to + // hasNextPage pages: the final page's endCursor is irrelevant because + // B1 flips the pass to resolve regardless of its value. Fail closed + // BEFORE the checkpoint — no deltas persist, the cursor stays at the + // last valid boundary. + if ( + h.pageInfo.hasNextPage === true && + (h.pageInfo.endCursor === '' || + h.pageInfo.endCursor.trim() === '' || + h.pageInfo.endCursor === cursor) + ) { + const out = await failAtPosition(db, st, pageCurrent(), 'OWNERSHIP_HISTORY_FAILED') + return { ...out, pages } + } + let deltas try { deltas = reducePageDeltas(repositoryId, h.nodes) diff --git a/scripts/ownership-engine-proofs.mjs b/scripts/ownership-engine-proofs.mjs index 231a824..4283c8c 100644 --- a/scripts/ownership-engine-proofs.mjs +++ b/scripts/ownership-engine-proofs.mjs @@ -24,6 +24,7 @@ // E13 checkpoint commits but response is lost -> reconcile adopts it // E14 final page commits but response is lost -> reconcile -> resolve-ready // E15 stale worker cannot poison a cursor another worker advanced (real CAS) +// E16 continuing page returning the SAME cursor -> rejected pre-checkpoint import crypto from 'node:crypto' import { runAccumulateSlice, SLICE_STATUS } from '../lib/ownership/accumulate.mjs' @@ -549,6 +550,65 @@ export async function engineProofs(sql) { `E15: build must resume to resolve-ready, got ${done.status}`) } + // ── E16: non-progressing cursor on a continuing page — rejected pre-RPC ── + // Page 1 commits to 'idx:50'; the next synthetic page claims hasNextPage + // yet returns the SAME endCursor. Exactly-once assumes each accepted next + // cursor is a strictly new boundary — the engine must fail closed before + // own_checkpoint_page: no deltas, no cursor move, closed taxonomy. + { + const t = await tenant() + const nodes = syntheticNodes().slice(0, 80) + const db = makePgOwnershipDb(sql) + let checkpointCalls = 0 + const origRpc = db.rpc.bind(db) + db.rpc = async (fn, args) => { + if (fn === 'own_checkpoint_page') checkpointCalls += 1 + return origRpc(fn, args) + } + const transport = makeTransport({ nodes, pageSize: 50 }) + const origGql = transport.graphql + transport.graphql = async (q, v) => { + if (q.includes('history(first') && v.cursor === 'idx:50') { + // pathological provider page: claims continuation, returns the + // request cursor as its own endCursor + return { + repository: { + object: { + __typename: 'Commit', oid: v.oid, + history: { + pageInfo: { hasNextPage: true, endCursor: 'idx:50' }, + nodes: nodes.slice(50, 80), + }, + }, + }, + rateLimit: { cost: 1, remaining: 5000, resetAt: 'x' }, + } + } + return origGql(q, v) + } + const r = await runAccumulateSlice({ + db, transport, repositoryId: t.repoId, env: env(t), + }) + check(r.status === SLICE_STATUS.ERROR && r.code === 'OWNERSHIP_HISTORY_FAILED', + `E16: expected error/OWNERSHIP_HISTORY_FAILED, got ${r.status}/${r.code ?? 'none'}`) + check(checkpointCalls === 1, + `E16: the malformed page must never reach the RPC — got ${checkpointCalls} checkpoint calls`) + const st = await syncRow(t) + check(st?.phase === 'error' && st?.error === 'OWNERSHIP_HISTORY_FAILED', + 'E16: closed taxonomy only — phase=error, error=OWNERSHIP_HISTORY_FAILED') + check(st?.history_cursor?.current?.cursor === 'idx:50', + 'E16: durable cursor must rest at the last valid page') + const work = await workRows(t) + const expected1 = reduceHistoryToMonthly({ + repositoryId: t.repoId, scope: { id: 'repo' }, nodes: nodes.slice(0, 50), + }) + const projected = projectWorkToMonthly(work) + check(work.length > 0 && JSON.stringify(projected.months) === JSON.stringify(expected1.months), + 'E16: page-1 counters remain exactly once; the malformed page wrote nothing') + check(String(st?.error ?? '').includes('idx') === false, + 'E16: persisted error must not carry cursor/provider text') + } + // every proof tenant self-destructs — user deletion cascades through // repositories into every ownership table, leaving the seeded fixture // untouched for the backup/restore leg diff --git a/scripts/recovery-drill.mjs b/scripts/recovery-drill.mjs index 4bdad3e..8c8a8bf 100644 --- a/scripts/recovery-drill.mjs +++ b/scripts/recovery-drill.mjs @@ -821,7 +821,8 @@ async function main() { console.log('drill: engine proofs passed — pinned end-to-end ACCUMULATE, reducer differential,') console.log(' restart equivalence, stale/replay CAS, checkpoint-failure, pin-loss fail-closed,') console.log(' lease CAS, canonical abandon, zero-identity persistence, lost-response') - console.log(' reconciliation (commit-adopt, final-page handoff, stale-worker no-poison)') + console.log(' reconciliation (commit-adopt, final-page handoff, stale-worker no-poison),') + console.log(' and non-progressing-cursor fail-closed before checkpoint') // same backup path the operator uses const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'dl-drill-')) diff --git a/test/ownership-accumulate.test.mjs b/test/ownership-accumulate.test.mjs index 21f0a1c..98e9efe 100644 --- a/test/ownership-accumulate.test.mjs +++ b/test/ownership-accumulate.test.mjs @@ -1154,3 +1154,114 @@ test('UG: a stalled worker cannot poison the ACCUMULATE -> RESOLVE transition', const expected = reduceHistoryToMonthly({ repositoryId: REPO, scope: { id: 's' }, nodes }) assert.deepEqual(projectWorkToMonthly(db.workRows()).months, expected.months) }) + +// ══ cursor progression guard ═════════════════════════════════════════════ +// A continuing page (hasNextPage=true) must return a strictly advanced, +// non-empty endCursor — an identical or empty cursor would checkpoint the +// same boundary and re-page the identical slice forever, double-counting +// deltas on every commit. The guard fires BEFORE own_checkpoint_page. + +// transport wrapper: replace the history response at `atCursor` entirely +const withMalformedPage = (t, nodes, atCursor, pageInfo) => { + const orig = t.graphql + t.graphql = async (q, v) => { + if (isHistory(q) && v.cursor === atCursor) { + return { + repository: { + object: { + __typename: 'Commit', oid: v.oid, + history: { pageInfo, nodes }, + }, + }, + rateLimit: { cost: 1, remaining: 5000, resetAt: 'x' }, + } + } + return orig(q, v) + } + return t +} + +test('CA: same cursor on a continuing page fails closed before checkpoint', async () => { + const db = makeMemDb() + const nodes = syntheticNodes().slice(0, 30) + const t = withMalformedPage( + makeTransport({ nodes, pageSize: 10 }), nodes.slice(10, 20), 'idx:10', + { hasNextPage: true, endCursor: 'idx:10' }, // requests idx:10, returns idx:10 + ) + const r = await run(db, t) + assert.equal(r.status, SLICE_STATUS.ERROR) + assert.equal(r.code, 'OWNERSHIP_HISTORY_FAILED') + assert.equal(db._state.calls.checkpoint, 1) // page 1 only — malformed page never reaches the RPC + const sync = await db.loadSync() + assert.equal(sync.phase, 'error') + assert.equal(sync.error, 'OWNERSHIP_HISTORY_FAILED') + assert.ok(!String(sync.error).includes('idx')) // closed taxonomy, no cursor/provider text + assert.equal(sync.history_cursor.current.cursor, 'idx:10') // last valid boundary + // exactly the page-1 counters — the malformed page contributed zero + const expected1 = reduceHistoryToMonthly({ repositoryId: REPO, scope: { id: 's' }, nodes: nodes.slice(0, 10) }) + assert.deepEqual(projectWorkToMonthly(db.workRows()).months, expected1.months) +}) + +test('CB: a continuing page can never keep the cursor at null/non-string', async () => { + const db = makeMemDb() + const nodes = syntheticNodes().slice(0, 20) + const t = withMalformedPage( + makeTransport({ nodes, pageSize: 10 }), nodes.slice(0, 10), null, + { hasNextPage: true, endCursor: null }, + ) + const r = await run(db, t) + assert.equal(r.status, SLICE_STATUS.ERROR) + assert.equal(r.code, 'OWNERSHIP_HISTORY_FAILED') + assert.equal(db._state.calls.checkpoint, 0) // no checkpoint at all + const sync = await db.loadSync() + assert.equal(sync.phase, 'error') + assert.equal(sync.history_cursor.current.cursor, null) // unmoved + assert.equal(db.workRows().length, 0) +}) + +test('CC: empty/whitespace endCursor on a continuing page fails closed', async () => { + for (const bad of ['', ' ']) { + const db = makeMemDb() + const nodes = syntheticNodes().slice(0, 30) + const t = withMalformedPage( + makeTransport({ nodes, pageSize: 10 }), nodes.slice(10, 20), 'idx:10', + { hasNextPage: true, endCursor: bad }, + ) + const r = await run(db, t) + assert.equal(r.status, SLICE_STATUS.ERROR, `endCursor=${JSON.stringify(bad)}`) + assert.equal(r.code, 'OWNERSHIP_HISTORY_FAILED') + assert.equal(db._state.calls.checkpoint, 1) + assert.equal((await db.loadSync()).history_cursor.current.cursor, 'idx:10') + } +}) + +test('CD: normal cursor progression is unaffected', async () => { + const nodes = syntheticNodes().slice(0, 30) + const db = makeMemDb() + const r = await run(db, makeTransport({ nodes, pageSize: 10 })) + assert.equal(r.status, SLICE_STATUS.RESOLVE_READY) + const cursors = db._state.calls.checkpointArgs.map((a) => [a.p_expected_cursor, a.p_next_cursor]) + assert.deepEqual(cursors, [[null, 'idx:10'], ['idx:10', 'idx:20'], ['idx:20', 'idx:30']]) +}) + +test('CE: the final page imposes no progression rule — endCursor may be null', async () => { + const nodes = syntheticNodes().slice(0, 15) + const db = makeMemDb() + const t = makeTransport({ nodes, pageSize: 10 }) + const orig = t.graphql + t.graphql = async (q, v) => { + const res = await orig(q, v) + // terminal page: the provider may return a null endCursor — B1 flips + // the pass to resolve regardless of its value + if (isHistory(q) && v.cursor === 'idx:10') { + res.repository.object.history.pageInfo.endCursor = null + } + return res + } + const r = await run(db, t) + assert.equal(r.status, SLICE_STATUS.RESOLVE_READY) + const cur = (await db.loadSync()).history_cursor.current + assert.equal(cur.pass, 'resolve') + const expected = reduceHistoryToMonthly({ repositoryId: REPO, scope: { id: 's' }, nodes }) + assert.deepEqual(projectWorkToMonthly(db.workRows()).months, expected.months) +})