# Commerce over atproto — Technical Standard **Pilot authority:** `shop.lgtm.commerce.*` (on `lgtm.shop`, single-tenant) **Neutral standard authority (intended):** `standard.store` → `store.standard.*` **Status:** Draft **Audience:** implementers (any language) **Scope:** the portable contract behind atproto storefronts — the "cash register + warehouse" service, its records, and its API. Storefronts own their catalog and orders as atproto records and can move them across the ATmosphere. > **Normative vs reference.** This document is the **standard**: the model, the records/lexicons, and the API contract — everything you implement *against*. It is implementation-agnostic; nothing here mandates a language, framework, or datastore. The **reference implementation** — lgtm.shop's `lgtmsupplyco/commerce` Docker image (Rust, Postgres, Stripe/Printful, embedded admin) — is described separately in [REFERENCE.md](./REFERENCE.md) and is **non-normative**. When the two disagree, this document wins. --- ## 1. Motivation & the layered model - **Public shop window** — discoverable signed records in the seller's public repo (storefront + product listings). atproto-native, portable. - **Cash register + warehouse** — checkout, payments, orders, fulfillment, entitlements. The trusted private counterpart; destined for atproto **permissioned spaces**. - **Public proof (opt-in)** — buyer-owned, shop-attested **purchase claims** in the buyer's public repo: verifiable "I bought / I back this" badges. The social/funding flywheel. "Warehouse" spans three fulfillment families: **physical** (print-on-demand), **digital** (downloads/license keys), and **benefit** (grant access on Discord / GitHub / tangled.sh). Products span four kinds: **physical, digital, donation, membership** — so the same machinery powers a merch store _and_ recurring open-source funding. | Concern | Layer | Today (pilot) | Destination | | --------------------------- | ------------ | ------------------------------------- | ------------------------- | | Storefront, product listing | public | public record (canonical) + index | unchanged | | Order, line items | permissioned | index-canonical, protocol-shaped | relationship-space record | | Address | permissioned | index (encrypted) | buyer-authored record | | Receipt / entitlement | permissioned | index + **signed record** | buyer-held signed record | | Purchase claim (opt-in) | **public** | buyer's public record + index | unchanged | | Money custody | external | payment provider | payment provider | **Rule:** nothing private in a _public_ record. The public claim is a minimal, opt-in projection of the private receipt (linked by hash, §10). **The load-bearing invariant:** atproto records are **canonical**; every API, index, and admin surface is a **projection + write-through** over them, never the source of truth. Destroy any serving instance and the shop's catalog, orders, and receipts survive in the relevant repos — with one requirement the record set must satisfy: orders live in per-relationship spaces keyed by buyer DID, and the shop keeps a canonical enumeration of its relationships (a store-private `{authority}.relationship` record, §6). The protocol's own `listSpaces` (§8) does enumerate the spaces an account *holds a repo in*, filtered by authority DID and type — so a store that authors `order`/`receipt` in every order space can in principle rediscover its relationship set from its PDS. But `listSpaces` reflects only what the PDS still tracks (the oplog carries no history guarantee and does not survive account migration), and it yields bare space-refs, not the buyer↦space customer list. So the `relationship` record remains the authoritative, host-independent enumeration — it is what lets the shop rebuild after losing the index without leaning on PDS retention. This is what makes "run your own instance," "let a host run it for you," and "query a central index" three deployment topologies behind one contract (§13, §15) rather than three products. --- ## 2. Goals / non-goals ### Goals - A portable commerce contract: records + API that any implementation can serve and any client can consume. - **Two-directional integration seam:** _inbound surfaces_ (web checkout, hosted/embeddable donate widget) and _outbound capabilities_ (payment, fulfillment incl. benefit grants) — both uniform and pluggable. - Funding primitives: donations (one-time, pay-what-you-want) and memberships (recurring) that grant platform benefits. - Public listings + receipts on/aligned-with the protocol; private commerce data modeled as permissioned records from day one. - **Signed receipts** + **opt-in public purchase claims**, both protocol-native. - Single- and multi-tenant from one contract; a permissioned-store abstraction for the eventual protocol cutover. - **One contract, many deployments** — self-hosted single-tenant, hosted multi-tenant, and a central read-index (§15) are all the same records and the same API. ### Non-goals (v1) - Public **storefront-rendering** AppView — the buyer-facing client that renders a shop's window from listing records. (Distinct from the discovery/search index of §15, which *is* an AppView the standard enables; the non-goal is the *rendering* surface, not aggregation.) - Pooled funds (hosted = payment-provider Connect, seller is Merchant of Record). - Implementing the permissioned-spaces _protocol_ itself. The proposal **landed** ([permissioned-data proposal](https://github.com/bluesky-social/proposals/blob/main/0016-permissioned-data/README.md); reference impl in [atproto#5187](https://github.com/bluesky-social/atproto/pull/5187)), so the lexicon shapes are published, not speculative — but they ship as a single reference PR, not a network-wide PDS feature. Implement against the published shapes with a local stand-in (model the store on the mandatory **`com.atproto.simplespace.*`** member-list policy), and still expect drift until it's broadly deployed. - A general third-party app marketplace; integrations are provided behind uniform seams for v1. --- ## 3. Namespace strategy NSIDs are reverse-DNS and tied to domain authority. - **Pilot:** the pilot controls `lgtm.shop` and namespaces records under the `commerce.lgtm.shop` subdomain, so the pilot authority is **`shop.lgtm.commerce.*`** (e.g. `shop.lgtm.commerce.product`). - **Neutral standard:** the intended authority is **`standard.store`**, giving **`store.standard.*`** (e.g. `store.standard.product`). The `commerce` segment is dropped in the neutral form — a `standard.store` authority is self-evidently commercial. **Migration is leaf-stable for public records only.** `shop.lgtm.commerce.product` → `store.standard.product`: the authority segment changes but every **leaf** (`product`, `variant`, `storefront`, `purchaseClaim`, …) is preserved, and a public record re-creates cheaply under the new NSID. **The permissioned tier is different, and this is load-bearing:** a space's identity includes its *type* NSID (`{authority}.orderSpace`, §8), so an authority change makes every existing space a *different* space — new `at://` space URIs for every order/receipt/address/fulfillmentStatus, dangling `space_ref`s (including the one `checkout` returns), and no CID stability, which also breaks §9's "identical bytes." So the authority MUST be settled **before any protocol-canonical permissioned data exists**; until it is, protocol space creation is **frozen** (§16). Implementations SHOULD reference leaves through one authority constant — but that constant only makes the *public* cutover a one-line change, not the permissioned one. Throughout this document, `{authority}` denotes the active authority (`shop.lgtm.commerce` in the pilot, `store.standard` in the neutral form). --- ## 4. Architecture (conceptual) ``` INBOUND surfaces commerce core OUTBOUND capabilities ─────────────── ───────────────── ───────────────────── web checkout ─►┐ ┌────────────────────┐ ┌──► payment (e.g. Stripe) donate widget ─►┼────────►│ orders / checkout │─────────►┼──► fulfillment: operator/admin ►┘ │ receipts / claims │ │ physical (print-on-demand) │ memberships │ │ digital (gated downloads) └─────────┬───────────┘ └──► benefit (Discord/GitHub/tangled) ┌────────────┼────────────┐ ▼ ▼ ▼ PermissionedStore PublicRepo Registry (orders/receipts/ (storefront/ (resolve provider addresses) product/ per tenant per claims) capability) │ │ canonical atproto records ── firehose / index ──► read index (§15) ``` Three logical roles, which an implementation MAY collapse into one process or split apart: - **API + surfaces** — serves the contract (§13), hosts inbound surfaces and integration webhooks. - **Worker** — settles orders (payment → fulfillment → benefit), signs receipts, emits attestations. - **Sync / index** — consumes the firehose for public-claim indexing and (multi-tenant) listing indexing; performs permissioned-space sync at the protocol cutover (§8). Two stores sit behind the core, both projections over canonical atproto records: the **public repo** (storefront/product/claims) and the **PermissionedStore** (orders/addresses/receipts/fulfillment). Everything the API returns is read from these projections; every write lands in the canonical record and is mirrored into the projection. --- ## 5. Tenancy & custody Single-tenant = multi-tenant with one row, resolution off. A tenant is keyed by seller DID and carries OAuth material, integration config, and a **dedicated store identity** (`store_did`) — the space **owner** that holds the receipt/attestation signing key (stable across the migration). Resolution: `single` (fixed config) vs `multi` (host / subdomain / custom-domain / storefront DID). **Payment custody (non-negotiable):** single-tenant = direct payment-provider key (operator is Merchant of Record); multi-tenant = **provider Connect**, each seller is a connected account + MoR, the platform takes an application fee and **never custodies funds**. --- ## 6. Records & lexicons Records are the portable core of the standard — the artifact an implementation reads and writes, and the thing published as native lexicon **JSON**. This section describes them by role; the reference implementation's concrete storage schema is in [REFERENCE.md](./REFERENCE.md). ### Public — the shop window (seller's public repo) | Leaf | Shape (summary) | | -------------------- | ----------------------------------------------------------------------------------------------------------- | | `storefront` | Store identity, branding, currencies, shipping zones, policies. **Asserts its own `storeDid`** so a claim's `store_did` resolves canonically back to the shop (§10). Singleton, rkey `self`. | | `product` | Catalog entry — name, description, images, options; `status` ∈ `draft \| active \| discontinued \| archived`. | | `variant` | Purchasable unit — SKU, `kind` ∈ `physical \| digital \| donation \| membership`, `pricing` ∈ `fixed \| pay_what_you_want`, `recurring_interval` (membership), `availability` ∈ `available \| sold_out \| unavailable` (derived from private stock; **no counts leak**), integration refs, benefits. **Price is authoritative in a single native `currency`** (`unitAmount`); a multi-currency shop lists one variant per currency and any cross-currency total is a checkout-time computation — the public record always states the native price a global index can rely on. Under `pay_what_you_want`, **`minAmount` = floor (required)**, **`unitAmount` = suggested (optional)**. | ### Public — proof (opt-in, keytrace-shaped; buyer's public repo) | Leaf | Shape (summary) | | --------------------- | ------------------------------------------------------------------------------- | | `purchaseClaim` | Buyer-owned "I bought / I back this" badge. `{ store_did, kind, product_uri?, issued_at, attestation_ref, receipt_hash? }`. **`kind` ∈ `purchase \| membership`**: `purchase` is permanently true once attested (signature-cacheable); `membership` is live status (liveness-checked, not cacheable — §10). | | `purchaseAttestation` | Shop signature vouching for a claim (over the claimed facts + `receipt_hash`). | | `attestationKey` | Rotating shop signing key for the public-attestation path. If a shop publishes rotating keys it does so consistently, so verifiers implement one discovery path, not two. | ### Permissioned — the private counterpart (relationship space, §8) | Leaf | Author | Shape (summary) | | -------------------- | ------ | ----------------------------------------------------------------------------------- | | `order` | store | Order + line items; `surface`, `type` ∈ `one_time \| subscription`, status, money totals, payment/subscription refs. | | `address` | buyer | Buyer-authored shipping address (encrypted at rest; never public). | | `receipt` | store | Signed entitlement proof (§9). | | `receiptRevocation` | store | Tombstone: `{ receiptRef, revokedAt, reason }`. Revocation writes this; it never deletes the receipt — preserving the buyer's entitlement history *and* the merchant's audit/tax trail (§9, §12). | | `fulfillmentStatus` | store | Fulfillment / tracking state. | ### Store-private (operational, store's own repo — never public) | Leaf | Author | Shape (summary) | | -------------- | ------ | ----------------------------------------------------------------------------------- | | `relationship` | store | Canonical enumeration of the store's relationships: `{ buyerDid, spaceRef, createdAt }`. **Never public — it is the customer list.** Makes the shop's per-relationship spaces rediscoverable without the index (§1). | ### Space typing | Leaf | Shape | | ------------ | --------------------------------------------------------------------------------------------- | | `orderSpace` | `type: "space"` record; `collections` = the five permissioned leaves above (`order`, `address`, `receipt`, `receiptRevocation`, `fulfillmentStatus`). One space per (store, buyer) relationship (§8). `relationship` is store-private and NOT a space collection. | Money is integer minor units + ISO-4217 currency throughout. Kinds and statuses above are the closed vocabularies the API and lexicons share. --- ## 7. atproto integration **Public repo (shop window + claims):** lexicons are native **JSON**, generated/validated into the implementation's types. Admin writes `storefront`/`product`/`variant` (canonical) plus an index projection. **Buyers publish `purchaseClaim` to their own public repo** on opt-in (their OAuth session). **Public claims are indexed off the firehose** (filtered to the `purchaseClaim` collection) to power the **backer wall / supporter leaderboard** — which is a *join across two repos* (the buyer's claim + the shop's attestation), with a liveness condition on the attestation for `membership` claims (§10), not a single-collection scan. **Identity/social:** DID resolution + record caching for storefront/product reads; backlink/follow reads against storefronts. For benefit grants, read the buyer's existing **keytrace identity claims** to resolve their verified Discord/GitHub handle without a fresh OAuth link (§11). **Buyer auth:** atproto OAuth with a permissioned-space scope. The scope's positional value is always the space **type** NSID (never a bare collection), and the store authority must be named explicitly, because `authority` defaults to `self` (the buyer's own DID) and our order spaces are anchored on `store_did`. Whole-space read: `space:{authority}.orderSpace?authority={store_did}&action=read`. The write grant on the buyer's own `address` names the collection via the `collection=` param **on the orderSpace type** — `space:{authority}.orderSpace?authority={store_did}&collection={authority}.address&action=create&action=update` — *not* `space:{authority}.address?…`, which would parse but denote a different (nonexistent) space type and authorize nothing. Action vocabulary is `read_self`/`read`/`create`/`update`/`delete`; a separate `manage=create|update|delete` param governs space *management* (createSpace/addMember/…) and is what the store authority holds. (Grammar verified against the alpha `@atproto/oauth-scopes` `SpacePermission` parser; `space:` is a first-class scope type — the `include:` form some apps use just bundles these same `space:` grants under a published name.) The space type is the consent boundary (read is all-or-nothing across the space; writes are OAuth-attributed to the author). **Guest checkout** (email only) stays first-class; guest orders live in a store-only space, receipt emailed; link a DID later to migrate. **Permissioned spaces (cash register):** §8. *(Concrete libraries — firehose client, lexicon codegen, atproto R/W, identity resolution — are reference-impl choices; see [REFERENCE.md](./REFERENCE.md).)* --- ## 8. Permissioned spaces: mapping & migration Maps onto the `com.atproto.space.*` protocol ([permissioned-data proposal](https://github.com/bluesky-social/proposals/blob/main/0016-permissioned-data/README.md) / [atproto#5187](https://github.com/bluesky-social/atproto/pull/5187)) with `com.atproto.simplespace.*` for management. The space **authority** is the dedicated `store_did` — a `simplespace` space is anchored on the store's **own** DID, so there is **no per-space minted DID** — the `spaceDid` in a URI *is* the authority DID. The authority DID doc exposes `#atproto_space` (the credential-signing key, distinct from the receipt key) and `#atproto_space_host` (the space host service). A space is identified by `(authority, type, skey)` = `(store_did, {authority}.orderSpace, buyer DID)`, **one per (store, buyer) relationship**. Permissioned records are addressed by an `at://` URI with a literal `space` marker segment: `at://{spaceDid}/space/{spaceType}/{skey}/{authorDid}/{collection}/{rkey}` (the leading `{spaceDid}/space/{spaceType}/{skey}` names the space). The permissioned protocol **reuses** the public `at://` scheme rather than defining its own — the literal `space` marker sits where a collection NSID appears in a public URI, and the two are unambiguous because an NSID always contains dots while `space` never does. Members are `{store, buyer}` via the `simplespace` member list (`policy = member-list`, the default of three — `member-list` / `public` / `managing-app`); the 2-member list is the entire confidentiality boundary against cross-customer leakage. (The `managing-app` policy is an alternative worth holding for guest→DID linking and dynamic authorization: instead of mutating a member list, the authority calls `checkUserAccess` on our app per mint. App-level sync is gated separately by the `appAccess` policy — `open` / `allowList` of attested `client_id`s, §11.) Each member holds a repo within the space: the store authors `order`/`receipt`/`fulfillmentStatus`; the buyer authors `address`. **Reads are whole-space** (a space credential, or an OAuth `read` scope) and **writes are OAuth-attributed** to the author — there is no per-record/per-author partial read grant. The buyer holds the receipt by syncing the store's repo. The receipt is an **application-level signed record** (§9), independent of the protocol's *deniable* commit signature, so it stays a portable entitlement proof even though commits are non-rebroadcastable. Sync (protocol cutover): the buyer's PDS mints a **delegation token** (`getDelegationToken`) that the app exchanges with the authority for a short-lived **space credential** (`getSpaceCredential`); then pull the per-repo **oplog** with `listRepoOps` since a `rev`, reconciled against the repo's current signed commit (`getLatestCommit`, an LtHash-set digest MAC'd into the commit), falling back to `getRepo` (a serialized CAR) for full-state recovery. `listRepos` enumerates the **writer set** with each repo's `rev`; `registerNotify` + `notifyWrite` (withdrawn via `unregisterNotify`) give push instead of polling, and the space credential is **DPoP-bound**, not a bearer token — `getSpaceCredential` takes `{space, clientAttestation?}` plus a DPoP proof header, and returns a JWT bound to the proof key through its `cnf.jkt` claim. **Space deletion is member-preserving:** deleting a space does not entitle the authority to erase members' repos — the authority stops issuing credentials and notifies syncers (`notifySpaceDeleted`), and a syncer that misses it learns via a `SpaceDeleted` error on credential renewal, but the buyer's own `address` repo is theirs to keep. So a guest→DID migration re-homes the buyer's records rather than assuming teardown drops them. All commerce-core reads/writes go through one **permissioned-store interface** so the cutover is a backing-store swap. Conceptually: ``` ensure_space(owner, type, skey, members) -> SpaceId # members are plain DIDs; membership is binary put(addr, body) -> Cid # addr = (space, repo, collection, rkey); repo must be an authoring member get(addr) -> Record? list(space, repo, collection?) -> [Record] delete(addr) -> () # applyWrites delete → drops from repo state / revoke ``` **Migration path:** index-canonical with app-logic ACL → dual-write into PDSes → protocol-canonical (index demoted to a cache). Domain logic is untouched across all three; record addresses render behind one `at://` space-ref helper. **Access ≠ confidentiality.** The landed proposal provides *access control, not confidentiality* — PDSes and authorized apps that handle the data can read it; it is not E2EE. So application-layer encryption on PII (addresses, email, external tokens) stays on **even after** the protocol cutover; secrecy must not ride on the space (§14). --- ## 9. Signed receipts (private, protocol-native) The full entitlement proof; permissioned, rich, buyer-held. A `{authority}.receipt` record signed **the atproto way**: canonical **dag-cbor** + `store_did`'s key (`ES256`/`ES256K`, low-S); stores `record_cbor`, `sig`, and `receipt_hash`. Identical bytes pre- and post-spaces (index row now → permissioned record at cutover; no reissue). This asymmetric per-record signature is **load-bearing precisely because space commits are deniable** — the landed proposal signs repo commits with HMAC-bound (deniable) MACs, so the receipt cannot lean on the space commit for non-repudiation; the standalone `store_did` signature is what makes a receipt verifiable and portable offline. **Two separate checks — don't conflate them.** *Authenticity* is offline and portable: resolve the issuer DID doc → key → verify `sig` → check the order ref. *Entitlement* ("is it still valid?") is inherently **online**: under valid-if-live, validity is a function of the store's *current* revocation state, so a buyer holding a synced receipt cannot establish it is still good without reaching the network. Authenticity travels; entitlement does not. Gated downloads care only about the online property. **Revocation is a tombstone, not a deletion.** Revoking writes a `{authority}.receiptRevocation` record (`receiptRef`, `revokedAt`, `reason`); it does **not** `applyWrites`-delete the receipt. A silent delete would give the store unilateral, no-audit-trail revocation and erase history on both sides — the buyer's entitlement record *and* the merchant's audit/tax trail (a refunded sale must leave a canonical *negative event*, not the mere absence of a positive one; certificate revocation went to CRL/OCSP over "the cert vanishes" for exactly this reason). A receipt is **valid-if-live** = present AND not tombstoned. Refunds/disputes drive revocation (§12). The signature shape is the part that must be right from day one; gated digital downloads verify authenticity *and* check for a tombstone (the online entitlement step). --- ## 10. Public purchase claims (opt-in, keytrace-shaped) Verifiable "I bought / I back this" badges, **owned by the buyer, attested by the shop**. Modeled directly on keytrace's split of _who-publishes_ from _who-vouches_ — which is exactly the mutual opt-in. **Three leaves (§6):** - `{authority}.purchaseClaim` (≈ `dev.keytrace.claim`) — buyer-owned, **public**, in the buyer's repo. Carries a `kind ∈ purchase | membership` that splits two semantics which must not collapse into one leaf: **`purchase`** ("I bought this in 2026") is *permanently true* once attested — signature-verifiable and cacheable forever; **`membership`** ("I am currently a member") is *live status* whose validity is a liveness check (the shop revokes its attestation on lapse), so it is **not** cacheable. Backer-wall durability — the historical record of who showed up early — depends on `purchase` claims staying valid regardless of later membership state. - `{authority}.purchaseAttestation` (≈ `dev.keytrace.signature`) — the **shop's signature** over the claimed facts. Without it the claim is forgeable; with it, anyone can verify authenticity against the store key. - `{authority}.attestationKey` (≈ `dev.keytrace.key`) — a **rotating** shop signing key for the public-attestation path, published as a record, so the shop rotates without exposing the root `store_did` key. (Receipts stay root-signed — §9 — a lower-exposure private surface; rotation earns its keep on the high-volume *public* path.) A shop that publishes rotating keys does so consistently, so verifiers implement one discovery path, not two. **Mutual opt-in falls out of the model:** no buyer publish → nothing public (shop can't dox customers); no shop attestation → unverifiable (buyer can't fake a purchase). A _verified_ claim needs both. **Link to the private receipt:** the attestation signs `receipt_hash` (a commitment to the §9 receipt), so the public claim is cryptographically tied to the real purchase **without leaking purchase detail**. The public claim is a minimal projection; the private receipt is the preimage. **Backer wall = a join, not a scan.** The wall pairs the buyer's `purchaseClaim` with the shop's `purchaseAttestation` — two collections in two repos — and for `membership` claims adds a **liveness condition** on the attestation side. So a `purchase` claim verifies once and caches; a `membership` claim's verification is a live check against the shop's repo, which for a network-wide index is a per-claim liveness dependency at render time (a real scaling cost §7 must own). **Only show `verified = true`** — a claim with no valid attestation is hidden. A lapsed membership *persists* as an unverified claim rather than being deleted: the buyer owns the claim; the shop can only revoke its own attestation. **Canonical claim → shop binding.** A claim carries `store_did`, but the storefront/product live in the *seller's* public repo — so the `storefront` record MUST assert its own `storeDid` (§6), giving any third party a canonical `did → storefront` path. Without it, a funding claim (which has no `product_uri` — donations/memberships have no product) resolves to a signing key and nothing else, forcing an index to hold the mapping out-of-band and making it load-bearing infrastructure rather than a replaceable cache (§15). **The funding/guest gap (honest scoping).** Guest checkout is first-class and the `funding` widget targets drive-by supporters — but a guest has no DID and cannot author a `purchaseClaim`. So the highest-volume funding path produces **zero** backer-wall entries; the flywheel spins only for buyers who already hold atproto identity. Closing this needs either a shop-authored anonymous-backer claim or guest→DID conversion at claim time — an open design question (Appendix), not yet a mechanism. **Phasing:** public + additive → ships alongside private receipts; does **not** wait on permissioned spaces. --- ## 11. Integration seam — two directions Nothing is special-cased. Orders arrive through **inbound surfaces** and are settled through **outbound capability providers**; both are uniform, pluggable, and behind a stable interface. This is the future open adapter SDK. **Inbound surfaces** translate an external request into a **normalized draft order**, then hand it to commerce core: - **`web`** — a storefront client → payment-provider checkout. Default. - **`funding`** — a **Polar.sh-shaped** hosted/embeddable donate + membership surface: pay-what-you-want one-time donations and recurring memberships, embeddable as a button/widget in GitHub READMEs, tangled.sh repos, or any site. The OSS-funding entry point. **Outbound capabilities** settle a draft. Capabilities: `Payments`, `Fulfillment(physical|digital|benefit)`, `ShippingRates`. Provider roles: - **Payment** — create checkout, create subscription (memberships), refund. - **Fulfillment** — shipping rates, submit, status. Families: physical, digital, benefit. - **Benefit** — grant/revoke access on an external platform for an entitled buyer. **Uniform mechanics:** webhook ingress `POST /webhooks/:integration_id` → verify signature → dedupe by event id → enqueue. Config-schema-driven admin forms. A **normalized event vocabulary** (`PaymentCaptured`, `SubscriptionRenewed`, `SubscriptionCanceled`, `FulfillmentShipped`, `BenefitGranted`, …) so commerce core never sees provider-specific shapes. *(The concrete provider set — payment, print-on-demand, digital files, Discord/GitHub/tangled benefits — is a reference-impl choice; see [REFERENCE.md](./REFERENCE.md).)* --- ## 12. Checkout & lifecycle ``` inbound surface (web|funding) -> normalized draft order ensure_space(store_did, {authority}.orderSpace, skey=buyer|guest, members={store, buyer}) one_time: payment.create_checkout(...) (physical: shipping options + tax) subscription: payment.create_subscription(...) (membership) -> pay / authorize -> webhook -> verify -> dedupe -> enqueue worker: order -> paid/active physical -> fulfillment(physical).submit() digital -> entitlement + gated download benefit -> for each variant.benefits: benefit_provider.grant(link, benefit) mint + SIGN receipt (store key); write buyer-held if buyer opted in: emit purchaseAttestation (sign receipt_hash); buyer publishes purchaseClaim (public) subscription lifecycle: renewed -> extend entitlement, keep benefits past_due -> dunning grace canceled/refunded -> revoke benefits; write receiptRevocation tombstone (§9, never delete); revoke shop attestation (buyer's claim persists, becomes unverified) ``` Idempotent throughout (payment session/subscription id, fulfillment external id, webhook id, grant ref). --- ## 13. The API contract The API is **atproto-native XRPC**: query methods for reads, procedure methods for writes, defined as native lexicons under `{authority}.*` — so the API is part of the publishable standard exactly like the record lexicons, and mirrors the shape aggregating services already serve for other atproto namespaces. Two planes, split by posture. ### Conventions - **Transport:** `GET /xrpc/{authority}.{method}` for queries, `POST /xrpc/{authority}.{method}` for procedures. - **Bodies:** bare lexicon-typed JSON (no envelope). Failures use the standard XRPC error shape `{ "error": "", "message": "" }`. - **Addressing (atproto-native, shop-scoped):** every method is scoped to a shop DID (explicit `shop` param in multi-tenant / central-index deployments; implicit from host in single-tenant). Public records are referenced by **AT-URI** (`product`, `storefront`, `variant`). **Orders are referenced by an unguessable opaque capability id** — possession authorizes (guests have no session), and the order additionally carries its canonical `at://` space-ref. Public records are guessable-by-design; buyer-scoped resources must not be. - **Money:** integer minor units + ISO-4217 currency. - **Identity:** every method carries a buyer/merchant **DID** (§14). Read-plane methods over public records need no identity. > The method lexicons live as native JSON in `lexicons/shop.lgtm.commerce.*` (query/procedure docs) alongside the record lexicons, with reusable output objects in `shop.lgtm.commerce.defs`. ### Read plane — two conformance levels The read plane splits by what a deployment can serve, and an implementer must know which is which at design time — not discover it by failing. A **central read-index (§15) implements only the public subset**; the identity-bearing methods touch permissioned or ephemeral state and require a write-plane deployment. **Public read** — stateless, cacheable, a projection over public records; this is exactly what the clearinghouse serves, with `shop` ranging over the network: | Method | Kind | Params | Returns | | ------ | ---- | ------ | ------- | | `getStorefront` | query | `shop` | Storefront record. | | `listProducts` | query | `shop`, `cursor?`, `limit?` | Active products + purchasable variants, paginated. | | `getProduct` | query | `uri` (AT-URI) | One product + its variants. | | `getView` | query | `shop` | **Public boot payload:** storefront + products. What a client hits on start to render a shop window. | | `listBackers` | query | `shop`, `cursor?` | Verified `purchase` claims (backer wall; membership liveness per §10). | **Authed read** — requires identity, touches permissioned/ephemeral state, **not index-servable**: | Method | Kind | Params | Returns | | ------ | ---- | ------ | ------- | | `getAccount` | query | `shop`, identity | The buyer's cart + saved addresses + orders for this shop — the authed companion to `getView`. | | `getOrder` | query | `shop`, `order` (capability id) | Order status + totals + line items + fulfillment tracking. Per-order, capability-scoped — not cacheable across the network. | | `streamOrder` | query (SSE) | `shop`, `order` (capability id) | Live counterpart to `getOrder`: `text/event-stream` of `orderEvent` frames (snapshot on connect, one per transition, `done` on terminal). `Last-Event-ID` resume off a per-order `seq`; `: ping` heartbeat; ~10 min cap. Same capability scoping as `getOrder`. Transport-agnostic event body so a future WS `subscribeOrder` shares it. | | `getCart` | query | `cart` | Cart contents + computed totals. Ephemeral. | | `listAddresses` | query | `buyer` | Buyer's saved addresses. Permissioned PII. | ### Write plane — stateful, DID-scoped, write-through to canonical records | Method | Kind | Params | Returns / effect | | ------ | ---- | ------ | ---------------- | | `createCart` | procedure | `shop`, `buyer` (DID \| guest email) | New cart handle. | | `setCartItem` | procedure | `cart`, `variant`, `quantity` | Updated cart. | | `setCartAddress` | procedure | `cart`, `address` (inline or address-ref) | Updated cart + shipping options. | | `checkout` | procedure | `cart` | Converts cart → **`order`** (permissioned write-through) and initiates payment. Returns `{ order: { id, space_ref }, payment: { checkout_url } }`. | | `createAddress` | procedure | `buyer`, `body` | Buyer-authored `address` record (permissioned, buyer repo); returns address-ref. | (The read side of this state — `getCart`, `listAddresses` — is under *Authed read* above.) Deferred to a later slice: `card`, `subscription`, `profile`. **Identity params accept a DID *or* a handle.** Every parameter that names a person — `buyer` on `getAccount`/`listAddresses`/`createAddress`, and equivalents elsewhere — accepts an **`Actor`**: an atproto DID or a handle. A handle is resolved to its DID (cached) before use; a DID passes through unchanged. Callers never have to pre-resolve a handle, and the resolved DID remains the canonical key (spaces, carts, and records are DID-keyed). **Cart is ephemeral server state keyed by buyer DID — not an atproto record.** It becomes canonical only at `checkout`, when it converts to an `order` (which *is* a permissioned record). Carts mutate constantly and have no cross-app portability value; write-through stays where it earns its keep — orders, addresses, receipts. A cartless **direct checkout** (items + buyer in one call) is permitted as sugar over `createCart` + `setCartItem*` + `checkout`. ### Auth & the trusted-caller boundary Handlers consume a buyer/merchant **DID** as their identity primitive from day one. The **destination** is atproto-native: buyers and merchants authenticate via atproto OAuth or DID-signed requests. Until that lands, the write plane sits behind a **deployment-level trusted-caller boundary**: the write methods are reachable only by first-party services (the storefront backend, the `ssh` gateway), which assert an already-authenticated DID. *How* that boundary is enforced is a deployment choice — network isolation, a shared internal credential, or an IP allowlist behind a trusted proxy — and is **not part of this contract**; the reference impl's choice is in [REFERENCE.md](./REFERENCE.md). The public read subset stays open. Swapping this for real auth replaces "trust the DID asserted by a first-party caller" with "verify the DID on a signed/OAuth request" — everything downstream is identical. PII encryption (§8) is independent of this boundary and always on. **This boundary is single-tenant-only.** In multi-tenant a first-party caller asserts not just the *buyer* but the *shop* — so a bug in one caller crosses tenants. Multi-tenant therefore **requires** real per-tenant auth (atproto OAuth / DID-signed) from the start; the first-party-caller boundary MUST NOT be carried into a multi-tenant deployment. ### Versioning & rollout The XRPC surface rolls out as a **versioned parallel-run**: new `/xrpc/*` methods are mounted alongside any pre-existing ad-hoc HTTP endpoints, consumers migrate method-by-method, and the old surface is retired once no consumer depends on it. A running store never breaks during the transition. `/.well-known/did.json`, the store signing-key endpoint, `/healthz`, and `/readyz` are operational endpoints outside the XRPC contract and are unaffected. --- ## 14. Security model - **Webhook signatures** per integration; event-id dedupe. Idempotency across payment/fulfillment/grant ids. - **Tenant isolation** by tenant; resolution failure = hard 404. - **2-member relationship-space member list never widened** — membership is binary, so the list is the whole **access-control** boundary. Keep `isPublic = false`; scope app-level sync via the proposal's app-access policy (`#open` / `#allowList` of attested client ids). - **Access ≠ confidentiality.** The landed proposal provides access control, not confidentiality — so **keep app-layer encryption on PII** even after the protocol cutover; do not let address/email/token secrecy ride on the space. E2EE, if ever needed, is an application-layer add-on. - **PII** (address, email, external tokens) encrypted, never public, purgeable. - **Public claims:** only surface `verified` claims; verify the attestation against the named store key; treat unverified/forgeable claims as hidden. Claim content is buyer-chosen and minimal — never echo private receipt detail into a claim. - **MoR boundary:** Connect-only in hosted mode; the platform never custodies funds. - **Benefit grants:** least-privilege scopes; revoke promptly on refund/cancel/lapse. - **Gated downloads** via short-lived signed URLs tied to a non-revoked entitlement. - **Buyer-scoped addressing** is capability-based (unguessable ids); only public records are AT-URI-addressable (§13). - **Pre-publish moderation hook** on public product writes (physical-goods trademark liability) — leave the seam. --- ## 15. Deployment topologies & the clearinghouse Because atproto records are canonical and every surface is a projection (§1), one contract serves three topologies with no forks in the record or API shape: 1. **Self-hosted, single-tenant** — one shop runs its own instance (the reference impl's default; the `docker run` story). Proves the standard is implementable and portable — the role a self-hosted node plays in any federated system. 2. **Hosted, multi-tenant** — an operator runs the write plane for many shops behind provider-Connect + DID auth, so a merchant onboards without touching infrastructure. This is the same contract, centrally deployed; the admin surface becomes a hosted dashboard by pointing the same client at it. 3. **Central read-index (clearinghouse)** — a central deployment of the **public read subset** (§13), backed by a firehose index of every shop's public `{authority}.*` records across the network. Discovery and search over the ecosystem: this *is* a discovery **AppView** (distinct from the storefront-*rendering* AppView that's a non-goal, §2 — this one aggregates metadata, it doesn't render a shop's buyer-facing window). Low-risk: public records only, no money, no PII. The clearinghouse is **bobbin-shaped** — a stateless read-index over records it does not own. The defensible property is **not** "not a chokepoint": a discovery index has increasing returns, and the one everyone queries is the one worth publishing to, so a single dominant index is a likely equilibrium (as with any AppView). The real guarantee is **zero switching cost** — the canonical data lives in the shops' repos, so any index is a replaceable cache and a competitor stands up from the same firehose with no migration. That, plus the canonical `did → storefront` binding (§10), is what keeps the index a cache rather than out-of-band infrastructure. Its one hard prerequisite: the public records must actually be on the shops' PDSes, so the **live public-repo writer** (the dual-write step of §8's migration) gates it — you cannot index what was never published. These are deployment modes of one system, not three products. An implementation targets the contract; where it runs is a configuration decision. --- ## 16. Roadmap toward the standard - **Harden the records + API.** Align the record lexicons to the addressing model (§8) and author the XRPC **method** lexicons (§13) as native JSON. The records and the API are the "get involved" artifact, independent of any one implementation's readiness. - **Settle the neutral authority — before any protocol-canonical data exists.** A space's identity includes its type NSID (`{authority}.orderSpace`, §8), so an authority rename re-authors the *entire* permissioned tier (new `at://` space URIs, dangling `space_ref`s, no CID stability — §3). Public records rename cheaply; the permissioned tier does not. So decide `standard.store` (vs staying on the pilot authority), set up resolution, and **freeze** protocol space creation until then. This gates "onto the protocol" below — the roadmap must not build the expensive private tier under a name it intends to change. - **De-risk the seam, then prove the standard.** Two *distinct* milestones: (a) a second payment + fulfillment provider *in the reference impl* de-risks the integration abstraction (§11) — an implementation-internal test; (b) a **second, independent implementation** reading and writing the same records against the same XRPC contract — the only test that makes this a standard rather than a rename. One operator with many tenants is still one implementation. - **Onto the protocol** (gated by the authority decision). Swap the permissioned store to protocol-canonical (real spaces, delegation-token → space-credential exchange, signed-commit pull-sync); receipts become buyer-held permissioned records (same bytes); the index is demoted to a cache. - **Publish + stand up the clearinghouse.** Publish the lexicons + receipt/claim formats + integration SDK as the open standard; once public records land on PDSes, deploy the central read-index (§15). The reference implementation's build plan (slices, milestones) lives in [REFERENCE.md](./REFERENCE.md); it is one implementation's schedule, not part of the standard. --- ## Appendix — open questions - **Subscription lifecycle** edge cases: proration, dunning grace windows, mid-cycle benefit changes. - **Benefit revocation timing** vs refund windows / chargebacks (grace before revoke?). - **Claim privacy granularity:** configurable, but default to **store + product** (never amount), with a per-product "claimable" flag. Donations/memberships have no product — the claim references the **support tier** instead (or store-only); a tier needs its own "claimable" flag. - **Attestation key rotation** policy (keytrace daily-key vs longer-lived) and how verifiers fetch historical keys. - **Store identity provisioning/rotation** — `store_did` is the only controlled DID (per-relationship spaces anchor on it, `skey` = buyer DID); its DID doc additionally exposes `#atproto_space` (credential key) + `#atproto_space_host` (service). Provisioning and rotating that single identity is the remaining mechanics. - **Guest → DID linking:** migrating a guest relationship space when a buyer later authenticates. - **Funding/guest backer gap (§10):** guests can't author a `purchaseClaim`, so the highest-volume funding path produces no backer-wall entries. Resolve via a shop-authored anonymous-backer claim vs guest→DID conversion at claim time. - **Neutral authority — decision FROZEN pending (§3, §16):** `standard.store` (`store.standard.*`) vs staying on the pilot authority. Because the permissioned tier can't be renamed once protocol-canonical data exists, **no protocol space creation until this is decided** and resolution is set up. Blocks "onto the protocol." - **`attestationKey` optionality (§10):** required-if-used vs fully optional — settle so verifiers implement one discovery path. - **API auth destination:** the exact atproto-OAuth / DID-signed-request scheme that replaces the write-plane trusted-caller boundary (§13).