Skip to content

WIP: Memory/draft verification - #145

Draft
Hareet wants to merge 46 commits into
mainfrom
memory/draft-verification
Draft

WIP: Memory/draft verification#145
Hareet wants to merge 46 commits into
mainfrom
memory/draft-verification

Conversation

@Hareet

@Hareet Hareet commented Jul 28, 2026

Copy link
Copy Markdown
Member
  • scripts that were created by adversarial agents to verify and ground all claims that were in memory drafts based on previous reviews.

I'm not sure we'll merge this, but I wanted to share the scripts that were created in the hopes of making the reviews of the memory PRs easier. They added some of the grep and git commands Sugat used to identify inaccuracies.

https://github.com/medic/cht-agent/blob/93d47baca49c7147cc6085236154ce653094cb37/docs/memory-draft-verification.md#layer-2-ground-claims

Hareet and others added 8 commits July 27, 2026 11:00
validate-schema proves a draft is well-shaped, not that the shape is true.
`/issues/<pr-number>` redirects to `/pull/`, so a draft keyed to its own merge
PR validates perfectly — which is how ~60 of the first 107 drafts reached
review mis-keyed, and how five drafts naming symbols that do not exist in
cht-core passed CI green.

Adds `npm run verify-drafts`, the part of that gap a machine can close
hermetically: identity coherence (id/issueNumber/issueUrl agreement, the
issueNumber-equals-own-source_pr signature), filename-token vs frontmatter
agreement, cross-domain duplicate detection against the already-landed corpus,
and a near-miss lint against a committed cht-core vocabulary snapshot that
catches `con_create_people`, `docs_by_type` and `task.status`. Leakage and
metadata-distribution lints are warnings. `--online` adds the one check needing
the network — asserting issueNumber really names an issue — and reports
"unverified" rather than a pass when gh is throttled.

Wired into the existing CI job with --changed-only so per-file findings are
scoped to the PR's own drafts and pre-existing corpus debt does not fail
unrelated PRs; --online stays off in CI until a run-scoped GITHUB_TOKEN is
proven able to read medic/cht-core. actions/checkout gets fetch-depth: 0,
without which the base is unreachable and the diff silently empty.

Every fixture in test/fixtures/drafts transcribes a defect a reviewer actually
found in PRs #120/#121/#123/#130/#131/#132, so those classes cannot return
unnoticed. docs/memory-draft-verification.md states plainly what this does NOT
catch — absent symbols that are not near-misses, misattribution, inverted
semantics, mechanism and backport claims — all of which need the source tree.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Layer 1 (verify-drafts) is hermetic, so by construction it only sees defects
visible in the draft itself. The defects that actually survived two review
rounds need the source tree: a fabricated symbol resembling nothing real
(getOidc, isDue), a real symbol credited to the wrong file
(updateServiceWorker), an inverted mechanism claim ("preserved" a file the PR
deleted), and a backport attributed to the wrong release line.

Two stages, deliberately split. An LLM reads a draft and extracts checkable
CLAIMS — the one step that needs a model, since recognising an assertion in
prose is not mechanisable. Every claim is then settled by a git probe. The model
never decides truth; `git grep` does. An LLM verdict can flip between runs on
identical bytes, which is why this split, and not a "review the draft" prompt,
is what makes the result trustworthy.

claim-probes.ts holds the deterministic half, with three load-bearing rules:

- Word-bounded search only. Substring `getOidc` returns 8 hits in cht-core, all
  longer identifiers (getOidcUsername, getOidcBaseUrl); an unanchored grep would
  have CERTIFIED the exact hallucination the reviewer caught. -F -w returns 0.
- Absence is only provable at a commit, so probes are scoped to the draft's
  anchor. Where the anchor will not resolve — cht-core does not stamp every PR
  number into its subject, the SSO cluster among them — tree-scoped claims fall
  back to origin/master and are marked provenance: 'fallback', while claims
  needing the commit itself stay unverifiable. Unverifiable is never a pass.
- Reverts are not evidence. PR #10599 resolves to a commit reverting the change
  its draft describes; that anchor is refused outright.

Verified against the real checkout: it reproduces every round-2 finding,
including the two Layer 1 cannot reach — updateServiceWorker comes back "0 hits,
but 16 elsewhere: attributed to the wrong file" pointing at config-watcher.js,
and add-branding-doc.js comes back "was deleted, not modified". It also settles
the chtRolesSettings claim the first pass could not, naming the real parameter
chtPermissionsSettings.

Not wired into CI, on purpose: it needs a large checkout and an LLM, and a
required check that can flake gets de-required. Reports land in
outputs/verification/ (already gitignored) stamped with a content hash, so a
later promotion gate can refuse a report that does not match the draft bytes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…nd anchor-resolution limits

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…llback

Dogfooding ground-claims on the configuration branch surfaced three false
positives, all from treating a fallback probe as if it were an anchor probe.

Absence only refutes a claim at the draft's OWN commit. Under fallback we search
a tree that predates the change, so every symbol a post-cutoff PR introduces
looks fabricated. Draft 11057 (PR postdates the clone) had `weight` and
`header_tab` reported as defects when their absence from master is exactly what
you would expect. Absence under fallback is now unverifiable.

Misattribution gets the same treatment for the same reason: under fallback we
cannot distinguish a wrong file attribution from a symbol the PR itself added to
that file, and a common word like `weight` matches somewhere in any large tree
(165 hits). It is now reported as unverifiable with the real location as a hint,
so a human still sees it. At the anchor it remains a hard defect — that is the
updateServiceWorker case, and it still fires.

Finally, symbolHits retries a dotted symbol with optional chaining before
declaring it absent: source writes `res?.resources` while prose and claim
extraction normalise to `res.resources`, which made a real member access read as
fabricated.

Net effect on the configuration branch: 3 residual findings, all artifacts, drop
to 0 — while every genuine defect the sweep found still reports.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… references

Regenerating the vocabulary against a current cht-core checkout dropped
doc_by_type from the couch-view family — which would have silently disabled the
check that catches `docs_by_type`, the exact defect this family exists for.

The view has not gone anywhere: ddocs/medic-db/medic-client/views/doc_by_type/
is still there. What changed is that its callers moved to constants, so the name
no longer appears in any .js/.ts content. Mining from references makes the
vocabulary only as complete as the current call sites, and shrinks it silently
whenever a refactor introduces an indirection.

couch-view now mines ddocs/**/views/<name>/ — the repository's own declaration
of what views exist. The family grows 26 -> 32 (also more complete than the
original 29, since it now includes views declared but not referenced anywhere).

Snapshot refreshed to 1f3e56f226 (2026-07-27) from the current clone at
medic-cht-agent/cht-core; the previous snapshot was ~4 months stale. The
committed spec assertions (real terms in, fabricated terms out) are what caught
this, and they still pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…aleness

Measured against a same-day cht-core clone, six PR numbers across the corpus
still resolve to nothing — fetching does not fix that class. Two of the three
configuration drafts that would not anchor had real provenance problems: one
describes a feature absent from master, one credits a mechanism to the wrong PR.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ning

Grounding the messaging branch produced 11 findings, 6 of which were defects in
these probes rather than in the drafts.

BACKPORTS. `branch --contains <anchor>` can never see a backport, because a
cherry-pick is a different commit. Two drafts stating true backports were
reported as defects: #10073 -> f4e54b43f on 4.21.x, #10230 -> b567b0a8a on
4.22.x, both reachable only by searching for a commit that references the same
PR number. checkReleaseBranch now falls back to that search, and the branch
matcher tolerates prose forms ("4.21", "4.x") against refs ("origin/4.21.x").

OPTIONAL CHAINING. The previous retry replaced every dot, so it could only match
`doc?.fields?.patient_id`. Real source puts the `?` on an arbitrary subset —
`doc.fields?.patient_id` — and three claims in one draft were called fabricated
because of it. The retry now builds an -E pattern making `?` optional at every
position instead of guessing one spelling.

Both classes share a root cause worth naming: a probe that answers "not found"
is only trustworthy if it has searched every spelling the source could plausibly
use. Net on messaging: 11 findings -> 5, all 5 remaining confirmed as extraction
artifacts rather than content defects.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Hareet and others added 3 commits July 28, 2026 10:37
…hird branch

Each of these reported a correct draft as defective.

TRANSLATION KEYS vs TASK FIELDS. The task-field candidate pattern matched
`task.list` inside `task.list.complete`, a real translation key with 11
occurrences, and reported it as a fabricated field. The pattern now rejects a
match followed by `.<lowercase>`, so a longer dotted key is skipped while a
sentence-ending period still leaves a real field detectable.

BACKPORT PR NUMBERS. checkReleaseBranch searched only the draft's own PR number,
but a backport is carried by a different PR and the draft names it right in the
sentence ("backported to 4.13.x (PR #9555)"). Two true backport claims were
reported as defects. It now also tries every PR number quoted in the claim.

COLLAPSED CLUSTERS. A draft that merged a duplicate cluster carries
`source_prs[]` and its Related Files legitimately span all of them, but
file-touched checked only `source_sha` — so the sibling PRs' files read as
untouched. Sibling anchors are now resolved and their diffs unioned.

Pattern across all seven fixes so far: a probe answering "not found" is only
trustworthy if it has searched every place and spelling the source could
plausibly use, and every unresolvable anchor must degrade to unverifiable rather
than to a defect. The vocabulary snapshot is regenerated at 1f3e56f226.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
FOURTH false-positive class, proven live on the promote-configuration
review (#130): a PR merged into a FEATURE branch that later squash-merged
onto master leaves no trace a clone can see. The child's merge commit
lives only on the deleted branch and no squash subject carries its
number, so both local strategies failed and nine TRUE claims in the
11057 draft degraded to unverifiable — and the same blind spot produced
a wrong "not in cht-core" provenance diagnosis on 11021's removal.

resolveAnchor now takes the repo slug and, when the clone alone cannot
name the commit, asks the GitHub API which LOCAL commit carries the PR:

- merged PR whose merge commit is in the clone but whose subject has no
  "(#N)" stamp (the SSO cluster) -> anchored directly;
- merged into a feature branch -> one hop: the PR whose HEAD is that
  branch (pulls?head=owner:branch) supplies the squash the clone can
  see (11057 -> 10224-ui-extensions -> #11050 / 180c29ecf).

Resolution only — adjudication never leaves git, a sha the clone lacks
is never anchored to, and every failure (gh missing, curl down, rate
limit, unmerged PR) degrades to the existing unverifiable semantics
rather than a defect or a crash. gh is tried first for the
authenticated rate limit; anonymous curl is the fallback. Anchors
resolved this way carry a note surfaced in claims.json and REPORT.md;
--no-api-resolve keeps a run fully offline.

Grounding against an epic squash reuses the sibling-union
over-approximation already accepted for source_prs: file-touched sees
the union of every child's changes and statuses reflect the landed
state.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ng branch

Each reported a TRUE claim as a defect, or silently skipped a checkable one:

HAND-AUTHORED ANCHORS. The pre-pipeline drafts carry only source_prs[] —
no source_pr, no source_sha — so anchorFor never resolved them and every
commit-scoped claim degraded to unverifiable (24 across five messaging
drafts). The first source_prs entry is now the canonical PR candidate.

LINE-WRAPPED MEMBER CHAINS. request.post spelled as 'request\n  .post({'
is invisible to line-oriented git grep, so a correct symbol-in-file claim
read as misattribution (10073, africas-talking.js:80-81). A dotted symbol
that misses the scoped grep is re-checked against the file blob with
whitespace tolerated around the dots and word-ish boundaries at both ends.

PATH ABSENCE UNDER FALLBACK. checkPathExists returned ungrounded at the
fallback ref, but layouts move (api/ -> api/src/, protractor -> wdio):
three true 2018-era path claims in the 4278 draft were reported as
defects. Absence now degrades by provenance exactly like checkSymbol.

Pattern unchanged from the first seven fixes: a probe answering 'not
found' is only trustworthy if it has searched every place and spelling
the source could plausibly use, and anchor problems must degrade to
unverifiable rather than manufacture defects.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Hareet and others added 14 commits July 29, 2026 17:50
…eading, not probing

Round 3 on #120/#130 was almost entirely defects no existing layer could
see. Each class below is now checked mechanically, with the LLM confined
to identifying candidates that code then verifies.

SELF-CONTRADICTION (new Layer 3, check-coherence.ts). A grounding pass
corrects the sections that assert mechanism against code and leaves the
interpretive ones asserting what it just disproved, so both halves ground
independently while the document tells two stories. 10198 says template
safety comes from ng-if attributes 'not from scaffolded keys' in Solution
and that the controller 'scaffolds the minimum keys the template
requires' in Design Choices. The model may only IDENTIFY pairs and must
quote both sides verbatim; verifyContradictions drops any pair whose
quotes do not occur in the file, so a hallucinated finding cannot land.

DRIFT (claim-probes.ts). resource-icons.service.ts was real when its PR
shipped and is gone today: checking only the anchor certifies it, checking
only master refutes it, and both are wrong — the claim is true, the tense
is not. A claim that grounds at its anchor is now re-checked against
master and flagged when the draft names a dead entity with no temporal
qualifier. Exits 3, not 1: nothing is disproven, so 'ungrounded' keeps
meaning 'this is wrong'.

CROSS-REFERENCE ACCURACY (verify-drafts.ts, --online). #10754 sat in
Related Issues glossed as 'Scheduled task duplicate processing'; it is
'Cookies not being sent with secure: true'. Every #N there is now
classified and its gloss compared to the real title, with only total
disjointness reported so paraphrase stays free. PRs cited as issues warn
unless labelled 'PR #N'.

SNIPPET FIDELITY (claim-probes.ts). The 4278 fence was a composite of two
real helpers, so every symbol in it probed clean while the block existed
nowhere. Fences are matched against every file the draft names,
whitespace- and comment-insensitively, with elision markers as segment
boundaries.

STALE TIMESTAMPS (verify-drafts.ts). lastUpdated must not predate the
file's last commit; 13 rewritten drafts kept their old stamp.

Also: the extraction prompt no longer turns a REMOVAL claim ('removed the
parseResponseBody helper') into an existence probe, which cost a false
flag last run; gh-classify gains describeNumber for titles; Draft carries
its absolute path so git pathspecs work when scanning a sibling worktree.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…anches

CURL FALLBACK for the issues endpoint (gh-classify). The online checks
were unusable on a host without `gh`: every lookup threw and reported
'unverified', which is honest but checks nothing. fetchIssueRecord now
retries anonymously via curl, distinguishing a real 404 from a transient
failure by status code, so the same three-way answer reaches callers. This
is what let the cross-reference audit run at all — and it immediately
found #10754 on messaging and confirmed all nine configuration refs.

RELATIONSHIP GLOSSES are exempt from the title comparison. 'Blocker for
#10908' describes linkage, not the referenced issue's subject, so
comparing it to that issue's title flagged a correct reference (11057's
#10901). Any gloss opening with a relationship word, or containing another
issue number, is now skipped.

DRIFT IS PER ENTITY, not per sentence. A draft normally qualifies a dead
path once — in a note or an annotated Related Files entry — then names it
plainly elsewhere. Keying off the claim's own quote demanded the caveat in
every sentence and flagged 10278 four times after it had been fixed
properly. One honest mention now settles the entity.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…hecks

SUMMARY IS A SECTION. Two of three residual contradictions had one side in
the summary field, and the coherence prompt listed summary among the
sections a grounding pass 'corrects', so the model under-weighted it. It
is now checked against every other section first, with both real misses as
worked examples. The summary is the most-read line in a memory; it should
be the most scrutinised, not the least.

(added)/(modified) NO LONGER COUNT AS TIME-SCOPING. Those annotate what a
PR did to a file. Whether the path still exists today is a different
question, so treating them as scoping let a draft mark a path '(added)' in
Related Files and recommend it in the present tense elsewhere, unflagged.
'(deleted)' stays: it does say the path is gone.

GLOSS CHECKING GOT ONE TIER MORE HONEST. A wrong cross-reference sharing a
single incidental word with the real title passed - #10446 ('Dont send
empty messages') described as 'failed/invalid scheduled messages were not
being cleared', joined only by 'messages'. Tightening the blocking rule to
require two shared words would flag correct short glosses, so instead the
blocking rule still fires only on total disjointness and a new
related-ref-gloss-weak WARNING covers the gap: a gloss of four or more
content words sharing exactly one. Separately, a relationship prefix now
exempts only itself - 'parent improvement - <claim>' still has <claim>
checked, where before one leading word excused the whole line.

Also documents, without closing, what these checks cannot see: untagged
and comment-only fence content, snippets misattributed among a draft's own
files, prose aliases for a dead path, and that 'last edited' counts
stamp-only and revert commits. The coherence header no longer implies its
quote gate proves a contradiction is real - it proves the quotes are.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…s it to

Drift's mirror image, and the second false positive of this shape. A
time-scoping note added in response to review names the REPLACEMENT for a
dead path - '...replaced by custom-resource.service.ts in #11050' - and the
replacement by definition does not exist at an anchor that predates it, so
the prober refuted a true sentence. The 9467 'current master reads
err?.status' aside failed the same way last round and I reworded around it;
rewording is the wrong fix twice.

A tree-scoped claim that fails at its anchor is now re-checked at the
current ref when the quote scopes it forward ('replaced by', 'renamed to',
'on master', 'now reads', ...). Grounding there is reported with fallback
provenance and an evidence note saying the anchor lacked it, so the weaker
scope stays visible. A forward-scoped claim false in BOTH trees is still
ungrounded, and a claim with no forward wording is never rescued - absence
at the anchor remains a defect by default.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two precision fixes from a re-verification pass.

SILENT EMPTY SELECTION. --changed-only resolves its diff in the --dir
target, but display paths are relative to the repo running the tool. Point
it at another worktree and git returns paths in that repo's language while
the drafts are named in this one, so nothing matches, zero drafts are
selected, and the run reports clean. The existing guard only fired on an
empty diff, which is the shallow-checkout case, not this one. A non-empty
diff matching no draft now throws and says why. For a verification tool,
reporting 'clean' because it checked nothing is the worst failure mode
available, and it is the one a reviewer would never see.

'moved to' IS NOT FORWARD-SCOPING. Anchor-era prose says 'the guard moved
to the top of the function', meaning the PR moved it - not that the current
tree differs from the anchor. Leaving it in FORWARD_SCOPED could rescue a
genuinely wrong anchor-era claim by re-checking it against master. The
unambiguous markers ('replaced by', 'renamed to', 'since renamed', 'on
master', 'now reads') carry the real cases.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…udget

Anonymous GitHub allows 60 requests an hour and describeNumber had no
cache, so a corpus that cites the same issue from several drafts spent its
budget re-fetching a handful of numbers and reported everything after that
as 'unverified' - which reads like a clean run unless you check the count.
That is why the tasks-and-targets branch went unaudited: 23 of 25 online
checks were rate-limited on an earlier attempt.

Two changes: describeNumber takes a shared DescribeCache, and
checkIssueIsNotPr now goes through it instead of classifyNumber. A draft
almost always cites its own issue in Related Issues as well, so the two
checks were fetching the same record twice. For the 25 tasks drafts that
turns 58 requests into 33 distinct ones - a scan that did not fit now does.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The extraction half of ground-claims is an LLM and the settling half is
git, so verdicts were reproducible while COVERAGE was not. Measured over 13
drafts whose bytes did not change between two runs:

  claims extracted   run 1: 165    run 2: 180
  present in both:    78           present in only one: 189
  union:             267    -> one pass saw 61-67% of it, 29% overlap

A single run is a sample, so 'N grounded, 0 ungrounded' from one run
overstates what was checked - including in review comments already written.

Prose has no canonical decomposition into claims, so the semantic tier
('the fix throws on write errors') will always need a model. But the tier
the reviewer keeps catching - a named file, a named symbol, a path that has
since moved - is code-shaped and can just be enumerated. enumerate-claims
regexes the draft for repo paths and backticked identifiers, splits Related
Files (file-touched) from everything else (path-exists), dedupes, and hands
the lot to the same probes. Same bytes in, same claims out. It is unioned
with the model's output, so coverage is now exhaustive over code-shaped
claims and still sampled over semantic ones - which is what the tool can
honestly promise. It also runs when the model fails, so an outage degrades
coverage instead of erasing it.

Deliberately not extracted: bare lowercase words in backticks such as
'pending' or 'due', which are usually state strings and indistinguishable
from prose emphasis. A false 'this is fabricated' costs more than a missed check.

Paired with a fallback for dotted tokens prose writes whole and code never
spells: sms.clear_failing_schedules reached via config.get('sms')?.…,
smsparser.parse declared as exports.parse, RulesEngineService.fetchTargets
defined bare. All four were hand-adjudicated as false positives during
review; the final segment now resolves them to grounded with an evidence
note, so exhaustive extraction does not mean an exhaustive noise floor.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…tive noise

The first exhaustive run over all three branches produced 44 ungrounded
findings and every single one was an artifact - 41 of them mine. Triaging
them by hand would have relocated the work instead of removing it, and a
reviewer running the tool would have had to repeat that triage.

Six filters, each from a real false positive in that run:

- A FILENAME IS NOT A SYMBOL. Backticked smsparser.js, sender.component.ts,
  tasks.component.ts and friends were probed as identifiers. Real repo paths
  are already caught by the path regex.
- ABSENCE CONTEXT. A line saying the thing is gone, renamed or was never
  adopted is asserting its ABSENCE, so probing existence inverts the claim.
  This was the largest class: "Removed the parseResponseBody helper", "the
  original can_hide_target_count_past_goal permission was superseded",
  "isTelemetryOrFeedback -> isReplicableDoc". Note the arrow alternative has
  to sit outside the \b group - neither character is a word character, so \b
  could never match beside it and my first version of this was dead code.
- DISCLAIMED RELATED FILES. "api/controllers/sms-gateway.js (the endpoint
  under test; not modified)" is not a file-touched claim. Downgraded to
  path-exists rather than dropped, since the path is still asserted to exist.
- REMOTE ENDPOINTS. api/v2/broadcasts.json is RapidPro's, not ours, but it
  starts with api/ and ends in .json.
- OUR OWN FRONTMATTER KEYS. A draft discussing source_sha or domainFit is
  not claiming a cht-core symbol.
- ELLIPSES. `for...of` is prose.

Also tightened the LLM extractor, which invented a directory prefix for a
draft that named only a bare filename ("rendered from analytics.component.html"
became webapp/src/ts/components/analytics/... when the real tree has
modules/analytics/...). It is now told to use the path exactly as spelled or
emit nothing.

Worth recording what the 44 findings actually proved: the corpus is clean.
An exhaustive pass examined 10-15% more claims per draft than sampling did -
127 vs 117 on configuration, 262 vs 229 on messaging, 452 vs 395 on tasks -
and surfaced no new real defect on any branch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Self-inflicted, and worth recording as such. To stop the extractor
inventing a directory for a bare filename, I told it to emit the bare
string instead - and tasks-and-targets promptly went from 33 ungrounded to
50, because a claim of "integration.spec.js" cannot match a diff that
stores shared-libs/rules-engine/test/integration.spec.js. Seventeen new
findings, all of them real files, none of them defects.

Suppressing those claims would have been the wrong fix: prose names files
by basename constantly ("Updated unit tests across the rules engine
(integration.spec.js, pouchdb-provider.spec.js, provider-wireup.spec.js)")
and those are checkable, just not by exact path. file-touched and
path-exists now resolve a name containing no slash against the diff and the
tree respectively, and report which full path it matched.

The rescue is deliberately narrow: it fires ONLY when the draft gave no
directory at all. A claim naming webapp/src/ts/components/analytics/... when
the tree has modules/analytics/... stays ungrounded, because a wrong
directory is a real defect and must not be laundered by its basename. An
explicit status is still enforced against whatever the basename resolves to.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… squash

27 of the 34 remaining tasks-and-targets findings were one structural cause.
A PR merged into a feature branch has an unreachable merge commit, so it is
anchored at the epic's squash - but that squash is a DIFFERENT changeset. It
carries every sibling's work, and where the epic renamed things before
landing it does not contain the child's files under the names the draft
correctly records. 10390 lists 21 target-interval.* files that PR #10390
really created and that the #10423 squash landed as target.*, so an accurate
Related Files list read as 21 fabricated paths.

file-touched now asks the API for the child PR's own file list when the
anchor was reached by the epic hop, and says so in the evidence. Verified
live: 10390's target-interval.ts and api/src/controllers/target-interval.js
ground, while an invented path still refutes. Results are cached per PR, so
an epic's children cost one request each, and any transport failure falls
back to the squash diff rather than failing the run.

This is the last of the epic-squash blind spots. The first was anchor
resolution (adfb256), the second drift against master, and this is the
third: three different probes each assuming the anchor commit IS the PR.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…regression

2797d95 taught file-touched to ask the API for an epic child's own file
list, and in doing so returned early - skipping the sibling union that
collapsed clusters depend on. 9232 went from 2 ungrounded to 8. It is both
things at once: a cluster of three PRs (9232/9282/9317) whose Related Files
legitimately span all three, AND an epic child anchored at the squash of a
different PR. Neither source alone can settle it.

file-touched now absorbs all three sources before deciding: each epic
child's own PR file list, the anchor commit's diff, and every sibling
commit's diff. The evidence line names which sources were consulted.

Two more gaps closed while here:

- SUFFIX MATCHING. A sub-package names files relative to its own root
  ("test/qualifier.spec.ts" inside shared-libs/cht-datasource), which is a
  proper suffix of the real path but has a slash, so the basename rescue
  skipped it. Now any proper suffix resolves. A WRONG directory is still a
  defect: components/analytics/x.html is not a suffix of
  modules/analytics/x.html, and the test pinning that stays green.
- path-exists ON AN EPIC CHILD. Files a child created are absent from the
  squash tree when the epic renamed them before landing, so ls-tree refuted
  paths the PR demonstrably added. It now falls back to the PR's own file
  list and says the file is absent from the squash but added by the PR.

Verified live: 9232's rules-engine.service.ts and targets.less ground,
10390's test/qualifier.spec.ts and api/tests/mocha/controllers/
target-interval.spec.js ground, and an invented path still refutes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The last residual on configuration and messaging, and the third member of a
family already fixed twice. Drift is "true at the anchor, gone from master".
Forward-scoping is "false at the anchor, true on master". This is "false at
the anchor, true at the anchor's parent" - a Problem or Root Cause section
describing the state the PR CHANGED.

10604's Root Cause says the languages service "queried medic-client/
doc_by_type with key ['translations', true]", which is precisely what the
fix deleted, so probing the post-fix anchor refuted a correct sentence.
10073's names the parseResponseBody helper its PR removed. Both are now
grounded at anchor^, with the evidence saying why; a symbol absent from both
trees is still ungrounded, and the fabrication control still fails.

Scope is assigned by section, not guessed: sectionOfQuote locates a claim's
verbatim quote and reads the enclosing heading, so only Problem and Root
Cause get the parent-tree retry. Solution, Code Patterns and Testing keep
being judged at the anchor, where they belong.

Also applied the disclaimer filters to EVERY claim rather than only
enumerated ones. The model read 4278's "was not untested" sentence and
produced a file-touched claim from it; the enumerator would have skipped
that line. quoteDisclaims now runs over the merged set.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ster union

Three fixes, the first found by asking why configuration's claim total fell
from 113 to 91 between runs.

'INSTEAD OF' AND 'RATHER THAN' ARE NOT ABSENCE. I had put them in the
absence-context list, where they suppressed every claim on their line.
Measured on the configuration batch they hit 13 code-bearing lines - more
than every genuine absence pattern combined - because Design Choices is
built out of "chose X rather than Y" and "reused X instead of building Y",
and both name real symbols worth checking. That was pure lost coverage
masquerading as precision, and it explains most of the drop. A sentence
that really removes something says so with a verb the rest of the list
already catches.

A SENTENCE WITH NO CHANGE VERB DESCRIBES WHAT A FILE WAS. 4278 says the
endpoint "was not untested: api/tests/unit/controllers/sms-gateway.js
covered it at the unit level" - an existence claim about the file under
test, which the model extracted as file-touched. When such a quote carries
no change verb, no explicit status, and the path exists at the anchor, it
is now read as the existence claim it is.

A COLLAPSED CLUSTER MUST ASK ABOUT EVERY PR. 9553 names four PRs in
source_prs and its Related Files span all of them, but only one sibling
resolved to a local commit, so provider-wireup.spec.js - genuinely touched
by #9569 and #9570 - read as untouched. checkFileTouched now takes the
whole cluster and fetches each PR's file list.

Verified live: 4278's endpoint and 9553's spec both ground, and an invented
path in the same cluster still refutes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…dead lookup

The first fully-trusted run (0 extraction errors on all three branches) left
13 ungrounded, and nearly all of them were filters that only ever ran on half
the pipeline.

MODEL CLAIMS BYPASSED EVERY FILTER. The enumerator drops bare filenames,
strips call suffixes, skips this corpus's own frontmatter keys and refuses
ellipsis prose - but it applies those rules while extracting, so nothing
touched the LLM's claims. That is why emitter.nools.js, rules-engine.service.ts
and provider-wireup.js were probed as symbols, and why Number() reached git
with its parentheses attached. normaliseClaim now runs over the merged set.

RELATED ISSUES GLOSSES DESCRIBE OTHER TICKETS. 9486 cites #9432, "Merge
ensureTaskFreshness and ensureTargetFreshness into single event", and the
model produced two symbol claims from that title. They were never claims
about 9486's tree - and the irony is the gloss is only there because an
earlier fix made it quote the real issue title. Symbol claims from Related
Issues are dropped.

THE BASENAME LOOKUP WAS DEAD. pathExistsAt fell back to a `*/name` ls-tree
pathspec, which silently matches nothing:

  git ls-tree -r --name-only 88f9e463a -- '*/analytics.component.html'   # empty
  git ls-tree -r --name-only 88f9e463a | grep -c '/analytics.component.html$'   # 1

So every bare filename in a path-exists claim had been failing since the
rescue was added. It now lists the tree once per ref, caches it, and filters
in JS. 9277 and 10390's target-aggregates.service.ts ground as a result.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Hareet and others added 9 commits July 31, 2026 23:31
…ting

"change" as a verb

The last two tooling findings, both narrow.

BARE FILENAMES IN symbol-in-file. Prose attributes a symbol to a file by its
short name - "handleIntervalTurnover in provider-wireup.js" - and no git
pathspec resolves that, so the probe searched a path git could not find,
came back empty, and reported the symbol as misattributed. symbolHits now
expands a slash-free pathspec to the real path first. A genuinely wrong file
still fails: asking for handleIntervalTurnover in target-state.js is still
ungrounded.

"CHANGE" IS A NOUN HERE. The existence rescue for file-touched claims is
gated on the sentence carrying no change verb, and I had put bare
chang(e|es|ed) in that list. In this corpus "change" is almost always a noun
- 4278's sentence is "the api/src/ layout postdates this change" - so the
one rescue that sentence needed was blocked by the word "change" inside it.
Every remaining entry in the list is unambiguously verbal.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A PATH ABSENT FROM THE DRAFT WAS INVENTED BY THE EXTRACTOR. 10390 writes
"bespoke code in target-aggregates.service.ts" and "surfaced via
analytics.getTargetDocs". The model supplied
webapp/src/ts/modules/analytics/target-aggregates.service.ts and
webapp/src/ts/services/analytics.service.ts - neither string occurs anywhere
in the file - and both were then reported as defects. Probing a path the
draft never wrote proves nothing about the draft.

normaliseClaim now checks each claim's path against the raw text. If the
draft writes only the basename, the claim falls back to it and the basename
resolvers settle it; a symbol-in-file whose file was invented degrades to a
plain symbol check rather than a misattribution verdict; and a path with no
support at all is dropped.

The obvious substring test was wrong and a test caught it: `index.js`
contains the letters of `x.js`, so an invented path could be "rescued" onto
an unrelated file. Matching now requires a real boundary.

A CHANGE VERB ONLY COUNTS NEAR THE FILE. 4278's Root Cause names the
endpoint and then, 200 characters later, says the missing tests made it
"risky to modify or extend" - a statement about risk, not about what the PR
did, but enough to block the existence rescue when the whole quote is
scanned. The search is now windowed around the file mention.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…he budget

Anonymous GitHub allows 60 requests an hour, and one sweep of three branches
spends most of it on anchor resolution and epic-child file lists. The second
sweep in an hour therefore came back with 9 unverifiable on configuration and
31 on messaging - not regressions, just the budget gone, with unresolvable
anchors correctly degrading to unverifiable. But re-running after a fix is
what this workflow does constantly, and a gate that reports differently
depending on how recently it last ran is not a gate.

githubApi now reads and writes a cache under outputs/verification/, keyed by
API path. The data is immutable - a merged PR's file list, a closed PR's base
branch - so keeping it between runs is safe. A null is deliberately NOT
cached: a null is usually the budget running out, and persisting it would
bake a transient failure into every later run as though the PR did not exist.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
NOT EVERY "file" IS A PATH. The model produced symbol-in-file with
file = "analytics.getTargetDocs" - the dotted symbol itself. git searched for
that as a pathspec, found nothing, and reported the symbol as misattributed
to a path that never existed. A value with no slash and no file extension is
not a path: symbol-in-file degrades to a plain symbol check, other kinds are
dropped.

TIME-SCOPING IS A PROPERTY OF THE DRAFT, NOT OF ONE SENTENCE. driftFor tested
only the quoted line, so 9232 kept being flagged for a dead sidebar-filter
component it annotates in Related Files and for permissions it retires in
Design Choices - the extractor had simply quoted a different mention. A
reader warned once is warned. driftFor now consults the whole draft via
entityIsTimeScoped, which had been written and documented for exactly this
and never wired in.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…onale

check-coherence filed a pair on 10371 whose `why` read "These do not
conflict." The pair was counted, reported, and sent to a human to
adjudicate two statements the model had already cleared.

That noise is indistinguishable from a real non-recurring finding, which
is the expensive kind: the documented standard is that recurrence proves a
contradiction real while non-recurrence proves nothing, so every one-off
has to be triaged by hand against cht-core. A withdrawn pair costs that
triage and returns nothing.

verifyContradictions now discards a pair whose rationale negates it, on
the same footing as a fabricated quote or a sentence paired with itself.
The predicate is deliberately narrow — "These conflict: ..." still counts
as a finding — and is covered both ways in the spec.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
10390's Root Cause says target docs reached callers "via
analytics.getTargetDocs, so there was no centralized, reusable, typed
access path". Extraction pinned that symbol to
shared-libs/cht-datasource/src/local/target-interval.ts, and the probe
reported ungrounded.

No tree can satisfy that pairing. The sentence describes the state before
the fix; the file was added by the fix. Checking the parent — which the
pre-fix path already does — cannot help either, because the file does not
exist there. The verdict was reporting a real sentence as a fabrication.

The file attribution is the artefact, not the claim: prose naming a symbol
rarely names its home, so the model borrows a path from elsewhere in the
draft. When a pre-fix symbol-in-file claim names a path the anchor ADDED,
drop the binding and judge the symbol repo-wide at the parent instead. A
modified file keeps its binding — there the pairing is answerable, so a
miss stays a defect.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
11142 says "reducers/global.ts only gains a `search?: string` field on the
`TasksFilters` interface". That is true — the interface is declared at
webapp/src/ts/reducers/global.ts:292 — but the claim came back ungrounded
as "attributed to the wrong file".

basenameMatches exists because a `*/name` pathspec matches nothing in
ls-tree, so it lists the tree and filters on `p === file ||
p.endsWith('/' + file)`. That filter already handles a path tail. The
early `if (file.includes('/')) return []` above it meant tails never
reached it, so prose that shortens a path instead of dropping it entirely
got the worse verdict of the two.

Dropping the guard is safe: the suffix test is anchored on a leading
slash, so it matches whole path segments only — api/src/foo.js still
cannot resolve onto webapp/src/foo.js.

symbolHits now resolves whenever the pathspec is not LITERALLY in the
tree. It cannot use pathExistsAt for that test: pathExistsAt falls back to
basenameMatches itself, so with tails resolving it would report every
shortened path as present and skip the resolution it needs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
9718's Root Cause reads "The interval turnover mechanism in
provider-wireup.js snapshotted the last calculation at interval
boundaries instead of recalculating". Extraction offered "interval
turnover" as the symbol, git grep -F -w found it nowhere in the file the
sentence names, and the verdict came back as a misattributed symbol.

It is prose. No identifier in any language this corpus covers contains
whitespace, so normaliseClaim now drops a symbol containing any. Dotted
member chains, which is what the filter has to be careful of, have none.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
10230 says "Added a dedicated api/src/services/nepal-doit-sms.js service
that encapsulates the Nepal DoIT gateway's API integration". Extraction
turned the filename into a symbol and asked whether
api/src/services/nepal-doit-sms.js contains `nepal-doit-sms`. It does not,
and the verdict came back as a misattributed symbol on a branch that is
already up for review.

normaliseClaim now drops a symbol-in-file claim whose symbol is the stem
of the very file it is checked against. Whatever such a sentence asserts,
it is the file-touched claim the draft already makes in Related Files.

Deliberately narrower than "drop hyphenated symbols": Angular selectors
are kebab-case and drafts name them legitimately, so `overdue-filter`
survives when the file is not overdue-filter.*. Only self-reference goes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@sugat009 sugat009 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewing as a comment rather than a change request, since this is still a WIP draft and you have not put it up for approval. Flagging now because two of these affect whether the tool can do its job at all.

Headline: test/scripts/claim-probes.spec.ts contains raw NUL bytes, so git classifies it as binary. git diff --stat shows Bin 0 -> 41954 bytes and the API returns +0/-0 with no patch, which means the 77 tests in your largest spec cannot be reviewed by anyone. Worth fixing first, because it is also the spec that would demonstrate the two behaviours below.

I want to be fair about what this tool is up against. The methodology is right, and the drafts it targets do carry exactly the defects it enumerates. But the two heuristics below currently fail in the permissive direction, and the false-positive class they exist to suppress is real: your own b78ed35 on #120 retracted a fabrication count that turned out to be nested JSON keys. So the fix is not to make the checks stricter, it is to add a third verdict between grounded and absent.

CI is red at this head (Lint PR title on the WIP title, SonarCloud with 118 new issues), which I read as expected in-progress state rather than a defect.

For calibration, the four #123 items I filed today that a working version of this tool should catch: a draft saying a PR added files it deleted; a comparator claimed as a shared-libs/task-utils export when master has it in webapp/src/ts/reducers/tasks.ts; a UI label ("Previous month") that exists in neither tree; and a mechanism paragraph describing detection logic as missing when it pre-dated the PR. The first three are symbol and path claims squarely in scope.

* a false positive during review; resolving the last segment turns the whole
* class into a grounded verdict that says how it was reached.
*/
function lastSegmentHit(ctx: ProbeCtx, ref: string, symbol: string, pathspec?: string): string | null {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (important): lastSegmentHit grounds a dotted symbol whenever its final segment appears anywhere in the tree, with only a tail.length < 3 filter. Concrete failure: task.status (a real fabrication I caught by hand on the 10802 messaging draft, where the field is task.state) has tail status, which is everywhere in cht-core, so this returns a hit and checkSymbol reports grounded. The tool would wave through the exact class of defect it exists to catch.

The motivation in the docstring is sound and the false positives it names are real (smsparser.parse declared as exports.parse). The problem is the verdict it produces: it converts "cannot resolve this" into "grounded" rather than "needs a human". The evidence string is admirably honest about the weakness, but nothing downstream acts on that honesty.

Suggest a third outcome, e.g. tail-only, counted as unverified rather than grounded. Optionally require ancestry: after locating the tail, demand the preceding segment appear in the same file within a small line window, which keeps the exports.parse rescue and kills the task.status pass.

if (hits.length > 0) {
return verdict(claim, 'grounded', `${cmd} → ${hits.length} hit(s), e.g. ${hits[0]}`, undefined, prov);
}
const tail = lastSegmentHit(ctx, ref, claim.symbol);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue: this is the repo-wide call site, where the rescue is least constrained. The checkSymbolInFile caller passes a pathspec, so its version is much tighter. If the third-outcome change above is too big for now, scoping this call the same way would remove most of the exposure.

// PR did to it — 4278's "was not untested: api/tests/unit/... covered it"
// names the endpoint under test. If such a path exists at the anchor, the
// draft is right and the claim kind was simply mis-inferred.
if (status === undefined && !changeVerbNearFile(claim.quote, claim.file) && !claim.status

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue: the existence rescue in checkFileTouched reportedly neutralizes the Related Files check for the bare-path format the corpus actually uses, so a file that exists but was never touched by the PR passes. That is the updateServiceWorker defect class, which checkSymbolInFile's docstring explicitly says a bare existence check waves through, so the pipeline guards it in one place and not the other. Worth carrying the claim's provenance (the enumerator already knows it came from ## Related Files) and refusing the rescue for those.

@@ -0,0 +1,1045 @@
/**

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue: nothing in the pipeline records or checks a PR's base branch. That is how the corpus acquired 10767-feat10663-add-ui-extension-to-service-worker.md, already on main, which documents an appendUiExtensions hook from a PR merged to the feature branch 10224-ui-extensions and then abandoned; it never reached master, and appendUiExtensions is zero-hit there. gh-classify.ts already reads pr.base?.ref, so recording it on the anchor and flagging non-default bases looks cheap. Note 9232 and 10436 in the tasks-and-targets branch also merged to feature branches, so the pattern is common and only sometimes benign.

@@ -12,6 +12,10 @@ jobs:
steps:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue: the gate is wired only under on: pull_request, so it never runs on push to main. Combined with --changed-only, that means a draft can reach main unverified if it lands any other way. Also: an empty --base silently degrades --changed-only into a full-corpus scan rather than erroring, and vocab-near-miss is the only heuristic that hard-fails CI with no suppression path, so a truthful draft using an unusual-but-real symbol fails the build with no override.

Hareet and others added 12 commits August 7, 2026 14:19
…e model

`checkFileTouched` has always compared a claimed file status against the PR's
real file list — "X was modified, not added" is one of its verdicts. It just
never had a status to compare. The LLM extractor supplied one only when it felt
like it, and `enumerate-claims`, the half that is supposed to make coverage
reproducible, emitted every `file-touched` claim bare. So the check existed and
did not run.

That is the gap #10436 fell through for three review rounds. Its Testing section
said a mocha harness "was added ... (webapp/tests/mocha/.mocharc.js,
tsconfig.mocha.json, tsconfig.spec.json)" when the PR's own diff is deletions
only — config.spec.ts +0/-103, tsconfig.mocha.json +0/-9, .mocharc.js +0/-2.
Every symbol in the sentence is real, every path resolves, and the reviewer
caught it by hand against `gh pr view --json files`.

Inference is narrow on purpose, because a false "this is fabricated" costs more
here than a missed check. The verb must precede the path, within ~90 characters
and the same clause, and the path must not be a locating preposition's object.
The last rule carries most of the weight: "added a `dbQuery` wrapper in
pouchdb-provider.js" creates a symbol in a file the PR modified. A screen keyed
naively on verb-near-path returns 64 hits over the tasks-and-targets batch, 63 of
them that shape. The clause bound handles this corpus writing a paragraph per
line, where an opening verb would otherwise govern every path after it.

Measured on those 25 drafts: 6 statuses inferred, all confirmed by the PR file
lists. Against 10436's pre-fix text it reports `.mocharc.js` claimed as added and
actually modified — the reviewer's finding, from a deterministic probe.

Also documents three blind spots this layer does not settle: symbol attribution
(a real symbol introduced by a different PR — #10324 credited itself with
`telemetryKey`, which #10371 added), counterfactual claims, and backticked
placeholder templates no literal grep can match.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The backport probe searched the release branches for a commit referencing
the draft's own PR number, or a PR number quoted in the sentence. cht-core
stamps neither on a pick: it keeps the original subject, which names the
ISSUE, and appends a bare "(backport)".

So draft 9608's "cherry-picked as a backport to the 4.14.x release branch
(PR #9610)" was reported ungrounded. The backport is real and is the tip of
origin/4.14.x:

  69ae8c0ab fix(#9604): fix integer validation in sms rules (backport)

touching the same three files as the master commit. The probe searched
(#9608) and (#9610); the pick carries (#9604).

Mine the anchor's own subject for the reference it was stamped with — the
one token a cherry-pick must preserve — and list every reference searched in
the refusal message, so a miss is visible rather than silent.

Verified the other two backport claims in the same corpus, which the probe
had passed for the accidental reason that their picks do repeat the source
PR: 8746 reaches 4.4.x (2b5cd23dd) and 4.5.x (f67e9d0c8), 9434 reaches
4.11.x (806b5906f, "(#9436)").

Also records two things found while grounding forms-and-reports: XLSForm
column headers (instance::cht:duration, instance::cht:unique_tel) live only
inside zipped .xlsx XML and can never be grepped, so the probe calls them
fabricated when they are real; and the backport reasoning above.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…tion

Draft 9641 cites the `--skip-validate` upload that triggers its bug. The
extractor takes that as a symbol, and the probe ran

  git grep -n -F -w --skip-validate <sha>

which git answers with "error: unknown option `skip-validate'" and a usage
dump. The throw was not confined to that claim — it propagated and killed the
entire ground-claims run, so 41 drafts produced no report at all because one
draft mentioned a CLI flag.

Pass the needle after `-e` in both the fixed-string and regex searches. The
test doubles now assert `-e` is present rather than just tolerating it, so the
guard cannot be dropped without a red test, and there are two regression tests
built from the real shape: one that finds the flag, one that reports absence
instead of throwing.

Found by ground-claims pass 3 on cht-agent#122, where prose written during
this same review round was what first produced a dash-leading symbol.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The status inference was tuned on tasks-and-targets, where it produced 6
statuses and 0 false positives. forms-and-reports surfaced a prose shape it
mis-reads:

  Regenerated 53 config form fixtures … plus the e2e and cht-form test
  fixtures (e.g. tests/e2e/default/contacts/forms/ngo-create.xlsx,
  tests/integration/cht-form/default/forms/dates.xml)

"create" inside the fixture name ngo-create.xlsx matches ADD_VERB, sits within
VERB_REACH of the next path, and inferred that dates.xml was ADDED. It is `M`,
so checkFileTouched contradicted a sentence that is true — the expensive
direction, since a false "this is fabricated" is what makes an operator edit
correct prose into incorrect prose.

Note "Regenerated" is deliberately absent from ADD_VERB, because regenerating
a file that exists modifies it. The fixture's own name was the only thing in
that sentence that looked like a create.

Mask path-shaped tokens — unspaced, carrying a `/` or a file extension —
before the verb scan, preserving length so VERB_REACH still measures real
distance. Verbs contain neither slashes nor dots, so nothing real is hidden.
Masking happens after the clause cut so CLAUSE_BREAK still sees punctuation.

Two tests: the 8336 sentence must infer nothing, and a genuine "Added
…/ngo-create.xlsx" must still infer 'added', so the masking cannot be widened
into silence. The #10436 harness test still passes, which is the check that
matters — this narrows false positives without giving up the defect the
inference exists to catch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`source_prs[]` lists every PR a collapsed or hand-authored draft covers, but
`anchorFor` takes only the first entry as canonical. `file-touched` already
consulted the siblings; the tree-scoped probes -- `symbol`, `symbol-in-file`,
`path-exists` -- settled at the canonical anchor alone, so a symbol the fifth PR
introduced was judged at the first PR's commit, found absent, and reported as
fabricated.

Verified on contacts `9835`, which lists five source_prs and resolves to #10022,
the earliest. It returned 12 ungrounded claims and every one was false:

  git -C $CORE grep -l assertSameParentLineage origin/master | wc -l   # 5
  git -C $CORE grep -c 'minifyDoc\|assertSameParentLineage\|getUpdatedContact' \
    origin/master -- shared-libs/cht-datasource/src/local/libs/lineage.ts   # 3
  git -C $CORE grep -c assertPermissions origin/master -- api/src/controllers/person.js  # 4

Every symbol is real, on master, in exactly the file the draft names; they
arrived in #10081/#10083/#10222/#10246. Tree-scoped claims now retry at each
sibling anchor before being called ungrounded, and the evidence string names the
sibling that settled them. A single-PR draft has no siblings, so no verdict on
one can change -- covered by a test.

Also guards paths under `agent-memory/`. A domain-note banner citing
`agent-memory/schema.json` was adjudicated against cht-core, which has no such
tree (`git ls-tree origin/master --name-only` -- no `agent-memory`), so a file
that plainly exists came back ungrounded on four drafts. Now `unverifiable`:
not our tree to settle.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`outputs/` is gitignored but was not eslint-ignored, so an ad-hoc `.ts` left
there by one investigation fails `eslint .` for everyone -- the file sits
outside tsconfig, so @typescript-eslint/parser rejects it before any rule runs:

  outputs/audit/kinds.ts
    0:0  error  Parsing error: "parserOptions.project" has been provided ...

Two such files were failing the gate on this branch. Nothing under `outputs/`
is source; verification reports land there.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two false-positive classes, both found by running the gate on the contacts
branch after it was already clean by hand.

**A sentence that scopes itself to before the change was judged after it.**
`FORWARD_SCOPED` rescues "on master" claims by re-checking the current tree;
there was no mirror for the past. Problem/Root Cause claims are re-checked at
the parent, but only when extraction tagged them `pre-fix`, and it often does
not. `9394`'s Root Cause reads "before this PR contacts.effects.ts called
`getCurrentTargetDoc()` and forwarded exactly one doc" and came back ungrounded:

  git -C $CORE grep -c getCurrentTargetDoc bbe5dedd5  -- webapp/src/ts/   # 0
  git -C $CORE grep -n getCurrentTargetDoc bbe5dedd5^ -- webapp/src/ts/
  #   effects/contacts.effects.ts:182:      .getCurrentTargetDoc(this.selectedContact)
  #   services/target-aggregates.service.ts:362:  getCurrentTargetDoc(contact?) {

Real at the parent, deleted by the PR: the sentence is true and was reported as
fabricated. An explicit "before this PR" / "used to" / "previously" in the quote
now re-checks the parent, independently of whether extraction volunteered a
scope. Narrower than the section heuristic on purpose -- the marker has to be in
the sentence, not merely in a Problem section. A claim with no temporal marker
is not rescued; that is a test.

**A pair the model withdrew by downgrading it was still filed.** The existing
screen wants a negation next to the noun ("These do not conflict"). On contacts
`10804` the rationale read "a minor framing difference rather than a factual
conflict" -- no negation, so the pair was filed and a human sent to adjudicate
something the model had already cleared. This is the same defect the
"These do not conflict" screen was added for, in its other grammatical form.
"rather than a <conflict|contradiction|inconsistency>" now withdraws too.

`npm test` 1214 passing, `tsc --noEmit` and `eslint .` clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The online tier works without `gh` -- `gh-classify` falls back to anonymous
`curl` -- which is why nobody noticed it is badly degraded that way. Anonymous
gets 60 requests/hour and a single 37-draft domain exhausts it, so the run
reports `unverified` counts that read exactly like content defects and exits 3.

Measured on contacts, same bytes, same command:

  anonymous     37 drafts checked, 0 blocking, 1 warnings, 3 unverified   rc=3
  GH_TOKEN=...  37 drafts checked, 0 blocking, 1 warnings, 0 unverified   rc=0

Three anonymous runs each left drafts unverified and needed an hour's wait
between them. No code change is needed, only the token, so this is a runbook
fix rather than a tooling one -- but the failure mode is invisible unless you
already know to distrust an `unverified`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Third grammatical form of the same defect, third round in a row it has cost an
adjudication. The model cleared a pair on contacts `9177` with:

  "this is a difference of issue-vs-PR attribution, not necessarily exclusive"

and it is right — #9065 is the parent issue, #9090 the PR that implemented it,
so "get-person existed (from #9065)" and "PR #9090 added the person get-by-uuid
datasource" are both true. But the rationale never says "conflict", so neither
SELF_NEGATING (negation beside the noun) nor DOWNGRADED ("rather than a
conflict") matched, and the pair was filed.

The pattern across all three variants is that the model withdraws by describing
the RELATIONSHIP, not by naming the noun, so the screens have to key on the
withdrawal phrase. "not (necessarily|strictly|mutually) exclusive" now withdraws
too.

Worth stating plainly since this is the third widening: each variant was found by
running the checker again on bytes it had already passed, not by reasoning about
the regex. I would expect a fourth.

`npm test` 1217 passing, `tsc --noEmit` and `eslint .` clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every symbol probe so far answers "is this identifier real". None answers "did
the PR you credited actually add it", and that is the question the corpus keeps
getting wrong: #10071 credited place-create to #10099 when #10065 and #10089 had
landed it the week before. Every symbol in the sentence existed, every section
agreed, and three consecutive coherence passes plus nine ground passes said
nothing, because nothing was asking.

`introduced-by` asks it, and is settled per-file rather than tree-wide. Both
simpler versions were implemented first and both produce false accusations on
this very history, which is why the comment records them:

  "symbol exists at #N's parent" refutes #10065 introducing `createPlace` — a
  true claim — because the old `places` controller had an unrelated function of
  the same name elsewhere in the tree.

  "symbol appears on an added line of #N's diff" accepts #10099 introducing it —
  a false claim — because its one such line is an import edit in index.ts.

Locality separates them. For each file where #N adds a line naming the symbol,
ask whether that file already contained it at #N's parent. All of them did → #N
edited around something already there. Some file gained it → #N introduced it
there. Collisions in other modules cannot interfere because only the touched
files are read. Verified against the real history: #10099/createPlace ungrounded,
#10065 and #10089 grounded.

The enumerator only emits the claim when exactly one PR number governs a sentence
carrying a create verb. Two numbers ("via #10065 and #10089") is the shape a
CORRECT draft uses for work spanning PRs, so guessing between them would
manufacture defects; that case stays unverifiable, as does a PR whose diff never
names the symbol.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`vocab-near-miss` compares against a snapshot of TODAY's cht-core vocabulary, so
a draft that describes history correctly gets flagged. Contacts `9835` quotes the
report controller's permission list as it stood at #10222 --
`['can_view_reports', 'can_update_records']` -- and `can_update_records` is one
edit from the live `can_update_reports`, so the check called a true, precisely
scoped sentence a fabrication. Blocking, which makes the tempting fix "delete the
history".

The name was real:

  git -C $CORE log --all --oneline -S can_update_records -- api/src shared-libs config
  #   b6470973b feat(#10041): remote update report implementation (#10200)   <- added
  #   a89955a9f feat(#9835): refactor create/update cht-datasource apis (#10522)  <- renamed
  git -C $CORE grep -c can_update_records 8c92517ec -- api/src/controllers/report.js  # 1
  git -C $CORE grep -c can_update_records origin/master                              # 0

`verify-drafts` must stay hermetic to gate CI, so it cannot ask git about
history. The snapshot answers instead: a family may now declare `historical`
terms, which suppress a near-miss but are **never** offered as a suggestion --
kept out of `terms` precisely so the checker can never advise a reader to use a
name that no longer exists. `historicalNote` records the commit that removed each
one, so the list stays auditable rather than becoming a junk drawer for whatever
tripped the gate last.

Checked before the distance loop, so no input can surface a dead name as a
suggestion; that is a test, alongside one proving a genuine typo still resolves
to the live term and one proving a family without `historical` is unchanged.

`npm test` 1227 passing, `tsc --noEmit` and `eslint .` clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A drift note naturally reaches for the possessive — "master's `src/input.ts` is
types-only", "not master's shape" — and FORWARD_SCOPED only knew "on master" and
"current master". Without the marker the claim is judged at the anchor, where a
master-era symbol correctly does not exist, so a correct drift disclosure reads
as a fabrication.

Three such claims on the contacts branch: `PersonInput` (10043),
`assertHasValidParentType` and `minifyDoc` (10057). All three are real on master
and absent at their anchors, which is precisely what the notes say.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

2 participants