Delta orchestration: DeltaRunner wraps the LocalRunner; affected-set via blocking keys; deletion re-runs with Source-level exclusion¶
ADR-0018 fixed the what of incremental corpus deltas (content-hash Delta, affected-set recompute, drift-triggered Reconciliation, dual-mode deletion, WAL-backed Corpus Versions). This ADR records three implementation trade-offs S11 made that were not obvious from ADR-0018, and why.
1. The delta machinery is an orchestration layer AROUND the Runner, not a Stage in the DAG¶
A Delta is not a pipeline Stage — it spans the whole run (it compares this run's manifest to the last committed Corpus Version and patches the corpus-level derived set). So DeltaRunner wraps LocalRunner rather than adding a CapabilityKind: apply_delta runs the pipeline via the unchanged LocalRunner, reads the run's Stage checkpoints back, computes the Delta, drives the DeltaProcessor Capability, and commits a CorpusVersion. The Runner, the Pipeline contract, and every existing Stage are untouched (ADR-0018 "reuse the existing seams"). The one Capability S11 adds — DeltaProcessor — is a cross-cutting record-to-record operation the DeltaRunner invokes, not a node the topological sort schedules. A distributed/GPU blocking Provider is a drop-in behind that seam.
Rejected: making Delta a terminal Stage. It would have to reach backwards to the previous committed corpus (not a depends_on parent), which the DAG model has no vocabulary for, and it would run once per pipeline execution regardless — exactly an orchestration concern.
2. The affected set is a blocking-key neighborhood closure, and the churn baseline is content, not just ids¶
The reference DeltaProcessor recomputes only the affected set — the blocking neighborhoods (normalised-token blocks) the delta's changed/deleted documents touch, plus their key-neighbors (the recast IncrementalLinker). Two subtleties bit hard enough to record:
- Reach must unwrap the scoped carrier. A
DisambiguationRecord/GraphRecordholds its Evidence andsource_document_idson the inner payload (entity/relation/node/edge), not on the carrier. The retract-by-provenance patcher (graph/patcherport) therefore unwraps the payload before reading reach — otherwise a canonical entity's cross-document reach is invisible and a merged cluster is wrongly kept on deletion. - The Corpus Version fingerprint hashes record content, sorted-key, not just
record_id. A merge that foldsInternational Business MachinesintoIBMkeeps the lexicographic-min canonical identity:ibmwhile changing the cluster's membership. An id-only fingerprint would treat that as a no-op and the idempotency check would wrongly skip the commit. The fingerprint is a sha256 over each record's canonical (sorted-key) JSON, so a membership change flips it but an insertion-order difference in an openpropertiesbag does not — the exact "same corpus content" signal (G1 determinism).
3. Deletion (Retraction/Purge) re-runs the pipeline with the deleted documents EXCLUDED AT SOURCE¶
The surprising one. A split-on-delete (a merged cluster whose only bridging document is deleted must split) requires re-resolving the survivors without the deleted document's mentions. Rather than surgically edit derived records (fragile, and it cannot re-run the resolver), the DeltaRunner re-runs the real pipeline with the deleted document ids injected into every Source Stage's new exclude_document_ids config (ADR-0014's thin Source seam already supported a config widening). Doc-level Stages simply never see the deleted documents; the corpus-level Stages re-resolve the survivors through the genuine resolver — a real split-on-delete, not a bolted-on record edit. Retraction retains the tombstoned records in the prior Version (inspectable for audit/rollback); Purge physically erases them (see §5).
Rejected: a bespoke in-place record editor for deletions. It would duplicate the resolver's merge/split logic outside the resolver, drift from it, and forfeit the "the corpus is always the output of the real pipeline" invariant that makes a delta run and a from-scratch run converge (the drift-Reconciliation backstop relies on this).
4. Only an intentional deletion is a durable Source exclusion — an edit's incidental DELETED is not¶
Cumulative deletion (a document retracted at Version N stays excluded on every later run) must not be driven by every DELETED delta entry, because document ids are content-addressed (sha256 of the file bytes). An ordinary in-place edit therefore retires the old content-id and mints a new one, and the content-hash detector classifies that retired id DELETED even though the user retracted nothing. If the carried-forward exclusion unioned all DELETED ids, editing a file then reverting it to earlier content would find the reverted content-id blacklisted at Source and silently drop it forever (an edit-then-revert data-loss bug).
The fix: Delta.intentional_deletion marks a delta whose DELETED entries are a user-requested retract()/purge() (set only on the deletion path; apply_delta leaves it False). _prior_deletions carries forward only the DELETED ids of intentional_deletion Versions — so a retracted/purged document stays gone across re-runs (durable tombstone), while an edit's incidental content-id churn does not permanently blacklist that content. The corpus reflects the current source folder for adds/edits/reverts; only explicit retract/purge is sticky.
5. Purge is a physical erasure across the whole store, not just a forward logical exclusion¶
Purge is the GDPR / right-to-be-forgotten operation, and the S11 AC requires that nothing derived survives. A forward-only logical exclusion is insufficient: the purged document's PII-bearing derived records and raw text would linger in prior committed Versions' record logs, in the run checkpoints/export artifacts, and in the on-disk source file. So purge additionally: (a) physically deletes the source file(s) whose content-addressed id is purged (Storage.remove); (b) expunges every derived record whose Evidence-provenance reach touches a purged document from all committed Versions' record logs (CorpusStore.expunge_records, using the same graph/patcher reach); © wipes the re-derivable run artifact trees under _latence/runs (Storage.remove_tree), which hold raw chunk/mention text and the Export sink's records. The committed head Version (which already omits the purged document) remains the authoritative live set; the erasure loses no durable audit state, because a Purge is by definition the disposition that does not retain.
This intentionally trumps the normal Corpus Version immutability — a Purge is the one operation that rewrites audit history, by design. A Retraction is unaffected: it still retains its tombstones for audit and rollback.
5a. Purge integrity hardening (H-D1): abandoned-WAL residue + re-fingerprint the erased manifest¶
A hostile 360 audit (THEME D) found two independent holes in the erasure above, both traced and reproduced, and both now closed with regression tests (test_corpus_store.py, test_e2e_delta.py):
-
Abandoned WAL staging dirs leak plaintext (security S2).
expunge_recordsoriginally swept only committed Versions (committed_versions()— those with aMANIFEST.json). But a crash mid-commit by design leaves awal/v<N>/staging directory with a fully-stagedrecords.jsonland no manifest (an abandoned transaction;WriteAheadLog.recover()reports it in.abandonedand never deletes it). That staged file is invisible toread_records(so the corpus looks erased) yet holds a purged document's canonical entities/relations/Evidence in cleartext on disk — exactly the GDPR-erasure threat model. Purge now also sweeps every abandoned version's stagedrecords.jsonl(WriteAheadLog.abandoned_versions()), expunging reaching records the same reach-filtered way as a committed log (spec shape (a)). The invariant is now: after Purge, norecords.jsonlanywhere under the corpus root — committed or abandoned — reaches a purged document. We rewrite the abandoned file in place viawrite_atomicrather thanremove_tree-ing the dir (shape (b)) precisely to stay race-safe against a concurrent run's in-flight transaction: an atomic in-place rewrite of a not-yet-committed staging file cannot tear a live recovery (a concurrent reader sees valid JSONL before or after the swap; if that transaction later commits it simply becomes a committed Version a later Purge re-scans), whereas deleting a staging dir a live run is mid-commit on could corrupt it. No abandonedversion.jsonis touched — an uncommitted staging area has no authoritative manifest and is never read back. -
Stale
version.jsonbreaks idempotency (correctness S3).expunge_recordsrewrote each Version'srecords.jsonlbut not itsversion.json, so the storedCorpusVersion.fingerprint/record_countstill described the pre-purge record set. Re-running the identical delta over the post-purge corpus then hitcommit()'s idempotency check (current.fingerprint == fingerprint) comparing the new live set's real fingerprint against the head's stale stored one → mismatch → a spurious duplicate Version instead of the documented no-op; audit reads ofrecord_countalso lied. Purge now re-fingerprints and re-counts every rewritten Version'sversion.jsonfrom its post-purge records (_reconcile_manifest, reusing the same_fingerprinthelpercommit()computes with, so the idempotency compare is exact). Correcting every rewritten Version (not just the head whose fingerprint drives idempotency) keeps all audit metadata consistent with its records at negligible cost. This is the deliberate reconciliation of "immutable audit history" with "erasure re-writes the manifest to the erased reality": erasure already trumps immutability (§5), so the manifest must reflect the erased truth, not the pre-erasure one — the immutable delta/parent/timestamp lineage is preserved verbatim; only the two erasure-derived fields change.
6. CLI surface: latence delta / retract / purge (W14)¶
The DeltaRunner is exposed to operators as three commands on the existing latence Typer app (latence_core/cli.py), so an incremental corpus update needs no Python script and no full re-run. They are a thin adapter over the unchanged DeltaRunner — no new engine behaviour, only a surface — and deliberately reuse run's machinery: the same pipeline loader (_load_pipeline reads the stack YAML and builds the Pipeline), and the same report discipline (_emit_delta_report persists the quality-report.json + .md pair the DeltaRunner already writes, then prints the churn).
latence delta <stack.yaml> [--run-id RID]→DeltaRunner.apply_delta(pipeline, run_id)— the add/update path. A delta needs a freshrun_idevery time (the Runner re-parses under a new run namespace), so the default is a UTC-timestamped id (delta-<ts>) rather than a fixed one likerun'srun-0001; an operator can pin one with--run-id.latence retract <stack.yaml> --doc <sha256:id> [--doc …] [--docs-file PATH] [--run-id RID]→DeltaRunner.retract(...)— the soft, auditable, rollback-able default deletion.latence purge <stack.yaml> --doc … [--docs-file PATH] [--yes] [--run-id RID]→DeltaRunner.purge(...)— the hard GDPR erasure. Because it is destructive and non-recoverable (§5), it is guarded by a confirmation prompt (typer.confirm(..., abort=True));--yesskips it for scripted use.
Document ids (--doc, repeatable, and/or --docs-file, one id per line) are validated to be shaped like the content-addressed id the Source stamps (sha256:<64 hex>) with a clear usage error (exit 2) when none are given or one is malformed — the help points at records.jsonl's provenance.document_id or hashing the file. Typed framework errors (LatenceError + the empty-corpus ValueError) become a clean one-line message + non-zero exit (never a traceback), reusing the error taxonomy's error_category.
The churn output prints the affected-set size vs the whole corpus-level set and carries the honest O(corpus) caveat inline (issue #42): the committed corpus is affected-set-scoped, but a delta still re-extracts the whole current source today. The CLI help and the tutorial's "Incremental updates" section (docs/TUTORIAL-fresh-pod-walkthrough.md §7) repeat that boundary so it is never overclaimed as incremental compute. Tests: packages/latence-core/tests/test_cli_delta.py.