# UFP — Universal Fabrication Protocol v0.3

**The agent-only procurement protocol for turning design files into shipped physical objects.**

There is no human UI. The protocol is designed to be called by AI agents:
one request shape for every fabrication process, offers ranked like a
flight-search result, refinable with plain constraints, purchasable with a
single checkout call.

The canonical spec is code: [`packages/protocol/src`](../packages/protocol/src)
(Zod schemas). This document is the human-readable rendering.

## Roles

UFP names three roles. **Agents** buy: one request shape, ranked offers,
one checkout. **Networks** (aggregators) fan requests out to many vendors,
rank the results, and carry orders, reputation, and attestations — the
`/v1/*` funnel in this document is the network's agent-facing surface (the
*aggregator profile*). **Vendors** make: any shop can sell to agents by
hosting the vendor HTTP binding, with or without a network in between.
The vendor binding, the reputation objects, and off-network attestations
are specified in [VENDOR-BINDING.md](VENDOR-BINDING.md),
[REPUTATION.md](REPUTATION.md), and [ATTESTATIONS.md](ATTESTATIONS.md).

## Design principles

1. **One funnel.** Agents learn one flow — `quote → refine → checkout →
   track` — regardless of process. New fabrication methods (CNC, sheet
   metal, PCB) add a `process` variant, never a new tool.
2. **Constraints narrow, never widen.** "Must be here by Friday" and "must
   survive pool water" are first-class request fields, not free text.
3. **All-in pricing.** The buyer pays exactly what the offer says. UFP's
   take is inside the unit price; there is no fee line.
4. **Vendors are adapters — or native speakers.** API, GraphQL, or
   headless browser — the protocol doesn't care; and a vendor that hosts
   the [vendor binding](VENDOR-BINDING.md) needs no adapter at all.
   Capability discovery (`GET /v1/capabilities`) tells agents what the
   network can currently make.

## Flow

```
Agent                          UFP                              Vendors
  |  POST /v1/files (artwork)     |                                |
  |------------------------------>|                                |
  |  POST /v1/quotes              |  fan out to matching adapters  |
  |------------------------------>|------------------------------->|
  |  <- ranked offers (+previews pending)                          |
  |  POST /v1/quotes/:id/refine ("deadline": "2026-07-13")         |
  |------------------------------>|  re-filter                     |
  |  <- narrowed offers                                            |
  |  POST /v1/checkout            |  create order + payment link   |
  |------------------------------>|                                |
  |  <- checkout_url  (user pays on hosted page)                   |
  |          [payment webhook]    |  fulfillment job places the    |
  |                               |  real vendor order (API call   |
  |                               |  or headless browser checkout) |
  |  GET /v1/orders/:id           |                                |
  |------------------------------>|  status, ETA, tracking         |
```

## Authentication

`POST /v1/agents {"name": "my-agent"}` returns an API key (`ufp_sk_...`),
shown once. Send it on every call: `Authorization: Bearer ufp_sk_...`.

## The request document

```jsonc
// POST /v1/quotes
{
  "process": "decal",              // or "fdm_print" — or OMIT it entirely and
                                   // UFP routes the file itself (see Auto-routing)
  "spec": {
    "file_id": "f_abc123",         // from POST /v1/files
    "size_in": { "width": 3, "height": 3 },   // decal only, optional
    "cut": "triangle",             // FREE-FORM (optional) — pass the user's words.
                                   // Known cuts (contour|square|oval|kiss_cut) match
                                   // exactly; unknown asks get closest-match offers
                                   // flagged with `substitutions`, and the ask is
                                   // logged as a demand signal for vendor sourcing
    "material": "vinyl",           // free-form too — omitted = UFP picks compliant options
    "quantities": [1],             // one offer per quantity — ask for what the user
                                   // wants, even 1. Vendors with a higher minimum
                                   // quote AT their minimum and set requested_quantity
    "attachments": [               // optional — extra files for the SAME part
      { "file_id": "f_def456",     // (the PDF drawing next to the STEP); role is
        "role": "drawing" }        // optional — omitted, UFP infers it from the kind
    ]
  },
  "constraints": {
    "ship_to_zip": "78701",        // optional — drives shipping quotes; omitted, the
                                   // network stamps its assumed US destination and
                                   // declares it on routing.ship_to (see Auto-routing)
    "deadline": "2026-07-13",      // optional — offers that can't make it are dropped
    "processes": ["sheetmetal",    // optional lane filter — quote ONLY these processes
      "laser_cut"],                // (intersected with routing); absent = no filter;
                                   // on refine: inherited by the child, and an explicit
                                   // null CLEARS an inherited filter ("show everything
                                   // again") — see Refinement
    "outdoor_rated": true,         // optional (UV/weather exposure)
    "submersible": true,           // optional ("pool water")
    "high_temp": true,             // optional (vendor heat-resistance claim)
    "food_safe": true,             // optional (vendor food-safety certification)
    "max_budget_cents": 3500,      // optional, all-in cap
    "reliability": {               // optional vendor-reliability floor
      "min_stars": 4,              // displayed rating floor (no-review vendors excluded)
      "min_reviews": 10,           // at least N verified-purchase reviews
      "proven_spec_orders": 10     // ≥N completed orders for THIS process+material
    },
    "requirements": [              // optional catch-all — verbatim buyer terms,
      "UV resistant",              // up to 20; see "Requirements & callouts"
      "anodized: red",
      "iso 13485",
      "tolerance: 0.1mm"
    ],
    "callouts": [                  // optional drawing dimensions the agent extracted
      { "name": "hole_diameter", "value": 6.5, "unit": "mm",
        "tolerance_mm": 0.1, "critical": true }
    ]
  }
}
```

Attribute constraints (`outdoor_rated`, `submersible`, `high_temp`,
`food_safe`) filter offers by **vendor-advertised material claims only** —
a material with no claim is dropped, never assumed to comply. Claims are
an **open vocabulary**: beyond the four named flags, any boolean key is a
legal vendor-advertised claim (`"iso_13485": true`) on capability sheets
and offers alike — the honest-claims rule is unchanged (a key appears only
when the vendor actually advertises it; absent means "no claim", never
"no"), and adding a claim key needs no schema change. Which materials
claim what is discoverable per process via `GET /v1/capabilities`
(each material entry carries its attribute flags, plus size/quantity
ranges), so agents can pre-filter before quoting. A process entry may
also claim `tolerance_mm` — the tightest ± tolerance (in mm) the vendor
holds for that process; absent means no claim, and a buyer demanding a
tolerance excludes vendors with no claim on record.

The `reliability` constraint filters on the vendor's track record instead
of the material's: `min_stars`/`min_reviews` floor the verified-purchase
rating, and `proven_spec_orders` demands at least N completed orders for
the exact process+material being quoted — "a vendor that prints PC
reliably". Like every constraint it narrows, never widens. The records
behind it are specified in [REPUTATION.md](REPUTATION.md).

### Attachments

`spec.attachments` carries additional files for the **same part** — the
PDF drawing next to the STEP, a spec sheet, a BOM: up to 10 entries of
`{"file_id": "f_…", "role"?: "drawing" | "spec_sheet" | "bom" |
"centroid" | "aux"}`. `role` is optional everywhere; omitted, it is
inferred from the file kind (artwork alongside a geometry file is the
`drawing`; a second geometry file is `aux` — UFP never guesses that two
geometry files are the same part). Attachments never route or price a
quote — they inform whoever fabricates — and they persist with the
part's stored spec, so reorders by part number replay the full document
set automatically.

### Requirements & callouts

`constraints.requirements` is the **catch-all channel**: up to 20 verbatim
terms lifted straight from the buyer's conversation — materials, finishes,
certifications, tolerances — with no structure demanded of the agent:

```jsonc
"constraints": {
  "ship_to_zip": "78701",
  "requirements": ["UV resistant", "anodized: red", "iso 13485", "tolerance: 0.1mm"],
  "callouts": [
    { "name": "hole_diameter", "value": 6.5, "unit": "mm",
      "tolerance_mm": 0.1, "critical": true }
  ]
}
```

Resolution is deterministic on the network side, and every term's fate is
visible: terms the network resolves against its vocabulary are **applied**
and affect the offers ("UV resistant" behaves like `outdoor_rated: true`);
terms it cannot resolve come back in the quote's `notes` ("couldn't apply
…") and land in the demand ledger, so recurring unresolved terms directly
drive what vocabulary and vendors the network builds next. Terms **fill,
never override**: a requirement never beats an explicit spec or constraint
field the agent also set. A merely-unknown term is never an error — there
is no wrong place to put a term.

`constraints.callouts` carries the dimensions the buyer's agent read off
the drawing, as structured intent: `{name, value, unit ("mm"|"in"),
tolerance_mm?, critical?, note?}`. Every callout persists with the quote;
the **tightest tolerance wins** — the smallest `tolerance_mm` across
callouts (and any resolved tolerance requirement) prunes vendors whose
published per-process `tolerance_mm` claim cannot hold it, and vendors
with no tolerance claim on record are excluded when a tolerance is
demanded (absent is "no claim", never "close enough").

For `fdm_print`, `spec` takes `file_id` (STL or STEP — STEP models are
converted to a mesh server-side), free-form `material` (canonical:
`pla | petg | asa` — unknown asks like "abs" or "polycarbonate" get
closest-match offers flagged with a `substitutions` entry and are logged
as demand, the same closest-match-over-hard-reject rule as every other
process), optional `color`, and `quantities`.

For `sheetmetal`, `spec` takes `file_id` (DXF flat pattern or STEP model),
free-form `material` (canonical: `mild_steel | aluminum_5052 |
stainless_304` — "steel", "5052", "stainless" resolve), optional
`thickness_in` (quoted at the nearest stocked gauge), optional `units`
(`mm | in`, DXF only, default mm), optional `bend_count` (> 0 prices CNC
bending), free-form `finish` (`as_cut` default, `deburred`,
`powder_coat`), and `quantities`.

**Filterable criteria for sheetmetal** — the constraint vocabulary agents
can rely on: `outdoor_rated` drops bare mild steel (it rusts; the vendor
makes no outdoor claim) while aluminum and stainless pass; when no finish
was pinned, mild steel survives the outdoor filter by upgrading to a
`powder_coat` finish (vendor-advertised UV/weather protection).
`submersible` currently matches nothing — no sheet-metal vendor
advertises it, and absent means "no claim". `deadline` and
`max_budget_cents` narrow as on every process. Discover material
attributes, per-material `thicknesses_in`, `finishes`, size and quantity
ranges via `GET /v1/capabilities` before quoting.

For `print` (paper printing — business cards, flyers, posters), `spec`
takes `file_id` (print-ready PDF preferred; PNG/JPEG accepted), optional
`product` (`business_cards | flyers | posters` — omit it to quote every
kind the artwork fits), free-form `size` ("letter", "24x36", "half
letter"; cards are 3.5x2, flyers run 8.5x5.5 | 8.5x11 | 11x17, posters
12x18 through 36x48 — unknown sizes get closest-match offers), free-form
`paper` ("matte", "glossy", "recycled", "14pt"), free-form `color`
("full color" — black-and-white asks are quoted full color with a
substitution note, since the launch vendor prices CMYK only), `sides`
(`single | double`), and `quantities` (defaults: cards/flyers 100,
posters 1 — small poster runs route to a large-format lane that sells
from quantity 1).

**Filterable criteria for `print`:** `deadline` is the workhorse — it
prunes shipping options (and whole offers) whose production + transit
ETA misses the date, which is how "must be here by Friday" picks the
faster lane. `max_budget_cents` caps the all-in total. `outdoor_rated`
and `submersible` correctly EXCLUDE print offers: paper stocks carry no
such vendor-advertised claims (absent means "no claim", never "yes").
Selecting recycled stock is a spec choice (`paper: "recycled"`), not a
constraint. Product kinds, per-product size presets, stocks, and color
options are discoverable up front via `GET /v1/capabilities` (`products`,
`sizes`, `materials`, `colors` fields), so agents can pre-filter before
quoting.

For `cnc`, `spec` takes `file_id` (STEP only — machinists quote true solid
geometry), free-form `material` (canonical: `aluminum_6061 | aluminum_7075 |
stainless_304 | titanium | brass | acetal | delrin | abs | nylon_pa6 | peek |
acrylic`), optional `finish` (`standard | anodized | polished | bead_blasted`),
and `quantities`. Filterable criteria: `outdoor_rated` (aluminum 6061,
stainless 304, titanium, acrylic), `submersible` (most stocks), `high_temp`
(aluminum 6061, stainless 304, nylon PA6, PEEK), plus `deadline` /
`max_budget_cents`.

For `resin_print` (SLA/DLP), `spec` takes `file_id` (STL or STEP), free-form
`material` (canonical: `standard | tough | high_temp | high_detail`),
optional `color`, and `quantities`. Filterable criteria: `high_temp`
(high-temperature resin only — no resin currently claims outdoor/submersible/
food-safe status), plus `deadline` / `max_budget_cents`.

For `metal_print` (metal additive — SLM/DMLS/LPBF + binder jet), `spec` takes
`file_id` (STL/STEP/IGES), free-form `material` (canonical alloys:
`stainless_316l | stainless_17_4ph | titanium_ti64 | aluminum_alsi10mg |
inconel_718 | cobalt_chrome | tool_steel_h13 | copper`), free-form
`technology` (`laser_powder_bed | binder_jet | ebm` — adapters map synonyms
like SLM/DMLS and flag technologies they don't run), free-form `finish`
(`as_printed | bead_blast | machined_critical | polished | heat_treated`),
and `quantities`. Metal fans on file drops like every additive lane (default
powder: 316L stainless) — expect its offers well above the plastic ones on
the same part; that's the physics, stated on each offer's note. Naming it
(`process: "metal_print"`, or "metal 3d printing" / "SLM" / "printed
titanium" in the process vocabulary) prunes the fan to metal-capable vendors.

## Auto-routing

Omit `process` (`"here's a file, how much for 6?"`) and UFP detects what the
file is and quotes every process that can make it:

- **artwork** (png/jpg/svg/pdf) → decal + print offers (live)
- **stl** → fdm_print + resin_print + metal_print offers (live)
- **step/stp** → fdm_print + resin_print + metal_print + cnc + sheetmetal
  offers (all live; mesh conversion happens server-side where a process
  needs it, and the sheet-metal vendor extracts the flat pattern)
- **dxf** → sheetmetal offers (live); laser-cut / print ride along as
  not-yet-live candidates

Live processes fan out over a small default material set (PLA + ASA for
FDM; mild steel + 5052 aluminum for sheetmetal; aluminum 6061 + POM for
CNC; standard + tough resin for resin_print; 316L stainless for
metal_print — constraints like `outdoor_rated` still narrow the set), so
one request returns a few sensible variants per process. The response carries a top-level
`routing` object:

```jsonc
"routing": {
  "detected_file_kind": "dxf",
  "candidates": ["sheetmetal"],          // quoted live above
  "also_possible": [                     // no vendor on UFP yet — each ask is
    { "process": "laser_cut",            // logged as a demand signal that
      "reason": "2D profile ready for laser cutting" }, // drives vendor sourcing
    { "process": "print",
      "reason": "flat artwork could be printed" }
  ],
  "ship_to_zip": "67202",                // the destination shipping was ACTUALLY priced for
  "ship_to": {                           // present ONLY when the request carried no
    "zip": "67202",                      // ship_to_zip: the network stamped its assumed
    "assumed": true                      // US destination on the whole fan (prices stay
  }                                      // all-in with real freight). Absent = the buyer
}                                        // supplied the ZIP.
```

A process that gains a live vendor graduates from `also_possible` to
`candidates` automatically (as CNC did when Craftcloud landed).

**Assumed destination.** When `constraints.ship_to_zip` is absent, core
stamps `67202` (Wichita KS — mid-CONUS sits in one broad carrier zone, so
central rates are representative; see DECISIONS 2026-07-30) on every run
before the fan: adapters always receive a ZIP and carry zero
default-destination logic of their own. The wire declares the assumption
via `routing.ship_to { zip, assumed: true }`; clients must present the
destination as network-assumed — never user-chosen — and invite a real
ZIP. A refine that lands `ship_to_zip` reprices and drops the marker from
the child. The assumed ZIP can never reach an order: checkout forces full
shipping-address collection (Stripe) when the caller supplies none, and
fulfillment ships to the collected address.

Passing `process` explicitly behaves exactly as before and skips routing.

## The offer

```jsonc
{
  "id": "of_xyz",
  "vendor": "stickerapp",
  "vendor_display_name": "StickerApp",
  "vendor_logo_url": "https://…/favicons?domain=stickerapp.com&sz=128",
                                    // the vendor's brand mark for offer cards
                                    // (ALC-46); absent when UFP has no curated
                                    // mark — render the name alone, never a
                                    // placeholder image
  "spec_resolved": { "size": "3x3 in", "cut": "contour", "material": "vinyl", "finish": "glossy" },
  "quantity": 25,
  "requested_quantity": 1,          // only present when a vendor minimum bumped the
                                    // quantity — lower minimums are a competitive axis
  "substitutions": [                // only present when this offer is a closest-match
    { "field": "cut",               // stand-in for an ask no vendor supports yet;
      "requested": "triangle",      // supporting the ask is a competitive axis too
      "provided": "contour" }
  ],
  "unit_price_cents": 112,          // take rate already inside
  "subtotal_cents": 2800,
  "shipping_options": [
    { "id": "standard", "label": "Standard (free)", "cost_cents": 0,
      "eta_date": "2026-07-20", "eta_business_days": 8 },
    { "id": "express", "label": "Express", "cost_cents": 1500,
      "eta_date": "2026-07-16", "eta_business_days": 6 }
  ],
  "total_from_cents": 2800,
  "preview": { "status": "ready", "url": "https://…/previews/pv_1.png",
               "source": "vendor_render" },
  "attributes": { "outdoor_rated": true },  // only vendor-advertised claims appear;
                                            // absent = "no claim", not "no"
  "price_basis": "cached_matrix",   // vendor_api | cached_matrix | estimate
  "expires_at": "2026-07-09T03:00:00Z"
}
```

**Previews** are "whatever the vendor shares" for those exact options —
for StickerApp, a screenshot of their own wizard's live render of the
user's artwork with the selected cut. They resolve asynchronously: re-fetch
`GET /v1/quotes/:id` until `preview.status` is `ready`. Previews are cached
by artwork hash + options, so repeat quotes resolve instantly.

**`price_basis`** tells agents how firm the number is: `vendor_api` (live
quote), `cached_matrix` (UFP's scraped price table), `estimate` (geometry-
based heuristic). Agents should caveat estimates to their users.

**Closest-match semantics.** Agents should pass spec words verbatim
("triangle", "die cut", "chrome"). Known values and synonyms resolve
exactly; anything else returns offers for the closest supported options,
each carrying a `substitutions` entry, plus a quote note explaining the
stand-in. Every request — including unfulfillable and invalid ones — is
recorded as a demand signal, so repeated asks for an unsupported cut,
material, size, or process directly drive which vendors UFP sources next.

## Refinement

`POST /v1/quotes/:id/refine` with any subset of constraint fields creates a
child quote (the `parent_quote_id` chain preserves the narrowing history).
An **empty body re-reads the quote** — useful for polling previews.

The body may also carry a **spec delta** — `{"spec": {"material"?, "color"?,
"quantities"?}}` — for "actually, in PLA" / "make it 5" mid-thread changes.
A spec delta is a spec **change**, not a narrowing: the child quote's spec is
the parent's spec with the delta fields overridden, and the affected lanes
**requote** with the new spec (through warm vendor sessions where available).
An explicit `spec.material` replaces the material fan-out — it is the ask,
not a filter — and like every material field it is free-form: unstocked asks
resolve to the closest stocked material with a `substitutions` entry.
Material changes belong here, **not** in `requirements` (a bare material term
in `requirements` still fills an empty spec.material as before, but the spec
delta is the first-class path). Constraint fields in the same body keep their
merge-and-narrow semantics unchanged.

**Process narrowing** ("no, I meant in sheet metal") is a **constraint**, not
a new quote: `constraints.processes` on a refine prunes the fan to the listed
lanes while warm vendor sessions survive the narrowing. The child inherits
the parent's filter like any other constraint; a new array replaces it; an
explicit `null` **clears** an inherited filter — the one sanctioned widening
("show everything again") — and the child's stored constraints omit the key.
The pruning is always visible in the quote's notes (quoted lanes, excluded
lanes), `routing.candidates`/`expected_vendors` reflect the filtered lanes,
and a filter that excludes every candidate lane quotes nothing — honestly,
with a note, never an error. Values are canonical process names; hosts may
resolve friendlier vocabulary before the wire (the MCP server maps "sheet
metal", "3d printing", "any"…).

**Refining while the quote is still streaming.** Constraints may arrive at
any time — a client never waits for `"complete"` to refine. A **pure
narrowing** (deadline / budget in either direction, attribute demands,
lane-subset `processes`, attribute-only `requirements`) of a quote still
`"quoting"` answers in one round trip with zero new vendor asks: the child
comes back already seeded with every landed offer re-judged under the merged
constraints, and keeps filling as the in-flight vendors answer — the network
**re-steers the running work onto the child** instead of restarting it. The
child's `routing.expected_vendors` carry the **remaining** expected wait for
each inherited lane (time already served is subtracted), so bar animations
paced with them read short for a quick prune and long for a heavy one.
Refining the child again mid-stream chains the same way. A **spec delta or
destination change** mid-stream is a mind-change: the superseded fan is
cancelled wholesale (no vendor is ever asked the same question twice) and
the child re-prices through warm sessions. Landed offers always survive on
their own quote — a superseded quote settles `"partial"` with real,
orderable offers.

**Comparison scenarios.** The body may carry a `scenario` label (free-form,
max 40 chars, e.g. `"carbon steel"`) that turns the refine into a
**side-by-side lane** instead of a replacement: refines with *different*
labels coexist and stream concurrently under the same parent ("how do these
prices compare to carbon steel?" = one labeled refine per alternative), a
refine *reusing* a label supersedes only that scenario's in-flight work, and
a refine *without* a label is a mind-change — it resets the baseline and
supersedes every open lane. Each labeled child echoes `scenario` on every
read (`QuoteResponse.scenario`) so pollers can bucket offers by lane. At most
**4 distinct active labels** per parent: a 5th is rejected with the active
labels listed — reuse one to replace it, or refine unlabeled to reset.
Scenario lanes share the parent's design file, so they re-price through the
same warm vendor sessions.

**Organize by.** The body may carry `order_by` — how the quote's `offers[]`
should **present**, never what they contain. Two shapes: a named key
(`{"order_by": {"key": "arrival"}}` — `recommended` | `price` | `unit_price`
| `total_price` | `arrival` | `rating`; optional `direction: "desc"` reverses
the key's natural cheapest/soonest/best-first direction) or an **explicit
order** the agent judged itself (`{"order_by": {"custom": ["of_b", "of_a"],
"label": "most green first"}}` — first id on top, `label` required because
only the agent knows what its order means; ids the quote doesn't carry are
ignored, offers not listed land below the listed ones in recommended order).
An `order_by`-**only** body is pure presentation: it re-orders the **same
quote in place** — no child, no vendor calls, instant — and the ordering
persists: every subsequent read serves `offers[]` already sorted and echoes
`QuoteResponse.order_by`, streaming offers slot into the asked order as they
land, and refine children **inherit** the parent's ordering until a body
replaces it or resets with `{"key": "recommended"}`. Combined with
constraints or a spec delta, `order_by` rides along and persists on the
child. `near_misses` keep their own closest-first order regardless.

## Checkout

```jsonc
// POST /v1/checkout
{
  "offer_id": "of_xyz",
  "shipping_option_id": "express",
  // ship_to / contact are OPTIONAL: omit them and the hosted payment page
  // collects the shipping address and email — agents can hand out a
  // checkout link the moment the user picks an offer.
  "ship_to": { "name": "…", "street1": "…", "city": "…", "state": "TX", "zip": "78701" },
  "contact": { "email": "buyer@example.com" },
  // OPTIONAL: the page the buyer is on when checkout starts. The
  // post-payment landing pages show a "Return to <site>" button pointing
  // here — without it the payment success page is a dead end. http(s)
  // only; pass a URL the client actually knows, never an invented one.
  "return_url": "https://make-this.ai/"
}
// -> { "order_id": "ord_…", "checkout_url": "https://checkout.stripe.com/…",
//      "total_cents": 4300, "currency": "usd" }
// Cached-matrix vendors also return "price_basis": "estimate_pending_verification":
// their checkout_url lands on a brief "confirming your price" page that reads
// the vendor's live price. The quote is a CEILING — the confirmed total can
// only match the quote or come in lower (drops pass straight to the buyer).
// If the vendor repriced upward beyond what UFP absorbs, the order expires
// unpaid and the agent simply re-quotes. Agents need no special handling.
```

The agent hands `checkout_url` to the user. UFP is the merchant of record;
after payment, a fulfillment job places the real vendor order (Slant3D API
call, or a headless-browser checkout on StickerApp using UFP's account),
shipping directly to the buyer.

## Part numbers & reorders

Every **paid** order mints a durable, vendor-agnostic part number
(`UFP-…`), delivered to the buyer on the receipt email together with a
one-click reorder link. Identity is content-addressed — same design bytes
plus same normalized spec equals the same part — so a reorder re-shops the
part across **all** current vendors, not just whoever made it last time.

```jsonc
// Reorder: no file re-upload, spec fields override the stored ones.
// POST /v1/quotes
{ "process": "decal", "spec": { "part_number": "UFP-mK2…", "quantities": [100] },
  "constraints": { "ship_to_zip": "78701", "ship_to_country": "US" } }

// Inspect a part:  GET /v1/parts/UFP-mK2…
```

### Privacy

Parts are **unlisted** by default: the part id is an unguessable
capability token that only exists on the buyer's receipt, and the part
page shows a preview and spec summary — never the design file itself.

Owners can switch a part to **locked** mode from the manage page (a magic
link in their receipt email — possession-based, no account). When locked,
the part number alone is inert: viewing the part page and reordering both
require the part's **share key** — `/part/UFP-…?k=<key>` on the web, or
`spec.share_key` alongside `spec.part_number` on `POST /v1/quotes`
(`GET /v1/parts/:id` takes it as a `?share_key=` query parameter).
Without it, locked parts return nothing beyond the lock itself.

The share key can be **rotated** at any time from the manage page, which
immediately invalidates every previously shared link.

Order lifecycle: `pending_payment → paid → placing → placed →
in_production → shipped → delivered` (or `failed` / `cancelled`), exposed
at `GET /v1/orders/:id` with an event log and tracking when available.

## The .ufp part file

The request IS the CAD plus every constraint on top of it — in one
standardized file. A `.ufp` is a plain STORE-mode ZIP with exactly two
required members (`UfpManifestSchema` in `ufp-file.ts` is the contract):

```
bracket.ufp
├── ufp.json            manifest: format version, spec, constraints,
│                       design ref, provenance (pretty-printed JSON —
│                       readable and hand-editable)
└── design/bracket.step the design bytes, verbatim
```

It replaces the multi-document part (CAD + drawing PDF + notes) with one
deterministic artifact a PLM/BOM system can hold, and it round-trips:

- **Download**: every quote with a design file announces `ufp_url` (and
  `design_url` for the bare design — "save just the .step", though plain
  unzip works too, by design). `GET /quotes/:id/part.ufp` wraps the
  design bytes with the quote's **current** spec + constraints; refines
  mint child quote ids, so each URL's bytes are stable and the child's
  .ufp carries the refined intent. Public, quote-id-as-capability — the
  same trust model as `routing.viewer_mesh_url`.
- **Upload**: a `.ufp` is accepted anywhere a design file is (`POST
  /v1/files`, MCP `design_file`/`file_url`, buyer-web drop). Intake
  unwraps it — the inner design registers through the ordinary
  normalize/sniff path (adapters, mesh derivation, and the viewer see a
  plain design file) — and the manifest's saved intent is persisted with
  the file. Quote creation then applies it as **fill-if-absent
  defaults**: anything the request states explicitly wins, requirements
  terms union, a saved deadline that already passed is skipped, and
  every applied/skipped field is named in the quote's `notes`. Refine
  children inherit the merged parent instead of re-applying, so a
  constraint the buyer clears never resurrects.

Unknown manifest keys are ignored (forward compatibility); nested
containers are rejected; a file named `.ufp` that isn't a readable
container fails intake loudly instead of landing as junk artwork.

## Vendor reviews

Every offer carries a `vendor_rating` — the vendor's star value plus an
**honest review count** (a vendor with zero reviews shows `review_count: 0`)
and facet averages when reviewers scored them:

```jsonc
"vendor_rating": { "stars": 4.6, "review_count": 12,
                   "facets": { "quality": 4.5, "timeliness": 4.8, "accuracy": 4.7 },
                   "reliability": [ { "process": "fdm_print", "material": "pc",
                                      "orders": 14, "stars": 4.9, "on_time_rate": 0.93 } ] }
```

`reliability` is the per-process/material track record with honest sample
sizes ("prints PC reliably") — filterable via the `reliability` constraint;
records and grading live in [REPUTATION.md](REPUTATION.md).

A reliability entry may instead carry a **`spec_path`** — an ordered
subprocess drill-down, broad → niche, derived from the resolved spec of
the work performed (`["folding"]`, `["folding", "powder_coat"]`,
`["folding", "powder_coat", "flat_black"]`): a vendor can be great at
folding, fine at powder coat, and weak at flat black specifically, and
every rung is its own honestly-sampled group. `recent_stars` (the plain
average of the group's most recent reviews, present only when the group
holds more reviews than the network's window) shows a rung trending away
from its lifetime record. Offers additionally carry
`vendor_rating.spec_track` — the same entries narrowed to THIS offer's
resolved spec, ordered broad → niche (process rollup, material facet,
then each subprocess rung), so a renderer can show the rating for what
is actually being bought without re-deriving the chain:

```jsonc
"spec_track": [ { "process": "sheetmetal", "orders": 57, "stars": 4.9 },
                { "process": "sheetmetal", "spec_path": ["folding"],
                  "orders": 31, "stars": 4.6 },
                { "process": "sheetmetal",
                  "spec_path": ["folding", "powder_coat", "flat_black"],
                  "orders": 6, "stars": 3.1, "recent_stars": 2.6 } ]
```

Reviews are **verified-purchase only**: leaving one requires possession of
the unguessable order id, the same capability model as checkout links.
One review per order — resubmitting updates it, never duplicates.
Off-network transactions that vendors attest (and buyers confirm) produce
separately labeled `attested_transaction` records — never blended into
verified-purchase stars; see [ATTESTATIONS.md](ATTESTATIONS.md).

```jsonc
// POST /v1/orders/:id/review
{ "stars": 4,                                  // required, 1-5
  "comment": "clean cut, arrived a day early", // optional
  "facets": { "quality": 5, "timeliness": 5 }  // optional, each 1-5
}
```

`GET /v1/orders` lists the calling agent's recent orders (newest first) so
an assistant can resolve "that order from last week" to an order id.
Buyers without an agent get the same ability via a branded review page:
`GET /order/:id/review` (star links are emailed after delivery). Ratings
feed offer ranking, so consistently good vendors win more orders.

## Endpoint summary

| Method & path              | Purpose                                     |
| -------------------------- | ------------------------------------------- |
| `POST /v1/agents`          | Self-serve API key (public)                 |
| `GET /v1/capabilities`     | What the network can make (public)          |
| `POST /v1/files`           | Upload artwork/STL/STEP/DXF, or register a URL |
| `POST /v1/quotes`          | Fan-out quote -> ranked offers              |
| `GET /v1/parts/:id`        | Inspect a part number (reorders)            |
| `GET /v1/quotes/:id`       | Re-read (previews resolve async)            |
| `POST /v1/quotes/:id/refine` | Narrow with constraints (incl. `processes` lane filter; `null` clears) / change spec (material, color, quantities) / labeled comparison `scenario` |
| `POST /v1/checkout`        | Order + hosted payment link                 |
| `GET /v1/orders`           | The agent's recent orders                   |
| `GET /v1/orders/:id`       | Status, ETA, tracking, event log            |
| `POST /v1/orders/:id/review` | Verified-purchase vendor review           |
| `POST /v1/attestations`    | Vendor-reported off-network transaction (vendor-key authed) |
| `GET /quotes/:id/part.ufp` | The all-in-one .ufp part file (public, quote-id capability) |
| `GET /quotes/:id/design`   | The bare design file inside it (public)     |
| `POST /webhooks/stripe`    | Payment confirmation (Stripe-signed)        |

Object ids are 96-bit random capability tokens; quotes expire after 24h.
`POST /v1/attestations` is the one endpoint not called by agents: vendors
authenticate with an attestation key (`ufp_vk_…`, distinct from agent API
keys) to report transactions the network never saw — see
[ATTESTATIONS.md](ATTESTATIONS.md).

## MCP surface

The same funnel is exposed as an MCP server (`/mcp`, Streamable HTTP) with
six tools — `get_fabrication_quote`, `refine_quote`, `create_checkout`,
`get_order_status`, `list_orders`, `leave_review` — plus an embedded
quote-comparison widget for ChatGPT (MCP Apps standard). REST and MCP hit
the same service layer; behavior is identical.
