Inspiration

A source your fleet trusted when memories were written is found compromised later: a vendor tool, a scraped page, an MCP server that turns hostile after months of clean use. What did it influence, and can you revoke exactly that, without destroying everything else memory ever learned?

ADK gives every memory an author (MemoryEntry.author, Event.author): who appended it. It does not give memory an origin: where the content came from. Text a user typed and text scraped from a hostile page are indistinguishable once stored, which is the precondition for OWASP ASI06 (memory poisoning). Without a derivation graph, "which memories descended from this compromised tool" is not a hard question, it's an unanswerable one, so an enterprise fleet without one is left with purge everything, purge every department that touched it, or leave the poisoned lineage in place.

What it does

On a 600-record fixture, a whole-app or whole-department purge destroys 100% of fleet memory to contain one bad tool. Custody's derivation graph narrows that to exactly 40 records, 93% preserved. With no derivation recorded, the smallest unit you can safely purge is a user. With it, the unit is a record.

Custody sits on top of Memory Bank through the existing BaseMemoryService port ADK already defines, so it doesn't fork ADK or replace any part of it:

from custody.adapters.adk import CustodyMemoryBank
memory = CustodyMemoryBank(downstream=VertexAiMemoryBankService(...))

That's the whole integration surface a fleet has to adopt. Underneath it, Custody wires into six more pieces of the Google Agent Platform, each governing a different trust boundary rather than duplicating one: Agent Registry for live tool tools/list publishing and stale/drifted-tool admission, Agent Identity plus Agent Gateway (IAP) for a time-boxed, CEL-conditioned tool lease enforced before dispatch, Model Armor for inline prompt-injection/jailbreak screening on routed content, Cloud Scheduler driving a daily Provenance Auditor sweep that turns a department's trust withdrawal into an actual revocation, Firestore as the durable derivation graph and revision catalog that survives a Cloud Run cold start, and Gemini 3.5 Flash on Vertex AI drafting a human-readable verdict on quarantined content, structurally forbidden from ever writing a trust or origin fact itself.

How we built it

Origin labeling is a pure function with no model and no I/O: every memory event is typed USER / MODEL / TOOL / DERIVED off ADK's real event graph, never inferred by a model. Enforcement happens at the write, not at retrieval, because Memory Bank derives memories server-side and a stored memory can't be matched back to a source event afterward, so a session is split into trusted and quarantined content before it's ever written.

Here's what the system is actually made of (Mermaid source, renders live on GitHub: docs/architecture.md, and as an interactive page at https://custody-incident-cave2.vercel.app/architecture.html):

flowchart TB
    subgraph fleet["The governed population"]
        A1["sales agent"]
        A2["support agent"]
        A3["finance agent"]
    end
subgraph custody["Custody"]
    direction TB
    REV["revision.py<br/>pin live MCP tool revisions<br/>block drift before dispatch"]
    ORIGIN["origin.py<br/>label by structure<br/>USER · MODEL · TOOL · DERIVED"]
    SPLIT["service.py<br/>split before the write"]
    GRAPH["graph.py<br/>derivation graph<br/>descendants · revoke"]
    CAT["catalog.py<br/>per-department grants"]
    GATE["action.py<br/>export must cite trusted memory"]
end

subgraph google["Google Cloud"]
    MB[("Memory Bank<br/>via BaseMemoryService")]
    FS[("Firestore<br/>graph · quarantine · audit")]
    GEM["Gemini via Vertex<br/>explains a quarantine<br/>never labels one"]
end

QUAR[["quarantine<br/>withheld, reviewable"]]

A1 & A2 & A3 -->|"discover and bind tools"| REV
REV -->|"admitted session events"| ORIGIN
ORIGIN --> SPLIT
CAT -->|"which tools this<br/>department vouched for"| SPLIT
SPLIT -->|"trusted only"| MB
SPLIT -->|"untrusted"| QUAR
SPLIT -->|"records"| GRAPH
GRAPH --> FS
QUAR --> GEM
GRAPH -->|"revocation"| MB
MB -->|"retrieval"| A1 & A2 & A3
A1 -->|"export request"| GATE

Every node in that diagram is live and evidenced, not planned, including Firestore, which is the durable backend for the derivation graph, revision catalog, and demotion log, verified to survive a real Cloud Run cold start (the seed record's admission timestamp was byte-identical after forcing a new revision). Here's what each layer actually does live, not just what it's named:

Revision admission and dispatch binding: R1/R2 (custody/revision.py). An MCP tool's live tools/list definition is digest-pinned per department. Live proof R1 deploys a v1 FastMCP tool to Cloud Run, publishes its real tools/list to Agent Registry, then deploys a schema-identical v2 to the same URL without updating Registry: the negative control dispatches v2, Custody recomputes the live digest, emits revision_mismatch, and blocks before the server's call counter ever moves. Extended further: approval now also carries the Cloud Run revision name and resolved image digest serving that surface, so a same-schema, different-image swap is caught too, not just a schema change, a gap the first version of this control had and a live proof found. R2 closes a second, separate gap: an allowed call and the tools/list read that authorized it aren't automatically the same event across process boundaries (a TOCTOU window), so dispatch is now cryptographically bound to the exact surface read that admitted it, proven against a fresh Cloud Run process via Firestore-backed nonce replay detection.

Agent Gateway / Agent Identity: S1 (IAP + CEL). A deployed custom Agent Runtime carries AGENT_IDENTITY and is bound to a regional custody-fleet-egress Agent Gateway. The live proof installs one proof-owned, server-expiring IAM condition that time-boxes a single named tool's admission:

api.getAttribute('iap.googleapis.com/mcp.toolName', '') == '' ||
(request.time < timestamp('<10-minute-expiry>') &&
 api.getAttribute('iap.googleapis.com/mcp.toolName', '') == 'lookup_customer')

Four controls run under that one policy: an allowed call during the lease, a different tool name denied under the same live lease (proving a narrow admission, not a broad historical allow), the same tool denied once the request.time boundary passes even though the handshake clause is still open, and a final call after the policy reverts to safe-deny. Cloud Logging independently records ALLOWED/200 and DENIED/403 under four distinct W3C trace IDs; Admin Activity records the initial→allow→deny etag chain.

Model Armor: M1. One owned Template (custody-approved-tool-ingress) with prompt-injection/jailbreak filtering at MEDIUM_AND_ABOVE. Two proof-bound sanitizeUserPrompt calls: a jailbreak payload and a clean payload, both carrying the same per-run marker, are issued live; Model Armor blocks the first and allows the second, both independently logged server-side and re-read by insert ID. Model Armor and origin/derivation are complementary, not the same claim: a memory can be admitted by Custody's rules and still be screened by Model Armor before it ever reaches that path.

Cloud Scheduler and the Provenance Auditor: G5. A daily Cloud Scheduler job (custody-g5-auditor, confirmed firing on its own natural schedule, not just a manual trigger) drives two things on one tick: it seeds an elapsed-time heartbeat record, and it runs the Provenance Auditor: a department can withdraw trust it granted (/demote), and the next sweep walks every outstanding demotion through the same deterministic CustodyGraph.revoke traversal G3 proves offline. An immediate reread between demotion and sweep proves the gap between "demoted" and "revoked" is real, not simulated.

The Custody Reviewer: Gemini 3.5 Flash on Vertex AI. A quarantined item (produced by the existing poisoning-detection logic) is handed to Gemini with an injected explain callable, and returns a Verdict, a human-readable summary of what the content attempted. Verdict structurally carries no trust or origin field, enforced by both a unit test and an AST-parse test that fails a future edit adding either import: there is no code path from a model response to a stored fact, matching the "no model decides a fact" discipline every other layer holds itself to. The live proof shows Gemini correctly explaining a real attempted export and reproducing a per-run random marker, proving it read the specific quarantined content rather than echoing a fixed string.

Selective live Memory Bank deletion: D1/D2. Memory Bank's stock ingest_events write path gives back no handle to delete a specific memory later, and a same-scope second write was observed silently overwriting an earlier memory in place. The fix is an additive, deterministic write path (memories.create(config={"memory_id": ...})) that gives every custody record a collision-free, revocable name; revoking a tool deletes exactly its descendants' memories from live search_memory results while sibling memories stay retrievable. G1's own live ADK Runner was migrated onto this same path, so it isn't a side door: it's now how the fleet actually writes.

The fleet at N=25. Twenty-five live department worker agents (sales, legal, hr, finance, engineering, procurement, marketing, support, compliance, security, product, design, data, infrastructure, research, customer_success, billing, payroll, recruiting, facilities, logistics, partnerships, privacy, risk, treasury), each a real ADK Runner/Gemini turn plus a tool-origin write, all sharing one CustodyMemoryBank instance and one process-wide derivation graph, not twenty-five isolated toy graphs. Each department runs sequentially against that shared graph, not concurrently, so this proves isolation holds across many real departments on one shared graph, not that it holds under concurrent multi-department writes, which this script does not exercise. Two departments (sales, finance) independently trust and invoke a tool with the same name; one revocation removes exactly those two departments' tool-origin memories while the other twenty-three stay untouched, independently confirmed by 25 separate live Memory Bank rereads, not the producer's own claim.

F1: a genuine live cross-department derivation chain. Sales writes a tool-origin fact and its own model restatement (one derived_from hop); support retrieves that exact text via a real load_memory call and earns a live, content-hash-matched derived_from edge into it (a second hop); finance does the same off support's restatement (a third hop); engineering performs one independent, unrelated write as a negative control. Revoking the chain's root tool removes all six chain-hop records across three departments, confirmed by a live reread, while engineering's independent memory and every department's unrelated conversational memory survive untouched.

Agent Observability: O1. ADK's own GCP OpenTelemetry export wraps a live admission in one span carrying the admitted record's digest as a custody.digest attribute. A real environment limit changed this proof's shape: this project's Cloud Trace read APIs don't expose a read/list path for the exported trace, so the falsifiable claim actually verified is that the span's trace ID, span ID, and digest are also recorded together in one server-authored Cloud Logging entry, stated exactly that way rather than rounded up to "verified in Cloud Trace."

The Dependency Cartography GUI (live, https://custody-incident-cave2.vercel.app/) renders the same blast-radius data as an interactive SVG node graph: click a node to inspect it, click "Revoke exact descendants," and watch the lineage flip to struck-through and the evidence panel refill, live. The companion Architecture & Evidence page reads every proof's own real captured artifact and computes a pass / stale / failing chip from that row's own offline judge, so the page reports its own staleness rather than asserting freshness.

These claims are evidence-gated rather than asserted. The final capture had make gates at 5/5 PASS and scheduler-gates at 10/10 PASS. The evidence page exposes twelve proof rows and labels each row PASS or STALE from its own artifact; R2 and D1/D2 are intentionally stale in the filmed snapshot rather than presented as fresh. Under injected failure: Memory Bank unreachable, Agent Registry timeout, and a malformed live tool surface are proven to fail closed across 13 dedicated tests.

Challenges we ran into

A schema match is not an identity match. Our first cut of revision admission pinned only a tool's tools/list schema. Live testing found it missed a same-schema, different-container-image swap, fixed by binding admission to the resolved Cloud Run image digest too. An allowed call and the surface read that authorized it aren't automatically the same event. That TOCTOU gap between admission-check and dispatch is real across process boundaries (R2), and needed a second, separate live proof with its own Firestore-backed nonce replay detection to close, rather than a stronger version of the first check. Server-side memory derivation breaks naive selective deletion. Memory Bank's stock ingest_events write path gives back no handle to delete a specific memory later, and a same-scope second write can silently overwrite an earlier one in place. Selective deletion needed an additive, deterministic memory_id-pinned write path instead of trying to delete through the existing one, then migrating G1's own live Runner onto it too, so the fix wasn't a side door. Cloud Trace's read API has no list/read endpoint for the traces we export. O1's observability proof had to change shape mid-build: instead of independently verifying storage in Cloud Trace itself, the falsifiable claim became the trace/span/digest binding recorded in Cloud Logging, stated as exactly that, not rounded up to a stronger claim we couldn't back. Elapsed-time can't be fast-forwarded, so we waited it out. One capability (the Provenance Auditor's multi-day trust span, G5) needs real calendar days between first deploy and revocation. It stayed BLOCKED rather than simulate a timestamp until enough days genuinely passed. At final capture on 2026-08-31, the Scheduler-backed admission span was 18 real days, scheduler-gates reported 10/10 PASS, and make gates reported 5/5 PASS. No test clock or fast-forward was used.

Accomplishments that we're proud of

The final capture included 409 offline tests with 0 failures and the independent live proof rows described above. Freshness is reported per artifact rather than collapsed into a permanent all-green claim. Proving fail-closed behavior, not just fail-open avoidance, under 13 dedicated failure-injection tests against a deliberately broken build. A revocation that survives replay: revoking the same compromised tool twice removes nothing further and appends no second audit record, proven at every scale from a 5-record fixture up to the live N=25 fleet. A live, interactive Dependency Cartography page that lets a judge click "Revoke exact descendants" and watch the blast radius redraw in real time, not a static screenshot standing in for the same claim. An Architecture & Evidence page that computes its own staleness from each proof's own offline judge, instead of asserting a fixed "evidence" label that could silently go stale without anyone noticing.

What we learned

Granularity, not detection, was the hard part. A derivation graph turns the unit of safe deletion from a user down to a record. On our own fixture that's the difference between destroying 100% and 7% of fleet memory to contain one compromised tool. A live command is worth more than a paragraph: every claim here is backed by a command that independently re-reads evidence from Google Cloud under resource identifiers the code itself owns. That discipline is what actually caught the real bugs above: an image-digest-blind admission check, a dispatch/surface-read TOCTOU, a demotion log whose cold-start survival needed its own offline proof, and a selective-deletion path that Memory Bank's stock write silently defeated. A prose-only writeup would have missed all of them, and we would have shipped every one of them wrong if we'd trusted the first version that "worked."

What's next for Custody

Extend toward a real enterprise pilot beyond the hackathon: proper multi-tenant auth in place of the current synthetic public proof endpoints, a stated retention and deletion policy, and pricing/onboarding for a fleet that wants this governing real production Memory Bank data rather than a fixture. E2D's mechanism handles free-form synthesis by refusal, capping any model paraphrase or fusion at INFORM so it can never authorize an action, rather than trying to untangle it. A cheap preliminary falsifier (branch research/rsm-crux-falsifier, pushed, not merged) tested one narrow input a real repair mechanism would need: can an LLM judge reliably tell whether a claim depends on a given source. Result: reliable, 95 to 100%, for ordinary attribution, and reliable, 0 of 8 false positives, on the harder redundant-support case only when support-mode is declared explicitly at claim-creation time, unreliable without it. This is real, narrow, positive signal for one input a claim-level repair mechanism would need, not a test of the hard problem itself (decomposing text a model has already fused) or evidence of novelty.

Update: the fleet grew a real multi agent story

Everything above was written before two more agents landed, both merged, tested, and live proven against real Vertex AI credentials since. Custody's fleet is now six roles, not four. Three are the governed population it protects, sales, support, and finance agents pulling from shared memory. Two draft language but never decide trust: the existing Reviewer, and a new Onboarding agent that turns a plain language department request into a structured, non authoritative vouch draft, still submitted through the existing deterministic endpoint by a human. One role is fully deterministic, the Auditor, sweeping demotions into revocations on schedule with no model involved. A new Escalation agent drafts the post revocation incident notice after the Auditor has already acted, again structurally barred from writing trust or origin data, the same AST enforced contract every drafting role in this repo holds itself to.

We also found and fixed a real bug in Google's own ADK Memory Bank service while building this: search_memory never populated MemoryEntry.custom_metadata, so paraphrased retrievals had no way to carry a structural identity forward. Filed and fixed upstream as google/adk-python #6946 and #6947, tested against ADK's full local unit suite: 13,493 passed, 0 failed.

Built With

  • agent-gateway
  • agent-identity
  • agent-registry
  • cloud-logging
  • cloud-run
  • cloud-scheduler
  • fastmcp
  • firestore
  • gemini
  • google-adk
  • iap
  • model-armor
  • pytest
  • python
  • sqlite
  • vercel
  • vertex-ai
Share this project:

Updates

Submission history