Skip to content

store: the A2A task store and the MCP call log are durable, and a restart is what proves it - #8

Open
MattJackson wants to merge 2 commits into
devfrom
feat/durable-a2a-task-store
Open

store: the A2A task store and the MCP call log are durable, and a restart is what proves it#8
MattJackson wants to merge 2 commits into
devfrom
feat/durable-a2a-task-store

Conversation

@MattJackson

Copy link
Copy Markdown
Contributor

Closes the A2A goal item D.19 for this backend, plus the same-defect-class gap in the MCP tool-call log.

Read this first — these PRs are necessary but NOT sufficient. See "The other half" at the bottom: the plugin ABI does not carry these methods, so over the dlopen boundary the engine still gets the trait defaults. That is core work, not this repo's.

The defect

busbar_api::Store has ten methods whose defaults are accept and keep nothingput_task/append_task_event/append_mcp_call return Ok(()), get_task returns None, the list_* methods return empty. This backend overrode none of them. Every in-flight A2A task, and every MCP tool-call record, was lost on every restart, and nothing anywhere reported it: the return value of a write is not evidence that anything was stored.

Two commits, one per plane. On the task side:

  • tasks — every TaskRow field a real column, task_id primary key, (state, updated_at) index for the retention sweep. put_task UPSERTs ON CONFLICT (task_id) DO UPDATE: the engine writes through on every state transition, so a second write must replace the row.
  • task_events(task_id, seq) primary key, and append_task_event UPSERTs. This is where the task contract genuinely differs from append_mcp_call's fork check — the contract says a store "must upsert on that pair … rejecting or duplicating a replayed seq breaks the chain the engine will verify on read". Copying the call log's behaviour here would have been wrong in a way that looks right.
  • Retention drops terminal rows only, from a closed named set so an unrecognised state token from a newer engine is never deleted, and takes the task's provenance chain with it via an AFTER DELETE trigger. The cascade is load-bearing: there is no purge_task_events_before in the trait, so without it task_events has no bound. A trigger rather than an FK, because an FK would also impose write ordering — no event before its task row — and the engine is under no such obligation.

Schema v7 → v8, additive: new tables only, no backfill, nothing dropped.

.busbar-ref

Bumped from c8780349 (1.5.3) to 5a4f0195, the dev commit that carries both contracts — the old pin predates them and would not compile these methods. The engine-side diff of crates/api between the two is purely additive: TaskRow, TaskEventRow, and six defaulted trait methods; nothing else in the Store trait moved. The full pre-existing suite was green against the new ref immediately after the cherry-pick, so the bump dragged in nothing that broke. ci.yml is unchanged and still builds against the same-named core branch.

How this was verified

Red before green. All 8 new tests were run first against the unimplemented state and seen to fail — got None back on a new connection, left: 0 / right: 4 on the purge count, and so on.

The property is "it survives a restart." Each durability test uses a disposable database, drops the write handle to close its connection, and reads the rows back through a new PostgresStore::connect. A round-trip on one live handle cannot distinguish a real write from a HashMap behind the same trait. Isolated databases rather than the shared one, because purge_tasks_before is global by updated_at and cannot be scoped — that isolation is also what lets the purge counts be asserted exactly instead of as >=.

Gates run locally

gate result
public-hygiene-lint --selftest / --root pass, 0 hits
cargo fmt --all -- --check pass
cargo build --all-targets pass
cargo clippy --all-targets -- -D warnings pass
cargo test (store suite, live PG 16) 50 passed, 0 failed
executable-config-lint --selftest / --root pass, 3 valid / 1 skipped
admin_api_e2e unverified — see below

admin_api_e2e fails locally with boot #2's admin listener never came up within 15s. Bisected: it fails identically with this change reverted to the cherry-pick commit, and under the same conditions even boot #1 — memory store, no plugin at all — fails the same way, with the child hanging in _dyld_start. The machine was saturated by three sibling worktrees compiling. So this is environmental and not caused by this change, but it is unverified on this branch and Linux CI is the arbiter.

Where Postgres cannot mirror sqlite exactly — stated, not papered over

  1. No per-write durability escalation. store-sqlite raises synchronous to FULL around these writes; Postgres's default synchronous_commit = on already fsyncs the commit. Same property, different mechanism.
  2. The cascade needs plpgsql. Present by default on PG (including the tested 16), but a cluster with it removed would fail migrate at schema creation.
  3. Signed BIGINT. A u64 past i64::MAX is refused rather than stored — same observable behaviour as sqlite, reached because this repo's clamp idiom would pin rather than wrap.

The other half — this is not shippable durability yet

busbar-plugin-abi's StoreRequest at 5a4f0195 has no variants for any of these ten methods, and busbar-plugin-loader's DynStore — the dyn Store the engine actually holds when a store is loaded as a plugin — overrides none of them, so it falls through to the same accept-and-keep-nothing defaults. Neighbouring methods (put_usage, put_key_with_credential, lookup_credential_secret) are implemented there, so this is a real gap rather than a mis-read.

Concretely: file-drop and runtime-install are the only two ways an operator gets this plugin, both go through DynStore, and across that boundary put_task is still a no-op no matter how correct this repo is. The backend side is now right and waiting; the ABI/SDK/loader half is core work and belongs in busbarAI.

…es it

The MCP plane's call evidence rode the admin audit ring: an in-memory,
size-bounded working set shared with admin mutations. Data-plane tool calls
arrive at request rate, so a busy afternoon evicts every admin row from the
ring -- the question of who changed a registration becomes unanswerable
exactly when an incident makes somebody ask. And nothing survived a restart.

So: `mcp_calls`, its own table, a different population from `audit_log` on
purpose. The chain is scoped to the PRINCIPAL -- (principal, seq) is the
primary key -- because a global chain would serialise every caller behind one
append and would make one caller's evidence unverifiable without possessing
every other caller's rows.

Opaque `body` plus only the columns a query needs. `principal` and `ts` are
the index columns; the chain columns -- seq, prev_hash, hash -- are REAL
columns rather than buried in the body, because durability here is
established by READING THE CHAIN BACK, and a digest reachable only by
decoding a payload can be neither constrained nor indexed. `version` and
`expires_at` are carried now and written by nothing: adding a column to a
populated table later is a rewrite, carrying it from the first migration is
free.

The insert is ON CONFLICT DO NOTHING, and the incumbent is read afterwards
WITHOUT a transaction -- safe precisely because this table is never
rewritten, so a row that exists cannot change underneath the read. Identical
is the at-least-once retry and succeeds; DIFFERENT is a forked or tampered
log and is an error, because overwriting destroys the one case worth
reporting.

v6 -> v7 is purely additive: the table is new, so the unconditional
CREATE TABLE IF NOT EXISTS is the whole migration and no backfill arm exists.

The test that carries this drops the store -- closing its connection
entirely -- then connects a genuinely new one and asserts the chain still
links from what the server hands back. Run with the four methods removed, so
the trait's accept-and-keep-nothing defaults apply, it fails with "got 0
records back"; that is the behaviour this backend replaces.

One finding worth keeping: retention is GLOBAL by `ts` and cannot be scoped
to a principal. Against the shared live database the purge test was deleting
every other test's rows, which is why the suite now bands its timestamps --
the purge test owns the low band and every other test sits above the highest
cutoff it uses. The cross-deletion is what surfaced it.

`.busbar-ref` moves off the 1.5.3 pin that predates this contract onto the
`dev` commit that carries it, so the ref names a core that actually has the
methods this backend overrides. CI needs no hand-held pin alongside it: it
already builds against the same-named core branch, so dev builds against dev
and a release builds against what ships.
…es it

A2A is async by design: a task spans turns, can sit interrupted waiting on a
human, and can outlive the process that started it. The six task methods on
the `Store` trait are DEFAULTED to accept-and-keep-nothing -- `put_task`
returns Ok(()) and stores nothing, `get_task` answers None for everything,
`list_tasks` empty, `purge_tasks_before` 0 -- and this backend overrode none
of them. So every in-flight task on a Postgres-backed deployment was lost on
the next deploy, and the write path never said a word about it. That is the
difference between a resume that is real and one that is nominal.

So: `tasks` keyed on `task_id`, and `task_events` keyed on `(task_id, seq)`.

Every field of a task row is a REAL COLUMN, unlike `mcp_calls`'s opaque
`body`, and that is not inconsistency. A call record carries open-ended
narration no query filters on, so it pays for a blob and saves the columns. A
task row is a small fixed state record where every field is part of what a
resume reads back, `state` and `updated_at` are the retention sweep's own
predicates -- indexed together for exactly that -- and there is no field left
over to make opaque.

`put_task` UPSERTS on `task_id`, because the engine writes through on EVERY
state transition and a second write for one task must replace the row rather
than accumulate a second one. `get_task` does NOT filter on principal: the
contract puts caller-scoping engine-side deliberately, since an authorization
check living in the backend is one an unauthorized reader bypasses by
configuring a different backend. `list_tasks` is UNFILTERED, terminal rows
included -- the boot rehydrate wants the active rows, the retention sweep the
terminal ones and the scoped listing one principal's, so a store that
pre-filtered for any one of those would break the other two.

`append_task_event` UPSERTS on `(task_id, seq)`, and this is the one place the
task contract genuinely DIFFERS from `append_mcp_call`'s in the commit before
this one. That one treats an occupied slot holding a different record as a
forked log and refuses it. This one is specified to upsert, so the engine's
write-through is idempotent on replay: "rejecting or duplicating a replayed
seq breaks the chain the engine will verify on read". Copying the call log's
fork check across would have been wrong in a way that looks right, so the
difference is stated at the method rather than left to be inferred.

Retention drops TERMINAL rows only, strictly older than the cutoff. The
terminal set is named as a closed list rather than derived by negation: an
unrecognised state token -- one a newer engine emits and this build has never
heard of -- must read as NOT terminal, because the wrong half to guess on is
the deleting half. An interrupted task waiting on a human is precisely the row
that legitimately sits still for a long time, and compacting it is losing the
work, not reclaiming space.

The purge CASCADES to the task's events, via a trigger rather than a foreign
key. The cascade itself is load-bearing: `purge_tasks_before` is the ONLY
retention method the contract gives this data, so a purge that left the
provenance behind would leave `task_events` with no bound anywhere in the
trait. A real FK would give the cascade for free but would also impose an
ORDER on the writes -- no event could be appended before its task row existed
-- and the engine is under no such obligation, since a `task.submitted` event
and the first `put_task` are two independent write-throughs. The trigger buys
the bound without inventing an ordering constraint the contract never stated,
and putting it in the database rather than in the DELETE's own SQL means a row
removed by any other route (an operator's psql session, an external job) takes
its chain with it too.

Postgres BIGINT is signed, so a `u64` past `i64::MAX` cannot round-trip.
`clamp` would pin it and the row read back would not be the row written, with
nothing ever reporting an error -- and the value that silently changes is the
ARTIFACT CURSOR, i.e. how much of a stream has been durably relayed, so a
pinned cursor either replays delivered artifacts or skips undelivered ones.
Refused instead, exactly as `append_audit` already refuses its own
out-of-range seq/ts, across all five affected fields (artifact_cursor,
created_at, updated_at, event seq, event ts).

Nothing escalates durability per write the way store-sqlite has to: sqlite
raises `synchronous` to FULL around these writes because its default is
weaker, whereas Postgres's default `synchronous_commit = on` already fsyncs
the commit that carries the transition. Same property, already the default.

v7 -> v8 is purely additive: two new tables and their cascade, so `SCHEMA`'s
own unconditional CREATE TABLE IF NOT EXISTS is the whole migration and no
`version < 8` backfill arm exists. Postgres has no CREATE TRIGGER IF NOT
EXISTS, so that one object is created under an explicit pg_trigger existence
check -- DROP-then-CREATE would also be idempotent but would take an ACCESS
EXCLUSIVE lock on `tasks` at every connect, for nothing.

THE TEST THAT CARRIES THIS drops the store -- closing its connection entirely
-- then connects a genuinely NEW one to the same database and reads the task
back off the server. A round-trip through one live handle proves nothing: it
cannot tell a backend that wrote to Postgres from one holding a HashMap behind
the same trait, nor either of those from the trait's own defaults exercised
through the very handle that "wrote". Run against the unimplemented state, all
eight new tests fail, and they fail on the property rather than on a detail:
"an in-flight task must survive a restart; got None back on a new connection",
"got 0 events back on a new connection", the unfiltered listing comes back
`[]` against four written rows, the purge reports 0 where 4 rows were owed,
and the range guard returns Ok for a cursor of u64::MAX. 42 passed, 8 failed
before; 50 passed after.

Each of these tests owns a disposable database rather than banding its
timestamps in the shared one, and that is a consequence of the method:
`purge_tasks_before` is GLOBAL by `updated_at` and cannot be scoped to a task
or a principal, so against a shared database a retention test's cutoff deletes
every other test's terminal rows. Isolation also lets the assertions be EXACT
-- "the purge removed four rows", "these seven survive" -- rather than the
`>=` a shared table forces, and an exact count is what proves a purge
performed the number it reported.

Gates run locally against a real postgres:16 and a sibling busbarAI at the
.busbar-ref commit: public-hygiene-lint --selftest and --root, cargo fmt --all
--check, cargo build --all-targets, cargo clippy --all-targets -D warnings,
and cargo test with BUSBAR_TEST_POSTGRES_URL set.
@MattJackson

Copy link
Copy Markdown
Contributor Author

CI is red for a reason outside this PR, and here is the proof

cargo fmt --check failed. The diff it reports is not in this repo — every hit is in the sibling busbar checkout:

Diff in .../busbarAI/crates/busbar/src/mcp/client/ssrf.rs:159:
Diff in .../busbarAI/crates/busbar/src/mcp/client/tests/ssrf_tests.rs:232:
Diff in .../busbarAI/crates/busbar/src/mcp/client/tests/ssrf_tests.rs:243:
Diff in .../busbarAI/crates/busbar/src/mcp/client/tests/ssrf_tests.rs:251:
Diff in .../busbarAI/crates/busbar/src/mcp/client/tests/ssrf_tests.rs:260:
Diff in .../busbarAI/crates/busbar/src/mcp/client/tests/ssrf_tests.rs:270:

cargo fmt --all from the plugin workspace reaches its ../busbarAI path dependency, so busbar's own formatting is inside this repo's fmt gate.

CI checks out busbar at branch dev (busbar_ref: ${{ github.base_ref || github.ref_name }}), a moving tip — not at the sha in .busbar-ref. Between this branch being cut and CI running, dev advanced to 3f389d37 ("security: the MCP SSRF guard let [::169.254.169.254] through to cloud metadata"), and that commit's ssrf.rs / ssrf_tests.rs are not rustfmt-clean.

Reproduced locally, both directions, from the same plugin workspace:

sibling busbar checkout cargo fmt --all -- --check
5a4f0195 (this branch's .busbar-ref) exit 0
3f389d37 (dev tip, what CI used) exit 1, the six diffs above

So this is an engine-side breakage that will redden every plugin repo's CI, on every PR, regardless of its contents, until cargo fmt is run on busbar dev. Nothing in this PR touches those files.

Deliberately not worked around here. The obvious dodge — pinning busbar_ref to the .busbar-ref sha — would turn the gate green without the underlying problem being fixed, and would also stop CI noticing engine drift. The fix belongs on busbar dev.

@MattJackson

Copy link
Copy Markdown
Contributor Author

Update: store-mysql#6 has now failed at the identical step on the identical six busbar files, and store-sqlite#7 / store-valkey#7 are green only because their runs checked busbar out before 3f389d37 landed — a re-run of either would go red the same way. This is fleet-wide and blocks every plugin repo until cargo fmt is run on busbar dev.

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