Skip to content

feat: freshness engine — keep code_verified claims honest as code changes - #96

Closed
divo12 wants to merge 21 commits into
Autoloops:mainfrom
divo12:feat/anchor-drift-invalidation
Closed

feat: freshness engine — keep code_verified claims honest as code changes#96
divo12 wants to merge 21 commits into
Autoloops:mainfrom
divo12:feat/anchor-drift-invalidation

Conversation

@divo12

@divo12 divo12 commented Jul 4, 2026

Copy link
Copy Markdown

Summary

Keeps code_verified memory claims honest as the code changes. Today a claim is checked against the code exactly once — at write time — and then drifts silently: a refactor renames a function, but the claim keeps its code_verified badge while pointing at code that no longer says what it claims. Worse than no fact — it hands the next agent a confident lie.

This PR builds a freshness engine that closes the loop end to end:

detect → surface → demote → re-verify

A per-anchor content fingerprint is the authority. The foreground (every graph context query) labels claims fresh/stale/unknown and quarantines stale ones — read-only. The background (hook worker) heals the durable graph: it demotes genuinely-drifted claims and hands them to an agent to rewrite. The compiler (tree-sitter) + a content hash are the judges — deterministic, no LLM in the detection path, zero false positives.

Why

Two ways a code_verified claim rots after it's written:

Kind What happens Detected before this PR?
Structural drift the anchored symbol is renamed / moved / deleted → the anchor no longer resolves detect-only (graph audit anchors), never healed
Content drift the symbol still exists at the same file#symbol, but its body changed → the fact is now wrong ❌ invisible — anchor resolves fine, so audit/query report it healthy

A per-anchor content hash of the resolved span unifies both: fresh iff the anchor resolves and the span hash matches the stored one; stale iff it won't resolve (structural) or the hash changed (content). Because the hash is taken over the working-tree file, it also catches uncommitted edits.

How it works

flowchart TD
    subgraph Write["① Write time — applyProposal"]
      A["agent saves a code_verified claim"] -->|hash resolved span| FP[("anchor_fingerprints<br/>baseline hash per anchor")]
    end

    subgraph FG["② Foreground — every graph context query (READ-ONLY)"]
      Q["graph context query"] --> RC["resolve anchors<br/>+ stat-prefiltered re-hash"]
      RC --> CF{"classifyFreshness"}
      CF -->|fresh / unknown| BEST["## Best Claims<br/>(unknown gets an 'unverifiable' caveat)"]
      CF -->|stale| NRV["## Needs re-verification<br/>(distrust signal to the agent)"]
    end

    subgraph BG["③ Background — hook worker on session end"]
      H["healDriftedAnchors<br/>changed files since checkpoint (git diff ∪ status)"] --> RIDX["reverse index: file → claims"]
      RIDX --> CF2{"classifyFreshness"}
      CF2 -->|fresh / unknown| SKIP["skip (early cutoff)"]
      CF2 -->|stale| DEM["demote: supersede → truth: unknown<br/>+ invalidation_events (one txn)"]
      DEM --> WL["re-verify worklist"]
      WL --> AG["④ agent re-reads current code,<br/>writes a corrected code_verified claim"]
    end

    FP -. baseline .-> RC
    FP -. baseline .-> CF2
    AG -. supersedes the unknown claim .-> FP
Loading

The single rule (code-anchors/freshness.ts, shared by both planes so the label the agent sees can never disagree with the demotion the daemon writes):

classifyFreshness(checks) →
  stale · structural   if every anchor stopped resolving
  stale · content      if a still-resolving anchor's span hash changed
  unknown              if a span can't be read right now (never a false "fresh")
  fresh                otherwise

Cheap by construction: a stat prefilter (mtime+size) skips the re-hash when a file is untouched; a reverse file → claims index means the background only re-checks claims in files that actually changed (never a whole-graph scan); early cutoff means no write when nothing drifted; a per-repo checkpoint (last-checked SHA) scopes each background pass.

Architecture & folder structure

New code is small and slots into the existing layer-organized knowledge-graph lib (pure domain → util/IO → repository → service → surface). Nothing reaches "upward" into apps/.

libs/
├── knowledge-graph/
│   ├── code-anchors/
│   │   ├── freshness.ts          ● DOMAIN  the one fresh/stale/unknown rule (classifyFreshness)
│   │   ├── span-hash.ts          ○ UTIL    sha256 of an anchored span (+ stat); realpath-contained to the repo
│   │   └── drift.ts              ● DOMAIN  structural-only drift scan (Option A: demote iff *all* anchors broken)
│   ├── anchor-fingerprints.ts    ● DOMAIN  index stored fingerprints; build stat-prefiltered AnchorChecks
│   ├── anchor-invalidation.ts    ● DOMAIN  pure: demoted claims → supersession writes (rebuilt claim + edges + event)
│   ├── invalidation.ts           ● DOMAIN  InvalidationEvent types + reasons (anchor_drift | content_drift)
│   ├── graph-context/
│   │   ├── claim-freshness.ts    ◐ SURFACE attachFreshness — one batched read, verdict per retrieved claim (read-only)
│   │   ├── context-builder.ts    ◐ SURFACE wires attachFreshness into the query path
│   │   ├── render.ts             ◐ SURFACE renders "## Needs re-verification" + truth on each claim
│   │   └── types.ts              ◐ SURFACE + freshness field on ClaimContextResult
│   └── service.ts                ■ SERVICE orchestration: applyProposal→writeFingerprints, healDriftedAnchors,
│                                            reverifyWorklist, invalidateDriftedAnchors (scan→build→apply→embed)
├── storage/sqlite/
│   ├── schema.ts                 ▲ DATA    + anchor_fingerprints, freshness_checkpoints, invalidation_events
│   └── repository.ts             ▲ DATA    fingerprint R/W, reverse index, checkpoint, worklist, demotion txn
├── hooks/worker.ts               ◆ BG      change-scoped heal pass + agent re-verify handoff (best-effort, per-repo isolated)
├── config/greplica-config.ts     ◆ BG      session.autoHealDrift flag (default on)
└── utils/git.ts                  ○ UTIL    git helpers: gitHeadSha + changedFilesSince (diff ∪ status, uncommitted-aware)
apps/cli/main.ts                            graph audit anchors [--invalidate]  (on-demand structural demotion)
scripts/check-*.js                          deterministic tests — no embedding-model download

Legend: ● pure domain · ○ util/IO · ▲ data-access · ■ service · ◐ read surface · ◆ background.

Data model (3 new tables, additive, IF NOT EXISTS)

erDiagram
    anchor_fingerprints {
        TEXT repo_id     "scopes the reverse lookup"
        TEXT claim_id
        TEXT file
        TEXT symbol      "'' sentinel for file-only anchors"
        TEXT content_hash "sha256 of the resolved span"
        INT  file_mtime_ms "stat prefilter"
        INT  file_size     "stat prefilter"
        TEXT resolver_status
        TEXT checked_at
    }
    freshness_checkpoints {
        TEXT repo_id          PK
        TEXT last_checked_sha  "background scopes diff from here"
        TEXT checked_at
    }
    invalidation_events {
        TEXT id                   PK
        TEXT repo_id
        TEXT original_claim_id
        TEXT superseding_claim_id
        TEXT reason               "anchor_drift | content_drift"
        TEXT broken_anchor
        TEXT resolver_status
        TEXT git_commit_sha
    }
Loading
  • anchor_fingerprints PK (claim_id, file, symbol); the '' symbol sentinel avoids SQLite's "NULLs are distinct in a composite PK" gotcha so INSERT OR REPLACE stays idempotent. Index (repo_id, file) powers the reverse file → claims lookup, scoped per repo (the DB is shared across repos).
  • Insert-only / non-destructive: claims are never mutated or deleted — a demotion supersedes the original with a rebuilt truth: unknown copy (broken anchors kept as evidence, about/evidenced_by edges re-pointed), so full history is preserved. invalidation_events is the queryable drift audit trail.

User-facing surface

# On-demand structural demotion (CLI, unchanged report + new write flag)
greplica graph audit anchors              # report-only — doubles as a dry run; exit 1 in CI if issues
greplica graph audit anchors --invalidate # demote fully-drifted claims (one memory commit + one txn)
  • Foreground: every graph context packet now shows each claim's truth, routes stale claims into a ## Needs re-verification section, and tags unknown-freshness claims with an "unverifiable" caveat. Zero graph writes on the read path.
  • Background: the hook worker heals drift and hands demoted claims to an agent to rewrite, gated by session.autoHealDrift (default on).

Key design decisions

  • One shared verdict rule (classifyFreshness) for both planes — the foreground signal and the background demotion can never disagree.
  • Read plane never writes; write plane is the only mutator. A query that hits an uncommitted edit still catches it live (re-hash) without paying to persist.
  • unknown never demotes. An unreadable span is flagged unverifiable, never auto-demoted — no false stales.
  • Option A (conservative structural policy). A multi-anchor claim demotes structurally only when every anchor is broken.
  • Content hash over working tree, not HEAD — catches uncommitted edits (the SHA-gate's blind spot); mtime is only a prefilter, the hash is the authority.
  • No re-verify queue table. The worklist is derived from existing state (truth: unknown + an invalidation_events row); a claim drops out for free once an agent supersedes it.

Test plan

  • npx tsc --noEmit clean; full npm test green.
  • Deterministic suites (no embedding-model download — stub context builder + fake agent runner):
    • check-anchor-drift.js — structural demote/supersede/event/history/edges + Option A + idempotency.
    • check-span-hash.js — span hashing, CRLF normalization, repo-containment (missing / absolute / .. / symlink → safe).
    • check-freshness.js — the classifyFreshness rule (fresh / structural / content / unknown) + fingerprint store + repo-scoped reverse index.
    • check-freshness-foreground.js — per-claim freshness in the builder + ## Needs re-verification rendering (read-only proof).
    • check-freshness-background.js — change-scoped heal (reverse-index scoping, early cutoff, uncommitted-aware, unknown-never-demotes, checkpoint), the re-verify worklist, and the agent-handoff prompt.
  • Verified with a real agent, live: drove greplica graph context through all four verdicts (content drift incl. uncommitted, structural (missing symbol), unknown via chmod 000, back to fresh on restore) and confirmed the durable graph was untouched by the read path.

Non-goals / limitations

  • Unsaved in-editor buffers (edited, not yet written to disk) are invisible to stat/git/hash — caught on the next query after save.
  • Granularity: the fingerprint hashes the whole resolved span, so a whitespace-only edit counts as a change (errs toward re-check).
  • Non-code (source_verified) staleness is a future detector behind the same classifyFreshness seam.
  • Background heal is coupled to the memory-update worker cycle for v1; decoupling its trigger is a follow-up.

divo12 and others added 9 commits July 3, 2026 12:25
…validation

Phase 1 of anchor-drift auto-invalidation. Adds the invalidation_events
table (audit trail for demoted claims), a domain type module, and a
transactional repository writer that reuses the shared insertProposalRecords
helper (extracted from createProposalRecords) so there is no duplicated
insert SQL. Integrity relies on the claim primary key plus the single
transaction.

- libs/knowledge-graph/invalidation.ts: InvalidationEvent/Input + typed unions
- schema.ts: invalidation_events table + indexes
- repository.ts: applyAnchorInvalidation, listInvalidationEvents,
  extracted insertProposalRecords
…riendly)

Phase 2 of anchor-drift auto-invalidation. scanDriftedClaims re-resolves
every code_verified claim's anchors and flags a claim as drifted only when
all of its anchors are broken (Option A). Uses one shared CodeAnchorResolver
so shared files parse once, and records per-claim resolver failures without
aborting the pass.
Phase 3 of anchor-drift auto-invalidation. buildAnchorInvalidation turns
drifted claims into the writes that demote them: a rebuilt truth:unknown
claim (broken anchors kept as evidence), cloned about/evidenced_by edges
(preserving evidenced_by reasons), a supersedes edge, and one invalidation
event each. Pure: edge ids are minted by normalizeProposal via an in-memory
graph lookup, and outgoing edges come from a from_id index built once.

Also consolidates the drift-status list into a single source of truth
(invalidationResolverStatuses + guard) shared by drift.ts and the rebuild.
Phase 4 of anchor-drift auto-invalidation. Wires scan -> build -> apply:
re-verifies code_verified claims, demotes fully-drifted ones atomically via
applyAnchorInvalidation, embeds the rebuilt claims, and returns a consistent
envelope { memory_commit_id, invalidated[], errors }. No drift is a clean
no-op; the report-only auditCodeAnchors is untouched. Adds a small
libs/utils/git.ts helper for the HEAD SHA (best-effort provenance).
Phase 5 of anchor-drift auto-invalidation. The audit command now accepts
--invalidate: it prints the diagnostic report first, then demotes fully
drifted claims via invalidateDriftedAnchors and prints each demotion plus the
memory commit. Report-only runs are unchanged (exit 1 on issues, the CI
signal); --invalidate exits 0 on success. Flag parsing rejects unknown args
with the usage string, matching the existing CLI convention.
Phase 6 of anchor-drift auto-invalidation. scripts/check-anchor-drift.js
covers the happy path (rename -> demote, supersede, event row, history kept,
edges + reasons cloned), Option A (multi-anchor survives a partial break,
demoted only when all anchors break), and the never-demote cases (unchanged +
unsupported-language). Uses a stub context builder so the suite never
downloads an embedding model. Wired into npm test.
@divo12

divo12 commented Jul 4, 2026

Copy link
Copy Markdown
Author

@kushalpatil07 please review the PR , e2e testing with a coding agent will put results soon ; unit & e2e testing with CLI working

divo12 and others added 7 commits July 5, 2026 13:51
TDD: span-level sha256 of the resolved anchor's source (whole file for
file-only anchors), with graceful undefined on unreadable/escaping paths,
plus statAnchorFile for the freshness prefilter.
…drift scan

TDD: pure verdict rule — structural (all anchors broken) or content (a
resolving anchor's stored span hash changed). scanDriftedClaims now delegates
to it (structural-only via undefined baseline hashes), unchanged behavior.
…ndex)

TDD: anchor_fingerprints table (file-indexed) as the fingerprint cache and
reverse file->claims index; repository upsert (transactional INSERT OR
REPLACE), batched fingerprintsForClaims read, and claimIdsForFiles reverse
lookup. Data-access only.
TDD: KnowledgeGraphService.writeFingerprints resolves + span-hashes every
code_verified claim's anchors and upserts the fingerprints after apply
(best-effort per anchor). Wires check-span-hash + check-freshness into npm test.
Replace classifyFreshness's three positional parallel arrays with an
explicit AnchorCheck[] (each anchor bundled with its current and stored
span hash), and split the rule into named isStructurallyBroken /
hasContentDrift predicates. Removes the awkward paired-undefined-arrays
call in drift.ts and documents the 1-based span slicing in span-hash.
Behavior unchanged; all checks pass.
- classifyFreshness: carry structurally-broken anchors through the
  content-drift verdict instead of dropping them (mixed-anchor claims).
- span-hash: normalize CRLF/CR to LF before hashing so a cross-platform
  checkout doesn't report false content drift.
- anchor_fingerprints: make symbol NOT NULL with a '' sentinel; a nullable
  column in the composite PK let INSERT OR REPLACE accumulate duplicate
  rows for file-only anchors (SQLite treats each NULL as distinct).
- applyProposal: fingerprint writing is best-effort and no longer throws,
  so it can't fail an already-persisted apply.

Adds regression checks for each.
Implement freshness engine with content fingerprinting and storage
* feat: add 'unknown' freshness state for undeterminable anchors

A resolving anchor whose span can't be hashed right now (unreadable file
or resolver error) now yields state:'unknown' instead of a false 'fresh',
so the foreground can flag it as unverifiable rather than vouch for it.
A missing baseline alone (readable span, no stored hash) stays fresh.
Structural/content drift still take precedence.

* feat: index fingerprints + build stat-prefiltered freshness checks

New anchor-fingerprints module: indexFingerprintsByClaim groups stored
rows by claim (O(1) lookup after one batched read), and freshnessChecks
builds the AnchorCheck[] for a claim's resolved anchors with a cache-aside
stat prefilter — reuse the stored hash when mtime+size are unchanged,
otherwise re-hash the span live.

* feat: compute per-claim freshness in the context builder (read-only)

Add freshness: FreshnessVerdict to ClaimContextResult and a new
attachFreshness step in the builder: one batched fingerprint read for all
selected claims (no N+1), then stat-prefiltered checks -> classifyFreshness
per claim. Returns new objects; the query path performs no graph writes.

* feat: surface truth + Needs re-verification section in graph context

Every claim line now shows its truth. Stale claims are quarantined into a
new '## Needs re-verification' section (a distrust signal, omitted when
nothing drifted); unknown-freshness claims stay in Best Claims with an
'unverifiable' caveat.

* test: wire foreground freshness check into npm test

* refactor: clarify foreground freshness code

- anchor-fingerprints: collision-free JSON key (was a space-joined string),
  named StoredByAnchor type, and self-describing helpers (currentSpanHash,
  fileUntouched).
- render: use the named FreshnessVerdict type; replace the emoji caveats with
  plain ASCII [STALE]/[UNVERIFIABLE] markers consistent with the rest of the packet.
- context-builder: unnest the attachFreshness call into a named step.

No behavior change; all checks pass.

* fix: carry structurally-broken anchors through the unknown verdict

When a claim mixes structurally-broken anchors with undeterminable ones,
classifyFreshness returned state:'unknown' with broken:[], silently dropping
the broken anchors — inconsistent with the content branch and losing info
Phase 3's healer needs. Now the unknown verdict surfaces them too.

Also harden the foreground content/structural assertions to check state,
not just reason.
divo12 added a commit to divo12/greplica that referenced this pull request Jul 5, 2026
buildAnchorInvalidation now takes ClaimDemotion{claim, reason, anchors}
instead of structural-only DriftedClaim. Content demotions emit a
content_drift event recording the anchor's still-resolving status;
structural demotions keep the anchor_drift reason + drift-status guard.
Widen InvalidationReason and event resolver_status accordingly. Autoloops#96
structural path unchanged (regression green).
* feat: enumerate changed files (git diff + status, uncommitted-aware)

* feat: freshness_checkpoints table + fingerprint deletion (repository)

* feat: generalize demotion writer to content drift (ClaimDemotion)

buildAnchorInvalidation now takes ClaimDemotion{claim, reason, anchors}
instead of structural-only DriftedClaim. Content demotions emit a
content_drift event recording the anchor's still-resolving status;
structural demotions keep the anchor_drift reason + drift-status guard.
Widen InvalidationReason and event resolver_status accordingly. Autoloops#96
structural path unchanged (regression green).

* feat: add healDriftedAnchors (change-scoped structural + content heal)

Re-checks only claims in changed files (reverse index; full sweep when no
checkpoint), demotes genuinely-stale ones via the generalized writer,
deletes their fingerprints in the same txn, re-embeds, and advances the
freshness checkpoint. Never demotes on unknown. No agent spawn.

* feat: run change-scoped drift heal on the hook worker

Add session.autoHealDrift (default on) and a runDriftHealPass step in
runHookWorker: for each distinct active repo (deduped by root, gated by
config, lease-renewed), heal from the stored checkpoint. Best-effort and
deterministic — no agent spawn. Wire check-freshness-background into npm test.

* fix: harden background heal edge cases

- applyAnchorInvalidation always deletes demoted claims' fingerprints
  (derived from events), so the CLI invalidate path cleans up too.
- changedFilesSince returns undefined on git-probe failure (vs [] clean);
  heal full-sweeps on a bad checkpoint sha instead of silently skipping.
- content demotions name only the anchors that actually drifted
  (reuse hasContentDrift), keeping the audit event accurate.

Skipped: FK on freshness_checkpoints — no repo-delete path exists.
divo12 and others added 2 commits July 6, 2026 07:23
* feat: re-verify worklist from existing state (no queue table)

claimsNeedingReverify derives the worklist — current claims drift-demoted
to truth:unknown and not yet rewritten — from readGraphView + invalidation
events. A claim drops out for free once superseded by a fresh one.

* feat: hand drift-demoted claims to an agent for re-verification

Worker drains the re-verify worklist per active repo (gated by autoHealDrift,
capped at 3/cycle) and runs the platform agent with a prompt to re-verify each
claim against current code. Extract shared runMemoryAgent (reused by the
memory-update path); drop now-dead safePathSegment.

* fix: isolate re-verify per repo, log failures instead of aborting

A reverifyWorklist/requireRepo error on one repo (e.g. greplica not
installed there) aborted the whole re-verify pass. Wrap each repo in
try/catch and log the narrowed error, matching the heal pass's best-effort
isolation.
- anchor_fingerprints gains repo_id; reverse index is (repo_id, file) and
  claimIdsForFiles is repo-scoped, so a shared path across repos no longer
  cross-matches (was correct only by downstream intersection). New table, no
  migration. Regression test added.
- span-hash: resolveWithinRepo uses realpath containment, rejecting symlinks
  that resolve outside the repo (not just literal ../absolute).
- service: extract classifyCandidates from healDriftedAnchors (was ~52 lines).
- worker: runDriftHealPass logs per-repo failures (parity with reverify pass).
@divo12 divo12 changed the title Feat/anchor drift invalidation feat: freshness engine — keep code_verified claims honest as code changes Jul 6, 2026
changed-files.ts was a generic git-exec wrapper with no kg-domain knowledge,
near-identical to utils/git.ts. Merge it in — both now share one git() exec
helper — and drop the misplaced kg-domain file.
@divo12

divo12 commented Jul 6, 2026

Copy link
Copy Markdown
Author

Hi @kushalpatil07 ,
Please review the solution and PR. Happy to discuss over it

@kushalpatil07

Copy link
Copy Markdown
Contributor

Bro this is too extensive. Please discuss with me before implementing. Very unlikely this PR gets merged.
The 3 steps that you have defined, each of those need a lot of thought to be implemented.
Few questions I can think of right now.

  1. What span are you using for each symbol and why?
  2. Are you doing this for component code anchors as well?
  3. Why are you storing invalidation events?
  4. Is the checking of fingerprint expensive or not>
  5. Should the same hook worker that updates memory solve drift or a new one should be there?

@kushalpatil07

Copy link
Copy Markdown
Contributor

Closing this broad implementation as superseded by the incremental #111/#128 freshness path. Remaining detection, quarantine, and invalidation behavior stays tracked in #90/#91.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants