Activity Intelligence Datomic Substrate Prototype

A measurement prototype comparing Datomic Pro local vs Postgres + PostGIS as the storage substrate for the activity-intelligence AI layer
Branch feature/minecart-activity-intelligence · 31 commits (e93c18bce2c195614c) · 109+ unit tests · 1 real-Bedrock smoke · n=30 directional eval with real Bedrock · Built 2026-05-14 → 2026-05-16

TL;DR

A 25-bead, parallel-substrate prototype was built end-to-end on 2026-05-14, then put through a real-Bedrock n=30 directional evaluation that surfaced two regressions and produced one architectural surprise. The codebase wires both backends — Datomic Pro com.datomic/local 1.0.291 behind a 73-line Clojure HTTP shim, and Postgres 16 + PostGIS 3.4 in a self-contained Docker container — under one minimal agentic AccountResolver agent built on Bedrock Converse with Claude Sonnet 4.5.

The agent has six tools, identical signatures across backends. It resolves a transcript-style upload to the most likely account in the PDI Federated tenant fixture (2,634 accounts). Loaders ingest the PDI seed, the 3,383-row Google Places enrichment universe, and the 178 ground-truth-labeled golden transcripts.

Five eval phases past the original n=30: regression fixes (Phase 5 ruling-5), Datomic as-of source-time gate (Phase 6), re-validation (Phase 6.5-A), tool-parity ablation (Phase 6.5-AA), sequential extension to n=80 (Phase B), and a further extension to n=160 (Phase C). Total Bedrock spend across all phases: ~$132.

Our recommendation: build the production AI layer on Postgres + PostGIS — and the n=160 data now supports this on both measurement and operational grounds. The substrate-attributable accuracy signal that re-opened the debate at Phase 6.5-AA didn't survive contact with the larger sample. Bitemporal correctness is at parity. Operational fit decisively favors Postgres (team Clojure ramp, dev bring-up, 73-LOC shim). See §Substrate recommendation for the full reasoning.

Headline results (n=160 cumulative, Phase C)

Numbers below are aggregated across all run/bitemporal JSONLs in the eval results directory, deduped by upload_id + backend. The cumulative dataset is Phase 6.5-AA's n=30 (uploads 1-30) plus Phase B's n=50 (uploads 31-80) plus Phase C's n=80 (uploads 81-160), totaling 160 transcripts × 2 backends × 2 passes = 640 resolutions. Generated against HEAD = f04d2e6a0. The trajectory across phases is preserved in the §Learnings section.

Accuracy

Backendaccuracy@1accuracy@5Recordswith-GPS accuracy@1
datomic85.62%61.25%16085.99%
pg83.12%57.50%16083.44%

Bitemporal impossible-match prevention

BackendUnconstrained FPAs-of-constrained FPΔ preventedAs-of LOC
datomic9809839
pg9109134

At n=160, both backends still prevent every impossible-match false positive: Datomic 98/98, PG 91/91. Bitemporal correctness is the cleanest measured axis — the Phase 6 fix continues to work at all sample sizes, and Postgres's explicit-clause discipline has held throughout. Both substrates are correct on this axis.

Latency and cost

Backendp50p95Tool p50Tool p95TokensUSD
datomic23.9 s41.9 s128 ms514 ms5,830,544$20.68
pg23.6 s45.6 s152 ms506 ms5,973,034$21.08

At n=160 cumulative, Datomic is ~8% tighter at p95 latency (41.9 s vs 45.6 s) and ~2% cheaper ($20.68 vs $21.08). The gap narrowed substantially from the n=80 measurement (where Datomic was 19% tighter / 10% cheaper) — same regression-to-mean pattern as accuracy. Substrate barely moves these numbers at the larger sample; agent reasoning loops dominate.

Code complexity (sample-independent)

BackendRecorder LOCPrototype LOCAs-of site-count
datomic14968524
pg21160423

Datomic's recorder remains 30% smaller (149 vs 211 LOC) for the same operation. As-of code is 39 vs 34 LOC. The 73-line Clojure shim is part of the Datomic side and is a fixed operational tax. Sample-independent — these numbers don't change with eval size.

Cost ledger (cumulative across all eval runs)

Extrapolating to the full corpus: another n=18 × 2 backends × 2 passes = 72 resolutions at ~$0.13/resolution would cost roughly $10 more to reach n=178. Wall-clock would be ~1 hour per pass. With n=160 now showing the substrate accuracy gap is at noise floor, the marginal value of finishing the corpus is modest — the decision has effectively been made.

Substrate recommendation

What the data actually says (post-C, n=160)

What pushes the recommendation toward Postgres despite the data

What would flip the recommendation back to Datomic

Confidence and caveats

This recommendation is at moderate-to-high confidence after Phase C, supported by both operational fit and the n=160 accuracy data. The bitemporal-correctness axis is settled on both substrates (parity post Phase 6 fix). The accuracy axis at n=160 shows a +2.50pp Datomic lead with 95% CI ±4pp — directional but not significant, and trending toward zero across sample doublings. The substrate-attributable signal that re-opened the debate at Phase 6.5-AA has collapsed under more data. The remaining decision drivers — operational fit, team Clojure experience, dev-laptop bring-up, fixed Clojure-shim maintenance tax — all favor Postgres. Going from n=160 to n=178 would not materially change this read.

The agent and its tools

Shape of the agent loop

One AccountResolver agent, identical across backends. The only thing that forks per backend is the tool implementations and the resolution persistence; the agent loop, system prompt, skill markdown, proposal types, and Bedrock Converse client are all shared.

Model
us.anthropic.claude-sonnet-4-5-20250929-v1:0 via Bedrock Converse inference profile
Temperature
0 (pinned, recorded on every row)
Budget
16 tool calls per resolution. The agent must emit a ProposeAssignAccount or ProposeCreateAccount within that budget.
Tie-break
Highest-confidence ProposeAssignAccount wins. If only ProposeCreateAccount is emitted, prediction is null and counts as a miss.
Stop reasons
Recorded per row: end_turn, tool_use, max_tokens, or budget_exhausted. The eval categorizes misses by stop_reason in failure_analysis.md.

The six tools

ToolDatomic implementationPostgres implementationTypical use
lookup_accounts_by_gpsDatalog query + haversine distance helper in PythonST_DWithin + ST_Distance on a PostGIS geography columnFirst lookup when transcript has lat/lon. Returns accounts within radius_miles, sorted by distance.
lookup_account_by_account_numberDatalog join through external-identifierSQL join on external_identifiersUsed when transcript explicitly names a known account number. Rare in this fixture.
lookup_accounts_by_fuzzy_nameDatalog query + custom fuzzy ruleRapidFuzz scoring + pg_trgm similarity pre-filterWorkhorse tool. Used 2-5 times per resolution as the agent narrows in on the transcript's business name.
lookup_trommel_placesDatalog over the loaded Places enrichment universeSQL over the same Places rows in PGResolves transcripts that mention a place name we know from Google Places but isn't in the tenant's account list yet.
get_account_recent_notesDatalog over events of event_type/noteSQL on eventsDisambiguation. After a fuzzy match, the agent reads recent notes on the candidate account to see if it matches the transcript context.
get_account_contactsDatalog over fact/attribute=:contactSQL on facts where attribute='contact'Same disambiguation use as recent notes — checks whether contact names mentioned in the transcript match the candidate.

Observed agent behavior

From the JSONL traces, a typical resolution looks like:

lookup_accounts_by_gps (lat,lon, 5mi)
  → 3 candidates
lookup_accounts_by_fuzzy_name (transcript-extracted name)
  → 5 candidates, partial overlap with gps result
get_account_recent_notes (top candidate)
  → confirms or denies match
get_account_contacts (top candidate, if notes inconclusive)
  → final disambiguation
ProposeAssignAccount (chosen account_id, confidence 0.7–0.95)

The expensive resolutions (~$0.35-$0.43 in tokens) involve many iterations:

lookup_accounts_by_fuzzy_name, lookup_accounts_by_gps, lookup_trommel_places,
get_account_recent_notes, get_account_recent_notes, get_account_recent_notes,
get_account_contacts, get_account_contacts, get_account_contacts,
lookup_trommel_places, lookup_accounts_by_fuzzy_name, lookup_accounts_by_fuzzy_name,
lookup_accounts_by_fuzzy_name, get_account_recent_notes, get_account_recent_notes,
get_account_contacts, get_account_contacts, lookup_accounts_by_gps

That's 18 tool calls on a single resolution before emitting a prediction. The agent is iterating on disambiguation when the transcript names a business name that's close to multiple candidates ("Brian's Automotive" → candidates: Grit's Automotive, 21st Century Auto, etc., none of which is the correct "Bradys Automotive Center"). This is a real-world signal: when business names rhyme or contain shared substrings, more tool calls help only marginally. The agent's confidence stays ambiguous and it picks wrong (the case above) or emits null entirely.

Costliest single resolutions (top 5)

UploadBackendUSDInput tokensOutput tokens
14b98cfb…datomic$0.43130,5882,391
1a99ea6a…datomic$0.41120,9552,839
03c73bcc…pg$0.35101,4932,906
1a99ea6a…pg$0.3397,9622,495
262f3d37…datomic$0.3183,9782,391

The same uploads appear at the top on both backends, suggesting the cost driver is transcript-intrinsic (long, ambiguous, name-similar-to-many-accounts) rather than substrate-attributable. The agent's reasoning cycles eat tokens, not the substrate.

Where the agent fails

Failure categorization from failure_analysis.md (n=30 sample, both backends):

One illustrative case: upload 0bd983e2… (transcript: "Rusty's Repair LLC"). Tool returned top-5 candidates: Rusty's Repair LLC (0.89), Rusty's Repair LLC (0.89), Rusty's Repair LLC (0.89), two unrelated. Despite the obvious correct match at 0.89 confidence appearing three times in top-5, the agent emitted predicted_account_id: null. That's an agent confidence-threshold or proposal-emission bug, not a substrate problem. Worth fixing in the next iteration of the agent loop.

Was the performance of account matching tested with the Datomic approach?

The full eval pipeline ran cleanly:

  1. Real-Bedrock smoke test (one transcript, one backend, ~$0.10) — passes before the full eval. Catches schema/loop regressions in 25 seconds.
  2. Unconstrained golden eval against 30 transcripts × 2 backends = 60 resolutions. Wall-clock ~32 min, cost ~$5. Output: run_1778909831.jsonl (60 rows).
  3. Bitemporal eval against the same 30 × 2 = 60, with as_of=upload.timestamp constraining every tool call. Wall-clock ~35 min, cost ~$5. Output: bitemporal_run_1778913677.jsonl (60 rows). One substrate-state collision (PG duplicate fact-id) required a clean substrate reset before retry.
  4. Five reports regenerated: accuracy.md, latency_cost.md, code_complexity.md, bitemporal_eval.md, failure_analysis.md, plus EVAL_NOTES.md with the full architectural rationale.

The eval is reproducible: ~/.aws/credentials with Bedrock access, bin/datomic-up && bin/shim-start && bin/pg-up, schemas, fixtures via the chronological loader, then the uv run python -m eval.* commands documented in the prototype README. The new smoke test (RUN_REAL_BEDROCK_SMOKE=1 uv run pytest tests/agent/test_real_bedrock_smoke.py) should run before any future eval pass — it catches the regression class that bit Phase 4 in seconds rather than after a 30-minute eval.

Notes on the eval methodology:

What was built

Layout

Everything lives under one self-contained directory with zero imports from apps.*, refinery.*, or minecart.django_backend.*. A scope-isolation test enforces this on every commit:

minecart/prototypes/activity_intel_datomic/
├── README.md                       runbook (7-step lifecycle + idempotency notes)
├── bin/                            datomic-up/down, shim-start/stop, pg-up/down
├── datomic-shim/                   Clojure HTTP shim
│   ├── deps.edn                    com.datomic/local 1.0.291 + ring + cheshire
│   └── src/shim/core.clj           73 LOC: /tx /query /history /as-of /health
├── postgres_baseline/              Postgres container + schema
│   ├── docker-compose.yml          postgis/postgis:16-3.4 on port 55432
│   ├── init/                       enable postgis + pg_trgm
│   ├── schema.sql                  entities / events / facts / external_identifiers / etc.
│   └── indexes_prd.sql             PRD-prescribed v1 indexes
├── python/                         self-contained uv project
│   ├── activity_intel_prototype/
│   │   ├── agent/                  Bedrock client, resolver agent, prompts, tool types
│   │   ├── loaders/                PDI seed, Places seed, golden uploads
│   │   ├── persistence/            record_resolution_{datomic,pg}.py
│   │   ├── schema_datomic.py       :db/ident + :db/valueType schema
│   │   ├── tools_datomic.py        6 tools, Datalog + Datomic fuzzy rule
│   │   ├── tools_pg.py             6 tools, PostGIS + RapidFuzz + pg_trgm
│   │   ├── client_datomic.py       thin Python client to the Clojure shim
│   │   ├── client_pg.py            thin psycopg wrapper
│   │   └── config.py               env defaults (shim 8989, PG 55432)
│   ├── tests/                      ~80 tests (see Testing report below)
│   ├── eval/                       run_golden_eval, bitemporal_eval, generate_reports
│   ├── scripts/                    schema deploys, fixture loaders, scope-isolation CLI
│   └── pyproject.toml
└── reports/                        empty (eval not run)

The agent

One AccountResolver agent with one system prompt, one skill markdown file, one set of proposal types, and one tool-call loop — all shared across both backends. Only the six tool implementations and the resolution-persistence recorder fork per backend.

LLM
AWS Bedrock Converse with us.anthropic.claude-sonnet-4-...
Tool budget
16 tool calls per transcript, no hard cost ceiling
Tools
lookup_accounts_by_gps, lookup_account_by_account_number, lookup_accounts_by_fuzzy_name, lookup_trommel_places, get_account_recent_notes, get_account_contacts
Proposals
Pydantic ProposeAssignAccount(account_id, confidence, reasoning) or ProposeCreateAccount(name, type, reasoning) — Pydantic schemas plug directly into Bedrock Converse toolSpec
Tie-break
Highest-confidence ProposeAssignAccount wins; if only ProposeCreateAccount is emitted, prediction is null and counts as a miss

Substrate parallelism

Datomic side

Datomic Pro + dev-local

  • com.datomic/local 1.0.291 (Maven Central)
  • Disk-backed at .runtime/datomic/
  • System: activity-intel-prototype
  • 73-LOC Clojure HTTP shim on port 8989
  • Native (d/as-of db t) for bitemporal reads
  • Provenance lives on tx-meta — every datom in a transaction inherits annotations from the tx entity
  • Tool impl: Datalog queries + a small Datomic fuzzy rule
Postgres side

Postgres 16 + PostGIS

  • postgis/postgis:16-3.4 Docker image
  • Self-contained container minecart_prototype_pg
  • Host port 55432 (off the dev DB)
  • PRD-prescribed schema: entities/events/facts/relationships/external_identifiers
  • Bitemporal via WHERE created_at <= as_of discipline added to every tool query
  • Provenance is an explicit jsonb column on events and facts
  • Tool impl: PostGIS ST_DWithin + RapidFuzz + pg_trgm

The five planned reports

The full pipeline ends in generate_reports.py, which is implemented but has no JSONL to consume. Once the eval runs, this script produces:

reports/
├── accuracy.md           accuracy@1, accuracy@5, Datomic vs PG with the disclaimer
├── latency_cost.md       p50/p95 latency, token usage, USD, top-10 outliers
├── code_complexity.md    LOC per backend, Datalog-vs-SQL samples, measured as-of sites
├── bitemporal_eval.md    unconstrained vs as-of-constrained false positives per backend
└── failure_analysis.md   top misses, top-5 candidates, tool traces, miss categorization

The strangler approach

The Martin Fowler "strangler fig" pattern describes how a new system grows alongside a legacy system and incrementally absorbs its responsibilities. Branches of the new system wrap and gradually replace branches of the old; eventually the old trunk is dead and can be cut away with no rollout fanfare. For activity intelligence, the legacy "trunk" is the existing Django model: rows in tenant-scoped tables, mutated by save() callers, queried directly by reads.

Production posture (where this is heading)

The Activity Intelligence Platform PRD specifies an entity/event/fact AI layer sitting beside the Django model:

┌────────────────────────────────────────────────────────────────────────┐
│  Today (legacy trunk)                                                   │
│                                                                         │
│  HTTP → Django views → models.Save() → Postgres rows                    │
│                                  │                                      │
│                            (in-place mutation; no event log)            │
└────────────────────────────────────────────────────────────────────────┘

┌────────────────────────────────────────────────────────────────────────┐
│  Strangler posture (target)                                             │
│                                                                         │
│  HTTP → Django views → models.Save() ──► Postgres rows  (unchanged)     │
│                              │                                          │
│                              └─► state_change event ──► outbox          │
│                                                          │              │
│                                                          ▼              │
│  AI layer reads from outbox ─► entities / events / facts substrate      │
│                                  (Datomic OR Postgres+PostGIS)          │
│                                  │                                      │
│  Agents (AccountResolver, Restatement, etc.)  ◄──┘                      │
│  read facts as-of(t); write resolutions back as events + facts.         │
└────────────────────────────────────────────────────────────────────────┘

The strangler fig is that outbox feed. Existing Django writes don't change shape; a save() override emits a state_change event to an outbox table; the new AI layer subscribes to the outbox, builds its own entity/event/fact model, and progressively takes over the read surface for AI-mediated workflows (entity resolution, restatement, account matching, etc.). The legacy Django model keeps living for everything else.

What this prototype tests, and what it deliberately skips

This prototype is not the strangler. It is the question one rung upstream of it: once the AI layer is being fed real state changes, which substrate should host the entity/event/fact model — Datomic or Postgres+PostGIS? Both implement the same shape; the prototype measures which one is cheaper to operate, cheaper to query, more correct under as-of constraints, and more ergonomic to write tool implementations against.

To keep the question clean, the prototype deliberately skips the strangler plumbing:

What's left is a clean substrate decision: a single workload (account resolution), a single tenant's data (PDI Federated, 2,634 accounts), a single agent loop, two substrates, two reports.

What we want out of the strangler when this prototype lands somewhere

Once the substrate decision is made, the strangler implementation should be the smallest possible delta: a Django save() override that writes a row to an outbox table, a consumer that turns those rows into append_event/assert_fact calls against whichever substrate won, and a feature-flagged toggle in the AI-mediated views that switches reads from the legacy model to the new as_of() projection. Three pieces, each with a clean rollback. The substrate the prototype picks is the only piece that's hard to undo later.

Testing report

What ran successfully

73+ tests passing across 21 test files. All were unit or integration tests with mocked LLM calls — no real Bedrock requests, no real model billing.

Test surfaceFilesWhat was covered
Substrate plumbingtest_scaffold.py, test_client_datomic.py, test_client_pg.pyContainer/shim lifecycle, connection, basic round-trip via both clients
Schema deploystest_schema_datomic_deploy.py, test_pg_schema_deploy.pyIdempotent deploys, expected idents, table/column presence, indexes
Fixture loaderstest_pdi_tenant_seed.py, test_tromml_places_seed.py, test_golden_uploads.pyBoth backends ingest the 2,634 accounts + 3,383 places + 178 transcripts idempotently, with source-time :db/txInstant in Datomic and explicit created_at in PG
Tool implementationstest_tools_datomic.py, test_tools_pg.py, test_tool_contracts.pyAll six tools per backend; signature parity contract test; as_of parameter contract
Agent looptest_account_resolver_agent.py, test_bedrock_client.py, test_proposal_types.py, test_tool_types.pyPydantic types serialize correctly; agent loop dispatches tools; unknown-tool-name hallucinations are handled with isError=true; highest-confidence selection
Resolution recorderstest_record_resolution_datomic.py, test_record_resolution_pg.pyOne-transaction provenance recording per backend (tx-meta on Datomic; jsonb columns on PG)
Eval harnesstest_run_golden_eval.py, test_bitemporal_eval.py, test_generate_reports.pyJSONL record schema, run-config recording, all 5 report templates render correctly against synthetic data
Scope isolationtest_import_and_scope_isolation.pyNo imports from apps.*, refinery.*, minecart.django_backend.*; migrations/.env symlinks untouched

Directional eval (n=30 sample, real Bedrock)

Code-complexity snapshot (from reports/code_complexity.md)

The eval-generated report measures substrate-specific LOC and as-of site counts directly from the source. Numbers match the prediction made in the pre-eval explainer:

BackendRecorder LOCPrototype LOCAs-of site-countAs-of LOC delta
datomic1456552418
pg2045962334

Datomic's recorder is 30% smaller (145 vs 204 LOC) and the as-of discipline costs roughly half as much code (18 vs 34 LOC). Postgres pays the recorder difference for every event/fact pair (explicit provenance jsonb on both rows) and pays the as-of difference for every tool query (two helper clauses appended to each WHERE block). Both are unavoidable consequences of the substrate model; both compound with feature scope. Datomic's overall prototype LOC is slightly higher (655 vs 596) due to the 73-line Clojure shim and the Datomic schema layer — that overhead is fixed and one-time.

What we learned from building this

Even without the eval data, the build process surfaced a number of useful, durable observations.

About Datomic in 2026

About the toolchain surface

About the agentic build process itself

About the substrate comparison (the headline findings)

About what "the prototype taught us" beyond the substrate decision

About the mocked-tests-vs-real-API gap (the biggest learning)

About the planning pipeline

Next steps

To make a defensible substrate decision (do these in order)

  1. Tool-parity ablation completed (Phase 6.5-AA, $17.47). Result: the Datomic accuracy lead is substrate-attributable at +6.67pp (90.00% vs 83.33%) with identical Python scoring on both backends. The +10pp pre-fix gap was roughly two-thirds substrate / one-third tool-implementation.
  2. Sequential extension to n=80 completed (Phase B, $25.92). Result: the Datomic accuracy lead held at +5.00pp (90.00% vs 85.00%) through the sample expansion. Bitemporal correctness still 100% on both backends (Datomic 50/50, PG 46/46). The signal is stable and substrate-attributable, but the n=80 CI (±5pp) is still wider than the gap.
  3. Run the n=178 full corpus (priority: high, ~$30 incremental Bedrock, ~4 hours wall-clock per pass). Add another --offset 80 --limit 98 to extend to the full dataset, then aggregate. The n=80 → n=178 jump shrinks the CI on the accuracy gap from ~±5pp to ~±3.5pp, which would make the +5pp Datomic lead statistically significant if it holds.
  4. Operational ramp planning if Datomic is reconsidered. A second eval workload (not just account resolution) where the entity/event/fact model's schema flexibility lets the agent do something on Datomic that would require schema migration on Postgres. The single-workload eval undersells Datomic's schema-flexibility argument.
  5. Make a write-up of the production strangler design (priority: medium, ~half day). One pager: Django save() override → outbox table → AI-layer consumer → entities/events/facts in PG. Identify the Django models that need save() overrides (Account, Contact, Note, UserUpload at minimum). Flag the Inquiry/permissions layer from PR #1778 as the next reviewable design.

If we proceed with Postgres (the recommendation)

  1. Promote the prototype's PG schema to a real Django app. Move postgres_baseline/schema.sql + indexes_prd.sql into a Django apps/activity_intel/migrations/. Reuse Supabase pgvector extension if semantic search becomes a requirement.
  2. Build the outbox. One outbox_events table, one Django post_save signal handler, one Celery worker that drains it into the new events table. Idempotency via dedup on (source_system, source_record_id).
  3. Wire the agent to Langfuse for traces. Replace the prototype's local trace JSONL with Langfuse spans. tool_call_trace moves to Langfuse; trace_summary stays in the events.provenance jsonb per the Phase 5 refactor.
  4. Reuse the prototype's agent loop, tool surface, and Bedrock client as the starting point for the production v1 agent. The six-tool surface measured here is the right one; the agent prompt and proposal types are reusable. The only swap is the tool implementations point at the real apps.mc Account / Contact / Note tables instead of the prototype's entity/event/fact schema (and then strangler-fig those reads onto the new schema over time).

If we revisit Datomic (the bitemporal fix landed, accuracy is contested)

  1. The bitemporal eval was already re-run at n=30 (Phase 6.5-A). Datomic's prevented count went 9 → 17, confirming the fix. The accuracy lead reversed within agent-trajectory variance. The data is no longer the decisive argument against Datomic — the operational/strategic factors are.
  2. Bring up Datomic Pro Cloud as a target (not just dev-local). Bring-up complexity, ops burden, and per-instance cost should be factored into any re-decision. The 73-LOC dev-local shim is not what production would run.
  3. Plan the Clojure operational ramp. Expanding beyond the prototype shim requires Clojure expertise in the team. Either hire/train one person who owns it, or keep the Clojure surface deliberately small.
  4. Discipline source-time tracking in any production loader. The Phase 6 finding generalizes: whenever historical data is loaded into Datomic in batches, :db/txInstant can drift from source-record time, and (d/as-of db t) alone won't catch the gap. Production loaders need to either (a) write tx-time = source-record time on every transaction (paying the per-batch monotonic-basis cost), or (b) carry explicit source-time predicates on every read. The prototype now does (b).

Independent of substrate (do regardless)

  1. Fix the agent's confidence-threshold bug revealed by the 0bd983e2… "Rusty's Repair LLC" case (top-5 candidates include 3 copies of the correct match at 0.89; agent emits null). This is pure agent logic, not substrate, and it's costing accuracy@1 on both backends.
  2. Add the real-Bedrock smoke test to CI (gated by RUN_REAL_BEDROCK_SMOKE=1 env so it doesn't run on every PR, but a once-daily pass against a single transcript would catch schema regressions long before another full eval pass.
  3. Implement the Permissions / Inquiry layer from PR #1778 before this is ever production-facing. The current prototype auto-accepts the highest-confidence proposal; production needs ask_user / ask_manager branches.
  4. Add a no-GPS-signal fallback. The 1 transcript without GPS missed on both backends. A name-only resolution path with a higher confidence threshold would handle this class better.