Architecture
Sarvex V1 is a single Go binary that runs as one of three planes (serving, sync, control), a PHP WooCommerce plugin that runs on the merchant's own server, and a Vue 3 web console for merchants and…
Generated from sarvex-v1-architecture.md in the Sarvex repository — edit the source there, not this page.
In one sentence: Sarvex makes a WooCommerce or Magento merchant’s products transactable by AI shopping agents over the Universal Commerce Protocol (UCP) — Sarvex hosts the merchant’s UCP profile and checkout endpoints, keeps their data agent-ready, and monitors the integration so it never silently breaks.
Scope authority: this document describes the architecture as currently implemented in this repository. It is organised into a Backend Architecture part (the Go services, plugin, and shared packages) and a Frontend Architecture part (the Vue console), followed by explicit Gaps, Risks, and Dependencies sections. Where the code and this document disagree, the code wins and this document is the bug.
Status: implementation-derived architecture · regenerated from the current tree · supersedes prior design-session drafts
Table of contents
- Part 0 — System overview
- Part A — Backend architecture
- Part B — Frontend architecture
- Part C — Gaps
- Part D — Risks
- Part E — Dependencies
Part 0 — System overview
Sarvex V1 is a single Go binary that runs as one of three planes (serving, sync, control), a PHP WooCommerce plugin that runs on the merchant’s own server, and a Vue 3 web console for merchants and operators. The planes are separated by availability, latency, and trust characteristics and communicate through a NATS JetStream event bus (Watermill), never by direct in-process cross-plane calls.
flowchart TD
AG["🤖 AI shopping agents<br/>(UCP)"]
MER["👤 Merchant / Operator"]
STORE["🏪 WooCommerce store<br/>+ Sarvex PHP plugin"]
subgraph FE["FRONTEND"]
CON["Vue 3 console<br/>web/console"]
end
subgraph BE["BACKEND (single Go binary, --service selects plane)"]
SERV["SERVING plane<br/>internal/planes/serving"]
SYNC["SYNC plane<br/>internal/planes/sync"]
CTRL["CONTROL plane<br/>internal/planes/control"]
end
subgraph INFRA["Shared infrastructure"]
MONGO[("MongoDB")]
REDIS[("Redis")]
BUS[["NATS JetStream<br/>(Watermill)"]]
KMS["Vault<br/>(Transit + KV v2)"]
STRIPE["Stripe"]
KINDE["Kinde IdP"]
end
AG -->|"GET /.well-known/ucp"| STORE
AG -->|"UCP API + checkout (direct)"| SERV
STORE -->|"signals · heartbeat · profile fetch (HMAC)"| SYNC
SERV -->|"live stock/shipping/tax/order at checkout"| STORE
MER --> CON
CON -->|"REST + WebSocket (bearer)"| CTRL
SERV --- REDIS
SERV --- MONGO
SYNC --- MONGO
SYNC --- REDIS
CTRL --- MONGO
SERV -.-> BUS
SYNC -.-> BUS
CTRL -.-> BUS
SERV --- KMS
CTRL --- STRIPE
CTRL --- KINDE
Runtime technology (from go.mod / package.json):
| Concern | Choice |
|---|---|
| Language / runtime | Go 1.25 (backend), TypeScript / Vue 3 (frontend), PHP (plugin) |
| HTTP framework | labstack/echo/v4 |
| CLI / config | spf13/cobra + spf13/viper, .env via joho/godotenv |
| Primary datastore | MongoDB (go.mongodb.org/mongo-driver/v2) |
| Cache / sessions | Redis (redis/go-redis/v9) |
| Event bus | NATS JetStream via ThreeDotsLabs/watermill (+ noop bus for unit tests; the same NATS runs locally via Docker Compose) |
| Signing / crypto | Vault Transit wraps store signing keys (hashicorp/vault/api), RFC 9421 HTTP message signatures (ucpsig) |
| Secrets | Vault KV v2 (hashicorp/vault/api) |
| Billing | Stripe (stripe/stripe-go/v72) |
| Identity provider | Kinde (kinde-oss/kinde-go backend verify, @kinde-oss/kinde-auth-pkce-js frontend) |
| Realtime | WebSocket (gorilla/websocket) |
| Frontend build | Vite, Pinia, Vue Router, axios, openapi-typescript |
| Deploy | Self-hosted Kubernetes (k3s): Helm charts + Argo CD GitOps + Cloudflare Terraform (deployments/selfhost); Docker Compose runs the same backing services locally (deployments/docker-compose.yaml) |
Part A — Backend architecture
A1. Process model & entrypoint
The backend is a single binary with three roles. main.go calls cmd.Execute() (cmd/root.go), a cobra root command that reads a required --service flag and starts exactly one plane:
--service=serving→servingapi.NewServingPlaneEntry(...)--service=sync→syncapi.NewSyncPlaneEntry(...)--service=control→controlapi.NewControlPlaneEntry(...)
Config is layered by viper: --configPath + --env resolve a configs.<env>.json file; --port, --tls are flags. Each plane’s entry.go wires its dependencies (datastores, bus, services) and registers Echo routes. This is why the planes cannot import each other in-process — they are separate deployables of the same binary (internal/planes/doc.go encodes the “never import another plane” rule).
Other cmd/ binaries are tooling, not part of the runtime: cmd/sarvex (merchant onboarding driver + UCP agent / conformance runner), cmd/sim (a persistent fake WooCommerce store for local dev), cmd/gateway-cli, cmd/verify-routes.
A2. The three planes
| Plane | Package | Character | Serves |
|---|---|---|---|
| Serving | internal/planes/serving |
Always-on, low-latency, agent-facing | /.well-known/ucp profile, UCP catalog reads, checkout sessions |
| Sync | internal/planes/sync |
Event-driven, eventually consistent, retryable | Connector ingest (/connector/*), catalog import/scoring, cache rebuild, surface artifacts |
| Control | internal/planes/control |
Interactive, authenticated humans, iterates daily | Console/operator REST API + WebSocket |
The plane boundary is the load-bearing invariant (internal/planes/doc.go, sarvex-v1-architecture.md §4.1 in prior drafts). Cross-plane communication is exclusively through the event bus. Mechanical enforcement via linter is documented in ADR-0004.
A3. Serving plane
Package: internal/planes/serving (+ .../serving/api for HTTP).
HTTP surface (serving/api, from route registration):
| Method + path | Purpose |
|---|---|
GET /.well-known/ucp |
The UCP business profile (per store). Served with the hosting rules the spec mandates. |
POST /catalog/search |
Agent catalog search (cache-only). POST per the reconciled ucp.dev REST transport (search_catalog), not GET+query params. |
GET /catalog/:itemId |
Agent product lookup (cache-only). |
POST /checkout-sessions |
Create a checkout session (live stock + pricing). |
GET /checkout-sessions/:checkoutId |
Read current session state. |
PUT /checkout-sessions/:checkoutId |
Full-replacement update (buyer / fulfillment / selection); live shipping + tax. |
POST /checkout-sessions/:checkoutId/complete |
Accept opaque payment credential + handler, create the real order, return the order. |
POST /checkout-sessions/:checkoutId/cancel |
Cancel a non-terminal checkout session. Repeated cancellation is idempotent success; cancelling a completed/failed session is a conflict. |
GET /orders/:orderId |
Read the order a checkout session’s complete placed — line items, fulfillment expectations/events, adjustments, totals. |
PUT /orders/:orderId |
Append a fulfillment event and/or an adjustment. Both are append-only logs; resending a known ID is a no-op. |
The checkout HTTP surface is now the five operations earlier design drafts described (create/get/update/complete/cancel) — closing G1 (below). Orders is a new resource on top of the same checkout session record: a completed CheckoutSession doubles as Sarvex’s record of the order it placed (serving/order_service.go), rather than a second, separately-owned store — GetByOrderID (a secondary index alongside the session store’s existing idempotency-key index) is the read path both GET /orders/:id and the order-negotiated capability (below) depend on.
Why orders exists: the dev.ucp.shopping.order capability was not part of V1’s original checkout-first cut, but the official UCP conformance suite’s reference shopping agent declares it unconditionally on every negotiated request (its fixture profile carries no other capability) — so its absence failed capability negotiation for checkout itself, not just order-specific calls. Declaring it required a route behind it, per the same rule that had kept cart/identity_linking out of the V1 profile (internal/pkg/ucp/catalogue/loader.go’s v1ImplementedCapabilities).
Components:
- Edge/handler + pipeline (
api/handler.go,api/pipeline.go,api/dependencies.go) — terminates agent requests, resolves the store, verifies/signs viaucpsig, runs negotiation. - Profile renderer + source (
profile_renderer.go,profile_source.go) — projects the definitions catalogue + per-store data into the rendered UCP profile. - Serving cache (
cache.go,cache_redis.go) — per-store versioned snapshots in Redis; atomically swapped by the rebuilder. - Rebuilder (
rebuilder.go) — consumesserving.*rebuild events and swaps snapshots. - Catalog source (
catalog_source_mongo.go,catalog_source_stub.go) — read model for catalog data. - Checkout session store (
session.go,session_store.go,session_memory.go,session_redis.go) — stateful sessions + idempotency, Redis-backed with in-memory fallback. - Request log emitter (
reqlog.go) — fire-and-forgetserving.log.requested. - Consumer (
consumer.go,events.go) — bus subscriptions.
Invariant enforced here: discovery/read paths (profile, catalog) read only the serving cache. Checkout is the deliberate, spec-mandated exception that calls the merchant backend live (via the sync-plane connector) for stock, shipping rates, tax, and order creation.
A4. Sync plane
Package: internal/planes/sync (+ .../sync/api, .../sync/connector, .../sync/feed).
HTTP surface (sync/api, HMAC-authenticated plugin traffic):
| Method + path | Purpose |
|---|---|
POST /connector/signals |
Thin change signal from the plugin (type, id, seq, HMAC). |
POST /connector/heartbeat |
Plugin/connector health + environment. |
GET /connector/profile |
Serves the current profile bytes to the plugin (Role 2 doorway source). |
The pipeline (catalog change → normalise → map → score → issues), single-writer per store:
- Ingestion (
ingestion.go) — consumessync.connector.signal_received, de-dups (Redis-backed per ADR-0007), orders per store, then pulls the full entity (thin events, fat pull) through the connector. - Resolve/mapping/ontology (
resolve.go,mapping.go,ontology.go,ontology_mongo_test.go) — maps raw attributes onto canonical product fields via the ontology (rule + learned tiers; no LLM tier). - Scoring (
scoring.go,scoring_rubric.go,readiness.go,readiness_store.go) — evaluates the mapped product against the loadable, versioned scoring rubric, producingReadinessResult{ overall, dimensions, issues[] }. Dimensions: completeness, consistency, attribute quality, delivery data. There is exactly one score stage (runScoreStage→ScoreAndPersist); live changes, bulk import, sweep corrections and the audit command all go through it, so they cannot disagree. A product is agent-ready when no issue blocks checkout (zero blockers) — there is no score floor. Surface scoping: attribute fields and rules carry an optionalsurfacetag (empty = core, applies to every store). The sync plane readscontrol_store_surfaces(read-only) and resolves/scores a store only against core plus the surfaces it is on (any enrollment state butavailable), so a surface’s own attribute vocabulary is neither shown nor scored for a store that is not on that surface. The control plane asks sync to rescore (the existingrequest_auditcommand) when a store enters or leaves a surface. Ontology: the rule-tier mappings (catalogue/v1/ontology_seed_data.go) are versioned catalogue content that sync — the ontology’s one writer — upserts intosync_ontologyat startup (seed-prefixed ids only; learned rules are untouched). - Eligibility (
eligibility_rules.go) — UCP-eligibility rules feedingblocks_checkout. - Consistency engine (
consistency_engine.go) — detects divergence across sources; emitssync.reconciliation.discrepancy_found. - Catalog store (
catalog_store.go,catalog_mongo.go,product.go,order.go,order_mongo.go) — the working truth, partitioned bystore_id. - Catalog import (
catalog_import.go,import_job.go,import_job_mongo.go,job_server.go) — initial + resync full imports, driven by thecontrol.catalog.importcommand; emitssync.catalog.import_progress. - Rebuilder inputs (
rebuilder_builder.go,rebuilder_transform.go) — builds product snapshots, emitssync.catalog.products_ready. - Sweep (
sweep_handler.go) — scheduled reconciliation full-pulls as the safety net for dropped signals. - Order intent (
checkout_order_intent.go,order.go) — records the order the serving plane already created atcomplete(serving.checkout.order_intent→sync.order.created); models order status + sync state. - Connector registry (
sync/connector) — the outbound store-API client (ucp.Connectorimplementations) with scoped credentials, retry/backoff, circuit breaking. - Surface artifacts (
sync/surface) — the sync plane’s half of an agent surface. AGeneratorrenders one surface-scoped artifact for a store (no bundled surface generates one today; a surface that does registers itsGeneratorinsync/surface/all); theRunnergenerates it only for stores that have started enrolling on the surface (it readscontrol_store_surfaces, read-only), on enrollment, on catalog change (debounced) and on an hourly safety net. The bytes go to the serving cache under an unguessable token and a record tosync_surface_artifacts(sync is its one writer). The serving plane servesGET /artifacts/{token}cache-only and reports each fetch on the bus (serving.surface_artifact.fetched, token only); the runner attributes it and records the surface’s first-fetch signal, then emitssync.surface_artifact.updated, which the control plane’s surface subscriber answers by re-evaluating that enrollment. Withdrawal unpublishes. Nothing in the runner names a surface.
A5. Control plane
Package: internal/planes/control (+ .../control/api for HTTP). Each subdirectory is a service; api/handler.go wires them via an injected Dependencies struct.
Services (internal/planes/control/*):
| Service | Responsibility |
|---|---|
account |
Merchant accounts, team members, invites (email sign-in links via mailer.go), locales. |
auth |
Principal resolution: Kinde JWT bearer verification (bearer.go) + connector HMAC (hmac.go). |
store |
Store lifecycle under a merchant (merchant → store), store settings, profile. |
connector |
Connector/pairing lifecycle (single writer of the pairing record), resync trigger. |
catalog |
Catalog reads, readiness, remediation apply/revert, audits, write-backs. |
task |
The one task spine (detected + prescribed tasks), owner (you/us/external), resolve. |
journey |
The operator-facing concierge funnel (task reconciliation, websocket push, board stage) over the pure lifecycle: prospect → connected → synced → ready → live. Descriptive, not decisive — see activation. |
activation |
The store’s own UCP activation lifecycle — the actual truth of whether a store is live. draft → connector_linked → profile_published → verified → active (+ suspended), driven by endpointhealth’s probes. Zero dependency on any agent surface: a store reaches active with none enrolled. |
surface |
Generic, registry-driven agent-surface enrollment (per-store, zero-to-many) — available → prerequisites_pending → configured → submitted → verifying → enrolled. Registered (surface/all): directagent (zero-config); further surfaces add themselves with one line. A descriptor may declare a typed config_schema the console renders as a form and SetConfig validates; a surface with none keeps free-form config. Artifacts and verification signals generated by a surface’s sync half are read back through the generic synced-artifact adapters (surface/synced.go). See docs/HOWTO-add-surface.md. Depends one-way on activation (enrollment can’t progress past available until the store is active), never the reverse. |
engagement |
Operator-facing display labels for journey’s 5 stages (name, description, typical duration) — descriptive metadata only, journey’s transition table owns every move. |
validation |
Validation runs + conformance records, spec-pinned. |
monitoring |
Semantic probes (probe.go), monitoring loop.go, consistency + discrepancy subscribers. |
billing |
Stripe integration, plans, portal config, webhook handling. |
operator |
Cross-merchant board, act-on-behalf, audit log, manual-step capture. |
facts |
Merchant business-facts capture. |
realtime |
WebSocket hub fanning control.journey.updated to open console sessions. |
domain |
Shared control-plane domain types (Principal, StoreID, etc.). |
engagement, sim, integration |
Engagement metadata, simulator glue, integration-test harness. |
HTTP surface (control plane, sarvex-v1-control-plane.yaml generated from Go code per ADR-0009; merchant_id/store_id are never URL params for tenant scope — resolved from the authenticated principal; path segments use hyphenated casing per ADR-0006):
- Account/identity:
GET/PATCH /account,GET /account/members,POST /account/members,GET/PUT /notifications/preferences. - Stores:
GET/POST /stores,GET/PATCH/DELETE /stores/:store_id,GET/PUT /stores/:store_id/profile. - Connectors/pairing:
GET /connectors,GET/DELETE /connectors/:connector_id,POST /connectors/:connector_id/resync,POST /stores/:store_id/connectors/pairing,POST /connectors/pairing/complete. - Catalog/readiness:
GET /stores/:store_id/catalog/products[/:sku],.../catalog/readiness,.../catalog/remediations,POST .../catalog/audits,POST .../catalog/remediations/apply,POST .../catalog/write-backs/:write_back_id/revert. - Journey:
GET /stores/:store_id/journey,GET .../journey/stream(WebSocket). - Health/monitoring:
GET /stores/:store_id/health,.../health/consistency. - Presence (UCP profile view):
GET /stores/:store_id/presence,.../presence/manifest,POST .../presence/rebuild. - Orders:
GET /stores/:store_id/orders[/:order_id]. - Payment config:
GET/PUT /stores/:store_id/payment-config. - Validation:
GET /stores/:store_id/validation/status,GET .../validation/runs,POST .../validation/runs. - Tasks:
GET /tasks[/:task_id],POST /tasks/:task_id/resolve. - Billing:
GET /billing,POST /billing/portal-link,POST /billing/webhooks/stripe. - Operator (elevated scope):
GET /operator/board,GET /operator/audit-log,GET /operator/timeline-metrics,GET/POST /operator/merchants,GET/POST /operator/merchants/:merchant_id/members,GET/POST /operator/merchants/:merchant_id/stores,POST /operator/act-on-behalf,GET/POST /operator/manual-steps,PUT /operator/manual-steps/:step_id/retag,POST /operator/manual-steps/bulk-retag.
A6. Shared packages (internal/pkg)
The UCP protocol machinery lives in internal/pkg/ucp and is shared by all planes:
internal/spec/vYYYYMMDD— one module per UCP spec generation Sarvex models:v20260408and, as of the 2026-08-25 spec release,v20260825. Each holds that generation’s deep schema types (capabilities, payment, fulfillment, messages, enums, andextensions/—buyerconsent,fulfillment,discount,ap2; see its ownDISCREPANCIES.md), its profile document form (wire), itsgeneration.Generation(profile), itswireadapt.Codec(codec: checkout/cart/order translation and envelope, catalog search/lookup) and itsspec.Version(release: id, served capabilities, codec, profile). No generation imports another.internal/specis the one registry (Latest,Baseline,Serving,Servable,Codecs,Generations), assembled ininternal/spec/all— adding a generation means a new module and one line there; nothing else changes, which a canary generation in the tests proves.ucp/wire— the generation-neutral profile constructs (Profile/UCP/Capability/Service/PaymentHandler/JWK), the same in every generation Sarvex serves. A generation’s profile document differs only in the name of its key set (signing_keys, thenkeys); eachinternal/spec/vYYYYMMDD/wireholds its own document form, and a profile is decoded leniently from either name.ucp/wireintersect— the UCP capability intersection algorithm (name+version selection, orphan pruning to fixpoint), implemented once, generically, over any generation’s wireCapabilitytype — not duplicated per generation.ucp/generation— the profile-document interface (its registry is the spec registry’sGenerations()view) pairing eachtypes.Versionwith its profile document:DecodeProfile(dispatched by sniffing a raw profile’s declareducp.version) andEncodeProfile, which writes Sarvex’s own profiles — the published business profile’s keys, the endpoint-health probe’s reference profile — in the form of the generation they declare. Each generation’s implementation is itsinternal/spec/vYYYYMMDD/profilepackage.ucp/ucpsig— RFC 9421/9530 HTTP message signing/verification.ucp/ucpnego— a largely-unused reference/example implementation of the negotiation protocol (profile,negotiate,httpx/echoxtransport, workedexamples) — not the production path; seeucp/negotiationfor that. Itsnegotiatepackage’s multi-parent-extension pruning rule (any parent present survives) disagrees withucp/negotiation’s (all parents must survive) — a pre-existing inconsistency between the two, not yet reconciled.ucp/negotiation— negotiation wiring used by the serving plane; the production negotiation engine. Not yet generation-registry-aware:ValidateVersionstill gates on a single business-declared version string per store (today always 2026-04-08). Wiring it toucp/generationso a request can be dispatched to either generation is scoped, follow-up work — it is not safely separable from making the catalog/checkout response-building path generation-aware too (an agent negotiated into a generation Sarvex then serves in the other generation’s shape is worse than not negotiating at all).ucp/catalogue(+catalogue/v1) — the definitions catalogue: supported capabilities/extensions/payment handlers, attribute fields, business-facts definitions, and the scoring rubric — loadable, versioned content (loader.go,seeder.go,catalogue_data.go,scoring_rubric_data.go). Still single-generation (v20260408) as of this writing; becoming a per-generation registry mirroringucp/generationis scoped, follow-up work once a v2026-08-25 catalogue data set is authored.ucp/connectors—base+woocommerce,shopify,magentostore-API clients.ucp/validator— per-platform validators (woocommerce,shopify,magento) +registry.ucp/paymenthandler— handler adapters:googlepay,shoppay,stripe,adyen,braintree,paypal(broader than the design’s google_pay/shop_pay pair).ucp/resilience— retry / circuit-breaker helpers.ucp/event_types.go,ucp/command_types.go— the single source of truth for bus topic names.
Cross-cutting infra packages: eventbus (NATS JetStream + noop + envelope), commandbus, domainevents, devinfra (local/test addresses of NATS and Vault), secretmanagement, jwkmanagement, keypair, email (+ templates), middlewares, retry, concurrency, simstore, contracts, domain, common, utils.
A7. The WooCommerce plugin (PHP)
Location: plugin/woocommerce (sarvex-ucp-gateway.php + src/). The plugin stays thin — it processes nothing and decides nothing. Two roles:
- Role 1 — signalling (outbound).
SignalSender.phpfires HMAC-signed (HMAC.php) change signals to the sync plane’sPOST /connector/signalswith a hard timeout and local retry. Thin events, fat pull — Sarvex pulls the full entity. - Role 2 — profile doorway (inbound).
ProfileProxy.phpserves/.well-known/ucpon the merchant’s own domain by fetching Sarvex’s bytes (GET /connector/profile) and caching last-known-good. It does not proxy the UCP API — the profile’sservices.endpointpoints directly at Sarvex. - Pairing (
PairingManager.php) — one-time pairing code exchange for the HMAC secret + scoped store REST credentials.
Supporting: Plugin.php, Autoloader.php, Logger.php; tests under plugin/woocommerce/tests.
A8. Data stores
| Store | Backend | Written by | Character |
|---|---|---|---|
| Merchant registry (accounts, stores, members, connector config + scoped creds, payment config) | MongoDB | Control plane | Tenant root; merchant → store → connector |
| Catalog store (normalised products, mappings, scores, findings, orders) | MongoDB | Sync plane | Partitioned by store_id |
| Ontology library (rule + learned mappings) | MongoDB | Sync mapping workers | The one deliberately cross-tenant store |
| Definitions catalogue (capabilities, extensions, handlers, scoring rubric) | Loadable content (catalogue/v1, seeded to Mongo) |
Spec-reconciliation | Cross-tenant, versioned content |
| Serving cache (per-store snapshots) | Redis | Sync rebuilder → Serving | Read-optimised, atomically swapped |
| Checkout sessions + idempotency | Redis (memory fallback) | Serving | Short-lived, strongly consistent |
| Import jobs / feed status | MongoDB | Sync | Job progress |
| Task / journey / conformance / audit / billing projection | MongoDB | Control services | The console’s backbone |
| Event log | serving.log.requested → bus/sink |
All planes | Append-only |
| Secrets (store creds, handler config, signing key refs) | Vault (KV v2 / Transit) | Control | Never in DB in raw form |
ID generation: Entity identifiers use different schemes based on entity type and lifecycle. See ADR-0008 for the selection matrix and rationale.
A9. Event & command topics
Topic names are constants in internal/pkg/ucp/event_types.go / command_types.go (the single source of truth). Observed topics:
- Commands (control → sync):
control.catalog.import,control.connector.resync_requested,control.feed.regenerate_requested,global.request.audit(re-score a catalog),global.apply.remediation(apply a write-back’s fixes: product, store, records marked applied with each field’s prior value) andglobal.revert.remediation(undo a write-back: restore each prior value on the product and in the store, reopen the records). - Journey:
control.journey.signal(facts in),control.journey.updated(state out, fanned to WebSocket). - Sync events:
sync.connector.signal_received(the one intentional intra-sync bus hop),sync.catalog.import_progress,sync.catalog.products_updated,sync.catalog.products_ready,sync.task.raised,sync.reconciliation.discrepancy_found,sync.order.created. - Serving events:
serving.checkout.order_intent,serving.log.requested,serving.snapshot.swapped,serving.rebuild.requested. - Store:
control.store_profile_updated.
The bus is reserved for hops that cross a plane boundary (plus sync.connector.signal_received, which decouples the HTTP request path from per-store-ordered ingestion). Most intra-plane hand-offs are direct Go calls.
A10. Backend invariants
These are enforced in code and are stop-and-report conditions if violated:
- Three planes, isolated. No plane imports another (
internal/planes/doc.go). Cross-plane = bus only. - Discovery/read serves only the cache. Serving profile/catalog never call the store or control plane live. Checkout is the one spec-mandated live exception, and only through the sync-plane connector.
- One writer per store. Each store’s catalog/serving data has exactly one writing component; everyone else consumes events.
- Two-level tenant isolation. Account data by
merchant_id, catalog/serving data bystore_id; every store belongs to one merchant.merchant_id/store_idare resolved server-side from the bearer claim / HMAC credential / authorized operator scope — never URL params. The only cross-tenant stores are the ontology + definitions catalogue. Enforcement pattern documented in ADR-0005. - Authentication is external. Kinde issues the JWT (OAuth 2.0 auth-code + PKCE); Sarvex services only verify (issuer, audience, JWKS signature, expiry) and read identity + scope. No passwords, no sessions, no login/logout endpoints minted by Sarvex. Connector traffic uses HMAC, not the JWT.
- Idempotency at every external edge. Inbound signals de-dup by sequence; checkout mutations require idempotency keys; order recording is idempotent.
- Spec version is data. Scoring rubric, definitions catalogue, and required-attribute sets are versioned loadable content; conformance records pin the version.
- Payment-credential boundary is structural. The checkout schema has no field able to store a raw credential. Sarvex sees opaque references +
handler_idonly.
Part B — Frontend architecture
Location: web/console — the merchant + operator console. Package @sarvex/console (v0.2.0).
B1. Stack & build
- Framework: Vue 3 (
<script setup>SFCs) + TypeScript. - Build/dev: Vite (
npm run devon :5173;make consoleruns mock-mode with no backend,make console-realproxies/api→ control plane :8081). - State: Pinia.
- Routing: Vue Router.
- HTTP: axios (
src/api/client.ts). - Types:
openapi-typescriptgeneratessrc/api/schema.d.tsfromsarvex-v1-control-plane.yaml(npm run gen:api) — the frontend consumes the same contract as the backend. - Test: Vitest +
@vue/test-utils+ jsdom (co-located*.test.ts).
B2. Routing & surfaces
Routes (src/router) map to view “surfaces” (src/views). Auth/operator guards run in a navigation guard; a shell meta toggles the app chrome; a connect-gate redirects unpaired stores to /connect.
| Path | View | Meta |
|---|---|---|
/login |
LoginView |
public, no shell |
/connect |
ConnectSurface |
pairing gate |
/path (default) |
PathSurface |
the journey/task spine |
/store |
StoreSurface |
catalog, products, orders, presence |
/health |
HealthSurface |
monitoring + consistency |
/checkout-sessions/:checkoutId |
CheckoutSessionDetailView |
checkout escalation detail (drill-down from the Analytics surface’s Checkouts tab) |
/validation |
ValidationView |
validation runs/status — not in NAV; superseded by /path |
/analytics |
AnalyticsSurface |
tabbed surface (?tab=) over Checkouts (CheckoutsPanel), Lost Revenue (LostRevenuePanel — GET /ledger/summary, /ledger/entries), Channel Dashboard (ChannelPanel — GET /channel/*), and Agent Behavior Insights (AgentInsightsPanel — GET /insights/*); the Lost Revenue/Channel/Insights tabs are analytics-tier gated |
/account |
AccountHomeView |
account + members + billing |
/settings |
SettingsSurface |
account/store settings drawer |
/board |
BoardSurface |
operator only — cross-merchant board |
/board/:merchantId/:storeId |
EnterAsMerchantView |
operator only — act-on-behalf |
/_/styleguide |
StyleguideView |
design-system reference |
Within /analytics, the Lost Revenue, Channel, and Insights tabs are billing-gated: each panel always renders, but TierGate.vue shows a locked panel (with an upgrade CTA to /settings) until billing.plan is on the analytics tier (lib/*.ts’s isAnalyticsTier, mirrored server-side by the OpenAPI’s requireAnalyticsTier). Routing never hides the page — the tier check is a content gate, not a nav gate.
/validation is deliberately not linked from NAV: its content already lives on /path — JourneyBar’s stage nodes preview journey progress — so a separate nav destination would duplicate the Path spine. The route stays live (deep links, StoreSwitcher’s stage badges, etc.) but is reached by URL, not the top nav.
The generic agent-surface enrollment flow (SurfacesSurface) is built but not routed: with only the zero-config direct_agent surface there is nothing to enroll in. A surface that needs enrollment adds its descriptor, its console copy folder (src/surfaces/<id>) and a route.
B3. State: Pinia stores
src/stores — one store per backend domain, each backed by the matching src/api module: account, auth, billing, board, catalog, channel, connectors, health, insights, journey, ledger, notifications, operator, orders, payment, presence, storeProfile, stores, tasks, theme. stores.ts holds the active-store selection that scopes store-scoped calls.
B4. API layer & type generation
src/api wraps the control-plane REST contract: client.ts is the axios instance (bearer injection, base URL / proxy), and one typed module per resource (account, billing, catalog, channel, connectors, health, insights, journey, ledger, orders, payment, presence, remediations, storeProfile, stores, tasks, validation, operator, notifications). mocks.ts + fixtures.ts provide a full mock backend so the console runs standalone (mock mode). All request/response shapes are the generated schema.d.ts types.
B5. Authentication
src/auth implements a provider abstraction (provider.ts) with two modes selected by VITE_AUTH_MODE:
kinde— real Kinde Authorization Code + PKCE via@kinde-oss/kinde-auth-pkce-js(lazy-imported so simulator builds don’t carry it).simulator(default) — mints a JWT-shaped token locally for offline/dev work.
The provider exposes login/logout/token so client.ts attaches the bearer and the router guard reads auth + operator status. This mirrors the backend’s “external IdP, verify only” model.
B6. Component system
src/components is organised by area:
ui— the design-system primitives (Chip,Field,Switch,Icon,OwnerChip,CopyField,LoadingButton,ErrorBanner,Skeleton,TierGate).shell— app chrome (AppShell,StoreSwitcher,UserMenu,ThemeToggle,ReadinessPill,Mark).journey—JourneyBarand per-stage cards underjourney/stages. (FeedStepCardis archived — seeweb/console/archive/README.md.)task—TaskFeed,TaskCard,TaskDrawer.connect— pairing steps (ConnectIdleStep,ConnectPairingStep,ConnectPairedStep).store—StoreProductsPanel,StoreOrdersPanel,StorePresencePanel,ProductDrawer,ManifestView.operator—OperatorBar,MerchantCard,NewMerchantModal,AddStoreModal,ManualStepModal,AuditLog.settings—SettingsDrawer,AccountSettingsPanel,StoreSettingsPanel.ledger—LedgerSummaryHeader,LedgerCauseBreakdown,LedgerEntriesTable.channel—ChannelKpiRow,ChannelFunnel,ChannelTrendChart,ChannelComparison.insights—CapabilityGapsSection,NegotiationSection,ProductDemandSection,AttributeGapsSection.
src/lib holds pure helpers (datetime, issue, plans); src/styles holds the theme.
Part C — Gaps
Where the implementation is incomplete relative to the intended V1 design (each cross-checked against the code):
G1 — CheckoutClosed.canceloperation missing.POST /checkout-sessions/:id/cancelnow exists (serving/checkout_service.go’sCancel), alongside the newdev.ucp.shopping.ordercapability andGET/PUT /orders/:id(A3, above) — added together once the official UCP conformance suite’s negotiation requirement surfaced the order-capability gap.- G2 — Outbound order status-sync loop not wired end-to-end.
sync/order.gomodelsOrderStatusandOrderSyncState{created,synced,failed}andcheckout_order_intent.gorecords the order created synchronously atcomplete, but there is no order-handoff/status-sync worker driving confirmed → shipped → cancelled/refunded updates back toward the originating agent platform. The reverse-direction lifecycle sync is modelled, not operating. - G3 — Merchant-facing task/alert email not sent. Email templates exist (
notification.*.tmpl,task_notification.*.tmpl) andGET/PUT /notifications/preferencesis live, but only the member-invite mail path (account/mailer.go) is wired to actually send. No code consumessync.task.raisedto notify the merchant by email; notification preferences currently gate nothing. - G4 — Identity-linking capability represented but unimplemented.
dev.ucp.shopping.identity(OAuth 2.0 buyer linking) is present in the catalogue surface but there is no auth/token flow implementation — consistent with the design’s “defer to first need,” but a gap against the full surface. - G5 — IdP is Kinde. Both backend (
kinde-oss/kinde-go) and frontend (@kinde-oss/kinde-auth-pkce-js) use Kinde; any other IdP reference in older material is stale. - G6 — Magento connector is scaffolded, not proven.
internal/platform/magento(connector +validator) exists, but WooCommerce is the only end-to-end-exercised platform (scenarios, plugin, E2E journey). Magento is “late-V1” and unverified. - G7 — Cross-tenant listing is the only multi-store affordance. The model is keyed
merchant → storeand the operator board lists a merchant’s stores, but there is no merchant-facing multi-store management UI (by design for V1) — noted so it isn’t mistaken for missing tenancy support. G8 — Google Merchant Center surface.Removed from the open-source release (kept only as history underinternal/archive/gmc). The generic surface framework anddirect_agentremain.
Verify each gap against the current tree before building on it — this list is derived from a read of the code at regeneration time, not a maintained backlog.
Part D — Risks
Architectural and operational risks implied by the current design/implementation:
- R1 — Checkout depends on a third party’s live server. By design, checkout calls the merchant backend synchronously for stock/shipping/tax/order creation. A slow or down WooCommerce store can and should fail a checkout cleanly — but this makes the connector’s latency budget and circuit breaker (
ucp/resilience,sync/connector) money-path-critical, not sync-plane hygiene. A regression there directly fails real transactions on Google’s surface. - R2 — Serving-plane availability is the whole product’s SLA. Because the profile edge and checkout live in the serving plane, any coupling that lets a control-plane or sync-plane failure reach serving would violate the core promise. The plane isolation invariant (A10.1/A10.2) must be actively defended in review; it is the single most consequential invariant.
- R3 — Spec drift. Materialized: ucp.dev published a breaking-change release (2026-08-25) after Sarvex was built against 2026-04-08. Mitigation is now partially real rather than aspirational —
ucp/types,ucp/wire, anducp/generationare a working per-generation registry (§A6), with both generations’ deep schema types, envelopes, and negotiation-decode paths landed and tested side by side, proven not to interfere with each other. What remains before Sarvex can actually transact in the new generation:ucp/catalogueis still single-generation;ucp/negotiation’s per-request version gate still only recognizes one business-declared version; and the serving-plane response-building path (profile_renderer.go,catalog_contract.go,checkout_contract.go) and the connector/checkout money-path (connectors/*,checkout_service.go) are still hardcoded tov20260408types throughout. These three are coupled — none is safely finishable alone — and are the scoped follow-up work. A third future spec release is now materially cheaper to absorb than the first one was: the registry pattern established here means it needs a newtypes/wire/generationpackage trio, not a redesign. - R4 — Payment-handler config surface is broad and secret-bearing. Six handler adapters (
googlepay,shoppay,stripe,adyen,braintree,paypal) each carry processor/gateway secrets. Mis-scoping the secret boundary (Secret Manager/KMS) or leaking handler config into the profile/cache is a high-severity exposure. The credential boundary is structural for buyer instruments, but handler configuration is genuinely sensitive. - R5 — Dropped signals rely on the sweeper. Plugin signals are fire-and-forget with local retry; the reconciliation sweeper is the only safety net. If the sweep cadence is too slow (or disabled), price/stock drift can persist past the freshness window and produce disputed transactions before detection.
- R6 — Order status-sync gap (G2) can silently strand orders. Until the status-sync loop is operating, a Sarvex-created order whose merchant-side status changes (cancelled/refunded/shipped) will not propagate back — a correctness risk for the order capability that monitoring must cover in the interim.
- R7 — Auth-mode footgun in the console. The frontend defaults to the simulator auth provider when
VITE_AUTH_MODEis unset. A misconfigured deployment could ship simulator auth to a real environment; build/release config must assertkindefor anything non-local. - R8 — Bus-only cross-plane coupling shifts failure modes to delivery semantics. Correctness now depends on JetStream delivery/ordering guarantees and idempotent consumers. At-least-once delivery + the noop bus used in unit tests mean those tests may not exercise real ordering edge cases; consumer idempotency must be treated as load-bearing, not incidental.
Part E — Dependencies
External systems, libraries, and internal build/order dependencies the architecture relies on:
External services (runtime, third-party):
| Dependency | Used by | Purpose | Failure impact |
|---|---|---|---|
| Kinde | Control plane (verify), Console (login) | Identity provider (OAuth 2.0 + PKCE), JWKS | No new sign-ins / API auth fails; verify is offline-cacheable via JWKS |
| Stripe | Control billing |
Subscriptions, customer portal, webhooks | Billing state stale; serving unaffected (degradation is generous by design) |
| NATS JetStream | All planes (bus) | Cross-plane events/commands | Sync/rebuild/journey stall; serving runs on last-known-good cache |
| Vault Transit | Serving/Control (jwkmanagement) |
Per-store signing keys | Cannot sign/rotate; cached profiles still serve |
| Vault KV v2 | Control/Sync/Serving (secretmanagement) |
Store creds, handler config | Connector auth + payment config unavailable |
| MongoDB | Control, Sync, Serving read model | Primary datastore | Writes/reads fail per plane; serving cache absorbs some reads |
| Redis | Serving | Serving cache + checkout sessions | Serving degraded; memory session fallback exists |
| Google UCP spec (ucp.dev) | Serving | The protocol Sarvex’s checkout/discovery endpoints conform to | Cannot be “down” — it’s a spec, not a service; a store’s own conformance is what activation.verified checks |
| Merchant WooCommerce store | Sync connector, Serving checkout | Source of catalog truth + order write target | Read failure degrades freshness; write failure fails checkout (R1) |
Key libraries (go.mod): labstack/echo/v4, go.mongodb.org/mongo-driver/v2, redis/go-redis/v9, ThreeDotsLabs/watermill, stripe/stripe-go/v72, kinde-oss/kinde-go, gorilla/websocket, spf13/cobra+viper, hashicorp/vault/api, nats-io/nats.go. Frontend (package.json): vue, pinia, vue-router, axios, @kinde-oss/kinde-auth-pkce-js, openapi-typescript, vite, vitest.
Contract dependencies: sarvex-v1-control-plane.yaml (OpenAPI 3.0.3 — control-plane HTTP + connector ingest; the console generates its types from it), sarvex-v1-syncing-plane-async.yaml (AsyncAPI 2.6.0 — sync-plane event contract). The serving UCP endpoints conform to the external ucp.dev spec, not these files.
Internal build-order dependencies (the dependency chain, not effort order):
- Definitions catalogue + scoring rubric (
ucp/catalogue) — blocks scoring, profile rendering, validation. - Plugin + pairing + profile serving — blocks ingestion and the profile doorway.
- Ingestion → mapping → scoring → catalog store (sync pipeline) — blocks readiness, tasks, journey.
- Serving cache + rebuilder + profile rendering + negotiation — blocks the agent-facing surface.
- Checkout (serving) — depends on the connector (sync) and negotiation; the centerpiece.
- Control plane (identity, journey, tasks, operator, billing) — can proceed alongside the pipeline.
- Monitoring — required before first production go-live.
- Validation orchestrator — the checkout build’s test harness; skeleton early.
The control plane can proceed in parallel with the sync/serving pipeline; the definitions catalogue gates scoring, checkout, and validation.