Skip to content

Restore change detail (unified diff + chunk semantics) once Replicator provides a persistent content store #222

Description

@gregoryfoster

Reviewed 2026-08-19 in a backlog staleness pass. Still blocked — but the blocker is not quite what this issue says, and #221 (its other dependency) has closed. Two corrections and a design direction added below; everything else stands and remains accurate.

Correction 1 — Replicator does not solve this as assumed

The header says the content store is "forthcoming in the TBD Replicator service". Replicator now exists and permanent replication shipped (replicator#29, closed — GCS per RepSpec, via AsyncGcsDriver.create_if_absent). That does not deliver what this issue needs:

  • It stores raw fetched bytes, not the extracted text the fingerprint is computed over. Diffing raw HTML is not the same product as diffing extracted content, and this issue's "Diff target" question below is precisely about that choice.
  • It is per-RepSpec, so coverage is partial. This issue's own acceptance criterion — "Diff availability does not depend on whether the item is Archiver-linked" — fails against a per-RepSpec store the same way it fails against the Archiver-fetch option, and for the same reason.

So "wait for Replicator" is not by itself a plan. Something has to be specified.

Correction 2 — Watcher already holds the text it needs to store

src/workers/pipeline.py:

content_bytes = "\n".join(c.text for c in result.chunks).encode()
fingerprint = "sha256:" + hashlib.sha256(content_bytes).hexdigest()

The extracted text exists in memory at apply time — it is what the fingerprint is computed over — and is then discarded. ChangeRevision persists only content_fingerprint, content_size_bytes, schema_version, captured_at.

This means deferred design question 1, option B (denormalize previous extracted text onto ChangeRevision, size-capped and compressed) requires nothing from Replicator and could be built today. It is recorded here as available, deliberately not chosen — see the direction below.

Direction — a Replicator-side sidecar, not a Watcher-side column

The preferred shape is for Replicator to store the full extracted text as a sidecar artifact alongside the blob it already holds, addressed by URI, rather than Watcher freighting text through its own database or the bus.

This follows the pattern already in force. Nothing puts content on Redis today — SourceRevisionObservedEvent on content.revisions carries blob_uri, extracted_fingerprint, spec_fingerprint, sizes and media types, but never the bytes. The bus is claim-check by design; the data plane is the blob store. A sidecar keeps that property, where a denormalized ChangeRevision column moves content into Watcher's Postgres and a bus-carried payload would move it into Redis. Both are the wrong direction.

The wrinkle to resolve, stated plainly: Replicator does not extract. It fetches raw bytes; extraction is Watcher's, because Watcher holds source_specs. So a sidecar requires either

  • Watcher writes the sidecar into Replicator's store after extraction — needs a write path into a store Watcher currently only reads from, and blob_uri is a host-local file:// by contract, or
  • Replicator gains extraction — a substantial boundary move that would need source_specs on the fetch command, and contradicts the current split.

The first is much the smaller change and fits replicator#7 (Object-store blob backend (claim-check) — remove the shared-filesystem data-plane coupling), which is already about giving the blob store a real addressable backend rather than a VM-local path. That issue is the natural place for this to land, and this issue should not be unblocked until it does.

Retention is the other half: blobs today are deliberately transient (TTL sweeper, BlobUsage ceiling). A diff sidecar must outlive its blob by at least one check interval, which is a different retention class and needs stating in whatever contract change carries it.

Status of the original dependency

#221 is closed (2026-07-10) — the dead surface this issue restores has been stripped as planned. The 89d64eb restore anchor below remains valid.


Blocked by: a persistent content store — forthcoming in the TBD Replicator service.
Depends on: #221, which removes the dead surface this issue restores.

Context

Phase 5 (#156) dropped the Snapshot/Change tables and the diff engine. ChangeRevision
stores a content fingerprint and nothing else (change_revision.py),
so at dispatch time watcher has no previous content to diff against.

The notification UI kept offering Diff snippet, Full diff, and Significance score
anyway. notify.py:188 hard-codes unified_diff = "",
and real change_detected metadata never carries significance / added / modified /
removed, so all three toggles have been inert in production since #156. Only the preview
appeared to work, because it was fed canned fixtures.

#221 strips that surface: the toggles, the ContentOptions fields, and the now-unreachable
plumbing all come out. This issue restores the capability once there is somewhere to read
previous content from.

Watcher is, for now, a fingerprint-shift notifier: it reports that a page changed,
not what changed.

Restore anchor

All removed code is preserved in git at 89d64eb (the commit immediately preceding the
#221 strip). Restore from there rather than rewriting — in particular
_truncate_unified_diff_lines is hunk-boundary-aware and carries real test coverage that
would be tedious to reconstruct.

git show 89d64eb:src/core/notifications/content.py
git show 89d64eb:src/api/schemas/content_config.py
git show 89d64eb:src/core/notifications/preview_fixtures.py

Inventory of what #221 removes

Location Removed
content_config.py include_diff_snippet, diff_snippet_lines, include_diff_full, include_significance
content.py _build_diff_text, _render_unified_diff_block, _normalize_unified_diff_lines, _truncate_unified_diff_lines, _format_chunks_changed, _compute_change_summary, _DEFAULT_DIFF_SNIPPET_CAP, the diff_snippet / diff_full / chunks_changed / change_summary context keys, and the unified_diff / diff_snippet_cap params on build_body + build_template_context
notify.py:185-201 the unified_diff = "" stub and its pass-through
preview_fixtures.py compute_preview_unified_diff, _PREVIEW_PREVIOUS_TEXT, _PREVIEW_CURRENT_TEXT, and the added / modified / removed / significance fixture keys
default_templates.py TemplateVariable entries for change_summary, added, modified, removed, diff_snippet, diff_full, chunks_changed, significance; and CHANGE_DETECTED_BODY_BLOCK_LINES
notification_form_content_body.html the entire Changes toggle group
routes.py the corresponding branches in _parse_content_config_from_form and the preview route

What restoring actually requires

Restoring is not re-adding the checkboxes. Three separate problems, in ascending cost:

  1. Previous content must be readable at dispatch time. Today it exists only as an
    Archiver SourceRevision, plus a TTL-bounded scratch file (WATCHER_CACHE_DIR,
    default 600 s) that is written only when archiver_info_source_id is set
    dashboard-created WatchedItems have none. This is what Replicator is expected to solve.
  2. Unified-diff computation no longer exists. compute_unified_diff is gone from
    src/ entirely; only the rendering helper survived (and Notification Template UI/UX polish: event labels, Context layout, and strip the dead diff/significance surface #221 removes that too).
    Cheap to rebuild on difflib once (1) lands.
  3. Chunk semantics are a rebuild, not a restore. added / modified / removed /
    significance / chunks_changed were produced by the diff engine Phase 5 deleted.
    Nothing in the current codebase computes them. Treat as a separate scope.

Deferred design questions

  • Storage source. Read previous text from Replicator/Archiver at change time (a network
    hop on the change path), or denormalize previous extracted text onto ChangeRevision
    (size-capped, compressed)? The Archiver-fetch option silently yields no diff for
    WatchedItems without archiver_info_source_id — a worse failure mode than no diff at all,
    so whatever Replicator exposes must cover every item.
  • Diff target. Extracted text (what the fingerprint is computed over) or raw bytes?
  • Does significance come back at all, or is a unified diff sufficient signal?
  • Default body. Notification Template UI/UX polish: event labels, Context layout, and strip the dead diff/significance surface #221 reduces the change_detected default body to the header block.
    Restoring a diff means re-deciding where it sits and whether it's on by default
    (verbose channels vs. Slack).

Acceptance

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions