From e7b1826f2385970a96a00abb2671faf5011b6c24 Mon Sep 17 00:00:00 2001 From: Andrei Hasna Date: Tue, 11 Aug 2026 05:52:57 +0300 Subject: [PATCH 1/3] feat(safe-read): prove a collection read complete, or refuse Six published claims were corrected inside three hours on 2026-08-11 across five tools, every one a bounded or failed read reported as the population. Every agent involved already held the rule and several had written parts of it, so the remedy is an abstraction rather than more prose. The design principle: COMPLETENESS MUST BE PROVEN, never inferred from the absence of a marker. A read is accepted only when it carries one of five named proofs, and refused otherwise. Five mechanisms, each with both fixture arms: 1 cut short truncation, including the stderr-only notice 2 failed-as-empty rc, error objects, parse failures, missing rows key 3 unpaginated a clean first page that proves nothing 4 predicate ignored a query verb that returns rows for any predicate 5 defaulted scope a complete, honest read of a narrower set Mechanism 3 is why a checker over captured bytes is not enough: establishing completeness needs a second observation. Widening alone is unsound against a silent server clamp, so the widened count must also clear a clamp census. src/safe-read.ts stays pure so importing the contract pulls in no subprocess surface; src/safe-read-exec.ts spawns from an argv array, never a shell. Agent: vespasian --- package.json | 10 +- src/cli/index.ts | 34 ++- src/cli/read.ts | 107 ++++++++ src/safe-read-exec.ts | 564 ++++++++++++++++++++++++++++++++++++++++ src/safe-read.ts | 393 ++++++++++++++++++++++++++++ tests/safe-read.test.ts | 500 +++++++++++++++++++++++++++++++++++ 6 files changed, 1606 insertions(+), 2 deletions(-) create mode 100644 src/cli/read.ts create mode 100644 src/safe-read-exec.ts create mode 100644 src/safe-read.ts create mode 100644 tests/safe-read.test.ts diff --git a/package.json b/package.json index ced62b1..eb4cf61 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,14 @@ "types": "./dist/validators.d.ts", "import": "./dist/validators.js" }, + "./safe-read": { + "types": "./dist/safe-read.d.ts", + "import": "./dist/safe-read.js" + }, + "./safe-read/exec": { + "types": "./dist/safe-read-exec.d.ts", + "import": "./dist/safe-read-exec.js" + }, "./no-cloud": { "types": "./dist/no-cloud.d.ts", "import": "./dist/no-cloud.js" @@ -109,7 +117,7 @@ "LICENSE" ], "scripts": { - "build": "bun run deployment:check && bun run todos:check && rm -rf dist && bun build src/index.ts src/schemas.ts src/validators.ts src/deployment.ts src/deployment/index.ts src/deployment-artifacts.ts src/no-cloud.ts src/server-backend.ts src/service-contract.ts src/secure-local-store.ts src/conformance.ts src/artifact-scan.ts src/kit/generate.ts src/auth/index.ts src/sdk/generate.ts src/client/transport.ts src/client/storage.ts --root src --outdir dist --target bun && bun build src/todos/index.ts --root src --outdir dist --target bun --packages external && bun build src/cli/index.ts --outdir dist/cli --target bun && cp src/cli/contracts-cli.ts dist/cli/contracts-cli.js && cp src/hasna.contract.schema.json dist/hasna.contract.schema.json && tsc -p tsconfig.build.json --emitDeclarationOnly --outDir dist", + "build": "bun run deployment:check && bun run todos:check && rm -rf dist && bun build src/index.ts src/schemas.ts src/validators.ts src/safe-read.ts src/safe-read-exec.ts src/deployment.ts src/deployment/index.ts src/deployment-artifacts.ts src/no-cloud.ts src/server-backend.ts src/service-contract.ts src/secure-local-store.ts src/conformance.ts src/artifact-scan.ts src/kit/generate.ts src/auth/index.ts src/sdk/generate.ts src/client/transport.ts src/client/storage.ts --root src --outdir dist --target bun && bun build src/todos/index.ts --root src --outdir dist --target bun --packages external && bun build src/cli/index.ts --outdir dist/cli --target bun && cp src/cli/contracts-cli.ts dist/cli/contracts-cli.js && cp src/hasna.contract.schema.json dist/hasna.contract.schema.json && tsc -p tsconfig.build.json --emitDeclarationOnly --outDir dist", "typecheck": "tsc --noEmit", "test": "bun test --timeout 120000", "lint": "tsc --noEmit", diff --git a/src/cli/index.ts b/src/cli/index.ts index ec347fb..a049e3d 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -15,6 +15,7 @@ import { secureLocalStorePolicy } from "../secure-local-store"; import { runVendorKit } from "./kit-runner"; import { runIssueKey } from "./issue-key"; import { formatArtifactScanReport, resolveAssetInventoryWaivers, scanPublishedArtifact } from "../artifact-scan"; +import { runSafeReadCli } from "./read"; function collectJsonFiles(root: string): string[] { const stat = statSync(root); @@ -67,7 +68,7 @@ function preflightJsonUsageErrors(argv: string[]) { return false; } - if (!["schemas", "validate", "conformance", "no-cloud-scan", "repo-conformance", "vendor-kit", "issue-key", "artifact-scan", "secure-local-store"].includes(command)) { + if (!["schemas", "validate", "conformance", "no-cloud-scan", "repo-conformance", "vendor-kit", "issue-key", "artifact-scan", "secure-local-store", "read"].includes(command)) { return reportParserJsonError("commander.unknownCommand", `unknown command '${command}'`); } @@ -77,6 +78,14 @@ function preflightJsonUsageErrors(argv: string[]) { return false; } + // `read` carries an entire foreign command line after `--`, including that + // command's own flags. Preflighting those against this program's option set + // would reject `contracts read -- todos list --limit 5` for an option that is + // not ours to validate. Commander handles it. + if (command === "read") { + return false; + } + const allowedOptionsByCommand: Record> = { schemas: new Set(["--json", "-j"]), validate: new Set(["--json", "-j", "--schema"]), @@ -476,6 +485,29 @@ export function createContractsProgram() { } }); + program + .command("read") + .description("Run a Hasna collection read and either prove it complete or REFUSE (exit 2)") + .argument("[command...]", "The command to run, after --. e.g. contracts read -- todos list --json") + .option("--rows-key ", "Key holding the row array (auto-detected when omitted)") + .option("--total-key ", "Key holding a self-declared population size") + .option("--limit-flag ", "Flag the target uses to bound rows (default --limit)") + .option("--limit ", "Bound to pass on the first read; enables the widening proof") + .option("--widen-to ", "Bound for the widening probe (default limit * 4)") + .option("--known-clamp ", "The server cap this surface really imposes, if you have established it") + .option("--cursor-flag ", "Flag the target uses to page, e.g. --cursor") + .option("--max-pages ", "Refuse rather than page beyond this many pages (default 200)") + .option("--sibling-arg ", "Repeatable: argv of a sibling verb carrying an aggregate", collectOption) + .option("--sibling-path ", "Dotted path to the aggregate, e.g. by_scope.global") + .option("--probe-positive-arg ", "Repeatable: tokens forming a query that MUST match something", collectOption) + .option("--probe-negative-arg ", "Repeatable: tokens forming a query that MUST match nothing", collectOption) + .option("--allow-empty", "An empty result is a legitimate answer here") + .option("--assume-complete", "Waive proof; recorded in the evidence and never silent") + .option("-j, --json", "Output JSON") + .action((command: string[], options: Record) => { + process.exitCode = runSafeReadCli(command ?? [], options as never); + }); + program .command("issue-key") .description("Mint an API key (prefix hasna__): stores the hashed record and prints the secret ONCE") diff --git a/src/cli/read.ts b/src/cli/read.ts new file mode 100644 index 0000000..d40d104 --- /dev/null +++ b/src/cli/read.ts @@ -0,0 +1,107 @@ +/** + * `contracts read` — run a Hasna collection read and either prove it whole or refuse. + * + * Exit codes follow the convention probe-guard already established on this fleet, so + * an agent does not have to hold two vocabularies: + * 0 the read is proven complete; rows are on stdout + * 2 REFUSED — completeness could not be established (the interesting one) + * 3 usage error + * + * A refusal is never an empty result. Nothing is printed to stdout that a caller + * could mistake for a population. + */ + +import { safeRead, type SafeReadResult } from "../safe-read-exec"; + +export interface ReadCliOptions { + rowsKey?: string | undefined; + totalKey?: string | undefined; + limitFlag?: string | undefined; + limit?: string | undefined; + widenTo?: string | undefined; + knownClamp?: string | undefined; + cursorFlag?: string | undefined; + maxPages?: string | undefined; + siblingArg?: string[] | undefined; + siblingPath?: string | undefined; + probeNegativeArg?: string[] | undefined; + probePositiveArg?: string[] | undefined; + allowEmpty?: boolean | undefined; + assumeComplete?: boolean | undefined; + json?: boolean | undefined; +} + +function toInt(value: string | undefined, flag: string): number | undefined { + if (value === undefined) return undefined; + const n = Number(value); + if (!Number.isInteger(n) || n <= 0) { + throw new Error(`${flag} must be a positive integer, got '${value}'`); + } + return n; +} + +export function runSafeReadCli( + argv: string[], + options: ReadCliOptions, + io: { log: (s: string) => void; err: (s: string) => void } = { + log: (s) => console.log(s), + err: (s) => console.error(s) + } +): number { + if (argv.length === 0) { + io.err("contracts read: no command given. Usage: contracts read [options] -- [args...]"); + return 3; + } + + let result: SafeReadResult; + try { + result = safeRead({ + argv, + rowsKey: options.rowsKey, + totalKey: options.totalKey, + allowEmpty: options.allowEmpty, + limitFlag: options.limitFlag, + limit: toInt(options.limit, "--limit"), + widenTo: toInt(options.widenTo, "--widen-to"), + knownClamp: toInt(options.knownClamp, "--known-clamp"), + cursorFlag: options.cursorFlag, + maxPages: toInt(options.maxPages, "--max-pages"), + siblingArgv: options.siblingArg, + siblingPath: options.siblingPath, + probeNegativeArgs: options.probeNegativeArg, + probePositiveArgs: options.probePositiveArg, + assumeComplete: options.assumeComplete + }); + } catch (error) { + io.err(`contracts read: ${error instanceof Error ? error.message : String(error)}`); + return 3; + } + + if (options.json) { + // On refusal `rows` is absent, not empty: a consumer that reaches for it must + // get undefined and fail, never a plausible zero. + io.log( + JSON.stringify( + result.ok + ? { ok: true, proofs: result.proofs, rowCount: result.rowCount, pages: result.pages, rows: result.rows, evidence: result.evidence } + : { ok: false, code: result.code, error: result.reason, pages: result.pages, evidence: result.evidence }, + null, + 2 + ) + ); + return result.ok ? 0 : 2; + } + + if (result.ok) { + io.err(`contracts read: PASS — ${result.reason}`); + for (const line of result.evidence) io.err(` ${line}`); + io.log(JSON.stringify(result.rows, null, 2)); + return 0; + } + + io.err(`contracts read: REFUSED [${result.code}]`); + io.err(` ${result.reason}`); + for (const line of result.evidence) io.err(` ${line}`); + io.err(" No rows are printed. A refused read is not an empty set."); + return 2; +} diff --git a/src/safe-read-exec.ts b/src/safe-read-exec.ts new file mode 100644 index 0000000..a1faf5e --- /dev/null +++ b/src/safe-read-exec.ts @@ -0,0 +1,564 @@ +/** + * safe-read-exec — run a Hasna collection read and either prove it complete or refuse. + * + * The pure contract lives in ./safe-read. This module is the half that can actually + * establish completeness, because establishing it requires a SECOND OBSERVATION: + * another page, a wider bound, or a sibling aggregate. No inspection of a single + * payload can do it, which is why a checker that only reads captured bytes passes + * the quietest member of this family. + * + * TWO PROPERTIES OF THE CAPTURE PATH, both deliberate: + * + * - The command is spawned from an ARGV ARRAY and never through a shell. That is + * not a style preference: composing a command into a shell string is how prose + * and identifiers get eaten by backticks and unbalanced quotes, and a helper + * that reintroduced it would be shipping one defect inside the fix for another. + * - stdout and stderr are captured SEPARATELY and the exit status is read from the + * process itself. Never a pipe, never 2>&1. A pipe takes its status from the last + * stage and can cut a large payload at one buffer; merging the streams corrupts + * the JSON with whatever diagnostics the tool emitted. + * + * WIDENING IS THE DEFAULT, NOT A FLAG. The lesson that shaped this API: a corrected + * count went 1 -> 3 by widening the same read and was STILL WRONG, then 3 -> 15 by + * changing shape. Widening agrees with itself until it happens to exceed the + * population, so the widened read is only accepted when it returns FEWER rows than + * its own bound — the one condition under which a bound cannot be what limited it. + */ + +import { spawnSync } from "node:child_process"; +import { + classifyRead, + locateRows, + readDottedPath, + type CapturedRead, + type CompletenessProof, + type ReadVerdict, + type RefusalCode +} from "./safe-read"; + +/** + * 256 MiB. Node's spawnSync defaults to 1 MiB and signals ENOBUFS on overflow — a + * truncating capture path inside the tool that exists to catch truncation. The + * overflow is also handled explicitly below rather than trusted to be impossible. + */ +const CAPTURE_BUFFER_BYTES = 256 * 1024 * 1024; + +/** A runaway cursor loop is a hang; bound it and refuse rather than spin. */ +const DEFAULT_MAX_PAGES = 200; + +/** + * Server-side caps that a surface applies SILENTLY, replacing the bound you asked + * for without saying so. Keyed by " ". + * + * Source: knowledge k_mso1r678_fhgm1o, "Conversations Readbounds Census Matrix", + * measured 2026-08-11 against @hasna/conversations 0.5.43 and the hosted service. + * Evidence grade is carried per row exactly as that census carries it, because a + * source-read bound and an exercised bound are different claims and merging them is + * how a table starts asserting more than anyone measured: + * M = live behaviour exercised + * S/U = established from installed client or bundled server source; live + * behaviour not safely measurable + * + * This table is an OPTIMISATION, not the safety property. An absent entry falls back + * to the round-number heuristic below, which is deliberately conservative. + */ +export const KNOWN_CLAMPS: Record = { + "conversations read": { cap: 500, grade: "M", note: "requests above 500 silently return 500" }, + "conversations channel read": { cap: 500, grade: "M", note: "same path as read; the clamp is hidden above 500" }, + "conversations search": { cap: 500, grade: "M", note: "server max 500 plus a 48 KiB compact-JSON budget" }, + "conversations since": { cap: 500, grade: "S/U", note: "requests above 500 can hide the clamp" }, + "conversations pinned": { cap: 500, grade: "S/U", note: "bare JSON carries no truncation metadata" }, + "conversations notifications": { cap: 500, grade: "S/U", note: "neither surface signals the server clamp" }, + "conversations agents list": { + cap: 500, + grade: "M", + note: "server hard-caps 500 newest by last_seen_at and IGNORES the client limit entirely" + }, + "conversations blockers": { cap: 500, grade: "S/U", note: "JSON ignores --limit; the flag moves only the human surface" }, + "conversations project list": { cap: 1000, grade: "M", note: "exactly 1000 cannot prove more; the probe is itself clamped" }, + "knowledge list": { cap: 200, grade: "M", note: "--limit above 200 is REJECTED at rc=1 rather than clamped" } +}; + +/** + * Counts that are far more likely to be a cap than a population. + * + * Used only when the surface is absent from KNOWN_CLAMPS. Landing exactly on one of + * these after widening is refused rather than accepted: the cost of a false refusal + * is one extra flag from the caller, and the cost of a false pass is a page + * published as a population. + */ +const SUSPICIOUS_COUNTS = new Set([10, 20, 25, 50, 100, 200, 250, 500, 1000, 2000, 5000]); + +/** + * Flags that widen a surface's SCOPE, as opposed to its row bound. + * + * MECHANISM 5, AND IT IS UNLIKE THE OTHERS. The read is complete, the predicate is + * correct, pagination is followed, and the declared total is honest — only the scope + * is defaulted, and nothing in the output is wrong. Measured 2026-08-11: + * + * knowledge list --limit 1 --json total=1526 + * knowledge list --limit 1 --include-archived --json total=1567 (41 invisible) + * todos list --json --limit 9000 7301, converged, a SUBSET + * todos list --all --json --limit 40000 40000 + * + * THE ENVELOPE'S `total` IS SCOPE-RELATIVE, so reconciling rows against it CONFIRMS + * a defaulted scope with a clean three-way agreement (1526 collected, 1526 distinct + * ids, 1526 declared — and 41 short). Total-reconciliation, which is otherwise this + * module's strongest proof, is structurally blind here. + * + * This census is deliberately TINY and is not an attempt to discover every scope + * flag on every CLI. That is a per-surface fact, it rots, and guessing it is worse + * than declaring the limitation. Naming the scope beside the number is the defence. + */ +export const SCOPE_WIDENERS: Record = { + "knowledge list": { flag: "--include-archived", grade: "M", note: "default hides archived items (1526 vs 1567)" }, + "todos list": { flag: "--all", grade: "M", note: "default is a subset; --all never reveals its own population" } +}; + +/** Longest matching " " prefix of the argv. */ +export function lookupScopeWidener(argv: string[]): { key: string; flag: string; grade: "M" | "S/U"; note: string } | null { + const words = argv.filter((token) => !token.startsWith("-")); + for (let take = Math.min(words.length, 4); take >= 1; take -= 1) { + const key = words.slice(0, take).join(" "); + const hit = SCOPE_WIDENERS[key]; + if (hit) return { key, ...hit }; + } + return null; +} + +/** Longest matching " " prefix of the argv. */ +export function lookupClamp(argv: string[]): { key: string; cap: number; grade: "M" | "S/U"; note: string } | null { + const words = argv.filter((token) => !token.startsWith("-")); + for (let take = Math.min(words.length, 4); take >= 1; take -= 1) { + const key = words.slice(0, take).join(" "); + const hit = KNOWN_CLAMPS[key]; + if (hit) return { key, ...hit }; + } + return null; +} + +export interface SafeReadRequest { + /** argv of the command under test. argv[0] is the binary. */ + argv: string[]; + rowsKey?: string | undefined; + totalKey?: string | undefined; + allowEmpty?: boolean | undefined; + /** Flag used to bound rows, e.g. "--limit". */ + limitFlag?: string | undefined; + /** Bound passed on the first read. Required for the widening proof. */ + limit?: number | undefined; + /** Bound used for the widening probe. Defaults to limit * 4. */ + widenTo?: number | undefined; + /** + * The server-side cap this surface really imposes, when the caller has + * established it. Overrides the census and disables the round-number heuristic. + */ + knownClamp?: number | undefined; + /** Flag used to page, e.g. "--cursor". */ + cursorFlag?: string | undefined; + maxPages?: number | undefined; + /** argv of a sibling verb carrying an aggregate, e.g. ["mementos","stats","--json"]. */ + siblingArgv?: string[] | undefined; + /** Dotted path to the aggregate in the sibling payload, e.g. "by_scope.global". */ + siblingPath?: string | undefined; + /** Tokens appended to argv forming a query that MUST match nothing. */ + probeNegativeArgs?: string[] | undefined; + /** Tokens appended to argv forming a query that MUST match something. */ + probePositiveArgs?: string[] | undefined; + /** Waive proof explicitly. Recorded in evidence; never silent. */ + assumeComplete?: boolean | undefined; + /** + * Acknowledge that the narrower default scope is the one you want, and say why. + * Turns a silent default into a recorded choice; it does not widen anything. + */ + scopeAck?: string | undefined; + /** Injectable for tests. */ + run?: ((argv: string[]) => CapturedRead) | undefined; +} + +export interface SafeReadResult { + ok: boolean; + code?: RefusalCode | "capture_overflow" | "spawn_failed" | undefined; + reason: string; + proofs: CompletenessProof[]; + rows: unknown[]; + rowCount: number; + pages: number; + /** + * The scope the count was taken under. A NUMBER WITHOUT ITS SCOPE IS NOT A + * POPULATION: `1526 (default)` and `1567 (--include-archived)` are different + * facts and neither is "the population" on its own. + */ + scope: string; + evidence: string[]; +} + +/** Spawn with both streams captured to memory, separately, with no shell. */ +export function runCaptured(argv: string[]): CapturedRead { + const [bin, ...args] = argv; + if (!bin) throw new Error("empty argv"); + const proc = spawnSync(bin, args, { + encoding: "utf8", + shell: false, + maxBuffer: CAPTURE_BUFFER_BYTES + }); + if (proc.error) { + const err = proc.error as NodeJS.ErrnoException; + if (err.code === "ENOBUFS") { + // The capture path itself truncated. Reporting the bytes we did get would be + // the very defect this tool exists to refuse. + return { stdout: "", stderr: `capture overflowed ${CAPTURE_BUFFER_BYTES} bytes`, code: 251 }; + } + return { stdout: "", stderr: `spawn failed: ${err.message}`, code: 252 }; + } + return { + stdout: proc.stdout ?? "", + stderr: proc.stderr ?? "", + code: proc.status ?? 253 + }; +} + +function withFlag(argv: string[], flag: string | undefined, value: string | number): string[] { + if (!flag) return argv; + const out = [...argv]; + const at = out.indexOf(flag); + if (at >= 0 && at + 1 < out.length) { + out[at + 1] = String(value); + return out; + } + out.push(flag, String(value)); + return out; +} + +/** + * Read a collection and prove it whole, or refuse. + * + * The order below is the order in which the four mechanisms can be ruled out + * cheaply, and each step is skipped only when an earlier one already produced a + * proof — so the expensive checks cost nothing on surfaces that declare a total. + */ +export function safeRead(request: SafeReadRequest): SafeReadResult { + const widener = lookupScopeWidener(request.argv); + const scopeLabel = + widener && request.argv.includes(widener.flag) + ? widener.flag + : request.scopeAck + ? `default (acknowledged: ${request.scopeAck})` + : "default"; + const result = safeReadInner(request); + // Stamp the scope onto every outcome, so no count leaves this module naked. + return { + ...result, + scope: result.scope && result.scope !== "default" ? result.scope : scopeLabel, + reason: result.ok ? `${result.reason} [scope: ${scopeLabel}]` : result.reason + }; +} + +function safeReadInner(request: SafeReadRequest): SafeReadResult { + const run = request.run ?? runCaptured; + const evidence: string[] = []; + const maxPages = request.maxPages ?? DEFAULT_MAX_PAGES; + + evidence.push(`argv: ${request.argv.join(" ")}`); + + // --- mechanism 5: DEFAULTED SCOPE --------------------------------------------- + // Checked first and cheaply. Every later proof is scope-relative, so a number + // established under a defaulted scope is a correct count of the wrong set. + const widener = lookupScopeWidener(request.argv); + const scope = widener && request.argv.includes(widener.flag) ? widener.flag : "default"; + if (widener && scope === "default" && !request.scopeAck) { + return { + ...fail( + "scope_defaulted", + `${widener.key} defaults to a NARROWER SCOPE than its full population (${widener.note}, evidence ${widener.grade}). ` + + `The read may be complete, the predicate correct and the declared total honest, and the number still be a subset — ` + + `a declared total is scope-relative, so reconciling against it CONFIRMS this rather than catching it. ` + + `Pass ${widener.flag} for the wider scope, or --scope-ack to record the narrower one as a deliberate choice.`, + evidence, + 0 + ), + scope + }; + } + if (widener) evidence.push(`scope: ${scope} (widener ${widener.flag} available, grade ${widener.grade})`); + else evidence.push(`scope: ${request.scopeAck ?? "unqualified"} (no scope widener in census for this surface)`); + + // --- mechanism 4: PREDICATE IGNORED ------------------------------------------ + // Run first. If the surface is not honouring the predicate, every count below is + // a count of the wrong population and no amount of paging repairs it. + if (request.probeNegativeArgs?.length || request.probePositiveArgs?.length) { + const probe = probePredicate(request, run, evidence); + if (probe) return probe; + } + + let argv = request.argv; + if (request.limit !== undefined) argv = withFlag(argv, request.limitFlag ?? "--limit", request.limit); + + let captured = run(argv); + let verdict = classifyRead(captured, { + rowsKey: request.rowsKey, + totalKey: request.totalKey, + allowEmpty: request.allowEmpty, + limit: request.limit + }); + evidence.push(...verdict.evidence); + let pages = 1; + + // --- mechanism 1 -> 3: follow the cursor to exhaustion ------------------------ + if (!verdict.ok && verdict.code === "unfollowed_cursor" && request.cursorFlag) { + const accumulated: unknown[] = []; + let cursor = verdict.nextCursor; + // Re-locate rows from the first page: classifyRead withholds rows on refusal, + // deliberately, so a caller can never use a partial result by accident. + const first = locateRows(safeParse(captured.stdout), request.rowsKey); + if (first.ok) accumulated.push(...first.rows); + + while (cursor && pages < maxPages) { + const paged = withFlag(argv, request.cursorFlag, cursor); + captured = run(paged); + pages += 1; + verdict = classifyRead(captured, { + rowsKey: request.rowsKey, + totalKey: request.totalKey, + allowEmpty: true, + limit: request.limit + }); + const located = locateRows(safeParse(captured.stdout), request.rowsKey); + if (located.ok) accumulated.push(...located.rows); + if (verdict.ok) break; + if (verdict.code !== "unfollowed_cursor") { + return fail(verdict.code!, `paging stopped at page ${pages}: ${verdict.reason}`, evidence, pages); + } + cursor = verdict.nextCursor; + } + if (cursor && pages >= maxPages) { + return fail("unfollowed_cursor", `still paging after ${maxPages} pages; refusing rather than reporting a partial set`, evidence, pages); + } + evidence.push(`paged to exhaustion over ${pages} page(s), ${accumulated.length} row(s)`); + return { + ok: true, + reason: `read proven complete by cursor_exhausted over ${pages} page(s)`, + proofs: ["cursor_exhausted"], + rows: accumulated, + rowCount: accumulated.length, + pages, + scope: "default", + evidence + }; + } + + if (verdict.ok) { + return { ok: true, reason: verdict.reason, proofs: verdict.proofs, rows: verdict.rows, rowCount: verdict.rowCount, pages, scope: "default", evidence }; + } + + // Only the "nothing proves this whole" refusals are recoverable by a second + // observation. A nonzero exit or an error object is a fact about the read and no + // amount of widening changes it. + const recoverable: RefusalCode[] = ["completeness_unproven", "page_cap_reached", "declared_total_mismatch", "stderr_truncation_notice"]; + if (!recoverable.includes(verdict.code!)) { + return fail(verdict.code!, verdict.reason, evidence, pages); + } + + // --- proof by SIBLING AGGREGATE ---------------------------------------------- + // The aggregate is named by the caller and never guessed. Measured reason: + // `mementos stats --json` carries `total`, and that field equals by_status.active + // with archived sitting OUTSIDE it — so an auto-detected "total" from a sibling + // would silently compare against the wrong population. + if (request.siblingArgv?.length && request.siblingPath) { + const sib = run(request.siblingArgv); + if (sib.code !== 0) { + evidence.push(`sibling aggregate unavailable (exit ${sib.code}); falling through`); + } else { + const aggregate = readDottedPath(safeParse(sib.stdout), request.siblingPath); + if (aggregate === undefined) { + evidence.push(`sibling path '${request.siblingPath}' absent or non-numeric; falling through`); + } else { + evidence.push(`sibling ${request.siblingPath}=${aggregate} vs rows=${verdict.rowCount}`); + if (aggregate === verdict.rowCount) { + const rows = locateRows(safeParse(captured.stdout), request.rowsKey); + return { + ok: true, + reason: `read proven complete by sibling_aggregate_agrees (${request.siblingPath}=${aggregate})`, + proofs: ["sibling_aggregate_agrees"], + rows: rows.ok ? rows.rows : [], + rowCount: verdict.rowCount, + pages, + scope: "default", + evidence + }; + } + return fail( + "declared_total_mismatch", + `sibling aggregate ${request.siblingPath}=${aggregate} disagrees with ${verdict.rowCount} row(s) read`, + evidence, + pages + ); + } + } + } + + // --- proof by WIDENING -------------------------------------------------------- + if (request.limitFlag !== undefined || request.limit !== undefined) { + const flag = request.limitFlag ?? "--limit"; + const base = request.limit ?? verdict.rowCount; + const wider = request.widenTo ?? Math.max(base * 4, base + 1); + const widened = run(withFlag(request.argv, flag, wider)); + pages += 1; + if (widened.code !== 0) { + return fail( + "completeness_unproven", + `widening probe to ${flag} ${wider} exited ${widened.code}, so completeness is still unproven. ` + + `stderr: ${widened.stderr.trim().slice(0, 200)}`, + evidence, + pages + ); + } + const wideVerdict = classifyRead(widened, { + rowsKey: request.rowsKey, + totalKey: request.totalKey, + allowEmpty: true, + limit: wider + }); + const wideRows = locateRows(safeParse(widened.stdout), request.rowsKey); + const wideCount = wideRows.ok ? wideRows.rows.length : -1; + evidence.push(`widened ${flag} ${base} -> ${wider}: ${verdict.rowCount} -> ${wideCount} row(s)`); + + if (wideVerdict.ok) { + return { ok: true, reason: `widened read proven complete by ${wideVerdict.proofs.join(" + ")}`, proofs: wideVerdict.proofs, rows: wideVerdict.rows, rowCount: wideVerdict.rowCount, pages, scope: "default", evidence }; + } + if (wideCount >= 0 && wideCount < wider) { + // `wideCount < wider` is necessary and NOT sufficient. A surface that silently + // clamps never honoured `wider` at all, so the comparison is against a bound + // that was never applied. Refuse whenever the count lands on a cap this + // surface is known to impose, or on a round number that is far more likely to + // be a cap than a population. + const clamp = request.knownClamp ?? lookupClamp(request.argv)?.cap; + const clampNote = lookupClamp(request.argv); + if (clamp !== undefined && wideCount >= clamp) { + return fail( + "hidden_clamp_suspected", + `widening to ${wider} returned ${wideCount} row(s), which meets this surface's known server cap of ${clamp}` + + (clampNote ? ` (${clampNote.key}, evidence ${clampNote.grade}: ${clampNote.note})` : "") + + `. The bound you asked for was never honoured, so a count below it proves nothing. ` + + `Page with a cursor, or cross-check against a sibling aggregate.`, + evidence, + pages + ); + } + if (clamp === undefined && SUSPICIOUS_COUNTS.has(wideCount)) { + return fail( + "hidden_clamp_suspected", + `widening to ${wider} returned exactly ${wideCount} row(s) — a round number, and this surface is not in the ` + + `clamp census, so a silent server cap cannot be ruled out. Widening cannot tell a population of ${wideCount} ` + + `from a hidden cap at ${wideCount}. Supply --known-clamp once you have established the real cap, or prove ` + + `completeness with a cursor or a sibling aggregate.`, + evidence, + pages + ); + } + if (wideCount > verdict.rowCount) { + evidence.push(`population grew between reads (${verdict.rowCount} -> ${wideCount}); returning the wider read`); + } + if (clampNote) evidence.push(`clamp census: ${clampNote.key} cap=${clampNote.cap} grade=${clampNote.grade}`); + return { + ok: true, + reason: `read proven complete by stable_under_widening (${wideCount} row(s), strictly below the bound ${wider}${ + clamp !== undefined ? ` and below the known cap ${clamp}` : " and not a round number" + })`, + proofs: ["stable_under_widening"], + rows: wideRows.ok ? wideRows.rows : [], + rowCount: wideCount, + pages, + scope: "default", + evidence + }; + } + return fail( + "page_cap_reached", + `widening to ${wider} returned ${wideCount} row(s), which still equals the bound. The population is at least ${wideCount} and the read is still a page.`, + evidence, + pages + ); + } + + if (request.assumeComplete) { + const rows = locateRows(safeParse(captured.stdout), request.rowsKey); + evidence.push("CALLER WAIVED PROOF (--assume-complete): this result is asserted, not established"); + return { + ok: true, + reason: "completeness ASSUMED by the caller, not proven", + proofs: ["assumed_complete"], + rows: rows.ok ? rows.rows : [], + rowCount: verdict.rowCount, + pages, + scope: "default", + evidence + }; + } + + return fail(verdict.code!, verdict.reason, evidence, pages); +} + +function probePredicate( + request: SafeReadRequest, + run: (argv: string[]) => CapturedRead, + evidence: string[] +): SafeReadResult | null { + const count = (argv: string[]): number | null => { + const cap = run(argv); + if (cap.code !== 0) return null; + const located = locateRows(safeParse(cap.stdout), request.rowsKey); + return located.ok ? located.rows.length : null; + }; + + if (request.probePositiveArgs?.length) { + const positive = count([...request.argv, ...request.probePositiveArgs]); + evidence.push(`predicate probe positive -> ${positive === null ? "unreadable" : positive} row(s)`); + if (positive === null || positive === 0) { + return fail( + "predicate_inert", + `a predicate known to match returned ${positive === null ? "an unreadable result" : "zero rows"}. ` + + `The query surface is not matching what it should, so a zero from the real query would mean nothing.`, + evidence, + 0 + ); + } + } + + if (request.probeNegativeArgs?.length) { + const negative = count([...request.argv, ...request.probeNegativeArgs]); + evidence.push(`predicate probe negative -> ${negative === null ? "unreadable" : negative} row(s)`); + if (negative === null) { + return fail("predicate_ignored", "the negative predicate probe produced an unreadable result", evidence, 0); + } + if (negative > 0) { + return fail( + "predicate_ignored", + `a predicate known to match NOTHING returned ${negative} row(s). The surface is ignoring the predicate, ` + + `so these rows are not the set you asked for. Use an exact-identity lookup instead of this query verb.`, + evidence, + 0 + ); + } + } + return null; +} + +function safeParse(text: string): unknown { + try { + return JSON.parse(text); + } catch { + return null; + } +} + +function fail( + code: SafeReadResult["code"], + reason: string, + evidence: string[], + pages: number +): SafeReadResult { + return { ok: false, code, reason, proofs: [], rows: [], rowCount: 0, pages, scope: "default", evidence }; +} + +export type { ReadVerdict }; diff --git a/src/safe-read.ts b/src/safe-read.ts new file mode 100644 index 0000000..e44663b --- /dev/null +++ b/src/safe-read.ts @@ -0,0 +1,393 @@ +/** + * safe-read — the collection-read contract between a Hasna CLI and its consumer. + * + * WHY THIS EXISTS. On 2026-08-11 six published claims were corrected inside three + * hours across five tools, every one a bounded or failed read reported as the + * population. Every agent involved already held the rule; several had written parts + * of it. A correctness property that depends on the caller remembering is the rule + * that was already broken, so this is the mechanical form. + * + * THE DESIGN PRINCIPLE, and everything below follows from it: + * + * COMPLETENESS MUST BE PROVEN. IT IS NEVER INFERRED FROM THE ABSENCE OF A FLAG. + * + * The quietest failure in this family is a first page that is complete, well-formed, + * rc=0 and parses, with no artefact of failure anywhere in the output. Nothing in + * such a payload can be inspected to reveal it. A checker that looks for a + * truncation marker and finds none has learned nothing, because several Hasna + * collection verbs cap with no marker at all — measured: `repos repos --json` + * returns 50 rows at rc=0 with a clean body, and the notice "showing 50 of 1793" + * appears only on stderr. + * + * So this module enumerates the PROOFS OF COMPLETENESS (see CompletenessProof) and + * refuses any read that carries none of them. Absence of evidence is a refusal, not + * a pass. + * + * This module is PURE: it classifies bytes you already captured and spawns nothing. + * The executor that runs the command, pages it, and cross-checks it lives in + * ./safe-read-exec, so that importing the contract never pulls in a subprocess + * surface. + */ + +/** Reason a read was refused. One tag per distinct mechanism, so callers can branch. */ +export type RefusalCode = + // --- mechanism 2: FAILED-AS-EMPTY ------------------------------------------- + | "nonzero_exit" + | "unparseable_stdout" + | "error_object" + | "store_unavailable" + | "rows_key_missing" + // --- mechanism 1: CUT SHORT --------------------------------------------------- + | "unfollowed_cursor" + | "declared_total_mismatch" + | "stderr_truncation_notice" + // --- mechanism 3: UNPAGINATED ------------------------------------------------- + | "completeness_unproven" + | "page_cap_reached" + | "hidden_clamp_suspected" + | "population_moved" + // --- mechanism 4: PREDICATE IGNORED ------------------------------------------- + | "predicate_ignored" + | "predicate_inert" + // --- mechanism 5: DEFAULTED SCOPE --------------------------------------------- + | "scope_defaulted"; + +/** + * The accepted proofs that a read covered its whole population. + * + * A verdict of `complete` must name at least one of these. They are listed in + * descending order of strength; `assumed_complete` is a caller waiver and is + * recorded in the evidence so it can never be silent. + */ +export type CompletenessProof = + /** rowCount equals a total the surface declared itself (e.g. knowledge `total`). */ + | "declared_total_satisfied" + /** Paged until the surface said there was no next page. */ + | "cursor_exhausted" + /** rowCount equals an aggregate read from a sibling verb the caller named. */ + | "sibling_aggregate_agrees" + /** + * Re-read at a strictly larger bound returned fewer rows than that bound AND + * fewer than any clamp that bound might have been silently replaced by. + * + * THIS IS THE WEAKEST PROOF AND IT IS UNSOUND AGAINST AN UNKNOWN HIDDEN CLAMP. + * Measured (knowledge k_mso1r678_fhgm1o, 2026-08-11): `conversations read` + * silently returns 500 for any request above 500, so `count < requestedBound` + * says nothing when the surface never honoured the bound. Two-step widening does + * not rescue it either — a true population of 300 and a hidden clamp of 500 both + * yield equal counts below both bounds, so the two cases are indistinguishable by + * widening at any number of steps. The executor therefore refuses whenever the + * widened count lands on a known or suspected clamp, and only accepts this proof + * strictly below it. + */ + | "stable_under_widening" + /** The caller explicitly waived proof. Always recorded, never inferred. */ + | "assumed_complete"; + +export interface CapturedRead { + /** Bytes the command wrote to stdout. Captured to a file, never through a pipe. */ + stdout: string; + /** Bytes the command wrote to stderr. Truncation notices hide here. */ + stderr: string; + /** The command's own exit status — never a pipeline's last stage. */ + code: number; +} + +export interface ClassifyOptions { + /** Key holding the row array. Auto-detected when omitted. */ + rowsKey?: string | undefined; + /** Key holding a self-declared population size. Auto-detected when omitted. */ + totalKey?: string | undefined; + /** An empty result is a legitimate answer rather than a suspicious one. */ + allowEmpty?: boolean | undefined; + /** The row bound that produced this read, if one was passed. */ + limit?: number | undefined; +} + +export interface ReadVerdict { + ok: boolean; + /** Present only when ok. */ + proofs: CompletenessProof[]; + /** Present only when !ok. */ + code?: RefusalCode | undefined; + /** One-line human explanation. Always present. */ + reason: string; + /** Rows, only when ok. Never a degraded [] standing in for a failure. */ + rows: unknown[]; + rowCount: number; + /** The population the surface declared, when it declared one. */ + declaredTotal?: number | undefined; + /** Opaque cursor for the next page, when the surface offered one. */ + nextCursor?: string | null | undefined; + hasMore?: boolean | undefined; + /** Everything the classifier observed, for pasting into a record. */ + evidence: string[]; +} + +const ROWS_KEY_CANDIDATES = [ + "rows", + "entries", + "items", + "results", + "records", + "tasks", + "messages", + "data" +] as const; + +/** + * Keys that mean "the size of the whole population". + * + * `count` is DELIBERATELY ABSENT. Measured on `todos show --json`, `comments_page.count` + * is the size of the page in hand, not of the population, so auto-detecting it would + * manufacture `declared_total_satisfied` on exactly the reads that are truncated. + */ +const TOTAL_KEY_CANDIDATES = ["total", "total_count", "totalCount", "totalItems"] as const; + +/** + * Truncation notices that Hasna CLIs write to STDERR while exiting 0. + * + * Anchors are deliberately avoided. In JavaScript and Python `m` makes `^`/`$` match + * per line and `s` makes `.` match a newline; in jq `m` IS dotall and there is no + * per-line anchor flag at all. A pattern that leans on anchors therefore changes + * meaning when it is carried between those engines, silently and in the direction of + * a false negative. These patterns match substrings and need no anchor. + */ +const STDERR_TRUNCATION_PATTERNS: Array<{ re: RegExp; note: string }> = [ + { re: /showing\s+(\d+)\s+of\s+(\d+)/i, note: "surface reported a bounded read" }, + { re: /\btruncated\b/i, note: "surface used the word truncated" }, + { re: /to see the rest/i, note: "surface offered a route to the remainder" }, + { re: /page with --cursor/i, note: "surface offered a cursor" } +]; + +function isPlainObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** Locate the row array without ever letting a miss become an empty list. */ +export function locateRows( + parsed: unknown, + rowsKey?: string +): { ok: true; rows: unknown[]; key: string } | { ok: false; error: string } { + if (Array.isArray(parsed)) { + return { ok: true, rows: parsed, key: "" }; + } + if (!isPlainObject(parsed)) { + return { ok: false, error: `payload is ${parsed === null ? "null" : typeof parsed}, not a list or an envelope` }; + } + if (rowsKey) { + const explicit = parsed[rowsKey]; + if (!Array.isArray(explicit)) { + return { + ok: false, + error: `--rows-key '${rowsKey}' is ${explicit === undefined ? "absent" : `a ${typeof explicit}`}, not an array` + }; + } + return { ok: true, rows: explicit, key: rowsKey }; + } + for (const candidate of ROWS_KEY_CANDIDATES) { + if (Array.isArray(parsed[candidate])) { + return { ok: true, rows: parsed[candidate] as unknown[], key: candidate }; + } + } + const arrayKeys = Object.keys(parsed).filter((k) => Array.isArray(parsed[k])); + if (arrayKeys.length === 1) { + return { ok: true, rows: parsed[arrayKeys[0]!] as unknown[], key: arrayKeys[0]! }; + } + return { + ok: false, + error: + arrayKeys.length === 0 + ? `no array-valued key in envelope {${Object.keys(parsed).join(",")}}` + : `ambiguous rows key: ${arrayKeys.join(", ")} — pass --rows-key` + }; +} + +/** Read a dotted path such as `by_scope.global` out of a parsed payload. */ +export function readDottedPath(parsed: unknown, path: string): number | undefined { + let cursor: unknown = parsed; + for (const segment of path.split(".")) { + if (!isPlainObject(cursor)) return undefined; + cursor = cursor[segment]; + } + return typeof cursor === "number" ? cursor : undefined; +} + +/** + * Classify one captured read. + * + * Returns `ok: false` with a RefusalCode whenever completeness cannot be + * established from this payload ALONE. That includes the ordinary, healthy-looking + * case of a bare array with no envelope: this function does not know whether such a + * read is complete, and says so rather than guessing. The executor answers that + * question by widening or by a sibling aggregate; see ./safe-read-exec. + */ +export function classifyRead(captured: CapturedRead, options: ClassifyOptions = {}): ReadVerdict { + const evidence: string[] = []; + const refuse = (code: RefusalCode, reason: string): ReadVerdict => ({ + ok: false, + code, + reason, + proofs: [], + rows: [], + rowCount: 0, + evidence + }); + + evidence.push(`exit=${captured.code}`); + evidence.push(`stdout=${captured.stdout.length}B stderr=${captured.stderr.length}B`); + + // --- mechanism 2: FAILED-AS-EMPTY ----------------------------------------- + // rc is checked before the body is even parsed. A CLI that writes + // {"ok":false,...} to stdout at rc=1 is the exact shape that ran the fleet + // knowledge monitor blind for ~11.6h: a reader degraded the dict to [] and a + // guard skipped the branch. + if (captured.code !== 0) { + return refuse( + "nonzero_exit", + `the command exited ${captured.code}. A failed read is not an empty set. stderr: ${ + captured.stderr.trim().slice(0, 300) || "(empty)" + }` + ); + } + + if (captured.stdout.trim() === "") { + return refuse("unparseable_stdout", "stdout was empty; there is no payload to judge"); + } + + let parsed: unknown; + try { + parsed = JSON.parse(captured.stdout); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return refuse( + "unparseable_stdout", + `stdout is not JSON (${message}). A parse failure is a refusal, never an empty list.` + ); + } + + if (isPlainObject(parsed)) { + if (parsed.ok === false) { + const err = typeof parsed.error === "string" ? parsed.error : JSON.stringify(parsed).slice(0, 200); + return refuse("error_object", `the surface returned an error object at exit 0: ${err}`); + } + if (typeof parsed.error === "string" && parsed.error.length > 0) { + return refuse("error_object", `the surface returned an error object at exit 0: ${parsed.error}`); + } + if (parsed.store_exists === false) { + return refuse( + "store_unavailable", + "the surface reported store_exists=false; zero rows here means the store was unreachable, not empty" + ); + } + } + + const located = locateRows(parsed, options.rowsKey); + if (!located.ok) { + return refuse( + "rows_key_missing", + `${located.error}. Refusing rather than reporting zero rows: a reader that cannot find the rows has measured nothing.` + ); + } + const rows = located.rows; + const rowCount = rows.length; + evidence.push(`rows=${rowCount} under ${located.key}`); + + // --- mechanism 1: CUT SHORT ------------------------------------------------- + // stderr first, because it is the stream a caller who redirected only stdout + // never sees, and it carries the most explicit notice the fleet emits. + for (const { re, note } of STDERR_TRUNCATION_PATTERNS) { + const hit = re.exec(captured.stderr); + if (hit) { + return refuse( + "stderr_truncation_notice", + `stderr carries a truncation notice (${note}): "${hit[0]}". The body parsed cleanly at exit 0; only stderr says the read was bounded.` + ); + } + } + + let declaredTotal: number | undefined; + let hasMore: boolean | undefined; + let nextCursor: string | null | undefined; + + if (isPlainObject(parsed)) { + const totalKeys = options.totalKey ? [options.totalKey] : TOTAL_KEY_CANDIDATES; + for (const key of totalKeys) { + const value = parsed[key]; + if (typeof value === "number") { + declaredTotal = value; + evidence.push(`declared ${key}=${value}`); + break; + } + } + if (typeof parsed.has_more === "boolean") hasMore = parsed.has_more; + else if (typeof parsed.hasMore === "boolean") hasMore = parsed.hasMore; + const cursor = parsed.next_cursor ?? parsed.nextCursor; + if (typeof cursor === "string" || cursor === null) nextCursor = cursor as string | null; + } + + if (hasMore === true) { + // This refusal MUST carry the cursor. It is the one refusal a caller can act on + // automatically, and an executor that cannot see next_cursor here silently + // stops after page one — measured: the paging fixture accumulated 2 rows of 5 + // because an earlier version of this branch dropped the field. + return { + ...refuse( + "unfollowed_cursor", + `the surface says has_more=true (cursor ${nextCursor ?? "unset"}). You are holding a page and about to call it a population.` + ), + rowCount, + declaredTotal, + nextCursor, + hasMore + }; + } + + if (declaredTotal !== undefined && rowCount < declaredTotal) { + return refuse( + "declared_total_mismatch", + `holding ${rowCount} of a declared total of ${declaredTotal}. The surface told you the size of its own population and this read does not cover it.` + ); + } + + // --- proofs ------------------------------------------------------------------ + const proofs: CompletenessProof[] = []; + if (declaredTotal !== undefined && rowCount >= declaredTotal) { + proofs.push("declared_total_satisfied"); + evidence.push(`rowCount ${rowCount} >= declared total ${declaredTotal}`); + } + if (hasMore === false || nextCursor === null) { + proofs.push("cursor_exhausted"); + evidence.push(`surface declared no next page (has_more=${hasMore}, next_cursor=${String(nextCursor)})`); + } + + if (proofs.length === 0) { + // The quiet case. Nothing in this payload is wrong; nothing in it proves + // completeness either. Whether the read is whole is a question this payload + // cannot answer, and the honest verdict is that it is unproven. + const cap = + options.limit !== undefined && rowCount === options.limit + ? ` rowCount equals the requested limit ${options.limit}, which is what a silent cap looks like.` + : ""; + return { + ok: false, + code: rowCount === options.limit ? "page_cap_reached" : "completeness_unproven", + reason: + `the payload carries no total, no cursor and no truncation notice, so nothing in it proves the read is whole.${cap} ` + + `Establish completeness by widening the bound, or by a sibling aggregate.`, + proofs: [], + rows: [], + rowCount, + declaredTotal, + nextCursor, + hasMore, + evidence + }; + } + + if (rowCount === 0 && !options.allowEmpty) { + evidence.push("empty result accepted only because a proof of completeness was present"); + } + + return { ok: true, proofs, reason: `read proven complete by ${proofs.join(" + ")}`, rows, rowCount, declaredTotal, nextCursor, hasMore, evidence }; +} diff --git a/tests/safe-read.test.ts b/tests/safe-read.test.ts new file mode 100644 index 0000000..4855ddd --- /dev/null +++ b/tests/safe-read.test.ts @@ -0,0 +1,500 @@ +/** + * Two-sided fixtures for safe-read. + * + * FOUR MECHANISMS, EACH WITH BOTH ARMS. A one-sided test passes whichever way round + * the condition is installed, which is the failure this whole module exists to + * refuse — so a fixture set that only exercised the happy path would be this defect + * inside its own fix. + * + * mechanism 1 CUT SHORT truncated -> REFUSE | untruncated -> PASS + * mechanism 2 FAILED-AS-EMPTY error -> REFUSE | genuine empty -> PASS + * mechanism 3 UNPAGINATED capped -> REFUSE | provably whole -> PASS + * mechanism 4 PREDICATE IGNORED inert -> REFUSE | discriminating -> PASS + * + * FIXTURE HYGIENE. Every fake surface here is a pure in-memory function. Nothing + * spawns a process and nothing writes to a path, so a copied fixture cannot land + * output in a real log directory; the one test that does touch the filesystem uses + * the OS temp dir via mkdtemp and removes it. See "fixtures write nowhere" below, + * which asserts that property rather than leaving it to inspection. + */ + +import { afterAll, describe, expect, test } from "bun:test"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { classifyRead, locateRows, readDottedPath, type CapturedRead } from "../src/safe-read"; +import { KNOWN_CLAMPS, lookupClamp, runCaptured, safeRead } from "../src/safe-read-exec"; +import { runSafeReadCli } from "../src/cli/read"; + +const ok = (stdout: string, stderr = ""): CapturedRead => ({ stdout, stderr, code: 0 }); + +/** A fake surface: maps an argv to a captured read. Spawns nothing, writes nothing. */ +function surface(handler: (argv: string[]) => CapturedRead) { + const calls: string[][] = []; + return { + calls, + run: (argv: string[]) => { + calls.push([...argv]); + return handler(argv); + } + }; +} + +function flagValue(argv: string[], flag: string): number | undefined { + const at = argv.indexOf(flag); + return at >= 0 && at + 1 < argv.length ? Number(argv[at + 1]) : undefined; +} + +// --------------------------------------------------------------------------- +// MECHANISM 1 — CUT SHORT +// --------------------------------------------------------------------------- +describe("mechanism 1: cut short", () => { + test("KNOWN-BAD: a stderr-only truncation notice is refused (the repos shape)", () => { + // Measured live 2026-08-11: `repos repos --json` returns 50 rows at rc=0 with a + // clean body; the notice appears only on stderr. A checker reading stdout alone + // passes this, which is why the helper captures both streams. + const verdict = classifyRead( + ok( + JSON.stringify(Array.from({ length: 50 }, (_, i) => ({ id: i }))), + "warning: showing 50 of 1793 repo(s) (offset 0, limit 50). Pass -n 1793 or page with --cursor 50 to see the rest.\n" + ) + ); + expect(verdict.ok).toBe(false); + expect(verdict.code).toBe("stderr_truncation_notice"); + expect(verdict.rows).toEqual([]); + }); + + test("KNOWN-GOOD: the same body with a clean stderr and a satisfied total passes", () => { + const verdict = classifyRead( + ok(JSON.stringify({ total: 50, rows: Array.from({ length: 50 }, (_, i) => ({ id: i })) }), "") + ); + expect(verdict.ok).toBe(true); + expect(verdict.proofs).toContain("declared_total_satisfied"); + expect(verdict.rowCount).toBe(50); + }); + + test("KNOWN-BAD: rows short of a declared total is refused", () => { + const verdict = classifyRead(ok(JSON.stringify({ total: 1524, rows: [{ id: 1 }, { id: 2 }] }))); + expect(verdict.ok).toBe(false); + expect(verdict.code).toBe("declared_total_mismatch"); + }); + + test("KNOWN-BAD: has_more=true is refused; KNOWN-GOOD: has_more=false passes", () => { + const bad = classifyRead(ok(JSON.stringify({ rows: [1, 2], has_more: true, next_cursor: "c1" }))); + expect(bad.ok).toBe(false); + expect(bad.code).toBe("unfollowed_cursor"); + + const good = classifyRead(ok(JSON.stringify({ rows: [1, 2], has_more: false, next_cursor: null }))); + expect(good.ok).toBe(true); + expect(good.proofs).toContain("cursor_exhausted"); + }); +}); + +// --------------------------------------------------------------------------- +// MECHANISM 2 — FAILED-AS-EMPTY +// --------------------------------------------------------------------------- +describe("mechanism 2: failed-as-empty", () => { + test("KNOWN-BAD: {ok:false} on stdout at rc=1 is refused, not degraded to []", () => { + // The shape that ran the fleet knowledge monitor blind for ~11.6h. + const verdict = classifyRead({ stdout: JSON.stringify({ ok: false, error: "store unreachable" }), stderr: "", code: 1 }); + expect(verdict.ok).toBe(false); + expect(verdict.code).toBe("nonzero_exit"); + expect(verdict.rows).toEqual([]); + expect(verdict.rowCount).toBe(0); + }); + + test("KNOWN-BAD: an error object at rc=0 is still refused", () => { + const verdict = classifyRead(ok(JSON.stringify({ ok: false, error: 'Project not found: "iproj-ads-ops"' }))); + expect(verdict.ok).toBe(false); + expect(verdict.code).toBe("error_object"); + expect(verdict.reason).toContain("iproj-ads-ops"); + }); + + test("KNOWN-BAD: store_exists=false is refused even with a well-formed empty list", () => { + const verdict = classifyRead(ok(JSON.stringify({ ok: true, store_exists: false, total: 0, rows: [] }))); + expect(verdict.ok).toBe(false); + expect(verdict.code).toBe("store_unavailable"); + }); + + test("KNOWN-BAD: unparseable stdout is refused rather than read as zero rows", () => { + const verdict = classifyRead(ok('{"rows":[1,2')); + expect(verdict.ok).toBe(false); + expect(verdict.code).toBe("unparseable_stdout"); + }); + + test("KNOWN-BAD: a missing rows key refuses instead of reporting zero", () => { + const verdict = classifyRead(ok(JSON.stringify({ ok: true, total: 3, payload: { nested: true } }))); + expect(verdict.ok).toBe(false); + expect(verdict.code).toBe("rows_key_missing"); + }); + + test("KNOWN-GOOD: a genuinely empty set with a declared total of 0 passes", () => { + const verdict = classifyRead(ok(JSON.stringify({ ok: true, store_exists: true, total: 0, rows: [] })), { + allowEmpty: true + }); + expect(verdict.ok).toBe(true); + expect(verdict.rowCount).toBe(0); + expect(verdict.proofs).toContain("declared_total_satisfied"); + }); +}); + +// --------------------------------------------------------------------------- +// MECHANISM 3 — UNPAGINATED +// --------------------------------------------------------------------------- +describe("mechanism 3: unpaginated", () => { + test("KNOWN-BAD: a complete-looking first page with no marker is refused", () => { + // rc=0, valid JSON, clean stderr, no has_more, no total. Nothing in this payload + // is wrong and nothing in it proves the read is whole. + const verdict = classifyRead(ok(JSON.stringify(Array.from({ length: 20 }, (_, i) => i))), { limit: 20 }); + expect(verdict.ok).toBe(false); + expect(verdict.code).toBe("page_cap_reached"); + }); + + test("KNOWN-BAD: probe-guard's blind spot — 50 rows, no marker, is not a pass here", () => { + const verdict = classifyRead(ok(JSON.stringify(Array.from({ length: 50 }, (_, i) => i)))); + expect(verdict.ok).toBe(false); + expect(verdict.code).toBe("completeness_unproven"); + }); + + test("KNOWN-GOOD: widening below the bound proves completeness", () => { + const s = surface((argv) => { + const limit = flagValue(argv, "--limit") ?? 20; + return ok(JSON.stringify(Array.from({ length: Math.min(37, limit) }, (_, i) => i))); + }); + const result = safeRead({ argv: ["fake", "list", "--json"], limit: 20, limitFlag: "--limit", run: s.run }); + expect(result.ok).toBe(true); + expect(result.proofs).toContain("stable_under_widening"); + expect(result.rowCount).toBe(37); + }); + + test("KNOWN-BAD: a HIDDEN CLAMP defeats widening and must be refused", () => { + // knowledge k_mso1r678_fhgm1o: `conversations read` silently returns 500 for any + // request above 500. Widening 200 -> 800 yields 500 rows, which is below 800 — + // so `count < bound` would wrongly read as complete. Two-step widening cannot + // rescue it either: a true population of 500 and a cap of 500 are identical + // under every bound. The clamp census is what makes this refusable. + const s = surface((argv) => { + const limit = flagValue(argv, "--limit") ?? 20; + const served = Math.min(limit, 500); // the silent clamp + return ok(JSON.stringify(Array.from({ length: served }, (_, i) => i))); + }); + const result = safeRead({ + argv: ["conversations", "read", "--channel", "board", "--json"], + limit: 200, + limitFlag: "--limit", + widenTo: 800, + run: s.run + }); + expect(result.ok).toBe(false); + expect(result.code).toBe("hidden_clamp_suspected"); + expect(result.reason).toContain("500"); + }); + + test("KNOWN-BAD: an uncensused surface landing on a round number is refused", () => { + const s = surface((argv) => { + const limit = flagValue(argv, "--limit") ?? 20; + return ok(JSON.stringify(Array.from({ length: Math.min(limit, 100) }, (_, i) => i))); + }); + const result = safeRead({ argv: ["unknowntool", "list"], limit: 50, limitFlag: "--limit", widenTo: 400, run: s.run }); + expect(result.ok).toBe(false); + expect(result.code).toBe("hidden_clamp_suspected"); + }); + + test("KNOWN-GOOD: --known-clamp lets a real population above a round number pass", () => { + const s = surface((argv) => { + const limit = flagValue(argv, "--limit") ?? 20; + return ok(JSON.stringify(Array.from({ length: Math.min(limit, 100) }, (_, i) => i))); + }); + const result = safeRead({ + argv: ["unknowntool", "list"], + limit: 50, + limitFlag: "--limit", + widenTo: 400, + knownClamp: 5000, + run: s.run + }); + expect(result.ok).toBe(true); + expect(result.proofs).toContain("stable_under_widening"); + expect(result.rowCount).toBe(100); + }); + + test("KNOWN-GOOD: a cursor is followed to exhaustion and rows accumulate", () => { + const pages: Record = { + "": { rows: [1, 2], next: "p2" }, + p2: { rows: [3, 4], next: "p3" }, + p3: { rows: [5], next: null } + }; + const s = surface((argv) => { + const at = argv.indexOf("--cursor"); + const key = at >= 0 ? argv[at + 1]! : ""; + const page = pages[key]!; + return ok(JSON.stringify({ rows: page.rows, has_more: page.next !== null, next_cursor: page.next })); + }); + const result = safeRead({ argv: ["fake", "digest"], cursorFlag: "--cursor", run: s.run }); + expect(result.ok).toBe(true); + expect(result.proofs).toContain("cursor_exhausted"); + expect(result.rows).toEqual([1, 2, 3, 4, 5]); + expect(result.pages).toBe(3); + }); +}); + +// --------------------------------------------------------------------------- +// THE ARM THAT MUST NOT FAIL CLOSED — the envelope-less mementos shape +// --------------------------------------------------------------------------- +describe("envelope-less surfaces are not fail-closed", () => { + test("KNOWN-GOOD: a bare array with NO envelope is proven by a sibling aggregate", () => { + // `mementos list --json` returns a bare array: no total, no has_more, no + // store_exists. A guard that fail-closed whenever a total is absent would refuse + // this surface forever, which is a broken helper rather than a safe one. The + // aggregate lives on the SIBLING verb `mementos stats --json`. + const s = surface((argv) => { + if (argv.includes("stats")) { + // Note `total` here is by_status.active and EXCLUDES archived — which is why + // the caller names the path and the helper never guesses one. + return ok(JSON.stringify({ total: 640, by_status: { active: 640, archived: 41 }, by_scope: { global: 681, shared: 337 } })); + } + return ok(JSON.stringify(Array.from({ length: 681 }, (_, i) => ({ key: `m${i}` })))); + }); + const result = safeRead({ + argv: ["mementos", "list", "--scope", "global", "--json"], + siblingArgv: ["mementos", "stats", "--json"], + siblingPath: "by_scope.global", + run: s.run + }); + expect(result.ok).toBe(true); + expect(result.proofs).toEqual(["sibling_aggregate_agrees"]); + expect(result.rowCount).toBe(681); + }); + + test("KNOWN-BAD: the same bare-array surface is refused when the sibling disagrees", () => { + const s = surface((argv) => + argv.includes("stats") + ? ok(JSON.stringify({ by_scope: { global: 681 } })) + : ok(JSON.stringify(Array.from({ length: 400 }, (_, i) => ({ key: `m${i}` })))) + ); + const result = safeRead({ + argv: ["mementos", "list", "--scope", "global", "--json"], + siblingArgv: ["mementos", "stats", "--json"], + siblingPath: "by_scope.global", + run: s.run + }); + expect(result.ok).toBe(false); + expect(result.code).toBe("declared_total_mismatch"); + }); + + test("a bare array is refused with a NAMED, actionable code rather than a generic failure", () => { + const verdict = classifyRead(ok(JSON.stringify([{ key: "a" }, { key: "b" }]))); + expect(verdict.ok).toBe(false); + expect(verdict.code).toBe("completeness_unproven"); + expect(verdict.reason).toContain("sibling aggregate"); + }); +}); + +// --------------------------------------------------------------------------- +// MECHANISM 4 — PREDICATE IGNORED +// --------------------------------------------------------------------------- +describe("mechanism 4: predicate silently ignored", () => { + test("KNOWN-BAD: a query that returns rows for a predicate matching NOTHING is refused", () => { + // The `gh search issues` shape: identical rows for two different queries, and + // `org:` ignored outright. Every count taken from such a surface is a count of + // the wrong population, and no amount of paging repairs it. + const s = surface(() => ok(JSON.stringify(Array.from({ length: 30 }, (_, i) => ({ id: i }))))); + const result = safeRead({ + argv: ["faketool", "search", "widget"], + probeNegativeArgs: ["zzz-no-such-token-zzz"], + run: s.run + }); + expect(result.ok).toBe(false); + expect(result.code).toBe("predicate_ignored"); + }); + + test("KNOWN-GOOD: a discriminating query passes both probes", () => { + const s = surface((argv) => { + if (argv.includes("zzz-no-such-token-zzz")) return ok("[]"); + if (argv.includes("known-present")) return ok(JSON.stringify([{ id: 1 }, { id: 2 }])); + return ok(JSON.stringify({ total: 3, rows: [{ id: 1 }, { id: 2 }, { id: 3 }] })); + }); + const result = safeRead({ + argv: ["faketool", "search", "widget"], + probeNegativeArgs: ["zzz-no-such-token-zzz"], + probePositiveArgs: ["known-present"], + run: s.run + }); + expect(result.ok).toBe(true); + expect(result.rowCount).toBe(3); + }); + + test("KNOWN-BAD: a predicate known to match returning zero is refused as inert", () => { + const s = surface(() => ok("[]")); + const result = safeRead({ + argv: ["faketool", "search", "widget"], + probePositiveArgs: ["known-present"], + run: s.run + }); + expect(result.ok).toBe(false); + expect(result.code).toBe("predicate_inert"); + }); +}); + +// --------------------------------------------------------------------------- +// MECHANISM 5 — DEFAULTED SCOPE +// --------------------------------------------------------------------------- +describe("mechanism 5: defaulted scope", () => { + test("KNOWN-BAD: a scope-defaulting surface is refused even when every other check is clean", () => { + // The read is complete, the total is honest, the rows reconcile against it + // three ways — and it is 41 items short. This is the arm that proves + // total-reconciliation cannot see a defaulted scope. + const s = surface(() => ok(JSON.stringify({ total: 1526, rows: Array.from({ length: 1526 }, (_, i) => i) }))); + const result = safeRead({ argv: ["knowledge", "list", "--json"], run: s.run }); + expect(result.ok).toBe(false); + expect(result.code).toBe("scope_defaulted"); + expect(result.reason).toContain("--include-archived"); + // The surface was never even called: the refusal is cheap and pre-read. + expect(s.calls.length).toBe(0); + }); + + test("KNOWN-GOOD: passing the widening flag proves the wider scope and stamps it on the result", () => { + const s = surface(() => ok(JSON.stringify({ total: 1567, rows: Array.from({ length: 1567 }, (_, i) => i) }))); + const result = safeRead({ argv: ["knowledge", "list", "--include-archived", "--json"], run: s.run }); + expect(result.ok).toBe(true); + expect(result.rowCount).toBe(1567); + expect(result.scope).toBe("--include-archived"); + expect(result.reason).toContain("scope: --include-archived"); + }); + + test("KNOWN-GOOD: the narrow scope is allowed only as an explicit, recorded choice", () => { + const s = surface(() => ok(JSON.stringify({ total: 1526, rows: Array.from({ length: 1526 }, (_, i) => i) }))); + const result = safeRead({ + argv: ["knowledge", "list", "--json"], + scopeAck: "active items only, archived deliberately excluded", + run: s.run + }); + expect(result.ok).toBe(true); + expect(result.scope).toContain("acknowledged"); + expect(result.reason).toContain("archived deliberately excluded"); + }); + + test("todos --all is censused the same way", () => { + const s = surface(() => ok(JSON.stringify({ total: 7301, rows: Array.from({ length: 7301 }, (_, i) => i) }))); + const narrow = safeRead({ argv: ["todos", "list", "--json"], run: s.run }); + expect(narrow.ok).toBe(false); + expect(narrow.code).toBe("scope_defaulted"); + expect(narrow.reason).toContain("--all"); + }); + + test("every count carries its scope, including on surfaces with no known widener", () => { + const s = surface(() => ok(JSON.stringify({ total: 3, rows: [1, 2, 3] }))); + const result = safeRead({ argv: ["faketool", "list"], run: s.run }); + expect(result.ok).toBe(true); + expect(typeof result.scope).toBe("string"); + expect(result.scope.length).toBeGreaterThan(0); + }); +}); + +// --------------------------------------------------------------------------- +// The helper's own capture path and CLI surface +// --------------------------------------------------------------------------- +describe("capture path and CLI", () => { + test("the executor spawns from argv and never through a shell", () => { + // If this went through a shell, the metacharacters would be interpreted and the + // argument would not survive intact. It is echoed back verbatim instead. + const payload = '`whoami` $(id -u) ; rm -rf /tmp/nope'; + const captured = runCaptured(["printf", "%s", payload]); + expect(captured.code).toBe(0); + expect(captured.stdout).toBe(payload); + }); + + test("a nonzero exit from the real spawner is surfaced, not swallowed", () => { + const captured = runCaptured(["false"]); + expect(captured.code).toBe(1); + const verdict = classifyRead(captured); + expect(verdict.ok).toBe(false); + expect(verdict.code).toBe("nonzero_exit"); + }); + + test("CLI returns 2 on refusal and prints NOTHING to stdout", () => { + const out: string[] = []; + const err: string[] = []; + const code = runSafeReadCli(["false"], { json: false }, { log: (s) => out.push(s), err: (s) => err.push(s) }); + expect(code).toBe(2); + expect(out).toEqual([]); + expect(err.join("\n")).toContain("REFUSED"); + }); + + test("CLI --json omits `rows` entirely on refusal so a consumer cannot read a plausible zero", () => { + const out: string[] = []; + const code = runSafeReadCli(["false"], { json: true }, { log: (s) => out.push(s), err: () => {} }); + expect(code).toBe(2); + const parsed = JSON.parse(out.join("\n")); + expect(parsed.ok).toBe(false); + expect(parsed.rows).toBeUndefined(); + }); + + test("CLI returns 3 for usage, distinct from 2 for a fired guard", () => { + const code = runSafeReadCli([], { json: false }, { log: () => {}, err: () => {} }); + expect(code).toBe(3); + }); + + test("--assume-complete records the waiver in the evidence and is never silent", () => { + const s = surface(() => ok(JSON.stringify([1, 2, 3]))); + const result = safeRead({ argv: ["fake", "list"], assumeComplete: true, run: s.run }); + expect(result.ok).toBe(true); + expect(result.proofs).toEqual(["assumed_complete"]); + expect(result.evidence.join(" ")).toContain("WAIVED"); + }); +}); + +// --------------------------------------------------------------------------- +// Supporting units and fixture hygiene +// --------------------------------------------------------------------------- +describe("supporting units", () => { + test("locateRows never turns a miss into an empty list", () => { + expect(locateRows({ nope: 1 }).ok).toBe(false); + expect(locateRows({ rows: [1] }).ok).toBe(true); + expect(locateRows([1, 2]).ok).toBe(true); + }); + + test("`count` is not auto-detected as a total (it is a page size on todos)", () => { + const verdict = classifyRead(ok(JSON.stringify({ count: 100, rows: Array.from({ length: 100 }, (_, i) => i) })), { + limit: 100 + }); + expect(verdict.ok).toBe(false); + expect(verdict.declaredTotal).toBeUndefined(); + }); + + test("readDottedPath reaches a nested aggregate and returns undefined for a miss", () => { + expect(readDottedPath({ by_scope: { global: 681 } }, "by_scope.global")).toBe(681); + expect(readDottedPath({ by_scope: { global: 681 } }, "by_scope.shared")).toBeUndefined(); + }); + + test("the clamp census resolves the longest matching verb prefix", () => { + expect(lookupClamp(["conversations", "agents", "list", "--json"])?.cap).toBe(500); + expect(lookupClamp(["conversations", "read", "--channel", "x"])?.cap).toBe(500); + expect(lookupClamp(["totally", "unknown"])).toBeNull(); + for (const [key, entry] of Object.entries(KNOWN_CLAMPS)) { + expect(entry.grade === "M" || entry.grade === "S/U").toBe(true); + expect(key.length).toBeGreaterThan(0); + } + }); + + test("fixtures write nowhere: the only filesystem use is an OS temp dir", () => { + // Guards the trap that a copied fixture writes into a real log directory. Every + // other test above is pure in-memory; this one proves the single filesystem + // interaction is confined to mkdtemp and is cleaned up. + const dir = mkdtempSync(join(tmpdir(), "safe-read-fixture-")); + try { + const file = join(dir, "capture.json"); + writeFileSync(file, JSON.stringify({ total: 1, rows: [{ id: 1 }] })); + const verdict = classifyRead(ok(readFileSync(file, "utf8"))); + expect(verdict.ok).toBe(true); + expect(dir.startsWith(tmpdir())).toBe(true); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); + +afterAll(() => { + // Nothing global to tear down; asserted here so a future fixture that acquires + // state has an obvious place to release it. +}); From 9be6a5a6741338e16e0fc480b7b8d76cb5ae9321 Mon Sep 17 00:00:00 2001 From: Andrei Hasna Date: Tue, 11 Aug 2026 06:17:51 +0300 Subject: [PATCH 2/3] fix(safe-read): capture to files, add scope guard and the limits doc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three changes, the first found by the live test rather than the fixtures. CAPTURE PATH. The in-memory capture truncated. Measured on bun 1.3.14 arm64 against a 1.23 MB read (mementos list --scope global --json --limit 2000): a shell redirect gave 1227488 bytes that parse, while spawnSync with encoding:"utf8" and maxBuffer:256MiB gave 1079970 bytes that do not. That figure is exactly the 1 MiB default, so the option never reached the call, and the ENOBUFS a 1 MiB run does raise was not surfaced either — a clean exit-0 short read produced by the tool built to refuse clean exit-0 short reads. Capture now goes to files through file descriptors, which is the capture-path doctrine written out literally and has no ceiling. MECHANISM 5, DEFAULTED SCOPE. The read is complete, the predicate correct, pagination followed and the declared total honest, and the number is still a subset. total is scope-relative, so reconciling rows against it confirms this rather than catching it. Two censused surfaces now refuse unless the widening flag is passed or --scope-ack records the narrow scope, and every count carries the scope that produced it. EVIDENCE. The argv recorded is now the one actually run, not the one requested; the previous line sent a reader to reproduce a different command. docs/safe-read.md states what this does NOT protect against, with each item graded M or S/U. Agent: vespasian --- docs/safe-read.md | 192 ++++++++++++++++++++++++++++++++++++++++ src/cli/index.ts | 1 + src/cli/read.ts | 4 +- src/safe-read-exec.ts | 72 ++++++++++----- tests/safe-read.test.ts | 40 +++++++++ 5 files changed, 284 insertions(+), 25 deletions(-) create mode 100644 docs/safe-read.md diff --git a/docs/safe-read.md b/docs/safe-read.md new file mode 100644 index 0000000..9169973 --- /dev/null +++ b/docs/safe-read.md @@ -0,0 +1,192 @@ +# safe-read — prove a collection read complete, or refuse + +`contracts read [options] -- [args...]` + +Exit codes: **0** proven complete · **2** REFUSED · **3** usage. The convention matches +`probe-guard`, so an agent does not have to hold two vocabularies. + +A refusal prints **nothing** to stdout. In `--json` form the `rows` key is *absent* +rather than empty, so a consumer that reaches for it gets `undefined` and fails +instead of reading a plausible zero. + +## Why this lives in `@hasna/contracts` + +The shape of a collection read — `ok`, `total`, `total_pages`, `has_more`, +`next_cursor`, `store_exists` — is the interface contract between every Hasna CLI and +every consumer of it, and it is the one contract on the fleet with no owning domain. +`@hasna/contracts` exists for exactly that: "shared schemas and validators for Hasna +agent infrastructure contracts." + +Four reasons this package rather than another: + +1. **It is the only domain-less shared library.** Every other candidate owns a domain + — `guardrails` is request policy, `controls` is spend authorization, `shield` is + security scanning, `dispatch` is tmux delivery. A read-completeness contract is + none of those. +2. **It already ships both shapes this needs.** A library export for TypeScript + consumers, and a CLI binary for the population that actually hit this — agents + running commands in a shell, not programs importing a module. +3. **It avoids a 186th package**, a new release-age exclusion entry, and a new thing + to install on every machine. +4. **The nearest existing abstraction cannot be the home.** `probe-guard` is the + closest thing already deployed, and it has **no source repository anywhere on this + box** — a 112 KB script in `~/.local/bin` referenced by five skills. It is also a + different half of the problem: an assertion over bytes you already captured. + +### Relationship to `probe-guard` + +`probe-guard complete` asserts over a captured file. It is good and this does not +replace it. It cannot close mechanism 3, and it says so itself. Measured on the live +`repos repos --json` read, 50 rows of a population of 1793: + +``` +probe-guard complete --json r50.json + rc=0 + probe-guard complete: 50 row(s) under + no unfollowed pagination marker found (top level or nested) + WARNING: %d is a round number and a classic SILENT page cap... + PASS +``` + +`rc=0` — a pass, with an honest warning that no automation stops on. The same read +through this helper: + +``` +contracts read -- repos repos --json + rc=2 + contracts read: REFUSED [stderr_truncation_notice] + rows=50 under + No rows are printed. A refused read is not an empty set. +``` + +The difference is not cleverness. Establishing completeness requires a **second +observation** — another page, a wider bound, a sibling aggregate, or the other +stream — and a checker handed one captured file has only one. + +## The design principle + +> **Completeness must be PROVEN. It is never inferred from the absence of a flag.** + +A read is accepted only when it carries one of five named proofs: + +| proof | how it is established | +|---|---| +| `declared_total_satisfied` | rows equal a total the surface declared itself | +| `cursor_exhausted` | paged until the surface said there was no next page | +| `sibling_aggregate_agrees` | rows equal an aggregate on a sibling verb the caller named | +| `stable_under_widening` | a wider bound returned fewer rows than that bound *and* fewer than any clamp | +| `assumed_complete` | the caller waived proof; always recorded, never silent | + +## What this does NOT protect against + +This section is the most useful part of the document, because it says where the +guarantee stops. Each item is labelled **M** (live behaviour exercised) or **S/U** +(established from a source read or another agent's measurement, not exercised here). + +**1. A wrong predicate on a complete read. (M, by others)** +The read can be complete, the pagination followed and the total honest, while the +filter expression means something other than what its author intended. The measured +case is a regex flag whose letter survives a port and whose meaning does not: + +``` +jq test("^SECOND";"m") -> false "m" does NOT anchor per line +jq test("first.SECOND";"m") -> true "m" IS dotall +python re.search(r'^X', s, re.M) -> True same letter, opposite meaning +``` + +In Python and JavaScript, `m` is per-line anchors and `s` is dotall. In jq, `m` **is** +dotall and there is no per-line anchor flag at all. A pattern carried from a Python +script into a jq filter keeps its letter, silently changes meaning, and returns +`false` on text that plainly contains the match. A read helper cannot see this without +evaluating the caller's intent, and one that tried would become a regex linter. + +*This helper's own exposure to it:* **jq invocations = 0, anchored patterns = 0** +across all four source files, with a positive control confirming the probe finds a +token that is present (`RegExp` = 1) and not one that is absent (0). Both numbers are +reported because a zero alone cannot distinguish "clean" from "the check could not +have fired" — this is the second case, so the check is honestly **out of scope** here +rather than an all-clear. The two regex flags actually used are both `/i`. + +**2. A defaulted scope, beyond naming it. (M, by others)** +The read is complete, the predicate correct, pagination followed, and the declared +total honest — only the scope is defaulted, and nothing in the output is wrong: + +``` +knowledge list --limit 1 --json total=1526 +knowledge list --limit 1 --include-archived --json total=1567 41 items invisible +todos list --json --limit 9000 7301, converged, and a SUBSET +todos list --all --json --limit 40000 40000 +``` + +**The envelope's `total` is scope-relative**, so reconciling rows against it *confirms* +a defaulted scope with a clean three-way agreement rather than catching it. The +helper's strongest proof is structurally blind here. + +What it does instead: a small census (`SCOPE_WIDENERS`) refuses two known surfaces +unless the widening flag is passed or `--scope-ack` records the narrow scope as a +deliberate choice, and **every count carries the scope that produced it**. `1526 +(default)` and `1567 (--include-archived)` are different facts and neither is "the +population" alone. The census is deliberately tiny; auto-discovering every scope flag +on every CLI is a per-surface fact that rots, and guessing it is worse than declaring +the limit. + +**3. A probe that is structurally incapable of matching its subject. (M, by others)** +A caller can hand this helper a command whose own matcher cannot work. The measured +case: `basename(argv[0]) == "search"` returns zero for every Hasna CLI, because they +are all bun shims and the live `argv[0]` is `bun`. That is a method that cannot fire, +not a near-miss, and it was introduced *as a fix* for a self-matching full-cmdline +matcher — trading a false positive for a false negative, which is the one that gets +published. This helper guards the completeness of a read; it cannot see that the +command it was handed asks the wrong question. + +**4. An unknown hidden clamp. (M, here)** +`stable_under_widening` is the weakest proof and is unsound against a silent +server-side cap. `conversations read` returns 500 rows for any request above 500, so +`count < requestedBound` proves nothing when the bound was never honoured. Two-step +widening does not rescue it: a true population of 300 and a hidden clamp of 500 both +yield equal counts below both bounds at any number of steps. The helper refuses when +the widened count meets a censused cap or lands on a round number, which is +conservative in the safe direction — a real population of exactly 500 on an +uncensused surface is falsely refused, and the fix is one `--known-clamp` flag. +`KNOWN_CLAMPS` is an optimisation, not the safety property. + +**5. A moving population.** Two observations taken at different instants can +legitimately disagree. Growth between the first read and the widening probe is +reported and the wider read returned; a sibling aggregate that moves under a long +page walk produces a refusal that a re-run may clear. + +**6. Anything about whether the rows are CORRECT.** This establishes that you are +holding the whole set. It says nothing about whether the set is the one you wanted, +whether the fields mean what you think, or whether the values are true. + +## Evidence grades in the censuses + +`KNOWN_CLAMPS` and `SCOPE_WIDENERS` carry a per-row grade, copied from the discipline +of knowledge item `k_mso1r678_fhgm1o` (Conversations Readbounds Census Matrix, +measured 2026-08-11 against `@hasna/conversations` 0.5.43): + +- **M** — live behaviour exercised. +- **S/U** — established from installed client or bundled server source; live behaviour + not safely measurable. + +A source-read bound and an exercised bound are different claims, and merging them is +how a table starts asserting more than anyone measured. + +## Examples + +```bash +# Refuse a bounded read whose only notice is on stderr +contracts read -- repos repos --json + +# An envelope-less bare array, proven by a sibling aggregate +contracts read --limit-flag=--limit --limit 2000 \ + --sibling-arg mementos --sibling-arg stats --sibling-arg --json \ + --sibling-path by_scope.global \ + -- mementos list --scope global --json + +# Page a cursor surface to exhaustion +contracts read --cursor-flag --cursor -- conversations digest board --json + +# Catch a query verb that ignores its predicate +contracts read --probe-negative-arg zzz-no-such-token -- faketool search widget +``` diff --git a/src/cli/index.ts b/src/cli/index.ts index a049e3d..3fc7c6c 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -503,6 +503,7 @@ export function createContractsProgram() { .option("--probe-negative-arg ", "Repeatable: tokens forming a query that MUST match nothing", collectOption) .option("--allow-empty", "An empty result is a legitimate answer here") .option("--assume-complete", "Waive proof; recorded in the evidence and never silent") + .option("--scope-ack ", "Record the narrower default scope as a deliberate choice, and say why") .option("-j, --json", "Output JSON") .action((command: string[], options: Record) => { process.exitCode = runSafeReadCli(command ?? [], options as never); diff --git a/src/cli/read.ts b/src/cli/read.ts index d40d104..c8a7955 100644 --- a/src/cli/read.ts +++ b/src/cli/read.ts @@ -28,6 +28,7 @@ export interface ReadCliOptions { probePositiveArg?: string[] | undefined; allowEmpty?: boolean | undefined; assumeComplete?: boolean | undefined; + scopeAck?: string | undefined; json?: boolean | undefined; } @@ -70,7 +71,8 @@ export function runSafeReadCli( siblingPath: options.siblingPath, probeNegativeArgs: options.probeNegativeArg, probePositiveArgs: options.probePositiveArg, - assumeComplete: options.assumeComplete + assumeComplete: options.assumeComplete, + scopeAck: options.scopeAck }); } catch (error) { io.err(`contracts read: ${error instanceof Error ? error.message : String(error)}`); diff --git a/src/safe-read-exec.ts b/src/safe-read-exec.ts index a1faf5e..aefb302 100644 --- a/src/safe-read-exec.ts +++ b/src/safe-read-exec.ts @@ -26,6 +26,9 @@ */ import { spawnSync } from "node:child_process"; +import { closeSync, mkdtempSync, openSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { classifyRead, locateRows, @@ -36,13 +39,6 @@ import { type RefusalCode } from "./safe-read"; -/** - * 256 MiB. Node's spawnSync defaults to 1 MiB and signals ENOBUFS on overflow — a - * truncating capture path inside the tool that exists to catch truncation. The - * overflow is also handled explicitly below rather than trusted to be impossible. - */ -const CAPTURE_BUFFER_BYTES = 256 * 1024 * 1024; - /** A runaway cursor loop is a hang; bound it and refuse rather than spin. */ const DEFAULT_MAX_PAGES = 200; @@ -197,25 +193,49 @@ export interface SafeReadResult { export function runCaptured(argv: string[]): CapturedRead { const [bin, ...args] = argv; if (!bin) throw new Error("empty argv"); - const proc = spawnSync(bin, args, { - encoding: "utf8", - shell: false, - maxBuffer: CAPTURE_BUFFER_BYTES - }); - if (proc.error) { - const err = proc.error as NodeJS.ErrnoException; - if (err.code === "ENOBUFS") { - // The capture path itself truncated. Reporting the bytes we did get would be - // the very defect this tool exists to refuse. - return { stdout: "", stderr: `capture overflowed ${CAPTURE_BUFFER_BYTES} bytes`, code: 251 }; + + // Capture to FILES via file descriptors, which is the capture-path doctrine + // written out literally: `cmd > out 2> err`, then read the files. + // + // AN IN-MEMORY CAPTURE WAS TRIED FIRST AND TRUNCATED, WHICH IS WHY THIS IS NOT A + // STYLE CHOICE. Measured on bun 1.3.14, arm64, against a 1.23 MB payload + // (`mementos list --scope global --json --limit 2000`): + // + // shell redirect to a file 1227488 B parses, 684 rows + // spawnSync(encoding:"utf8", maxBuffer:256MiB) 1079970 B UNPARSEABLE, status 0, error undefined + // + // The 1079970 figure is exactly what an explicit 1 MiB maxBuffer produces, so the + // option did not reach the call — and the ENOBUFS that a 1 MiB run does surface + // was NOT raised, so the truncation arrived as a clean exit-0 short read. That is + // precisely the failure this module exists to refuse, inside its own capture path. + // File descriptors have no such ceiling. + const dir = mkdtempSync(join(tmpdir(), "hasna-safe-read-")); + const outPath = join(dir, "stdout.bin"); + const errPath = join(dir, "stderr.bin"); + let outFd: number | undefined; + let errFd: number | undefined; + try { + outFd = openSync(outPath, "w"); + errFd = openSync(errPath, "w"); + const proc = spawnSync(bin, args, { shell: false, stdio: ["ignore", outFd, errFd] }); + closeSync(outFd); + outFd = undefined; + closeSync(errFd); + errFd = undefined; + if (proc.error) { + const err = proc.error as NodeJS.ErrnoException; + return { stdout: "", stderr: `spawn failed: ${err.message}`, code: 252 }; } - return { stdout: "", stderr: `spawn failed: ${err.message}`, code: 252 }; + return { + stdout: readFileSync(outPath, "utf8"), + stderr: readFileSync(errPath, "utf8"), + code: proc.status ?? 253 + }; + } finally { + if (outFd !== undefined) closeSync(outFd); + if (errFd !== undefined) closeSync(errFd); + rmSync(dir, { recursive: true, force: true }); } - return { - stdout: proc.stdout ?? "", - stderr: proc.stderr ?? "", - code: proc.status ?? 253 - }; } function withFlag(argv: string[], flag: string | undefined, value: string | number): string[] { @@ -293,6 +313,10 @@ function safeReadInner(request: SafeReadRequest): SafeReadResult { let argv = request.argv; if (request.limit !== undefined) argv = withFlag(argv, request.limitFlag ?? "--limit", request.limit); + // Record the argv ACTUALLY RUN, not the one requested. An evidence line that + // prints the base argv while a bound was appended sends the next reader to + // reproduce a different command than the one that produced the verdict. + if (argv.join(" ") !== request.argv.join(" ")) evidence.push(`ran: ${argv.join(" ")}`); let captured = run(argv); let verdict = classifyRead(captured, { diff --git a/tests/safe-read.test.ts b/tests/safe-read.test.ts index 4855ddd..943536c 100644 --- a/tests/safe-read.test.ts +++ b/tests/safe-read.test.ts @@ -404,6 +404,46 @@ describe("capture path and CLI", () => { expect(captured.stdout).toBe(payload); }); + test("REGRESSION: a payload well past 1 MiB survives the capture path intact", () => { + // The first implementation used spawnSync(encoding:"utf8", maxBuffer:256MiB) and + // truncated a 1.23 MB read to 1079970 bytes at status 0 with error undefined — + // the exact 1 MiB-default figure, so the option never reached the call, and the + // ENOBUFS a 1 MiB run does raise was not surfaced either. A clean exit-0 short + // read, produced by the tool that exists to refuse clean exit-0 short reads. + // File descriptors have no such ceiling; this asserts the payload round-trips. + const rows = Array.from({ length: 40000 }, (_, i) => ({ id: i, pad: "0123456789abcdef" })); + const payload = JSON.stringify(rows); + expect(payload.length).toBeGreaterThan(1024 * 1024); + // The payload goes through a temp FILE rather than an argv element: Linux caps a + // single argument at MAX_ARG_STRLEN (128 KiB), so passing it inline would fail + // the spawn for a reason unrelated to what is under test. + const dir = mkdtempSync(join(tmpdir(), "safe-read-big-")); + try { + const file = join(dir, "big.json"); + writeFileSync(file, payload); + const captured = runCaptured(["cat", file]); + expect(captured.code).toBe(0); + expect(captured.stdout.length).toBe(payload.length); + const verdict = classifyRead(captured); + // Parses cleanly — the point is that it is not cut mid-string. + expect(verdict.code).not.toBe("unparseable_stdout"); + expect(verdict.rowCount).toBe(40000); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test("stdout and stderr are captured separately and never merged", () => { + // A merged capture corrupts the JSON with whatever diagnostics the tool emitted, + // which is how a valid payload becomes an unparseable one. + const captured = runCaptured(["sh", "-c", 'printf "{\\"rows\\":[]}"; printf "warning: showing 5 of 900" >&2']); + expect(captured.stdout).toBe('{"rows":[]}'); + expect(captured.stderr).toContain("showing 5 of 900"); + // And the stderr notice is what decides the verdict, not the clean body. + const verdict = classifyRead(captured); + expect(verdict.code).toBe("stderr_truncation_notice"); + }); + test("a nonzero exit from the real spawner is surfaced, not swallowed", () => { const captured = runCaptured(["false"]); expect(captured.code).toBe(1); From ea3ece15f87169214ea8fd0d8174e50d6210825a Mon Sep 17 00:00:00 2001 From: Andrei Hasna Date: Tue, 11 Aug 2026 06:49:13 +0300 Subject: [PATCH 3/3] fix(safe-read): fail closed on inconsistent reads Agent: unresolved-account002 --- docs/safe-read.md | 6 ++-- src/cli/read.ts | 4 +-- src/safe-read-exec.ts | 25 +++++++++++++++ src/safe-read.ts | 31 +++++++++++++------ tests/safe-read.test.ts | 67 +++++++++++++++++++++++++++++++++++++++++ 5 files changed, 118 insertions(+), 15 deletions(-) diff --git a/docs/safe-read.md b/docs/safe-read.md index 9169973..9f8ecb5 100644 --- a/docs/safe-read.md +++ b/docs/safe-read.md @@ -5,9 +5,9 @@ Exit codes: **0** proven complete · **2** REFUSED · **3** usage. The convention matches `probe-guard`, so an agent does not have to hold two vocabularies. -A refusal prints **nothing** to stdout. In `--json` form the `rows` key is *absent* -rather than empty, so a consumer that reaches for it gets `undefined` and fails -instead of reading a plausible zero. +A text-mode refusal prints **nothing** to stdout. In `--json` form stdout carries a +structured refusal, but the `rows` key is *absent* rather than empty, so a consumer +that reaches for it gets `undefined` and fails instead of reading a plausible zero. ## Why this lives in `@hasna/contracts` diff --git a/src/cli/read.ts b/src/cli/read.ts index c8a7955..eea7582 100644 --- a/src/cli/read.ts +++ b/src/cli/read.ts @@ -85,8 +85,8 @@ export function runSafeReadCli( io.log( JSON.stringify( result.ok - ? { ok: true, proofs: result.proofs, rowCount: result.rowCount, pages: result.pages, rows: result.rows, evidence: result.evidence } - : { ok: false, code: result.code, error: result.reason, pages: result.pages, evidence: result.evidence }, + ? { ok: true, proofs: result.proofs, rowCount: result.rowCount, pages: result.pages, scope: result.scope, rows: result.rows, evidence: result.evidence } + : { ok: false, code: result.code, error: result.reason, pages: result.pages, scope: result.scope, evidence: result.evidence }, null, 2 ) diff --git a/src/safe-read-exec.ts b/src/safe-read-exec.ts index aefb302..48be455 100644 --- a/src/safe-read-exec.ts +++ b/src/safe-read-exec.ts @@ -332,6 +332,14 @@ function safeReadInner(request: SafeReadRequest): SafeReadResult { if (!verdict.ok && verdict.code === "unfollowed_cursor" && request.cursorFlag) { const accumulated: unknown[] = []; let cursor = verdict.nextCursor; + if (typeof cursor !== "string" || cursor.trim().length === 0) { + return fail( + "unfollowed_cursor", + "the surface indicates another page but supplied no usable cursor; refusing rather than treating the first page as exhausted", + evidence, + pages + ); + } // Re-locate rows from the first page: classifyRead withholds rows on refusal, // deliberately, so a caller can never use a partial result by accident. const first = locateRows(safeParse(captured.stdout), request.rowsKey); @@ -354,6 +362,14 @@ function safeReadInner(request: SafeReadRequest): SafeReadResult { return fail(verdict.code!, `paging stopped at page ${pages}: ${verdict.reason}`, evidence, pages); } cursor = verdict.nextCursor; + if (typeof cursor !== "string" || cursor.trim().length === 0) { + return fail( + "unfollowed_cursor", + `paging stopped at page ${pages}: the surface indicates another page but supplied no usable cursor`, + evidence, + pages + ); + } } if (cursor && pages >= maxPages) { return fail("unfollowed_cursor", `still paging after ${maxPages} pages; refusing rather than reporting a partial set`, evidence, pages); @@ -446,10 +462,19 @@ function safeReadInner(request: SafeReadRequest): SafeReadResult { const wideRows = locateRows(safeParse(widened.stdout), request.rowsKey); const wideCount = wideRows.ok ? wideRows.rows.length : -1; evidence.push(`widened ${flag} ${base} -> ${wider}: ${verdict.rowCount} -> ${wideCount} row(s)`); + evidence.push(...wideVerdict.evidence.map((line) => `widened: ${line}`)); if (wideVerdict.ok) { return { ok: true, reason: `widened read proven complete by ${wideVerdict.proofs.join(" + ")}`, proofs: wideVerdict.proofs, rows: wideVerdict.rows, rowCount: wideVerdict.rowCount, pages, scope: "default", evidence }; } + if (wideVerdict.code !== "completeness_unproven" && wideVerdict.code !== "page_cap_reached") { + return fail( + wideVerdict.code!, + `widening probe failed: ${wideVerdict.reason}`, + evidence, + pages + ); + } if (wideCount >= 0 && wideCount < wider) { // `wideCount < wider` is necessary and NOT sufficient. A surface that silently // clamps never honoured `wider` at all, so the comparison is against a bound diff --git a/src/safe-read.ts b/src/safe-read.ts index e44663b..0fb0878 100644 --- a/src/safe-read.ts +++ b/src/safe-read.ts @@ -313,12 +313,20 @@ export function classifyRead(captured: CapturedRead, options: ClassifyOptions = if (isPlainObject(parsed)) { const totalKeys = options.totalKey ? [options.totalKey] : TOTAL_KEY_CANDIDATES; for (const key of totalKeys) { + if (!Object.prototype.hasOwnProperty.call(parsed, key)) continue; const value = parsed[key]; - if (typeof value === "number") { - declaredTotal = value; - evidence.push(`declared ${key}=${value}`); - break; + if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) { + return { + ...refuse( + "declared_total_mismatch", + `declared ${key} must be a non-negative safe integer, got ${JSON.stringify(value)}` + ), + rowCount + }; } + declaredTotal = value; + evidence.push(`declared ${key}=${value}`); + break; } if (typeof parsed.has_more === "boolean") hasMore = parsed.has_more; else if (typeof parsed.hasMore === "boolean") hasMore = parsed.hasMore; @@ -326,7 +334,8 @@ export function classifyRead(captured: CapturedRead, options: ClassifyOptions = if (typeof cursor === "string" || cursor === null) nextCursor = cursor as string | null; } - if (hasMore === true) { + const usableCursor = typeof nextCursor === "string" && nextCursor.trim().length > 0; + if (hasMore === true || usableCursor) { // This refusal MUST carry the cursor. It is the one refusal a caller can act on // automatically, and an executor that cannot see next_cursor here silently // stops after page one — measured: the paging fixture accumulated 2 rows of 5 @@ -334,7 +343,8 @@ export function classifyRead(captured: CapturedRead, options: ClassifyOptions = return { ...refuse( "unfollowed_cursor", - `the surface says has_more=true (cursor ${nextCursor ?? "unset"}). You are holding a page and about to call it a population.` + `the surface indicates another page (has_more=${String(hasMore)}, cursor ${usableCursor ? nextCursor : "unset"}). ` + + `You are holding a page and about to call it a population.` ), rowCount, declaredTotal, @@ -343,18 +353,19 @@ export function classifyRead(captured: CapturedRead, options: ClassifyOptions = }; } - if (declaredTotal !== undefined && rowCount < declaredTotal) { + if (declaredTotal !== undefined && rowCount !== declaredTotal) { return refuse( "declared_total_mismatch", - `holding ${rowCount} of a declared total of ${declaredTotal}. The surface told you the size of its own population and this read does not cover it.` + `holding ${rowCount} row(s) against a declared total of ${declaredTotal}. ` + + `The surface's count and payload disagree, so completeness is not established.` ); } // --- proofs ------------------------------------------------------------------ const proofs: CompletenessProof[] = []; - if (declaredTotal !== undefined && rowCount >= declaredTotal) { + if (declaredTotal !== undefined && rowCount === declaredTotal) { proofs.push("declared_total_satisfied"); - evidence.push(`rowCount ${rowCount} >= declared total ${declaredTotal}`); + evidence.push(`rowCount ${rowCount} equals declared total ${declaredTotal}`); } if (hasMore === false || nextCursor === null) { proofs.push("cursor_exhausted"); diff --git a/tests/safe-read.test.ts b/tests/safe-read.test.ts index 943536c..3124bb4 100644 --- a/tests/safe-read.test.ts +++ b/tests/safe-read.test.ts @@ -79,6 +79,19 @@ describe("mechanism 1: cut short", () => { expect(verdict.code).toBe("declared_total_mismatch"); }); + test("KNOWN-BAD: rows above, negative, fractional, or non-numeric declared totals are refused", () => { + for (const payload of [ + { total: 1, rows: [1, 2] }, + { total: -1, rows: [] }, + { total: 1.5, rows: [1, 2] }, + { total: "2", rows: [1, 2] } + ]) { + const verdict = classifyRead(ok(JSON.stringify(payload))); + expect(verdict.ok).toBe(false); + expect(verdict.code).toBe("declared_total_mismatch"); + } + }); + test("KNOWN-BAD: has_more=true is refused; KNOWN-GOOD: has_more=false passes", () => { const bad = classifyRead(ok(JSON.stringify({ rows: [1, 2], has_more: true, next_cursor: "c1" }))); expect(bad.ok).toBe(false); @@ -236,6 +249,46 @@ describe("mechanism 3: unpaginated", () => { expect(result.rows).toEqual([1, 2, 3, 4, 5]); expect(result.pages).toBe(3); }); + + test("KNOWN-BAD: has_more without a usable cursor is refused, never treated as exhausted", () => { + for (const nextCursor of [undefined, null, ""]) { + const s = surface(() => ok(JSON.stringify({ rows: [1, 2], has_more: true, next_cursor: nextCursor }))); + const result = safeRead({ argv: ["fake", "digest"], cursorFlag: "--cursor", run: s.run }); + expect(result.ok).toBe(false); + expect(result.code).toBe("unfollowed_cursor"); + } + }); + + test("KNOWN-BAD: a non-empty next cursor is followed even when has_more contradicts it", () => { + const s = surface((argv) => + argv.includes("p2") + ? ok(JSON.stringify({ rows: [3], has_more: false, next_cursor: null })) + : ok(JSON.stringify({ rows: [1, 2], has_more: false, next_cursor: "p2" })) + ); + const result = safeRead({ argv: ["fake", "digest"], cursorFlag: "--cursor", run: s.run }); + expect(result.ok).toBe(true); + expect(result.rows).toEqual([1, 2, 3]); + expect(result.pages).toBe(2); + }); + + test("KNOWN-BAD: widening cannot turn a failed second read into a successful empty population", () => { + let calls = 0; + const s = surface(() => + ++calls === 1 + ? ok(JSON.stringify(Array.from({ length: 20 }, (_, i) => i))) + : ok(JSON.stringify({ ok: true, store_exists: false, rows: [] })) + ); + const result = safeRead({ + argv: ["fake", "list"], + limit: 20, + limitFlag: "--limit", + widenTo: 80, + run: s.run + }); + expect(result.ok).toBe(false); + expect(result.code).toBe("store_unavailable"); + expect(result.rows).toEqual([]); + }); }); // --------------------------------------------------------------------------- @@ -468,6 +521,20 @@ describe("capture path and CLI", () => { const parsed = JSON.parse(out.join("\n")); expect(parsed.ok).toBe(false); expect(parsed.rows).toBeUndefined(); + expect(parsed.scope).toBe("default"); + }); + + test("CLI --json carries scope on success", () => { + const out: string[] = []; + const code = runSafeReadCli( + ["bun", "-e", "console.log(JSON.stringify({total:1,rows:[{id:1}]}))"], + { json: true }, + { log: (s) => out.push(s), err: () => {} } + ); + expect(code).toBe(0); + const parsed = JSON.parse(out.join("\n")); + expect(parsed.ok).toBe(true); + expect(parsed.scope).toBe("default"); }); test("CLI returns 3 for usage, distinct from 2 for a fired guard", () => {