~ knotless Search/
↑↓ move↵ open⇧↵ all results
0
0
main
266 lines · 20.5 KB Raw History

market: the RFP flow

  • how a requester rents a guest from a bidder over atproto records: rfp, bid, accept, receipt, event
  • index of this tree: README.md (steps S0 to S9); one row per lexicon: lexicons-market.md; the events under a receipt: guest-events.md
  • NSID prefix of every record here: com.publicdomainrelay.temp.; names below drop it (market.rfp = com.publicdomainrelay.temp.market.rfp)
  • constants: internal/atproto/market/nsid.go; record builders: internal/atproto/market/records.go, internal/atproto/market/attest.go
  • requester side: internal/market/requester (component internal/services/requester); bidder side: internal/market/bidder (component internal/services/bidder)
  • applications talk ONLY over these records and XRPC calls. a shared kcp is never a data channel (../KCP.md)

Parties And Repos

partyrepo it writeswhat it writes there
requesterits own (the MarketIdentity DID)compute.vm, policy record, market.rfp, market.accept, market.event + its payloads (compute.events.vm.delete, requester compute.events.guest.*), ingress.webRTC listen + offer, contract-scoped market.association
bidderits ownmarket.offering, market.heartbeat, market.bid + market.bids.free + compute.config.wif.simple, market.receipt, market.event + its payloads (compute.events.vm.*, guest-originated compute.events.guest.*), ingress.webRTC answer
guestnone. the guest holds no repo keyPOSTs its events to the bidder, which signs and writes them (guest-events.md)
  • every record lives in the repo of its author. no party writes into another party's repo
  • a party reads the other side's records by getRecord through a strongRef or a stable rkey, never by listRecords scans

Records Of One Contract

steprecordreporkeystrongRefs it holds
S3compute.vmrequesterTID (CreateRecord)none. user_data = cloud-init from cloudinit.BuildUserData
S3policy record computer.socialweb.temp.policy.ghaliterequesterTIDnone (policy.md)
S3market.rfprequesterTID (market.CreateRFP)payload -> compute.vm, policies[] -> policy record, ingress[] -> ingress.webRTC listen
S4compute.config.wif.simplebidder<bid rkey>-confignone
S4market.bids.freebidder<bid rkey>-payloadnone
S4market.bidbiddermarket.BidRKeyrfp -> rfp, config -> wif config, payload -> bids.free
S5market.acceptrequesterSessionRKey("accept", ...)rfp, bid, ingress[]. no payload (the code passes none)
S6market.receiptbiddermarket.ReceiptRKeyrfp, bid, accept, payload -> the RFP's compute.vm; cid = attestation CID over the accept
S7-S9market.event + payloadsenderper kind (below)receipt -> receipt, payload -> the compute.events.* record
  • sources: internal/market/requester/runner.go (compute.vm, policy, rfp, accept, release), internal/market/bidder/bids.go writeBidRecord, internal/market/bidder/adapters.go acceptContract, internal/market/bidder/submit.go createReceipt
  • market.accepts.* and market.receipts.* are defined lexicons that no Go code writes (lexicons-market.md)

Rkeys

Stable rkeys make every write idempotent and every read one getRecord. A restarted runner reads the record at its rkey before it writes (existingSignedRecord, participant.EnsureRecordAt).

recordrkeyfunctionfile
market.bidbid- + first 20 hex of sha256(bidderDID NUL rfpURI # rfpCID)market.BidRKeyinternal/atproto/market/records.go
market.bids.free, compute.config.wif.simple<bid rkey>-payload, <bid rkey>-configwriteBidRecordinternal/market/bidder/bids.go
market.accept13 chars of sha256("accept", repoDID, contract, rfpURI, rfpCID, bidURI, bidCID) over 234567a-zacceptRKeyFor -> market.SessionRKeyinternal/market/requester/secrets.go
market.receiptreceipt- + first 32 hex of sha256(acceptURI LF acceptCID)market.ReceiptRKeyinternal/atproto/market/attest.go
compute.events.vm.deleteSessionRKey("delete", receiptURI)market.ReleasePayloadRKeyinternal/atproto/market/records.go
requester market.event of vm.deleteSessionRKey("event", receiptURI)destroyEnvelopeinternal/market/requester/runner.go
bidder event payload + market.event<prefix>-payload-<32 hex>, <prefix>-event-<32 hex> of an idempotency keymarket.EventRKeysinternal/atproto/market/records.go
vm.terminated payload + eventkey terminated:<receiptURI>, prefix terminationmarket.TerminationKeyinternal/computevm/termination.go
requester ContractEvent payload + eventSessionRKey("contract-event", uid), SessionRKey("contract-event-envelope", uid)publishContractEventinternal/market/requester/contractevents.go
market.offering, market.heartbeat, market.relaysselfOfferingRKey, HeartbeatRKey, RelaysRKeyinternal/atproto/market/nsid.go
requester ingress.webRTC listenwebrtcingressDescriptorinternal/market/requester/requester.go
contract-scoped market.association, market.associationRequest<service>-<first 32 hex of sha256(did)>-<accept rkey>market.ContractScopedRKeyinternal/atproto/market/guest.go
  • the bidder's event idempotency keys (internal/market/bidder/syncs.go): onNetwork:<receipt>, onNetwork:<tag>:<receipt>, hostKeys:<receipt>, terminated:<receipt>, guest:<nsid>:<tag>:<kind>:<record sha256>:<receipt>, else <nsid>:<receipt>
  • so anyone can find the bidder's vm.terminated of a receipt with one getRecord: market.EventRKeys(market.TerminationKey(receiptURI)) (FindTermination in internal/market/requester/market.go; the atirc lease in internal/market/requester/atirc.go contractLease)
  • the requester finds a receipt with one getRecord at ReceiptRKey(acceptURI, acceptCID) (FindReceipt) and a missed bid at BidRKey (catchUpBids in internal/market/requester/bidcatchup.go)

strongRef Graph

flowchart LR
  VM["compute.vm (requester)"]
  POL["policy record (requester)"]
  RFP["market.rfp (requester)"]
  CFG["compute.config.wif.simple (bidder)"]
  BF["market.bids.free (bidder)"]
  BID["market.bid (bidder)"]
  ACC["market.accept (requester)"]
  REC["market.receipt (bidder)"]
  EV["market.event (sender)"]
  PAY["compute.events.* (sender)"]
  RFP -->|payload| VM
  RFP -->|policies| POL
  BID -->|rfp| RFP
  BID -->|config| CFG
  BID -->|payload| BF
  ACC -->|rfp| RFP
  ACC -->|bid| BID
  REC -->|rfp| RFP
  REC -->|bid| BID
  REC -->|accept| ACC
  REC -->|payload| VM
  EV -->|receipt| REC
  EV -->|payload| PAY
  • strongRef = {uri, cid} (com.atproto.repo.strongRef); a reader checks the CID it resolves equals the CID in the ref (RepoStore.Resolve)
  • market.event.payload must be in the repo of the event author (applyGuestEvent in internal/market/bidder/guestevents.go)

Signatures

  • market.rfp, market.bid, market.bids.*, market.accept, market.receipt, market.event and every event payload carry signatures (network.attested.signature, identity.md)
  • inline signature: market.SignInline hashes the record + metadata {$type, key, issuer} + the repo DID into an attestation CID and signs its bytes (internal/atproto/market/attest.go)
  • receipt binding: CreateReceiptRecordAt sets receipt.cid = attestation CID of the accept record, the receipt metadata and the accept repo DID. the requester checks it with market.VerifyReceipt; a bad one fails the contract ReceiptInvalid (acceptReceipt in internal/market/requester/runner.go)
  • the bidder checks the RFP signature before it provisions (acceptContract), and the event + payload signatures of every requester event (applyGuestEvent)

Discovery

recordwriterreaderrule
market.offering at selfbidder (internal/market/bidder/offering.go)requester (market.ReadOffering)appliesTo must hold compute.vm (market.OfferingApplies); endpointUrl = where submitRfp goes; withdrawnAt + empty appliesTo = withdrawn
market.heartbeat at selfbidder, every HeartbeatInterval (60 s)requester (market.HeartbeatCache), internal/marketpresencefresh while createdAt is at most HeartbeatTTL (300 s) old; offering names the offering (a legacy non-self rkey is read through it)
market.relays at selfno Go writer hererequester (market.RelayURLsOf)extra relays to ask listReposByCollection
  • candidate bidders (HTTPMarket.discover in internal/market/requester/market.go): DIDs from com.atproto.sync.listReposByCollection?collection=market.offering on each relay (configured + market.relays of the trust roots, internal/market/requester/discovery.go), or the contract's spec.bidderDids only
  • unnamed candidates without a fresh heartbeat are dropped (dropNoHeartbeat); spec.denyBidderDids are dropped
  • discovery results are cached 20 s

Delivery: XRPC Callbacks And Streams

Every record also reaches the other side two ways. The XRPC call is the fast path; the stream is the fallback and the only path with no inbound endpoint.

Comms modes

spec.comms.moderequester ingress (submitBid, submitEvent)streams
XRPCyesnone
Firehosenonerelay + Jetstream
Both (requester default)yesrelay + Jetstream
  • stream composer: internal/app/compose/streams.go; collections per role: compose.MarketStreamCollections
  • requester streams carry only market.bid, market.receipt, market.event; bidder streams only market.rfp, market.accept, market.event
  • payload records (bids.free, compute.events.*, ...) are never stream input. the reader fetches them through the strongRef
  • one sink per role (internal/eventstream/market.go) drops a uri|cid seen in the last 15 s (market_event_duplicate_dropped)
  • addressee index (internal/atproto/market/addressee.go, internal/eventstream/addressees.go): the requester keeps a bid whose rfp is one of its RFPs, a receipt whose accept is one of its accepts, an event whose receipt is one of its receipts. the bidder keeps every RFP, an accept whose bid is in its repo, an event whose receipt is in its repo. the rest: market_foreign_dropped
  • the bidder's requester streams (internal/app/compose/requesterstreams.go): only while it has live ComputeVMs, filtered to those requester DIDs, spec.resume: Recent

XRPC procedures

NSIDserved bycallerbodyanswerfile
market.submitRfpbidder ingressrequester{rfpUri, rfpCid, ingress?}200 {ok, name, phase: Deciding, ingress?}; 409 RFPClosed after the bid windowinternal/services/bidder/routes.go handleSubmitRFP
market.submitBidrequester ingressbidder{uri, cid, record} of the market.bid200 {ok}; 403 when the record is not the issuer'sinternal/services/requester/routes.go, Requester.SubmitBid in internal/market/requester/callbacks.go
market.submitAcceptbidder ingressrequester{acceptUri, acceptCid, ingress?}202 {ok, id, acceptUri, acceptCid} (id = the provisioning intent); the receipt follows as a record. the lexicon also allows uri, cid, submitEvent, ingresshandleSubmitAccept
market.submitEventboth ingressesthe contract party that wrote the event{uri, cid, record} of the market.event200 {ok}; 403 Forbidden when the sender is not the accept author (to a bidder) or the receipt author (to a requester)handleSubmitEvent, Requester.SubmitEvent
  • auth: a service-auth JWT with lxm = the NSID; the token issuer is the caller (AuthorizeCallback in internal/market/requester/callbacks.go, participant.Authorizer)
  • market.AuthorizeRecord(issuer, uri): the submitted record must be in the issuer's repo
  • the bidder answers submitBid too (it only parses the bid); the bidder routes exist only with inbound XRPC (inboundXRPC)
  • the requester's submitEvent takes only event kinds a requester consumes (isGuestEventNSID in internal/market/requester/events.go)
  • submitBid in the RFP, submitAccept in the bid, submitEvent in the accept and receipt: a DID service ref (did#serviceId, default ids pdr_temp_market, pdr_temp_compute_event) or an HTTP URL
  • ingress[] on the records and calls: ingress.webRTC listen/offer/answer records for a party whose HTTP endpoint is not reachable (lexicons-market.md)

Bid Window And Selection

  • bidder side window: market.RFPBidWindow = 10 min from the RFP createdAt, else from its TID rkey (internal/atproto/market/rfpwindow.go). the lexicon has no expiry
  • a closed RFP: submitRfp answers 409 RFPClosed, the stream sink drops it (market_rfp_closed_dropped), a Deciding Bid fails BidWindowOver (bidder_rfp_closed)
  • one bid per RFP: the Bid object is named market.BidRKey; after its TTL (24 h, BidTTLSeconds) the bidder's own market.bid at that rkey is the fact (bidder_rfp_already_bid, enqueueBid in internal/market/bidder/submit.go)
  • requester side window: RequesterSpec.bidWindowSeconds (default 30 s); the first policy-allowed free bid ends it (collectBids in internal/market/requester/runner.go)
  • status.chosen.reason: firstFree, bestWithinWindow, policy, resumed; status.bids keeps at most 20 (MaxRecordBids)
  • no bids: outcome NoBids; all denied: BidsDenied; winner denied by policy: PolicyRejected

Policies Attached

  • the requester writes its policy record first and puts its strongRef in market.rfp.policies (policy.md)
  • the bidder runs its own workflow and then every attached policy, before it bids and again on the accept (b.policy.EvaluatePolicies in acceptContract)
  • the requester runs the attached policy on the winner before it writes the accept (q.policy.Evaluate in runner.go)
  • a market policy always runs, both sides, every decision. no verdict skips a run (../SYSTEM.md)

Sequence: RFP To Held

sequenceDiagram
  autonumber
  participant R as Requester
  participant RP as requester repo
  participant S as relay / Jetstream
  participant B as Bidder
  participant BP as bidder repo
  participant G as guest VM

  R->>RP: S3 compute.vm (TID), policy record, market.rfp (TID, signed)
  R->>BP: getRecord offering + heartbeat at self of each candidate
  R->>B: submitRfp rfpUri rfpCid
  B-->>R: 200 name phase Deciding
  RP-->>S: commit market.rfp
  S-->>B: market.rfp (fallback, deduplicated)
  B->>RP: getRecord rfp, compute.vm, policies
  B->>B: bidder policy + every attached policy
  B->>BP: S4 wif config, bids.free, market.bid at BidRKey
  B->>R: submitBid
  BP-->>S: commit market.bid
  S-->>R: market.bid (fallback)
  R->>BP: getRecord bid at BidRKey (catch-up)
  R->>R: window, firstFree, attached policy on winner
  R->>RP: S5 market.accept at SessionRKey accept
  R->>B: submitAccept acceptUri acceptCid
  B-->>R: 202 provisioning intent
  B->>RP: getRecord accept, rfp, verify signatures, policies again
  B->>BP: S6 market.receipt at ReceiptRKey, cid binds the accept
  BP-->>S: commit market.receipt
  R->>BP: getRecord receipt at ReceiptRKey
  R->>R: VerifyReceipt, mint atirc token if spec.agent
  B->>G: ComputeVM, boot user_data
  G->>B: POST /v1/events vm.onNetwork iroh ticket
  B->>BP: S7 vm.onNetwork, vm.hostKeys + market.event
  B->>R: submitEvent
  R->>R: VMAddress + HostKeys set, secrets fetched, ComputeContract Held
  • waits (internal/market/requester/requester.go): receipt DefaultReceiptTimeout 5 min, iroh address DefaultVMReadyTimeout 120 s, host keys DefaultHostKeysTimeout 120 s, getSecrets fetch DefaultVMReadyTimeout
  • Held needs the iroh address and the host keys (waitGuest, runGuestAndHold in runner.go); a sandbox (compute.vm.kind: deno) needs its vm.sandbox result instead
  • the guest side of S7 and S8: guest-events.md

Release And Termination

  1. a release request stamps spec.releaseRequestedAt; the runner enters releaseContract (runner.go)
  2. guest requester first: releaseGuestRequester publishes guest.r2Release and waits for guest.r2Released (only with spec.association.fromGuest)
  3. atirc tokens: revokeBeforeDestroy (internal/market/requester/atirc.go) revokes a leased token while its lease is alive
  4. destroyEnvelope: compute.events.vm.delete at ReleasePayloadRKey(receipt) + market.event at SessionRKey("event", receipt), both signed
  5. submitEvent to the bidder; retried with releaseRetryDelay until the release deadline. no endpoint: the stream carries it
  6. bidder: applyRelease -> verifyInboundDelete (event author = accept author, receipt is its own), markReleaseRequested records the release request on the Bid and ComputeVM, and the ComputeVM is deleted (internal/market/bidder/adapters.go, internal/market/bidder/bids.go finishRelease)
  7. bidder: termination StateSync (internal/computevm/termination.go, adapter atproto.market.event) writes compute.events.vm.terminated {reason: deleted_on_request, request -> the vm.delete} + market.event at EventRKeys(TerminationKey(receipt))
  8. requester: Released once submitEvent answered 2xx, or once the bidder's vm.terminated appears (settleTerminated)
  9. requester deletes only its own contract-scoped records (deleteContractRecords: the com.fedproxy.rbac grant, the contract association)
sequenceDiagram
  autonumber
  participant R as Requester
  participant RP as requester repo
  participant A as atirc
  participant B as Bidder
  participant BP as bidder repo
  participant G as guest VM

  R->>R: spec.releaseRequestedAt, phase Releasing
  R->>B: submitEvent guest.r2Release (fromGuest only)
  B-->>R: submitEvent guest.r2Released
  R->>A: chat.atirc.revoke leased tokens (lease alive)
  R->>RP: S9 vm.delete at ReleasePayloadRKey + market.event
  R->>B: submitEvent vm.delete
  B->>B: verifyInboundDelete
  B->>G: stop, delete ComputeVM
  B->>BP: vm.terminated deleted_on_request + market.event at TerminationKey rkeys
  B->>R: submitEvent vm.terminated
  R->>BP: getRecord vm.terminated (FindTermination) if submitEvent failed
  R->>RP: delete own com.fedproxy.rbac + contract association
  R->>R: ComputeContract Released
  • a guest that stops for any other reason gets vm.terminated with request unset and a reason from market.VMTerminatedReasons (guest_poweroff, vmm_exited, oom, host_reboot, identity_mismatch)
  • a release whose destroy record or submitEvent keeps failing settles Released once the bidder's vm.terminated for that receipt appears
  • a bidder also releases a VM whose contract-scoped association expired 10 min ago (ComputeVM spec.associationExpiresAt, identity.md)

Revocation Ordering

  • no atirc token before a verified receipt (contractLease: "no atirc token is minted before it")
  • each token is leased to the receipt and the bidder's vm.terminated at-uri, so atirc can kill it when the VM ends even if the requester is gone
  • on release: guest requester contracts first, then the leased tokens (while the lease is alive), then vm.delete. a 401 saying the lease is not alive counts as revoked
  • the rest of the revokes run in the background from a release marker in the bundle secret, capped at 24 h (guest-events.md)

Records Policy

  • user records are never deleted. no sweep, retention or GC of repo records
  • the requester deletes only the contract-scoped records it wrote for that contract (rbac grant, association), never a record of another contract
  • one getRecord at a deterministic rkey; listRecords only as a legacy fallback

See Also