Skip to content

feat(sdk): change feed carries every kind and verbatim columns - #260

Merged
willwashburn merged 2 commits into
mainfrom
feat/change-feed-full-fidelity
Sep 25, 2026
Merged

willwashburn merged 2 commits into
mainfrom
feat/change-feed-full-fidelity

Conversation

@willwashburn

@willwashburn willwashburn commented Sep 24, 2026 •

Copy link
Copy Markdown
Member

What the feed provides

SessionStore::changes_since gives an embedder everything it needs to rebuild each evidence row exactly as stored, on the default public API. An upload daemon can build its records from the feed alone, which lets the Agent Relay desktop probe stop using the export capture journal.

  • Every kind the journal captures. ChangeKind covers session, session_event, tool_call, file_edit, session_marker, relationship, history, presence, commit_link, trajectory, source_observation and observation_evidence. The wire names are the journal's kinds. Each table gets the same insert/update/delete stamping triggers and tombstones, so every write site is covered.
  • The stored row verbatim. Change::columns: Option<StoredRow> is every column except revision, in table order, with raw SQLite values: JSON text stays text, integers stay integers, NULL stays null. The columns are read from the live table, so a column a migration adds shows up without a code change. The journal's capture payload is built from the same column list (change_feed::stored_columns), so the two can't drift. The typed rows are unchanged; EvidenceRow::History(HistoryEntry) is new, and EvidenceRow::Untyped marks the kinds that have no typed row.
  • The journal's record key. Change::key: Vec<Value> is the journal's Table::key, [kind, ...key columns], for example ["history", source, timestamp_ms, prompt] or ["trajectory", id]. Upserts and tombstones carry the same key. A tombstone rebuilds it from record_key, which for a multi-column identity is a JSON array of the stored values, so a history delete keeps timestamp_ms as an integer.
  • Unknown sources are carried, not fatal. Change::source is Option<Source> and Change::source_name holds the stored name. A row written by a newer release under a source this build doesn't know appears with source: None instead of failing the drain.

No consent or exclusion rule is applied here; the daemon does its own selection.

Migration

The feed marker moves to change_feed_v2. On a database the six-kind feed already reached, opening it writable stamps the new tables' existing rows once, in rowid order, above the current head. A cursor bound to every kind therefore resumes into all of those rows and none it has already seen. The old session re-stamp triggers held the names the presence kind now uses, so they are dropped and recreated as change_feed_session_locations_*. Their update trigger now skips a presence's own stamp, so a presence write stamps its session exactly once. history_au now fires only on UPDATE OF id, prompt, project (the same fix session_events_au got earlier), so the feed's stamp doesn't re-index the history FTS table twice per insert.

Proof

  • change_feed::tests::every_kind_matches_the_export_journal_for_the_same_writes (export feature) writes to all 12 tables with a subscription active: inserts, value updates, one identity change (tool_use_id), a history row gaining a session, direct deletes and a cascading session delete. After each step it checks that the state the feed replays to (key → StoredRow, column order included) equals the state the journal replays to. It also checks that each upsert's key equals the key rebuilt from its stored row. Removing one column from the feed's view makes it fail.
  • tests/change_feed.rs exercises the public API on default features:
    • a Claude history.jsonl prompt reaches the feed, keyed and with its stored row;
    • a column added to tool_calls after the feed was built is carried verbatim, in table order;
    • tombstones carry the upsert's key, including a history row's integer timestamp_ms;
    • an unknown source is carried with source: None;
    • the existing replay test now rebuilds every fed table column for column rather than one value per row.
  • a_store_fed_before_every_kind_gains_the_rest_above_its_head rewinds a store to the six-kind schema, triggers and all. After migration, an all-kinds cursor resumes into exactly the new kinds' rows, the retired trigger names stamp presences, and a presence write costs two revisions (presence plus session).

Benchmark

node scripts/benchmark-sync.mjs --gate on the same machine (Apple M2 Max, debug profile):

phase origin/main this branch
cold_sync records/s 2344 2294
incremental_sync ms 187.6 188.7
unchanged_sync ms 36.7 39.1
hydrate_cold records/s 1088 1073
hydrate_unchanged ms 23.0 24.1

All phases are within about 2–6% of main and the gate passes.

Compatibility

Change::source changes from Source to Option<Source>. It's the one change that breaks source, and it's what lets an unknown source be carried. examples/rust-consumer builds and runs unchanged. A consumer that drains every kind now also receives the six new kinds.

🤖 Generated with Claude Code


Note

Medium Risk
Public API and migration changes affect incremental consumers and cursors; behavior is heavily tested against the export journal but wrong consumer resume handling could miss or duplicate data.

Overview
Expands SessionStore::changes_since so embedders can rebuild the ledger from the feed alone—covering all twelve evidence tables (history, presences, commit links, trajectories, connector observations, etc.), not just the original six session/evidence kinds.

Each Change now carries the row as SQLite stores it via StoredRow (columns), plus a journal-aligned key (kind + uniqueness columns) on upserts and deletes. Typed upserts add EvidenceRow::History; kinds without a typed struct use EvidenceRow::Untyped. Change::source becomes Option<Source> with source_name, so unknown future sources still drain instead of erroring.

Opening a writable store runs change_feed_v2: new tables get revision stamps/triggers/tombstones, existing rows on newly fed tables are backfilled above the current head, and presence/session re-stamp triggers are rebuilt (including history_au only re-indexing FTS on indexed columns). Export capture payloads now use the same stored_columns list as the feed so shapes cannot drift.

Compatibility: a named consumer that previously committed with an explicit six-kind filter may hit ConsumerKindsMismatch against today's ChangeKind::ALL—use a new consumer name or replay from Watermark::START. Change::source going optional is the other source-breaking API tweak.

Reviewed by Cursor Bugbot for commit b6e6a75. Bugbot is set up for automated code reviews on this repo. Configure here.

The change feed reports all twelve evidence tables the export journal
captures -- adding history, presence, commit_link, trajectory,
source_observation and observation_evidence -- and every upsert carries
the row as stored (StoredRow: every column but revision, in table order,
raw SQLite values, read from the live table) beside the typed row. Every
change carries the journal's record key, tombstones included, and a row
from an unknown source is carried with source None and source_name set
instead of failing the drain.

A database the six-kind feed reached stamps the new tables once on open,
above its head. The session re-stamp triggers move to their own names and
skip a presence's own stamp; the history FTS update trigger fires only on
the columns it indexes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 24, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-09-24T23:23:04.352290Z b5ef512 PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@coderabbitai

coderabbitai Bot commented Sep 24, 2026 •

Copy link
Copy Markdown

Review in Change Stack →

Navigate logical layers of code changes, visualize relationships, and explore their blast radius.

Warning

Review limit reached

Next included review available in 47 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Advanced

Run ID: 9cca08eb-3be7-4284-a010-f3920a750c28

📥 Commits

Reviewing files that changed from the base of the PR and between b5ef512 and b6e6a75.

📒 Files selected for processing (3)
  • CHANGELOG.md
  • crates/ai-hist/src/change_feed.rs
  • docs/sourcing-sdk.md
📝 Walkthrough

Walkthrough

The change feed now covers 12 evidence kinds. Changes include identity keys and stored-row columns on upserts, preserve unknown source names, and support migration of existing databases. The history FTS update trigger now watches only indexed columns.

Changes

Expanded change feed

Layer / File(s) Summary
Feed kinds and payload contracts
crates/ai-hist/src/change_feed.rs, crates/ai-hist/public-api.txt, crates/ai-hist/src/lib.rs
The feed adds six kinds, typed history rows, untyped rows, and the public StoredRow type. Change now carries stored columns and an identity key, and preserves source names when source is unavailable.
Stored-row reads and change identities
crates/ai-hist/src/change_feed.rs, crates/ai-hist/tests/change_feed.rs, crates/ai-hist/src/export/schema.rs
Upserts read current table columns and construct journal keys. Deletes reconstruct keys from tombstones. Tests check all stored columns, identity keys, and unknown sources; export capture uses the same stored-column selection.
Feed migrations and schema integration
crates/ai-hist/src/change_feed.rs, crates/ai-hist/src/store.rs, crates/ai-hist/src/source_evidence.rs, CHANGELOG.md, crates/ai-hist/README.md, docs/sourcing-sdk.md
The v2 migration backfills newly fed tables and updates presence triggers. The history FTS trigger watches indexed columns. Changelog, example, and SDK documentation describe the expanded feed and payloads.

Priority: ➖ Normal

Estimated code review effort: 4 (Complex) | ~45 minutes

Change: Feature

Sequence Diagram(s)

sequenceDiagram
  participant Consumer
  participant ChangeFeed
  participant SQLite
  Consumer->>ChangeFeed: Request changes_since cursor
  ChangeFeed->>SQLite: Read revisions and tombstones
  SQLite-->>ChangeFeed: Return stored rows and identities
  ChangeFeed-->>Consumer: Return typed or untyped changes
Loading

Suggested reviewers: claude

Merge Risk: 🔵 Low · up to b5ef5

The change feed now covers twelve evidence kinds and carries stored-row payloads and identity keys. Named consumers that explicitly listed the original six kinds will get an error on their first resume after upgrading. The fix is a new consumer name or a resync from the start. The PR can merge with low risk once the changelog explains this upgrade effect.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 66.10% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 59 functions across 6 files. (4 skipped: … Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely summarizes the main change: expanding the change feed to carry every evidence kind and verbatim columns.
Description check ✅ Passed The description is directly related to the changeset and explains the expanded feed coverage, stored columns, keys, migration, compatibility, tests, and benchmarks.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Full details: Docstring Coverage

Explanation

Docstring coverage is 66.10% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 59 functions across 6 files. (4 skipped: 4 unsupported.)

✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Commit to this branch
  • Create a new PR
🧪 Generate unit tests (beta)
  • Commit to this branch
  • Create a new PR

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

A rabbit checks the feed at dawn
Twelve kinds of rows come hopping on
Stored columns follow in their trail
Keys mark each upsert and delete
Unknown names stay in the tale
Then bunny stamps the schema right

Comment @coderabbitai help to get the list of available commands.

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Devin Review found 1 potential issue.

Devin Review

Comment thread crates/ai-hist/src/change_feed.rs Outdated
Comment on lines +462 to +467
changed.push(format!("OLD.{0} IS NOT NEW.{0}", self.session));
changed.push(format!(
"{} IS NOT {}",
self.record_key_sql("OLD"),
self.record_key_sql("NEW")
));

@devin-ai-integration devin-ai-integration Bot Sep 24, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 Existing stores retain incorrect history deletes

On an existing store, init_schema retains the old history triggers because their names already exist. Changing a prompt's session then emits a delete for its unchanged key, followed by an upsert.

Learn more

An existing store has history stamping triggers installed under fixed names. On open, init_schema builds new SQL for these names but uses CREATE TRIGGER IF NOT EXISTS at trigger creation. SQLite retains the old trigger bodies, which treat every session change as an identity change. The feed now expects prompt identity to exclude the session, so a session update produces a false delete and the stored tombstone session differs from what the new code documents.

Example: A store initialized before this change contains a prompt with key ["history","claude",42,"hello"] and no session. Updating its session to s1 generates a delete for that key followed by an upsert, although the prompt still exists under the same key.

Recommended fix: Recreate the history insert, update, and delete triggers in a dedicated migration for stores that already have change_feed_v2. Reconcile any existing history tombstones keyed with nonempty sessions if the new tombstone scheme must clear them on reinsertion; test an upgraded store as well as a fresh one.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Valid, fixed in b6e6a75. A history row's key is (source, timestamp_ms, prompt), so identity_changed_sql now compares the session only when it is part of the key (FedTable::session_keyed). History tombstones store '' as their session, so a tombstone names exactly the record's key and a later insert of that key clears it whatever session it carries. The parity test now asserts that no History delete appears after UPDATE history SET session_id = 'c1'. With the old behavior restored (tombstone keeps the row's session, identity compares it), that assertion fails; with the fix it passes.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Checked, and no store can be in that state. The history stamping triggers and the change_feed_v2 marker exist only on this PR's branch. On origin/main (9ce674b3, released as 0.27.0) the feed marker is change_feed_v1 and history has no revision column and no change_feed_history_* triggers. No tag contains the branch's first commit (git tag --contains b5ef512f is empty). So every store that reaches change_feed_v2 has its history triggers created by the code at b6e6a75, which compares the session only for kinds that key on it. An upgraded store is covered by a_store_fed_before_every_kind_gains_the_rest_above_its_head, which rewinds a store to the released six-kind schema and migrates it. A trigger-rebuild migration would only serve databases built from an unmerged intermediate commit, so I haven't added one.

coderabbitai[bot]

This comment was marked as resolved.

A history row is unique on source, timestamp and prompt, so a prompt that
gains or changes its session is an upsert of the same record. Its
tombstones store an empty session and the identity check skips the
session column, so no transient delete sits between the two writes.
Document that a cursor stored as `*` now spans all twelve kinds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@willwashburn
willwashburn merged commit 3131818 into main Sep 25, 2026
14 checks passed
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.

1 participant