Gated behind SERVICES_ENABLED (default off; DB-backed and togglable at
runtime from admin /settings, env var is a hard override — see Feature
flags). First-class fixed-price instant mode, delegation
enforcement on every charge path, tax columns on service money, and
R2-backed file deliverables.
What this is
Services are bitroad's core offering. A buyer agent (or a human) commissions another agent to do something: spare GPU time, synthetic-data generation, specialist inference, vector-store hosting, code review by a specialist agent, research, data labelling. The buyer pays a fixed price in one call or accepts a quote, the payment is held in escrow (see Escrow), the seller submits a deliverable, and the payout releases when the buyer acknowledges it. This subsystem is that quote-and-accept layer, plus the one-call fixed-price path, on top of the shared payments rails.
Physical goods are the secondary rail: a seller posts a fixed-price product listing, a buyer agent pays, the seller ships. Goods keep working unchanged; the table below shows where the two rails differ.
The two agents do not talk peer-to-peer. They both talk to bitroad over MCP; bitroad is the medium, the escrow holder, and the dispute arbiter.
How services differ from goods
| Goods | Services | |
|---|---|---|
| Pricing | Fixed on the listing | fixed mode: deterministic formula; quote mode: negotiated per request |
| Discovery | Browse / search the catalog | services_search_listings, then purchase (fixed) or request a quote |
| Order trigger | Buyer confirms a purchase intent | Buyer accepts a quote / calls services_purchase |
| Fulfilment | Seller ships, carrier delivers | Seller submits a deliverable (URL, text, file, secret) |
| Payment timing | Charge at order placement | Charge at quote acceptance / instant purchase |
| Payout timing | Manual-schedule, held until release | Held until release (see Escrow) |
| Buyer protection | Returns + disputes | Disputes only (no returns concept for services) |
Two pricing modes
fixed — the primary agent flow. The listing carries a
deterministic price and a one-call buyer flow: services_purchase
over MCP / POST /api/v1/service-listings/{id}/purchase over REST
creates the quote thread directly in accepted and charges
immediately — no human seller round-trip, no 24h SLA. The price
formula is block billing in pure bigint maths:
price = fixed_price_pence
+ ceil(scope[fixed_unit_field] / fixed_unit_quantity) * fixed_unit_price_pence
Flat pricing leaves the three unit columns NULL. When unit pricing is
configured, fixed_unit_field must name a required int field in
the listing's scope schema with min >= 0 (validated at listing
create/update).
quote — bespoke work. Buyer files a scope of work
(requested), the seller answers with price + ETA + terms
(quoted), the buyer has SERVICE_QUOTE_ACCEPT_HOURS (default 24h)
to accept. A price outside the listing's optional
min_price_pence/max_price_pence band is rejected at submit time
and at every counter-offer (price_below_band / price_above_band;
bounds inclusive, a null bound is open on that side), and the charge
engine re-validates the standing price against the band at accept as a
backstop — a row whose quoted_price_pence somehow left the band can
never be charged.
services_request_quote against a fixed listing refuses with
listing_not_quote_mode (and vice versa for services_purchase).
Quote thread — state machine
requested → withdrawn (buyer-only, before quote submitted)
requested → quoted ──┬──► rejected (buyer-only; also = principal deny)
├──► expired (accept SLA passed; worker)
└──► accepted → paid → in_progress
│ │
pending_principal_confirmation ◄─────┤ ▼
│ (cap kickback) └──► delivered ──► released
├──► accepted → paid (re-accept w/ token) │
├──► rejected (principal denies) ├──► disputed ──► refunded (buyer wins)
└──► expired (confirmation TTL) │ └──────► delivered (seller wins / withdrawn)
└──► (revisions re-arm the release clock)
cancelled — admin-only, from any pre-release state (refunds if charged)
requested— buyer agent filed a scope-of-work;scope_jsonis validated against the listing'sscope_schema_jsonat this boundary.quoted— seller submitted price + ETA (+ terms).accept_sla_at = quoted_at + SERVICE_QUOTE_ACCEPT_HOURS. On a haggling-enabled listing either party may post a counter-offer from here (and the seller fromrequested); each counter stays inquoted, overwrites the standing offer, and restarts the accept SLA (see Haggling).pending_principal_confirmation— the charge tripped a delegation confirmation threshold or a payment-envelope kickback. A confirmation token is minted (24h TTL); the principal approves by re-accepting withacknowledged_confirmation + confirmation_token, or denies via reject. Past the TTL the worker expires the thread.accepted— transient: acceptance recorded, charge in flight.payment_intent_iddistinguishes the two sub-states, and neither lets a seller act (services_start_workrequirespaid):- NULL — reserved: the full charge snapshot (price, fee, tax,
envelope, payment method) is committed and the Stripe call is either
in flight or was interrupted. This is the services twin of
purchase_intents.confirming, and the reason a captured charge always has a local record to converge onto. See the reserve → charge → settle split in payments.md. - set — SCA pending: the charge ended
requires_actionand the quote is pinned to its PaymentIntent. Completing the challenge is finalised authoritatively by thepayment_intent.succeededwebhook (service_quote_idmetadata →finalizePaidServiceQuote, idempotent underFOR UPDATE), and a retry-accept replays the same PaymentIntent (stablesvc-chargekey) rather than minting a second charge.
A declined card unwinds the reservation: back to
quoted/pending_principal_confirmationfor a quote that had one, orcancelledfor aservices.purchasequote, which was minted by the same call and has no prior status. The row is kept either way — a committed record of an attempted charge is precisely what the old charge-inside-the-insert version threw away.- NULL — reserved: the full charge snapshot (price, fee, tax,
envelope, payment method) is committed and the Stripe call is either
in flight or was interrupted. This is the services twin of
paid— Stripe charge succeeded; funds sit in the seller's Stripe balance with payout held. Tax line written at this moment.in_progress— seller calledservices_start_work.delivered— ≥ 1 deliverable submitted.release_sla_at = now + SERVICE_RELEASE_DELAY_DAYS; each revision (additional deliverable) re-arms the window so the buyer always gets the full window on the latest submission.released— payout triggered (see Escrow). Terminal.disputed— open dispute pauses the thread and the auto-release timer. Buyer-win →refunded(terminal). Seller-win → back todeliveredwith release due immediately. Withdrawn → back todeliveredon the original clock.cancelled— admin-only; refunds in full if the quote was charged. Terminal.
Haggling — iterative counter-offers (seller opt-in)
Off by default. A seller sets haggling_enabled = true on a quote-mode
listing to allow multi-round negotiation; with the flag off, a thread
is single-round for the buyer (request → quote → accept/reject) and
a buyer services_counter_quote is refused with haggling_not_enabled.
A haggling listing must set a positive min_price_pence floor
(min_price_required_for_haggling otherwise): the buyer picks and
self-accepts any price within the band, so without a floor a buyer could
counter to 1p and pay 1p. The floor is enforced at listing create/update
and the band is re-checked on every counter.
The seller may always counter/revise their own standing offer
pre-accept, regardless of the flag — a revision is just a counter
authored by the seller.
- Who counters. The buyer principal counters a submitted quote
(
status = 'quoted'; haggling-enabled listings only); the seller counters arequestedor alreadyquotedthread (gated by the delegation'srespond_quoteaction; allowed even with haggling off). A thread inpending_principal_confirmationis awaiting human sign-off and cannot be countered. The seller console shows the round-by-round history and a counter/revise form onquotedthreads; the buyer console shows the history read-only (buyer counters go via MCP/REST). - Effect of a counter. It appends a row to
service_quote_offers, overwrites the quote'squoted_price_pence/quoted_eta_minutes/quoted_termswith the new offer, recomputesapplication_fee_pence, keepsstatus = 'quoted', and resetsaccept_sla_at = now + SERVICE_QUOTE_ACCEPT_HOURS. Because the thread stays inquoted, the existingexpireUnacceptedServiceQuotesworker and theservice_quotes_accept_sla_idxpartial index keep tracking it from the newest offer. - A counter is free — but band-checked. No charge, no
delegation-cap or payment-envelope evaluation happens at counter
time, but the listing's
min_price_pence/max_price_penceband is enforced on every counter from either party (sameprice_below_band/price_above_banderrors as submit). Caps, the envelope, and the confirmation-required kickback are all re-evaluated at accept against whatever the latest offer is — accept always charges the standingquoted_price_pencevia the charge engine, which also re-validates the band before charging, so a cap breach after N counters still kicks back topending_principal_confirmationand an out-of-band price is refused. - Bounded rounds.
MAX_HAGGLE_OFFERS = 6counters per thread (server/services/quotes.ts). Beyond the boundservices_counter_quotereturnshaggle_rounds_exhausted; only accept/reject remain. The initialsubmit_quoteis not counted (it writes no offer row). - Notifications. Each counter notifies the other party, reusing
the
service_quote_submittedkind with a per-offerevent_id.
Charging — same rails as goods
Quote acceptance AND fixed-price purchase run the exact enforcement
stack as confirmPurchaseIntent:
- Delegation evaluator (
delegations/evaluate.ts): per-tx cap, daily cap, total cap (cross-rail — see below), seller allow/deny, category allow/deny, confirmation threshold. - Payment envelope (when the instance has one): balance, per-tx,
per-day, scope checks; kickbacks transition the quote to
pending_principal_confirmationexactly like purchase intents; successful charges write an atomic CAS-guarded drawdown (envelope_drawdowns.service_quote_id). - Stripe destination charge to the seller's connected account with
application_fee_amount(same per-seller bps as goods, on the full price — services have no shipping line).
Cross-rail spend sums. Daily/total cap accounting
(delegations/spend.ts) sums goods orders (placed_at) and
charged service quotes (paid_at) under the delegation — in both
directions. Services cannot be used to sidestep goods caps and vice
versa.
Category namespace. The evaluators see service charges as category
service:<service_category> (e.g. service:synthetic_data). A
delegation or envelope with a category allow-list therefore denies
service spend unless the principal adds the service:* categories
explicitly — scoped delegations are services-closed by default.
Failure semantics. Hard delegation/envelope denials throw
policy_denied/conflict with nothing persisted (a denied fixed
purchase leaves no row). requires_action (SCA) returns 402 with the
client secret and writes nothing. A declined card throws 402 and the
quote stays acceptable with another payment method (the Stripe
idempotency key includes the per-call key, so a retry isn't glued to
the declined attempt).
Tax line + currency
service_quotes carries the same tax columns as orders — vat_pence,
vat_rate_bp, vat_treatment — written once at charge time by the
shared tax engine (packages/core/src/server/tax/engine.ts), plus a
currency snapshot of the seller's settlement currency taken at
thread creation:
- GB sellers — same as goods: a VAT-registered seller's inc-VAT
price splits at the listing's
vat_rate_bp; non-registered sellers getnone. The tax never changes the charge total. - US sellers — Stripe Tax computes an exclusive sales-tax line
(
vat_treatment = 'us_sales_tax') sourced to the buyer's default address (no saved address → zero-rated). The buyer's card is chargedprice + tax;quoted_price_pencestays the tax-exclusive price. Delegation/envelope evaluation and the envelope drawdown all use the tax-inclusive total, anddelegationSpendadds the exclusive tax back when summing service spend so caps match what was charged.
UK B2B reverse-charge place-of-supply rules are not produced;
reverse_charge is reserved. Payout release runs in the seller's
currency (payouts.create { currency: seller.currency }).
Tax-remittance wiring for US sales is handled separately — see
compliance.md.
Schema
Four tables in packages/core/src/db/schema/
(service-listings.ts, service-quotes.ts, service-quote-offers.ts,
service-deliverables.ts):
service_listings
A seller's advertised capability. Not an order — the order is the quote.
| Column | Notes |
|---|---|
id |
uuid |
seller_id |
FK |
title, summary |
Buyer-facing |
category |
Flat enum: gpu_compute / synthetic_data / inference / vector_hosting / code_review / research / other |
scope_schema_json |
Seller-defined field DSL (below); buyer scope must conform |
pricing_hint |
Free-text budgeting guidance for buyer agents |
min_price_pence, max_price_pence |
Optional band — prices outside it are rejected at submit and at every counter, and re-checked by the charge engine at accept |
eta_hint_minutes |
Typical turnaround (informational; becomes the quote ETA on fixed purchases) |
auto_quote_enabled |
Seller agent answers over MCP — informational/notification routing |
fulfillment_instance_id |
Optional FK → agent_instances. Null (default) = any seller key with respond_quote may fulfil (unchanged behaviour). Non-null = only that instance (or the seller owner via a console session) may quote/counter/start/deliver, and a dedicated key sees only its bound listings. See "Scoping a listing to a fulfilment key" below |
haggling_enabled |
Seller opt-in to iterative counter-offers (default false). Off = single-round (request → quote → accept/reject). See Haggling below |
pricing_mode |
quote / fixed |
fixed_price_pence, fixed_unit_field, fixed_unit_price_pence, fixed_unit_quantity |
Fixed-mode formula; check constraint requires fixed_price_pence when mode = fixed |
vat_rate_bp |
Inc-VAT rate used for the tax split at charge time |
status |
draft / active / paused / archived |
admin_hidden_at, admin_hidden_reason |
Admin takedown (see "Admin moderation" below). Kept separate from status so a seller pause and an admin hide never overwrite each other |
service_quotes
The negotiation log + the order, fused. One row per quote thread.
| Column | Notes |
|---|---|
service_listing_id, seller_id |
Listing + denormalised seller |
buyer_principal_id, buyer_agent_instance_id, buyer_delegation_id |
Buyer-side actor triple, mirrors orders |
scope_json, requested_summary |
Buyer-supplied (summary ≤ 4 000 chars) |
pricing_mode |
Snapshot of the listing's mode |
currency |
Seller settlement currency snapshot (gbp | usd | eur); all *_pence columns are minor units of it |
quoted_price_pence, application_fee_pence, quoted_eta_minutes, quoted_terms |
Quote terms (terms ≤ 2 000 chars); fee recomputed at charge |
vat_pence, vat_rate_bp, vat_treatment |
Tax line, written at charge (us_sales_tax is exclusive — charged on top) |
status |
See state machine |
payment_intent_id, stripe_charge_id, stripe_transfer_id, stripe_payout_id |
Stripe trail |
envelope_id |
Envelope the charge drew from, when one was active |
requires_confirmation, confirmation_token, confirmation_kind, confirmation_expires_at |
Cap-kickback flow, mirrors purchase intents |
requested_at … released_at, accept_sla_at, release_sla_at |
Lifecycle timestamps + worker deadlines |
idempotency_key |
Per AGENTS.md rule 2 |
service_quote_offers
Append-only counter-offer log: buyer counters (haggling-enabled
listings) and seller counters/revisions (any quote-mode listing).
The service_quotes row always holds the latest offer
(quoted_price_pence / quoted_eta_minutes / quoted_terms); this
table is the full round-by-round history.
| Column | Notes |
|---|---|
id, service_quote_id, created_at |
|
offered_by |
buyer / seller (service_offer_party enum) |
amount_pence |
Inc-VAT, minor units of the quote's currency |
eta_minutes |
Proposed turnaround for this offer |
terms |
≤ 2 000 chars, nullable |
The initial submit_quote does not write a row here — only counters
do. MAX_HAGGLE_OFFERS = 6 counters per thread; beyond that only
accept/reject remain. See Haggling below.
service_deliverables
The handoff. Append-only — a seller can submit multiple if the buyer asks for revisions before accepting delivery.
| Column | Notes |
|---|---|
id, service_quote_id, submitted_at |
|
kind |
url / text / file / secret |
payload_json |
url → {url}; text → {text}; file → {url, key, byte_size, mime_type, filename} (R2-hosted); secret → {envelope} (AES-256-GCM via integrations/crypto.ts) |
seller_note |
≤ 2 000 chars |
buyer_acknowledged_at |
Set when the buyer acknowledges delivery |
Secret payloads are decrypted only on the buyer-side read path and
never logged; sellers and anyone else see {"secret": "<redacted>"}.
File deliverables arrive base64-encoded at the surface
layer (file_b64 + file_name + file_mime_type, max 25 MB),
are pushed to R2 via the existing apps/web/src/lib/uploads/r2.ts
path under service-deliverables/<quote_id>/<uuid>/<name>, and the
core service stores only the object metadata. Without R2 configured
the surface returns 503 uploads_not_configured.
Related columns elsewhere: disputes.service_quote_id and
refunds.service_quote_id (each XOR order_id via check constraint),
envelope_drawdowns.service_quote_id.
Scope schemas
scope_schema_json is a constrained field DSL compiled to Zod at
validation time (server/services/scope-schema.ts) — not raw JSON
Schema, so validation stays in one vocabulary:
{
"rows": { "type": "int", "min": 1, "max": 1000000 }, // required by default
"format": { "type": "enum", "required": false, "values": ["csv", "jsonl"] },
"schema": { "type": "string", "max_length": 4000 }
}
Types: string (max_length), int/number (min/max),
boolean, enum (values). Max 32 snake_case fields. The compiled
object is strict — unknown scope keys are rejected. Validation runs at
request/purchase time only (validate at the boundary, trust
internally).
Escrow — held payouts, not custody
Bitroad does not hold customer funds — that would put it into FCA payment-institution / e-money territory. Stripe is the regulated custodian. Bitroad controls the timing of when funds leave Stripe for the seller's bank.
Mechanism: at Connect-account creation
(integrations/stripe.ts#createConnectAccount),
settings.payouts.schedule.interval = 'manual'. Funds from a paid
quote sit in the seller's Stripe balance. The release engine
(server/services/payouts.ts) calls payouts.create on the
connected account with idempotency key bitroad:svc-payout:<quote_id>
when a release condition fires. Refunds during the hold window pull
from that balance cleanly — no claw-back from the seller's bank.
Payout amount accounts for partial refunds (split disputes):
remaining = price − refunded
payout = remaining − (application_fee × remaining) / price
Applies to all sellers, all listing types. Goods sellers get the
same payout model — same dispute machinery, one payment path. The
goods-side releaseDueOrderPayouts automation (release on
carrier-delivery) is out of scope — see payments.md.
Release triggers (any one fires):
- Buyer calls
services_acknowledge_delivery(explicit accept). release_sla_atpasses with no open dispute (default 7 days).- Admin force-release (
deliveredthreads only — disputed threads resolve through the disputes pipeline instead).
Risk-tiered payout gate. A trigger firing is still subject to the
seller's payout tier (sellerPayoutsReleasable,
server/payouts/tier.ts — see payments.md): held
sellers (new / restricted, unless Founding-50) keep funds in their
Stripe balance and releaseServicePayout returns null, so the worker
simply retries until the seller graduates. An admin force-release still
holds a risk-restricted seller — the hold is a money-safety control,
not just a timing default.
Funds-settlement gate. Manual payouts draw on the connected
account's available balance, but a card charge's funds sit in the
pending balance for days — so a release trigger is not always
immediately payable. releaseServicePayout pre-checks the available
balance (getAvailableBalancePence) and returns null without touching
the payout when it can't cover the amount; checking first also keeps
the stable bitroad:svc-payout:<quote_id> idempotency key clean
(Stripe replays a failed keyed request's error for 24h).
Buyer acknowledgement is therefore recorded, not gated: it stamps
buyer_acknowledged_at on the deliverables and pulls release_sla_at
to now, then attempts the payout inline. If the funds haven't settled
the thread stays delivered and the worker's
releaseDueServicePayouts retries every tick until the balance clears
(an open dispute still pauses it). The buyer console hides the
acknowledge button once all deliverables are stamped and shows a
"payout releases once funds settle" note instead.
Disputes
The existing pipeline (disputes.md) extended for
service targets — disputes.service_quote_id XOR order_id:
- Service reason codes on
disputes.reason:service_not_delivered,service_quality,service_scope_mismatch. Goods-shipping reasons (not_received/defective/not_as_described) are refused on service targets at the input boundary, and vice versa; the generic trio (fraud_unauthorized_charge/duplicate_charge/other) works on both. - Auto-classification
(
disputes/auto-classify.ts#classifyServiceDispute):service_not_deliveredAND zero deliverables AND the seller's own delivery commitment (paid_at + quoted_eta_minutes) has passed → auto-resolve buyer, full refund (rulebuyer_service_not_delivered). No ETA on the quote → admin queue.service_quality/service_scope_mismatch→ admin queue (subjective, never auto-resolve).- High-value (> £500) → admin queue, as for goods.
- Filing flips the quote to
disputed, which pausesrelease_sla_at— the release worker additionally re-checks for open disputes before paying out. Resolution semantics are in the state machine above; buyer-win refunds runcreateRefundwithreverseTransfer: trueand a fee reversal pro-rated over the quoted price, pinned exactly at Stripe rather than left torefund_application_fee(payments.md). They write arefundsrow keyed on the quote — recording the reversal Stripe actually executed — credit the envelope when one is still active, and land the thread onrefunded.
Disputable statuses: paid, in_progress, delivered (and further
filings while disputed); ≤ 3 active disputes per thread.
REST surface
All routes 404 when SERVICES_ENABLED is off. Every write requires
Idempotency-Key with full replay semantics (AGENTS.md rule 2) and
emits an audit_log row (rule 10).
# Discovery (any commerce actor)
GET /api/v1/service-listings # active-listing search (?q ?category ?pricing_mode); ?mine=true → caller-seller's own
GET /api/v1/service-listings/{id}
# Buyer
POST /api/v1/service-listings/{id}/purchase # fixed-price one-call
POST /api/v1/service-quotes # request a quote (quote-mode listings)
GET /api/v1/service-quotes # list mine
GET /api/v1/service-quotes/{id} # detail incl. deliverables (party-scoped)
POST /api/v1/service-quotes/{id}/accept # accept + charge; carries acknowledged_confirmation/confirmation_token for cap kickbacks
POST /api/v1/service-quotes/{id}/reject # also: principal denies a pending confirmation
POST /api/v1/service-quotes/{id}/withdraw # before quoted only
POST /api/v1/service-quotes/{id}/acknowledge-delivery
# Seller
POST /api/v1/service-listings # create (delegation list_create)
POST /api/v1/service-listings/{id} # update incl. status flips (list_update)
GET /api/v1/sellers/me/service-quotes # incoming threads (?status=requested = open requests)
POST /api/v1/service-quotes/{id}/quote # submit price + eta + terms (respond_quote)
POST /api/v1/service-quotes/{id}/start # paid → in_progress (respond_quote)
POST /api/v1/service-quotes/{id}/deliver # JSON; file kind via file_b64 (respond_quote)
# Admin (is_admin principal session)
POST /api/v1/admin/service-quotes/{id}/cancel # with reason; refunds if charged
POST /api/v1/admin/service-quotes/{id}/force-release
MCP surface
Seventeen tools in apps/web/src/lib/mcp/tools.ts,
hidden from tools/list and refused at dispatch when the flag is off.
Buyer side
| Tool | R/W |
|---|---|
services_search_listings |
read |
services_get_listing |
read |
services_purchase |
write — fixed-price one-call |
services_request_quote |
write |
services_list_my_quotes |
read |
services_get_quote |
read — includes deliverables; secrets decrypt buyer-side only |
services_accept_quote |
write |
services_reject_quote |
write |
services_withdraw_quote |
write |
services_acknowledge_delivery |
write |
Seller side (gated by the delegation's respond_quote action; listing CRUD by list_create/list_update)
| Tool | R/W |
|---|---|
services_create_listing |
write — publish a service listing (list_create); updates/status flips stay REST-only |
services_list_open_requests |
read — polling tool for quote-mode listings: unanswered requests only (status = 'requested') |
services_list_my_sales |
read — polling tool for charged threads: status = paid | in_progress | delivered, or active (all three, default). The only MCP path to a fixed-mode purchase, which is born paid |
services_submit_quote |
write |
services_counter_quote |
write — counter-offer on a haggling-enabled thread; either party calls it (buyer counters a quoted thread, seller a requested/quoted one). See Haggling |
services_start_work |
write |
services_submit_deliverable |
write — file_b64 for file kind |
Per the existing MCP idempotency pattern, write tools auto-generate
auto-<uuid> keys when the client omits _meta.idempotencyKey (see
apps/web/src/lib/mcp/dispatch.ts).
How the seller agent learns about new requests
Polling. On whatever cadence the agent's host runs. Always works, no infrastructure needed. Which tool depends on the listing's pricing mode:
- fixed —
services_list_my_sales. A fixed purchase is created directly inaccepted→paid, so it never has arequestedphase andlist_open_requestscan never see it. Pollstatus=active(the default) and the same call also surfaces threads alreadyin_progress/delivered, so a restarted agent recovers its own in-flight work. - quote —
services_list_open_requestsfor unanswered requests to quote, thenservices_list_my_salesfor the ones the buyer accepted and paid for.
Both polls apply the same fulfilment read-filter: when the caller is a dedicated fulfilment key (one bound to ≥1 of the seller's listings via
fulfillment_instance_id) it sees only its bound listings' threads; a general key (bound to nothing) sees only unbound listings' threads; the seller human console sees all. See "Scoping a listing to a fulfilment key".- fixed —
Notifications.
service_quote_requestedfires through the existing notify pipeline on request (email to the seller human + webhook to a registered seller-side agent endpoint), with a once-per-thread reminder after 24h unanswered.Server-Sent Events on the MCP connection. Out of scope; the schema doesn't preclude it.
Scoping a listing to a fulfilment key
A listing carries an optional fulfillment_instance_id (nullable FK →
agent_instances). It binds who may fulfil that specific listing.
The binding is set from the listing editor's "Fulfilled by" selector in
the seller console (the create form and the [id] detail page's
Fulfilment card), or via fulfillment_instance_id on the create/update
APIs.
- Null (default). Any seller agent key with the
respond_quoteaction may quote / counter / start / deliver, and every seller key sees the listing's requests. This is the historical behaviour — unbound listings are byte-for-byte unchanged. - Non-null (bound). The listing is scoped to one seller agent instance.
Ownership. On create/update the bound instance must be a live
(non-revoked) agent of the seller's owner principal, else
fulfillment_instance_not_owned (409). A null on update unbinds.
Read-back. Seller-scoped reads echo the binding as
fulfillment_instance_id (uuid, or null when unbound): the
create/update responses, listMyServiceListings, and getServiceListing
when the caller owns the listing — so an agent can verify what it set.
Buyer-facing reads (searchServiceListings, and a get by a non-owner)
omit the key entirely; the binding is the seller's internal routing, not
catalogue data.
Write-guard. On the four seller fulfilment paths — submit_quote,
counter_quote (seller side only; buyer counters are unaffected),
start_work, submit_deliverable — a bound listing refuses any key
other than the bound one with not_fulfiller (403). The guard reads the
listing's live binding inside the same transaction, so a rebind
takes effect immediately mid-thread (no snapshot column on the quote).
Owner override (break-glass). A seller acting through a console
session (principal_self) always bypasses the guard — the human owner
can fulfil any of their listings regardless of the binding.
Strict read-filter. services_list_open_requests,
services_list_my_sales, and the seller quote list apply the binding to
reads too: a dedicated key (bound to ≥1 listing) sees only its bound
listings' quotes; a general key (bound to none) sees only unbound
listings' quotes; the console session sees all. The filter is a SQL
predicate on the query, so pagination stays correct — as does the
cursor, which is the last id of the page returned (the list filter is a
strict id > cursor).
Buyer-side flows (accept, reject, withdraw, acknowledge, buyer counter) are never affected by the binding.
The Bitroad store catalogue (sole-seller mode)
Since the 2026-09-13 pivot Bitroad is the only seller (seller sign-up is locked, accounts.md). The store's own listings come from two places:
- Seven hand-built agents in the separate seller-agents repo
(blind review, clean room, detonation chamber, commitment witness,
auction runner, estate escrow, first-transaction badge). Each has
dedicated fulfilment code and a
create-listing-*script there. - The catalogue in
apps/web/scripts/services-catalogue.ts:
43 fixed-price, machine-fulfilled listings whose summary is the whole
fulfilment contract. A generic prompted handler in the seller-agents
repo fulfils any of them from the listing's own title, summary and
scope, fetching buyer-supplied
*_urlfields; nothing per-listing lives outside the summary.
pnpm --filter @bitroad/web seed:services upserts the catalogue under
the bitroad store, keyed on (seller, title). New rows are drafts:
the worker has no ETA auto-refund, so an active listing with no running
fulfiller would hold a buyer's charge until they dispute. Pass
--activate once the generic handler is polling; --dry-run validates
without writing; --store <slug> targets another store (tests). Status
is never touched without --activate, and listings outside the
catalogue are never touched at all. Every entry is validated through
CreateServiceListingInput at seed time.
Catalogue 2 (seller-agents repo)
The second catalogue, fifty services an agent cannot do without another
agent (time, reachability, history, neutrality, coordination, vantage,
independence), lives entirely in the seller-agents repo: blueprints in
src/catalogue2/listings.ts, per-group handlers, and the create-listings-2
and activate-listings-2 scripts that manage them over the public REST API.
Nothing platform-side changes for them; they are ordinary fixed-price
listings under the bitroad store. Twelve blueprints are deferred (they
need a sandbox, a browser, inbound mail, an attack corpus, or a platform
aggregate endpoint) and are neither created nor activated.
Worker jobs
In the apps/web worker loop (each a no-op when the flag is off):
| Job | Cadence | Purpose |
|---|---|---|
expireUnacceptedServiceQuotes |
every 5 min | quoted → expired past accept_sla_at; also expires lapsed pending_principal_confirmation threads |
releaseDueServicePayouts |
every 1 min | payouts.create for delivered quotes past release_sla_at with no open dispute |
notifyDelinquentSellerOnService |
every 15 min | Once-per-thread email nudge when a requested quote sits > 24h (deduped on notify event-id) |
resolveStuckAcceptedQuotes |
every tick | Resumes charges that died between their reserve and settle transactions (accepted + NULL payment_intent_id, older than 60s) by replaying paymentIntents.create under the stable svc-charge key |
issueBadgesOnce |
every 15s | First-transaction badge automation (below); no-op unless BADGE_SERVICE_LISTING_ID is also set |
First-transaction badge
A marketing product sold as a normal fixed-price service listing: the buyer's agent purchases the badge, and the worker fulfils it automatically — no human seller in the loop.
- Configuration.
BADGE_SERVICE_LISTING_IDnames the official bitroad badge listing (owned by the platform's own seller store, so buyer/seller disjointness holds), read viabadgeServiceListingId()in flag.ts. Unset = the whole feature is off, guard included. - Purchase guard. Only true first-timers can buy the badge at all:
purchaseServicerefuses the badge listing withbadge_first_transaction_only(409) when the buyer has any prior order or paid service quote — the badge purchase must be their first transaction. Checked pre-charge inside the purchase transaction; a concurrent duplicate that slips the check is still fulfilled by the job below, just without a second congratulations email. - Job (badge-issue.ts):
finds
paidquotes on that listing with zero deliverables, renders a 1200×630 PNG (badge.ts, SVG → sharp; DejaVu fonts are installed in the worker image), uploads it to the public R2 bucket atbadges/<quote_id>.png, then — mirroringsubmitServiceDeliverableunderFOR UPDATE— files it as afiledeliverable and flips the quote todeliveredwith the normal release clock. Idempotent with no extra state: the paid-with-no- deliverable predicate is re-checked inside the transaction, and an upload failure before the transaction just retries next tick. - Ordinal. The badge headline is the purchase's position among
every paid bitroad transaction (goods orders + paid service quotes;
both carry a NOT NULL
agent_instance_id, so every counted transaction is agent-attributed). Ties onpaid_atbreak by quote id, so same-instant buyers never share a number. - Email.
service_badge_issuednotification (template badge-issued.ts) congratulates the buyer, embeds the badge by URL, and links the quote. Sent at most once per buyer: only when the badge purchase is the buyer's first paid transaction on the platform (a buyer has exactly one first transaction, so theisFirstcheck is the whole guard — no extra state). The purchase guard above makes non-first purchases rare (a race, or a quote paid before the env var was set); when one exists it is still fulfilled (file deliverable on the order) but gets the standardservice_deliverable_submittednotice instead. Suppressible from the buyer settings page like other non-mandatory kinds. - Escrow is unchanged — the payout to the platform's store releases on acknowledgment or the release SLA like any service sale.
Notifications
New notification_kind values:
service_quote_requested→ seller (also reused for the 24h nag)service_quote_submitted→ buyer (also reused for SLA-expiry notice)service_quote_accepted→ sellerservice_deliverable_submitted→ buyerservice_payout_released→ sellerservice_dispute_opened→ sellerservice_badge_issued→ buyer (first-transaction badge delivery, see above; dedicated congratulations template)
Cap kickbacks reuse the goods confirmation kinds
(purchase_confirmation_required / purchase_pending_principal_confirmation)
with service_quote_id in the payload.
Console surfaces
- Seller:
/sellers/[store]/services(+/new,/[id]) for listing CRUD and status flips;/sellers/[store]/service-quotes(+/[id]) to answer requests, start work, and deliver (url/text/secret — files go via API/MCP). The delegation editor exposes therespond_quoteaction. - Buyer:
/buyer/service-quotes(+/[id]) — accept/reject/withdraw, approve pending-confirmation charges with the token, read deliverables (secrets decrypt here), acknowledge delivery. - Fulfilment keys: sellers mint and manage the agent keys that fulfil
listings at
/sellers/[store]/agents— mint a key, scope it via the delegation editor (respond_quote,list_create,list_update), rotate, revoke. Keys are shown once at mint time. A listing'sfulfillment_instance_id(above) then points at one of these instances to scope its fulfilment. - All pages
notFound()when the flag is off; sidebar items hide. - Admin:
/services(+/[id]) and/service-quotes(+/[id]) in the admin console — see admin.md.
Admin moderation
An admin can hide a service listing from the admin console
(adminHideServiceListing / adminUnhideServiceListing in
server/admin/service-listings.ts, audit actions
admin.hide_service_listing / admin.unhide_service_listing, reason
required). While admin_hidden_at is set:
searchServiceListings, the public storefront,getServiceListing(non-owner),requestServiceQuoteandpurchaseServiceall treat the listing as not found.- The owning seller still reads it (with
admin_hidden_atvisible only in the admin console, not in the seller API output) and can pause or archive it, butupdateServiceListingrefusesstatus: activewithlisting_admin_hidden. - Open quote threads are untouched — an admin cancels those individually
(
adminCancelServiceQuote, which refunds in full if charged).
Unhide clears both columns; the seller then re-activates from their own console. Every action is audit-logged with the reason.
Validation
Per AGENTS.md rule 12, every MCP tool input and REST body parses
through Zod at the boundary. scope_json is validated against the
per-listing scope_schema_json at request/purchase time; no internal
re-validation on subsequent calls. Reason-family and target-XOR rules
for disputes live on FileDisputeInput.
Out of scope
- Recurring services / subscriptions (cron-billed inference).
- Multi-seller bid auctions. Quotes are 1:1.
- Off-platform delivery verification. The platform trusts the
seller's
submit_deliverableclaim; buyer recourse is a dispute. - Service categories in the catalog taxonomy. Flat enum; promote to the taxonomy DSL once 5+ categories exist.
- Real-time SSE notifications to seller agents.
- Goods payout-release automation (
releaseDueOrderPayoutson carrier delivery) — accounts are already on manual payouts.
Feature flags
SERVICES_ENABLED— gates the entire surface (REST, MCP, console pages, worker jobs). DB-backed and runtime-togglable from admin/settings(writes thefeature_flagsrowkey='services_enabled'); theSERVICES_ENABLEDenv var is a hard override — when it is set (1/true/0/false) it always wins and the DB toggle is ignored. With the env var unset, the DB value is authoritative (default off when the row is absent).The reader
servicesEnabled()in flag.ts stays synchronous (it's called from hot/sync sites — the memoised MCP catalogue filter, the dispatch gate, worker ticks). It serves a process-local cache that a non-awaited, error-swallowedrefreshServicesFlag()keeps warm on a 30s TTL; a DB error during that background refresh never propagates into the sync caller. Consequence: a toggle from admin/settingspropagates across the web, admin, and worker processes within ~30s (each services worker tick callsrefreshServicesFlag()first, so the worker converges within one tick). Setting the env var makes the flag deterministic and instant again (prod/tests rely on this).SERVICE_RELEASE_DELAY_DAYS— default7. Buyer-acceptance window before auto-release.SERVICE_QUOTE_ACCEPT_HOURS— default24. SLA on the buyer accepting a submitted quote.