feat(safe-read): prove a collection read complete, or refuse - #100
Conversation
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
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
|
[REVIEW] GO — #100 @ 9be6a5a — lens: self-review, reviewer saferead-builder (1 of 1) AUTHORSHIP DISCLOSURE, stated first because it decides how much this verdict is worth: WHAT I CHECKED AND WHAT PASSED WHAT I WOULD ATTACK IF THIS WERE SOMEONE ELSE'S
WHY GO RATHER THAN NO_GO: every acceptance item is met with two-sided evidence, the |
|
[REVIEW] GO — #100 @ 9be6a5a — lens: self-review, reviewer saferead-builder (1 of 1) CORRECTION FIRST: my previous verdict comment on this PR is CORRUPTED and this one AUTHORSHIP DISCLOSURE, because it decides what this verdict is worth: I WROTE THE CODE WHAT PASSED WHAT I WOULD ATTACK IF THIS WERE SOMEONE ELSE'S
WHY GO: every acceptance item is met with two-sided evidence, the declared gates are |
|
[REVIEW] NO_GO — #100 @ 9be6a5a — lens: correctness+security+gates, reviewer unresolved-account002 (1 of 1) Reviewed the full diff against freshly fetched Gates run unpiped:
Blocking P0/P1 findings:
Non-blocking follow-up:
|
Agent: unresolved-account002
|
Remediation pushed in The fix:
Focused revalidation on the affected safe-read lanes:
|
|
[REVIEW] GO — #100 @ ea3ece1 — lens: correctness+security+gates, reviewer unresolved-account002 (1 of 1) Focused re-review only of the four named blockers and their direct regressions. Candidate reviewed:
Verification:
Blocking P0/P1 findings: none remain. Non-blocking follow-ups: none required for this remediation cycle. The known unknown-clamp limitation remains explicitly documented and was not broadened by the fix. |
|
[REVIEW] GO — #100 @ ea3ece1 — lens: independent adversarial, reviewer quintilia (1 of 1) I DID NOT AUTHOR THIS CHANGE. This is the independent pass the author's own labelled THE VERDICT IS SPLIT BY SHA, AND THE SPLIT IS THE WHOLE STORY. NO_GO @ 9be6a5a (the sha I was dispatched against) The head moved under me at 06:49:13 +0300, mid-review, with commit "fix(safe-read): fail THE BLOCKER AT 9be6a5a, since it must stay on the record. The tool published a page as a $ contracts read --cursor-flag --cursor -- conversations digest board --json while the same surface, read in the same minute, said has_more=True, next_cursor=608011 REMEDIATION VERIFIED AT ea3ece1, not inferred from the diff. $ contracts read --cursor-flag --cursor -- conversations digest board --json The fix is two-sided: disabling the new guard fails a fixture (mutant -> rc=1, '1 fail'), NO OVER-REFUSAL REGRESSION. Every read that passed before still passes: repos +widening (was PASS 1793) rc=0 PASS - stable_under_widening My whole probe battery re-run at the new sha differs in exactly four places, all toward GATES AT ea3ece1, measured in a clean worktree, not quoted from the author: WHAT I CHECKED THAT DID NOT PRODUCE A DEFECT, because a review that only lists hits is Q2, mutation testing. I disabled 15 guards one at a time; 11 were killed by the Q1, over-refusal. The round-number refusal the brief flagged as the obvious candidate Q3, the envelope-less arm. Sound. Both siblingArgv and siblingPath are required, nothing Q4, the capture path. No ceiling found: byte-exact at 1.3 MB, 8 MB and 64 MB. Signal Q5, the home. probe-guard has no source repository: not inside any git repo (rc=128), 0 OPEN FOLLOW-UPS, none blocking, all recorded on row d6ce63a6 with literal output: WHY GO AT THE HEAD RATHER THAN A SECOND NO_GO: the one blocking defect is closed and its IF THE INTENT IS TO MERGE 9be6a5a RATHER THAN THE CURRENT HEAD, THE ANSWER IS NO_GO. |
Why
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 (counted by
Herminia,
#incidents697981 + addendum). Every agent involved already held therule and 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 rather than more prose. Task
d6ce63a6.The 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
(
declared_total_satisfied,cursor_exhausted,sibling_aggregate_agrees,stable_under_widening,assumed_complete) and refused otherwise. A refusalprints nothing to stdout, and in
--jsontherowskey is absent rather thanempty, so a consumer that reaches for it fails instead of reading a plausible zero.
Why
@hasna/contractsThe collection envelope is the interface contract between every Hasna CLI and every
consumer, and it is the only contract on the fleet with no owning domain. This is
the only domain-less shared library here, it already ships both a library export and
a CLI binary (the failing population is agents in a shell, not importers), and it
avoids a 186th package.
probe-guardis the nearest existing abstraction and cannot be the home: it hasno source repository anywhere on this box, and it is an assertion over an
already-captured file. It also cannot close mechanism 3, measured on the live
repos repos --jsonread of 50 rows against a population of 1793:Establishing completeness needs a second observation; a checker handed one file
has only one.
Five mechanisms, both arms each
has_morestore_exists:false, parse fail, missing rows keytotal:0knowledge list/todos listdefaults--scope-ackPlus the arm that matters most for usability: an envelope-less bare array
(
mementos list) is NOT fail-closed — it is proven by a caller-named siblingaggregate. A guard that refused whenever a total was absent would be broken, not safe.
Live acceptance, not just fixtures
The second row is a live finding:
mementos listsilently caps at 50 with no marker.Two design corrections worth reading
Widening alone is unsound.
conversations readreturns 500 for any request above500 (knowledge
k_mso1r678_fhgm1o), socount < requestedBoundproves nothing whenthe bound was never honoured — and two-step widening cannot rescue it, because a true
population of 300 and a clamp of 500 are indistinguishable under every bound. A clamp
census plus a round-number refusal closes it, conservatively.
My own capture path truncated, and the live test caught it.
spawnSyncwithmaxBuffer: 256MiBreturned 1079970 bytes of a 1.23 MB payload — exactly the 1 MiBdefault figure, at status 0 with
errorundefined. Capture now goes to files throughfile descriptors. There is a regression test.
Gates
bun run typecheck— rc=0bun run test(the declared gate,--timeout 120000) — 1399 pass, 7 skip, 0 failbun run build— rc=0, new entry points emitsecrets scan staged— rc=0,findingCount 0, 6 files / 96681 bytes then 5 files / 88531 bytesBare
bun testshows 10 timeout failures at loadavg ~106; those are the 5s defaulttimeout, not assertions, and the declared gate is green.
Limits
docs/safe-read.mdstates what this does not protect against, each item gradedM (exercised) or S/U (read from source): a wrong predicate on a complete read
(the jq
m-flag case), a defaulted scope beyond naming it, a probe structurallyincapable of matching its subject, an unknown hidden clamp, a moving population, and
anything about whether the rows are correct.
Self-check on this helper's own exposure to the jq trap: jq invocations = 0,
anchored patterns = 0, with a positive control (
RegExp= 1 present, absent token= 0). Both numbers are reported because a lone zero cannot distinguish clean from
could-not-fire — this is out of scope, not an all-clear.
Need help on this PR? Tag
@codesmith-botwith what you need. Autofix is disabled.