- Go service
- two lanes, one contract
- reads from a replica
- the shopper always pays
The problem
AI assistants are starting to shop for people. A shopper asks for "a waterproof jacket under a budget", and an agent platform wants to search a store, build a basket and get the person to checkout. For a managed hosting platform for online stores, that raises three questions:
- How does an agent read a store's catalogue without slowing the store down?
- How do agent platforms get an accurate product feed?
- How does a basket built by a machine become an order paid for by a person?
My answer to the last one shaped everything else: the agent never pays. There is no order, payment, refund or coupon tool. They are absent, not disabled. The agent builds a basket and hands over a link, and the shopper finishes on the store itself.
Two lanes and a contract
The work had two parts that ship separately: a shared Go service, and a plugin that runs inside each store. I planned it as two lanes with a contract between them: versioning, request signing, the handful of calls the plugin makes to the service, and the format of the hand-off link.
The contract came with fixtures, not just prose. The service lane shipped a fake service and a set of test vectors for the hand-off link: valid, expired, tampered and edge cases. One vector uses timestamps so far apart that subtracting them overflows a 64-bit integer, so the opener has to compare, never subtract. The plugin lane opens every vector in its own language and gets identical results. Each side built against a fake of the other, and joint checkpoints on a real store proved they fit.
Its own database and job runner
The service has its own MySQL database, its own identity that can reach only its own instances, and a default-deny network policy. Nothing it does needs the store's credentials.
Migrations are additive-only by allowlist. A failed deploy rolls back pods, not the schema, so a migration that drops a column cannot be undone by rolling back. There are no foreign keys on purpose: a cascade would turn small bounded deletes into one huge one.
Background work runs on a small job runner. Jobs are claimed per store and per kind with SKIP LOCKED, leased, renewed and handed back on shutdown. A claim counts as a failure until the run says otherwise, so a job that crashes its pod is retried with backoff instead of looping.
// a claim is a failure until proven otherwise
job := claim(ctx, store, kind)
defer job.ReleaseAsFailed()
if err := run(ctx, job); err == nil {
job.MarkDone()
}Kill switches per store and per channel skip work rather than fail it.
Reading the store from a replica
Every read comes from the store's analytics replica, through a SELECT-only database user. An agent's search never reaches the live store.
- Change detection runs hourly. Some product tables have no "updated at" column, so prices and stock are compared as digests page by page.
- Catalogue lookup answers search and cart-line validation from the replica, in a connection pool sized to the read user's limit.
- The feed builder produces a product feed hourly, then spot-checks a sample against the store's live cart API. Too many differences or missing rows hold the build, and the last good feed stays live.
agent platform ──▶ service ──read──▶ replica
│
├─ basket built via the store's cart API
▼
hand-off link ──▶ shopper's browser ──▶ checkoutIf a store displays prices differently from how it stores them, the feed build is skipped: the price then depends on the shopper's location, which a replica cannot know. An honest gap beats a wrong price.
Agents reach the service through a gateway that verifies signed requests and signs its replies. A basket with items always ends the same way: "this needs a human", plus a link. Order events flow back to agent platforms as webhooks, and an older event never arrives after a newer one.
Stats and alerts
The service records per-agent stats off the request path, so counting never slows a shopper down. Alerts are built from log lines, and the tests produce the real lines and commit them as fixtures. The alert filters are tested against those fixtures, so a renamed log field breaks a test, not an alert. Every per-store alert names the store, marked "unverified" when the name came from a URL before the signature was checked.
What I'd do differently
- Settle the contract before the first service code. Parts of the service were built against a draft and then moved onto the agreed contract. Waiting a little longer would have saved a whole alignment change.
- Run the real integration early. An integration test against a real store caught a session bug that a full set of green unit tests could not see. That test belongs at the start.
Takeaway
Let the agent do the browsing and the basket. Keep the payment with the person. With that one rule fixed, the rest is ordinary engineering: a contract with fixtures, reads from a replica, jobs that fail safely, and alerts tested like code.