Forensic Audit Trail for Antigravity Background Agent — Cloudflare R2 logging that reconstructs decisions six months later
How to design a decision-log audit trail for long-running Antigravity Background Agents so you can still reconstruct why an agent did what it did six months later — schema, R2 write layer, PII masking, and Polars queries.
About four months into running Antigravity Background Agents in production, a different kind of question started showing up in my head. Not "is the agent working today?", but "what exactly was the agent looking at when it bumped that Remote Config value last month?" — the kind of question you only ask when you actually need to reconstruct context after the fact.
I have been running an independent app business since 2014 as Masaki Hirokawa (the artist behind Dolice). The wallpaper apps have crossed 50 million cumulative downloads, and AdMob revenue optimisation runs through a chain of Antigravity agents. Agent A flips a Remote Config flag, Agent B reads that flag to rebalance ad fill, Agent C writes the next morning's report. If even one link loses its reasoning trail, the monthly report stops making sense.
The artistic side of my work (17 international art prizes) and the indie-developer side share one habit: a respect for the long view. Decision logs are not glamorous infrastructure, but four months in, I believe they are the kind of foundation worth investing in before you wish you had.
Long retention changes what "good logging" means
For short-term debugging, plain text in Cloud Logging is fine. I ran on exactly that for the first few weeks. The system started to creak the moment I tried to revisit a decision from three months earlier.
The text log only said "set Remote Config adSlot_A to 0.65". The reasoning behind 0.65 — which metric window, which feature flags, which prompt, which model version — was gone. By that point the agent itself had no memory of the run either. The only way to bring that context back is to write it down in structured form at decision time.
My three guiding rules ended up being:
Record the inputs, not just the outputs (observed metrics, preconditions, candidate scores).
Pin prompt and model to immutable identifiers (hash, version tag).
Strip PII at write time. Retroactive anonymisation almost never works in practice.
A four-layer schema that stays under 12 KB
The schema I settled on splits each log line into four layers. Each line stays around 12 KB even with reasoning candidates included.
Burning the version into agent_id ("remote-config-tuner@v17") is the single biggest gift you can give your future self. Three months later you can pull the exact prompt template out of the matching Git tag.
The prompt_hash field stores the SHA-256 of the prompt itself. Storing the full prompt on every request inflates R2 cost fast, so I keep only the hash inline and resolve hash to prompt text through a separate immutable table. That deduplication cut my storage by roughly 28%.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦A 12 KB-per-request JSON schema and a Cloudflare Workers write path that batches into R2 every minute through a Durable Object buffer
✦Four Polars query patterns that let you answer 'why did this agent pick A over B back in January?' from R2-backed NDJSON shards
✦Twelve regex patterns for masking API keys, device identifiers, and store receipts at write time, so EU GDPR and App Store retention rules are met by construction
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
The R2 write path — Workers, Durable Objects, lifecycle
The API surface that Antigravity Background Agents call lives on Cloudflare Workers. The write path has three stages.
Step 1: Emit a structured line from the Worker
async function emitDecisionLog(env: Env, log: DecisionLogV2) { const masked = maskPII(log); // see below const line = JSON.stringify(masked) + "\n"; // Do not PUT to R2 directly; buffer through a Durable Object const id = env.LOG_BUFFER.idFromName("global"); const stub = env.LOG_BUFFER.get(id); await stub.fetch("https://buf/append", { method: "POST", body: line });}
Writing to R2 once per request quickly makes the PUT API cost matter (about $0.36 per 10,000 requests). Buffering through a Durable Object and flushing every 60 seconds — or whenever the buffer reaches 1 MB — turned out to be the right granularity.
dateShard() returns a 2026/05/28/04 prefix. Polars prefix scans become dramatically faster when the keys are partitioned down to the hour.
Step 3: Lifecycle keeps storage cost predictable
R2 Object Lifecycle moves objects to Infrequent Access after 90 days and deletes them at 180 days. Monthly reports must be produced within 90 days, so the back half lives on the cheap tier. With about 70 MB/day of log volume across my app business, that comes out to roughly ¥320 per month in storage cost.
Twelve PII patterns stripped at write time
Values pulled from App Store Connect and AdMob carry store receipts, device identifiers, country codes, and the low octets of IP addresses. Removing those retroactively is unrealistic; do it in the write layer. The 12 patterns I currently strip:
Fully zeroing the IP makes regional analysis impossible, so I only mask the low two octets. The upper two are enough to estimate European share roughly, which is also what GDPR data minimisation prefers.
Two gotchas worth flagging. First, LLM explanations sometimes embed strings that look like API keys; the same regex catches them, which is intentional. Second, when storing receipt, keep originalTransactionId in a separate hashed table — otherwise duplicate-purchase detection breaks. That one bit me in production, and I recommend planning for it from day one.
Four Polars queries that bring the past back
Once the schema and write layer are in place, the NDJSON in R2 reads cleanly with Polars. Four queries I rely on:
import polars as pl# Pattern 1: read one agent's day, sorted by timedf = ( pl.scan_ndjson("s3://bucket/decision/2026/01/14/*.ndjson") .filter(pl.col("agent_id") == "remote-config-tuner@v15") .sort("ts") .collect())# Pattern 2: rebuild an agent chain from a seed trace_idchain = ( df.lazy() .filter(pl.col("trace_id").is_in(seeds)) .join(df.lazy(), left_on="trace_id", right_on="parent_trace_id", how="left") .collect())# Pattern 3: list the rejected candidates (anything other than `chosen`)explored = ( df.lazy() .explode("reasoning.candidates") .filter(pl.col("reasoning.candidates.label") != pl.col("reasoning.chosen")) .select(["ts", "agent_id", "reasoning.candidates"]) .collect())# Pattern 4: same prompt_hash, different model — which choice dominated?shift = ( df.lazy() .group_by(["reasoning.prompt_hash", "reasoning.model"]) .agg(pl.col("reasoning.chosen").mode().alias("dominant_choice")) .collect())
Pattern 3 has been the most valuable. Because the agent recorded its rejected candidates, you can audit not only what was picked but what was nearly picked. A churn-prevention decision I made in January turned out to have two scores within 4% of each other; three months later that became the basis for a proper A/B test.
Four-month notes — things I would change next time
ULID was fine, but UUID v7 might have been better in hindsight. Once a Postgres-backed agent enters the picture, UUID v7 sorts more predictably in B-trees. Switching mid-stream is painful, so decide early.
Keep before/after as unknown deliberately. Tightening the types breaks writes the day a Remote Config value silently changes shape. A schema for an audit log should be generous to the writer and strict to the reader.
Avoid R2's flat list API at depth. Even at 70 MB/day, listing by hour-level prefix (2026/05/28/04) keeps Polars scan_ndjson fast. Without it, list operations become the bottleneck before the data does.
What I plan to add next
The schema feels stable at V2. The next thing I want to fold in is cost attribution — Antigravity Credits, LLM token counts, and Workers CPU time per decision — recorded on the same row. Cost optimisation gets dramatically easier when each agent's economics show up alongside its reasoning. Across four sites running in parallel, it should also let me allocate cost per property cleanly.
Audit trails are not glamorous, but they earn their keep once an agent system crosses the six-month mark. If you are running an indie AI-agent stack long term, I hope these patterns save you a weekend or two of regret. Thank you for reading.
Share
Thank You for Reading
Antigravity Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.