Skip to content

Commit 6232c9e

Browse files
Merge pull request #65 from StepenkoAnatoli/fix/single-witness-footgun
A reviewer note is not a citation
2 parents 78f6998 + 7af81c4 commit 6232c9e

5 files changed

Lines changed: 65 additions & 4 deletions

File tree

‎docs/ARCHITECTURE.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -423,6 +423,7 @@ each line — is in the ADR named beside it and in
423423
| The collector's spend gate is a **protected environment whose existence is proven**, because GitHub silently creates an *unprotected* one when a workflow names a missing environment; a missing credential **refuses** rather than falling back to a keyless route | ADR-0033 |
424424
| A claim's **support is weighed, not just checked**: `corroboration` reports whether a closed unknown rests on one reading, several readings of one source, a **republished copy of one document across hosts** (`mirror`, via `lib/similarity.mjs`), or genuinely distinct documents — it never fails a corpus on its own, because single-sourcing is often the right answer. Rows are **deduped by id first**: `citedIds` returns every `E-##` mention, so prose naming one row twice used to produce it twice, and once grouping folded the duplicate it reported a "republished copy" about a row that was merely mentioned twice | [ADR-0036](adr/0036-the-gate-can-see-single-sourcing.md) |
425425
| `corroboration` **reports the closest pair it measured** on an `independent` verdict, and does not act on it. The threshold has two measured blind spots, and containment - the obvious repair for the second - ranks a genuinely different pair (0.506) ABOVE a real mirror (0.380) over 2404 pairs, so trying harder would report second sources as copies. The number informs a reviewer; the grade does not move | ADR-0036 |
426+
| A reviewer note is **not a citation**: `stripReviewNotes` in lib/corpus.mjs removes `[single-witness: ...]` and `[render-reviewed: ...]` before `citedIds` runs. Those notes are prose written INSIDE the same cell the citations are read from, so explaining why no second witness exists - by pointing at a row that demonstrates it - turned that row into a citation, and the unknown read as corroborated while its own note denied it. It caught its author three times in two days across three projects; the guard was right every time and the mechanism was the problem. The raw cell is untouched, because the check that reads the reason needs it | ADR-0038 |
426427
| A finding a reviewer has **considered and overruled** is recorded, not repeated forever: `[single-witness: …]` in an unknown's Evidence cell and `[render-reviewed: …]` in an evidence row's Finding cell turn a true-but-permanent warning into a pass **that prints the reason**. A reason under 30 characters is named (`single-witness-unreasoned`) rather than obeyed, and a note left on an unknown that has since been corroborated is reported (`single-witness-stale`) so the acknowledgement cannot rot. The render note lives on the ROW because `verifyLedger` hashes the whole capture file — a capture is evidence and stays byte-immutable, a judgement about it is corpus prose | [ADR-0038](adr/0038-a-judgement-the-gate-can-read.md) |
427428
| The collector lock is exclusive **on a local filesystem, and not promised on a network one**: Node states "the exclusive flag might not work with network file systems", a condition ADR-0020 never carried. Measured 2026-09-22 - 24 racers, 1 winner, 23 EEXIST on win32 - so the guarantee holds where it was tested and nothing detects where it is standing. Evidence: `docs/decisions/2026-09-22-wx-lock/` | ADR-0020 |
428429
| The SEARCH side is **selectable at dispatch**: collect.yml takes a search_transport input (auto | serpapi | firecrawl-cli) and collect-remote.mjs a matching --search-transport, passed as RESEARCH_KIT_SEARCH_TRANSPORT. ADR-0027 built the ladder and the collector never exposed it, so a dispatched run always took whatever auto-detect picked and two providers could not be compared on one topic through the kit at all. auto becomes the EMPTY STRING, falsy in the ladder, so the default is the previous behaviour exactly | ADR-0027 |

‎research-kit/README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -395,7 +395,7 @@ node research-kit/bin/selftest.mjs # all of it
395395
node research-kit/bin/selftest.mjs gate hook # just these files
396396
```
397397

398-
864 tests, offline, no key and no network. The runner **awaits** every test, so `ok` means
398+
867 tests, offline, no key and no network. The runner **awaits** every test, so `ok` means
399399
the assertions settled (ADR-0021), and each test is raced against a watchdog
400400
(`RESEARCH_KIT_TEST_TIMEOUT`, default 60s).
401401

‎research-kit/lib/corpus.mjs‎

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -331,7 +331,7 @@ export function readCorpus(root) {
331331
.map((r) => ({
332332
id: r.ID, text: r.Unknown ?? '', why: r[HEADERS.unknowns[2]] ?? '',
333333
status: (r.Status ?? '').trim().toUpperCase(), evidence: r.Evidence ?? '', line: r.line,
334-
cites: citedIds(r.Evidence ?? ''),
334+
cites: citedIds(stripReviewNotes(r.Evidence ?? '')),
335335
}));
336336

337337
const evidence = evidenceTable.rows
@@ -346,7 +346,7 @@ export function readCorpus(root) {
346346
.map((r) => ({
347347
id: r.ID, text: r.Subtopic ?? '', why: r[HEADERS.subtopics[2]] ?? '',
348348
status: (r.Status ?? '').trim().toUpperCase(), coveredBy: r['Covered by'] ?? '', line: r.line,
349-
cites: citedIds(r['Covered by'] ?? ''),
349+
cites: citedIds(stripReviewNotes(r['Covered by'] ?? '')),
350350
}));
351351

352352
const sources = sourcesTable.rows.map((r) => ({
@@ -399,6 +399,28 @@ function normalizeRawCell(cell) {
399399
return value.split(path.sep).join('/');
400400
}
401401

402+
/**
403+
* A reviewer's recorded judgement, removed before citations are counted.
404+
*
405+
* WHY. `citedIds` returns every `E-##` mention in a cell, and a `[single-witness: ...]`
406+
* reason is prose a reviewer writes INSIDE that cell. So explaining why no second witness
407+
* exists - by referring to a row that demonstrates it - silently turned that row into a
408+
* citation. The unknown then read as corroborated while its own note denied it, and
409+
* `single-witness-stale` fired on a corpus that was correct.
410+
*
411+
* It caught its author three times in two days, across three different projects. The guard
412+
* was right every time and the MECHANISM was the problem: a note about evidence is not a
413+
* citation of it.
414+
*
415+
* The raw cell is untouched - `unknown.evidence` still carries the note, because the check
416+
* that reads the reason needs it. Only the citation extraction sees the stripped text.
417+
*/
418+
const REVIEW_NOTE = /\[(?:single-witness|render-reviewed):[^\]]*\]/gi;
419+
420+
export function stripReviewNotes(text) {
421+
return String(text ?? '').replace(REVIEW_NOTE, ' ');
422+
}
423+
402424
/** Every `E-##` / `U-##` / `D-##` id mentioned in a free-text cell. */
403425
export function citedIds(text) {
404426
return [...String(text ?? '').matchAll(/\b([A-Z]+-\d+)\b/g)].map((m) => m[1]);

‎research-kit/test/checks.test.mjs‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -566,3 +566,34 @@ test('capture-completeness: the render review is read from the ROW, never the ca
566566
assert.ok(findings.some((f) => f.rule === 'partial-render'),
567567
'a note in the capture front-matter must NOT satisfy the review - the ledger hashes it');
568568
});
569+
570+
test('a row named INSIDE a single-witness note is not thereby cited', () => {
571+
// The footgun this removes. `citedIds` returns every E-## mention in the cell, and the
572+
// reason a reviewer writes lives in that same cell - so explaining why no second witness
573+
// exists, by pointing at a row that demonstrates it, turned that row into a citation.
574+
// The unknown read as corroborated while its own note denied it, and
575+
// `single-witness-stale` fired on a corpus that was correct. It caught its author three
576+
// times in two days before the mechanism was changed rather than the prose.
577+
const dir = withEvidence(makePassingProject(), [
578+
'| E-01 | 2026-09-14 | P | https://example.invalid/docs/limits | ten a minute | research/raw/x.md |',
579+
'| E-02 | 2026-09-14 | P | https://other.invalid/blog | a blog agreeing | research/raw/y.md |',
580+
], 'E-01 [single-witness: the vendor is the authority on its own limits, and E-02 shows what a third party repeating it is worth]');
581+
582+
const findings = runCheck('corroboration', snapshot(dir));
583+
assert.equal(findings[0].severity, 'pass');
584+
assert.equal(findings[0].rule, 'single-witness',
585+
'the note mentioned E-02; mentioning is not citing');
586+
assert.doesNotMatch(findings[0].detail, /stale/);
587+
});
588+
589+
test('a row cited OUTSIDE the note still counts', () => {
590+
// The other half: stripping must not swallow real citations that happen to sit near one.
591+
const dir = withEvidence(makePassingProject(), [
592+
'| E-01 | 2026-09-14 | P | https://example.invalid/a | x | research/raw/x.md |',
593+
'| E-02 | 2026-09-14 | P | https://other.invalid/b | x | research/raw/y.md |',
594+
], 'E-01 and E-02 both say so');
595+
596+
const findings = runCheck('corroboration', snapshot(dir));
597+
assert.equal(findings[0].severity, 'pass');
598+
assert.equal(findings[0].rule, 'independent', 'two genuine citations are still two');
599+
});

‎research-kit/test/corpus.test.mjs‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { PATHS, HEADERS, resolve, writeText, readText } from '../lib/core.mjs';
55
import {
66
readCorpus, readCaptures, parseTable, parseCapture, splitRow, escapeCell, tableRow,
77
repairRowArity, captureEntry, rememberCapture, cacheDecision, traceOf, captureOf,
8-
claimOf, sectionOf, appendRow, upsertRow, nextId, citedIds,
8+
claimOf, sectionOf, appendRow, upsertRow, nextId, citedIds, stripReviewNotes,
99
} from '../lib/corpus.mjs';
1010

1111
describe('corpus');
@@ -162,3 +162,10 @@ test('nextId continues the sequence it is shown', () => {
162162
assert.equal(nextId('E', [{ id: 'E-01' }, { id: 'E-09' }]), 'E-10');
163163
assert.equal(nextId('U', [{ id: 'U-3' }]), 'U-04');
164164
});
165+
166+
test('stripReviewNotes removes the note and nothing else', () => {
167+
assert.equal(stripReviewNotes('E-01 [single-witness: see E-02]').includes('E-02'), false);
168+
assert.equal(citedIds(stripReviewNotes('E-01 [single-witness: see E-02]')).join(), 'E-01');
169+
assert.equal(citedIds(stripReviewNotes('E-01 and E-02')).join(), 'E-01,E-02');
170+
assert.equal(stripReviewNotes('a [markdown](http://x) link'), 'a [markdown](http://x) link');
171+
});

0 commit comments

Comments
 (0)