# Deconfound kitchen trials: recipe → quote → checkout

Base: https://labs.deconfound.com/api/v2
OpenAPI: /openapi.json. REST auth: X-Deconfound-Api-Key.
Agents can use REST or native Streamable HTTP MCP at /mcp with X-Deconfound-Api-Key. No Sites gateway token or ChatGPT login is required. MCP clients must support custom headers; see /mcp-guide.html for connection and tool examples.

## API key scopes and retries

Use `ingredients:read`, `ideas:read`, `ideas:write`, `orders:read` and `orders:write` for the complete customer workflow. Operator access is separate.

- Saving an idea needs `ideas:write`; generating its recipe also needs `ideas:read`.
- Quoting or ordering a saved idea or website generation also needs `ideas:read`. Exact ingredients supplied in the request do not.
- Reading or changing saved customer orders, payment, delivery, offers or trial selections needs `orders:read` as well as the operation's write scope. Referencing an earlier order or experiment parent also needs `orders:read`.
- Submission-only keys may create ideas, explicit legacy lab formulas, trial briefs, standalone experiment plans and prompt requests with only the matching write scope. Creation and retries then return a stable receipt: `{ "request": { "id": "…", "createdAt": "…", "links": { "self": "…" } }, "receiptOnly": true }`. The envelope key is `idea`, `order`, `trial` or `experiment` on those routes. Later recipe edits, results, revisions and status are not returned. Read the linked resource with a read-enabled key.

Scopes are checked again on retries. Reusing a stronger key's idempotency key never grants its read permissions.

## Offer

Free-text experiment plans are unpriced. Generate a catalog-backed recipe before quoting work. Earlier receipts retain their saved quote.

Recipe trials are priced for the exact recipe. For illustration, a straightforward 500 g still lime trial quotes at $69 for one preparation, $99 for two and $119 for three; these are examples, not guaranteed starting rates. Prices depend on the exact ingredients, quantities and preparation method. Extra flavors, premium ingredients, carbonation, chilling and longer mixing can increase the quote. Comparisons and repeats share setup; always display the server quote rather than applying a fixed menu or calculating a discount locally. Once a fully priced request is submitted, its saved quote is the fixed order total for that scope. Later pricing-policy changes do not reprice it. A changed recipe or scope requires a new quote and explicit customer acceptance.
Each preparation includes up to 500 g, up to eight non-water ingredients, a reviewed mixing/chilling method, an as-made ingredient record, actual batch mass, labeled photos, visual observations, one maker's descriptive tasting notes, and a next-step recommendation.
Photos and maker's notes are bundled; there are no separate photo/taste fees.
A comparison changes one existing ingredient group while keeping other ingredients and the reviewed preparation conditions constant. Variations are distinct formulas, not statistical replicates.

Instrument readings (including pH and apparent Brix), larger batches, complex processes, unresolved sourcing and precision doses are outside automatic ordering. Choose a supported alternative; the normal flow does not create a review order. All ingredients, equipment and methods require approval before work. The starter commercial dose limit is 0.1 g in 0.01 g increments, subject to verified balance capability. Exact quantities are never rounded or silently diluted.
No consumer panel, nutrition assay, microbiology, safety validation or shelf-life study is included. Tasting occurs only after approval of source products and preparation. Unavailable observations must remain not measured, with a reason.

## 1. Create or supply a recipe

Use POST /ideas, persist the returned ID and revision, then POST /ideas/{id}/generate with the current revision and a separate idempotency key. Allow a 300-second client timeout for this two-pass foreground request; read the saved idea after an interruption. Read canonical idea.recipes, with reason and gaps for any unfulfilled directions. Each complete recipe returns services.quote and services.trialProposal with exact variant changes. A quote failure does not erase the recipe: retry quoting without regenerating.

The recipe source may be:
- idea: owned ideaId, current ideaRevision, optional variantId and acceptAlternative.
- generation: owned website generationId; do not use an API idea's generation attempt ID.
- ingredients: name, current catalogVersion, batchMassG, exact ingredients and optional carbonated, preparation, benchSettings, tests, replicates.

Tests default to [{"metric":"mass","min":null,"max":null}]. Explicit instrument tests are preserved and make that selection unavailable for automatic ordering. A null target means record only, not automatic pass. Read /exact-recipe-api.html for exact quantities and instructions. All explicit ingredient masses including water must sum to batchMassG; omitted water is calculated as the remainder.

## 2. Quote

POST /order-quotes:
```json
{
  "recipe":{"source":"idea","ideaId":"YOUR_IDEA_UUID","ideaRevision":3},
  "variationIds":["reduced_20","reduced_40"]
}
```
Omit variationIds or use [] for only the original. These IDs add 20% and 40% reductions in the group identified by trialProposal.axis: sweetener, otherwise flavor, otherwise acid. Show the exact changes before requesting the comparison. Automatic variants can be unavailable for custom methods. The original is always included unchanged.

The optional deliverables field defaults to ["photo","taste"]. Old photo/taste selectors remain accepted but both are bundled into all new quotes. replicates multiplies the number of actual preparations per version. More than three total preparations is outside automatic ordering.

Display quote.totalCents / 100 as “Test Trial for $XX”. Never calculate the price yourself. A null total cannot be ordered. Explain the gap and suggest an orderable alternative; never interpret it as zero. packageTotalCents names only the known kitchen portion when delivery needs a quote. It must not be presented as a complete delivered price. quote.includes/exclusions/reasons describe scope. quote.scopeHash binds the versioned quote and original source revision.

A numeric price does not establish physical eligibility. Check `review.eligible` on the quote response before enabling checkout. If false, show `review.reasons` and keep the recipe as a priced draft. Exact-product dose, application, allergen and process evidence must be qualified upstream; the order endpoint rechecks it and returns `422 PHYSICAL_QUALIFICATION_REQUIRED` when it is missing, expired or inconsistent. Do not call checkout, imply tasting approval, or silently substitute ingredients to bypass a hold. A qualified supported selection keeps the normal fixed-price order flow without a second lab-confirmation step.

## 3. Place an order

POST /orders with an Idempotency-Key header:
```json
{
  "selection":{
    "recipe":{"source":"idea","ideaId":"YOUR_IDEA_UUID","ideaRevision":3},
    "variationIds":["reduced_20","reduced_40"]
  },
  "quoteHash":"RETURNED_QUOTE_SCOPE_HASH",
  "shareWithTeam":true,
  "contactEmail":"you@example.com",
  "checkout":{"acceptQuote":true}
}
```
Show the exact recipe, selected services, complete quoted total and trial terms below, then obtain the customer’s agreement to order and share their recipe/contact information. Only then send `checkout:{"acceptQuote":true}` with the exact quoted selection and quote hash. For a complete supported quote in server payment mode `test` or `stripe_test`, this creates an order with pending checkout for that provider immediately; there is no operator-offer wait. Display its saved total as **Order total** and continue to the checkout step below. The saved quote stays fixed for this scope, including quoted shipping; later pricing changes do not reprice the order.

The response includes `order.snapshot.checkout`: `{kind:"quoted_order",quoteHash,executionHash,amountCents,currency:"USD",acceptedAt,termsVersion,terms}`. It records the accepted quote, frozen recipe fingerprint and trial terms. Private consent identity/audit data is not returned. `order.payment` starts at `status:"pending"`, `mode:"test"`, `fundsCaptured:false`; `provider` is `"stripe"` in Stripe sandbox mode or `"simulated"` in internal test mode. Creating this record collects no money and does not start or approve physical work.

Omitting `checkout` saves a supported fully priced request awaiting checkout, without payment or consent. Use the saved-quote acceptance endpoint below to continue. Null-priced or unsupported requests cannot be submitted. `acceptQuote:false` is invalid, and clients cannot supply a total, terms override or payment/provider details. The server validates the frozen recipe, quote and current source state.

### Website login

Website customers choose **Login to Order** and sign in before submitting the trial. After the same-origin guest-claim step, the recipe, order and resulting lab report belong to that account. Guests can still quote their own generated recipes, but `POST /orders` returns `401 AUTHENTICATION_REQUIRED` until login.

For authenticated same-origin browser sessions, `contactEmail` may be omitted: the server uses the verified account email and ignores a supplied value for new orders. API-key requests still require an explicit `contactEmail`, as shown above. Existing receipts retain their saved contact details on replay. This login rule applies to `/orders`; legacy submission routes retain their existing contracts.

## Order state

Use `order.orderStatus` from order reads. After successful checkout it is `ordered_pending_lab` — **Ordered - Pending Lab**. Until then it is `awaiting_checkout`. Preparing, reporting, completed, held and cancelled orders retain their actual fulfillment state. Old `status:new` inbox labels do not require lab approval. Payment is always a separate recorded fact. Both sandbox providers retain `fundsCaptured:false`: no real money is collected. A checkout redirect is never payment proof.

## Choose the current checkout mode

Read `GET /api/v2/lab-status`. The server returns `stripe_test` (hosted Stripe sandbox), `test` (internal simulator), or `unconfigured` (checkout unavailable). The authorized browser admin status endpoint returns the same field; changing lab intake does not change payment mode. Both configured modes use the order’s accepted saved price and collect no real money.

## Stripe sandbox checkout

After accepting the saved quote, use its returned `order.payment.id`. Create a session using the owner’s browser session or an API key with `orders:read` and `orders:write`:

```http
POST /api/v2/orders/ORDER_UUID/stripe-checkout
Content-Type: application/json
X-Deconfound-Api-Key: YOUR_API_KEY
Idempotency-Key: stripe-checkout-attempt-001

{"paymentId":"PAYMENT_UUID"}
```

The response is `{orderId, revision, orderStatus, payment, awaitingConfirmation, checkout}`. An active session returns `checkout:{url,sessionId,expiresAt}`; `checkout:null` with `awaitingConfirmation:true` means the signed webhook has not yet confirmed a completed session. Redirect the customer only to the returned HTTPS URL on `checkout.stripe.com`. Keep **Stripe sandbox · No charge** visible before redirecting. Card details are entered on Stripe’s hosted page, never sent to this API. Agents can hand the URL to a person; there is no dedicated MCP payment tool.

The server sets the exact accepted USD total. Caller-supplied totals, currencies, redirect URLs, provider fields and card details are rejected. There is no product catalog to maintain in Stripe: each session uses the saved order price.

Persist the request body and idempotency key before sending. After a timeout or connection failure, retry that exact pair rather than creating another session. Existing attempts are reconciled before another checkout can start. Sessions expire about 31 minutes after creation. Cancelling the hosted page can reuse its still-open session. After a confirmed expiration, refresh the order and use a new attempt key. `STRIPE_RECOVERY_REQUIRED` means an older unknown outcome needs operator reconciliation; do not repeatedly start new attempts. If configuration is incomplete, the endpoint returns `503 STRIPE_NOT_CONFIGURED`; a provider failure returns `STRIPE_UNAVAILABLE`. Follow `error.retryable` and `Retry-After`.

After the customer returns, read `GET /orders/{id}` for canonical payment and order status. Do not mark an order paid from a return URL or a browser query parameter. A verified payment moves an active order to **Ordered - Pending Lab**, with no lab-confirmation step. A payment that arrives after withdrawal, closure or a conflicting order change is still recorded and held for review rather than discarded. Preparation, report evidence and delivery remain separate actions.

Pausing the lab blocks new sessions. It does not stop signed payment webhooks from recording existing session outcomes. Stripe sandbox uses only test credentials and `livemode:false`; it does not collect real funds or issue real refunds.

## Simulated test checkout

Use this section only when `paymentMode` is `test`. It does not use Stripe.

For the standard flow, create the order with `checkout:{"acceptQuote":true}` as above, show **Place order · $XX** using the saved amount, and keep **Test mode · No charge** visible. Send the success outcome only after the customer confirms placement. A successful response can be shown as **Ordered - Pending Lab** with the order/report link. Failure and cancellation remain optional test controls; normal retry uses a new successful attempt after a confirmed failed/canceled outcome.

For an older priced request without `checkout`, read `GET /orders/{id}`. Show its original saved scope, total and trial terms, obtain agreement, then POST `/orders/{id}/checkout` with `{revision,quoteHash,acceptQuote:true}` and a new stable `Idempotency-Key`. The response is `{order}` with pending payment for the configured sandbox provider and `snapshot.checkout`. The original quote is retained even after pricing changes. This recovers the same order without a lab-confirmation step. Do not fabricate historical acceptance or payment. If an older order already has an actual revised or accepted offer, preserve that explicit contract via the [historical final-offer protocol](/final-offers.md).

After displaying that saved amount, simulate checkout using the owner’s browser session or an API key with `orders:read` and `orders:write`:

```http
POST /api/v2/orders/ORDER_UUID/test-payment
Content-Type: application/json
X-Deconfound-Api-Key: YOUR_API_KEY
Idempotency-Key: checkout-attempt-001

{"outcome":"succeeded"}
```

Use `failed` or `cancelled` to exercise those paths. The response is `{orderId, revision, orderStatus, payment}`. A new attempt after failure or cancellation needs a fresh key; success is terminal. A maximum of 20 outcomes can be recorded per order. The exact same key/body recovers the saved response after a connection failure, even after a lab pause, mode change or later closure. An old replay can reflect an earlier status, so refresh `GET /orders/{id}` for the current payment before displaying it. Different bodies with the same key return `409 IDEMPOTENCY_CONFLICT`.

New attempts require mode `test`, an open lab and an active owned service order. Guests receive `401 AUTHENTICATION_REQUIRED`; other-owner, missing, withdrawn or missing-snapshot orders return 404. Closed orders or cancelled fulfillment return `409 ORDER_CLOSED`; disabled mode returns `TEST_PAYMENTS_DISABLED`; legacy orders without simulated checkout return `TEST_PAYMENT_UNAVAILABLE`; terminal success returns `PAYMENT_ALREADY_SUCCEEDED`. `PAYMENT_CHANGED` requires a refresh; a malformed saved payment/quote match returns `PAYMENT_QUOTE_MISMATCH` and needs lab review. Direct quote checkout additionally returns `CHECKOUT_REVIEW_REQUIRED` for an incomplete quote or a newly issued review offer, `CHECKOUT_CONSENT_REQUIRED` for missing/mismatched saved consent, `CHECKOUT_QUOTE_CHANGED` if the accepted recipe or quote changed, and `PAYMENT_OFFER_MISMATCH` for payment bound to a different checkout path. `POST /orders` with `checkout` also rejects unavailable payment mode with `TEST_PAYMENTS_DISABLED`, changed quote hashes with `QUOTE_CHANGED`, and incomplete/custom quotes with `CHECKOUT_REVIEW_REQUIRED`. Source holds and invalid formulations remain enforced. Existing intake quotas remain in effect.

**This is our local simulation, not Stripe test mode. No card details are requested, no payment provider is called and no money is captured.** Success moves the order to **Ordered - Pending Lab**, with no additional order confirmation. It does not start preparation, delivery, or create report evidence. The Requests and Admin views keep the test label visible. REST order reads return this payment record. Native MCP exposes the same owned order and result records; payment simulation remains an explicit REST/browser action.

## Trial terms for direct checkout

Current version: `quoted-order-2026-10-20-v1`. Present these terms before sending `checkout.acceptQuote:true`. Retain the exact returned `snapshot.checkout.terms` and `termsVersion` with the receipt.

- **Cancellation:** Cancel before preparation to stop the trial. After preparation starts, contact the lab to cancel remaining work. This pilot uses simulated payments; no money is collected or refunded.
- **Corrections and rework:** Clerical report errors are corrected with history retained. If agreed work is missing or a preparation is made incorrectly, contact us for a correction or a separately agreed remake. An unfavorable flavor result does not include another iteration.
- **Sample delivery:** Report a missing, damaged, leaking or warm sample through your order or the contact below. Shipped samples are for research only. Not for human or animal consumption. We review the evidence and agree a replacement or other remedy before further work; no replacement is automatically booked.
- **Contact:** peter@deconfound.com.

The recipe, services and price are accepted for this request. A bench date, physical preparation or dispatch is not automatically scheduled by checkout.

## Optional cold-packed overnight shipping

Shipped samples are for research only. Not for human or animal consumption.

Add deliverables ["photo","taste","ship"] and destination {"country":"US","region":"CA","postalCode":"94133"}. Recognized ZIP codes in the contiguous US and DC receive a **$99-$209 planning allowance** based on straight-line ZIP-centroid distance from San Francisco, CA, origin ZIP **94133**. This is our estimate, not a carrier quote or address/service-availability check.

The allowance includes one insulated parcel, gel packs, leak-resistant packaging and overnight carrier contingency. Planned samples are up to 100 g per recipe version, at most three versions / 300 g total, subject to actual yield and lab approval. It does not promise the full preparation batch. Overnight refers to transit after approved dispatch, not the time from ordering.

When `shipping.status` is `estimated`, `shipping.totalCents` contains the shipping estimate and `quote.totalCents` includes the trial plus shipping. `shipping.destination` contains the resolved ZIP, city, state and country without coordinates. Origin ZIP 94133 currently returns $99 shipping; the recipe trial price varies. Show server values rather than calculating a price in the client.

Unknown ZIPs, mismatched states, Alaska, Hawaii and unsupported destinations return `shipping.status: review_required`, null shipping and full totals, and the known trial subtotal. A full `shippingAddress` {recipient,line1,line2?,city} is optional at ordering and allowed only when shipping is selected. The lab must approve availability, sample quantity, packaging and dispatch; no shipment or delivery date is confirmed by requesting an order. Existing receipts keep their saved terms.

### ZIP lookup

`GET /api/shipping/postal-code?zip=94133` is public and cached for 24 hours. It returns `{"postalCode":"94133","city":"San Francisco","region":"CA","country":"US"}`. ZIP+4 is accepted and resolves to its first five digits. Invalid format returns `400 INVALID_POSTAL_CODE`; an unknown or ambiguous ZIP returns `404 POSTAL_CODE_NOT_FOUND`. The request takes only `zip`. Use its returned region in the order quote; no city field is needed in `selection.destination`.

Postal lookup uses bundled [GeoNames](https://www.geonames.org/) data under [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/). The quote hash binds the exact destination, origin, tier policy and dataset version. Refresh the quote when any of these change.

## Recovery and errors

GET /orders/{id}; GET /orders?limit=20 for cursor pagination. Customers can read only their own orders. Existing receipts retain their original terms and prices.

After an ambiguous failure, retry the identical body with the same key. 409 QUOTE_CHANGED requires a fresh quote and renewed approval; 409 IDEMPOTENCY_CONFLICT requires recovering the earlier order or a new key for a genuinely new selection. Withdrawal is respected on replay.

Authenticated write traffic is limited to 120 calls per owner per minute (30 for a guest browser), including invalid submissions. MCP additionally permits 120 tool calls per owner per minute. JSON is limited to 2,048 nested values, 16 levels, 512 entries per array, 128 properties per object, and each endpoint's tighter collection bounds. Validation errors return at most 12 short issues.

Stripe Checkout allows 20 attempts per owner and 6 per order payment per minute, including retries. Honor `Retry-After` and retry the **same key and body** after an uncertain outcome. At most eight distinct keys can refer to one Stripe session and 160 to one payment; exceeding this returns `CHECKOUT_RETRY_LIMIT`. Recover using the original key or the payment-status route. Once Stripe confirms a session has expired, a new attempt can create a new session within the existing attempt limit. Generating keys repeatedly does not create a new payment or bypass throttling.

401 authentication; 403 ownership/scope/origin; 404 missing or other-owner record; 409 stale catalog/revision/quote, unavailable source, or retry conflict; 413 body limit; 415 content type; 422 invalid recipe, consent, destination, unavailable comparison or unsupported scope; 429 capacity; 503 storage unavailable. JSON bodies are limited to 64 KiB, except the operator output endpoint below, which allows 384 KiB. Errors include request IDs. Quoting creates no order.

Results are actual recorded evidence only. AI-generated recipes, hypotheses and proposed tests are not measurements. pH alone is not safety validation; apparent Brix is not sugar or nutritional analysis; photos do not measure NTU clarity; one maker's notes are not a consumer preference study.


## Recipe photos, descriptions and analysis

The normal Admin workflow is **Open order → add sample photos and a description to each recipe → add optional overall analysis → Mark complete**. **Save draft** keeps the work private. Completion publishes the descriptions, captions and photos together in the customer's report; it does not send an email or charge a payment.

Customers and agents retrieve the published report using `GET /orders/{id}/output`, `GET /orders/{id}/results`, or native MCP `get_order_results`. The first two return `output`; `GET /orders/{id}` returns it at `order.snapshot.output`. Read `output.completedAt` and `fulfillmentStatus` for completion. The separate `results.status` describes advanced batch records and may still be `not_started` when the sample report is complete.

`output.notes` is the overall analysis. Optional `output.recipeResults` contains `{recipeId, description, photoIds}` for each recipe. Match `recipeId` to the order's recipe ID, and resolve each `photoId` through the top-level `output.photos` array. Photos without a recipe association remain general report photos. Older reports with only `notes` and `photos` remain valid; an absent description or measurement is never inferred. Photo URLs require the order owner's session or API key.

### Publish or save an operator report

Only an authorized operator's signed-in, MFA-verified Admin session can call `POST /api/v2/admin/orders/{id}/output`. Customer API keys and MCP cannot publish results. Send the current order `revision` and an immutable `Idempotency-Key`. Example body after uploading the photo:

```json
{
  "revision": 5,
  "action": "complete",
  "notes": "The reference had the clearest lime aroma. Try a lower acid level next.",
  "photos": [
    {
      "id": "11111111-1111-4111-8111-111111111111",
      "caption": "Reference sample immediately after mixing"
    }
  ],
  "recipeResults": [
    {
      "recipeId": "original",
      "description": "Clear, pale yellow. No visible separation at the time of observation.",
      "photoIds": ["11111111-1111-4111-8111-111111111111"]
    }
  ]
}
```

The UUID and observations above are illustrative; use the actual upload ID and recorded observations. Include every ordered recipe exactly once when `recipeResults` is present and `action` is `complete`. Each recipe needs a nonempty description or an assigned photo. Use `action: "save"` for partial drafts, including blank entries. Descriptions and overall notes each allow 10,000 characters. Up to three recipes, four photos per recipe and twelve photos overall are supported.

Every assigned photo must appear in top-level `photos`, belong to this order, and have been uploaded using a batch ID from that recipe. A photo cannot be assigned twice. Missing or wrong associations return `422` with `RECIPE_NOT_FOUND`, `RECIPE_RESULTS_INCOMPLETE`, `PHOTO_NOT_FOUND`, `PHOTO_RECIPE_MISMATCH` or `VALIDATION_ERROR`. Upload raw JPEG, PNG or WebP bytes to `/api/v2/admin/orders/{id}/photos` with `Content-Type`, `Idempotency-Key` and `X-Batch-Id`; each upload is limited to 2 MiB. No external image URL is fetched.

Earlier clients can omit `recipeResults`; their completion still requires overall notes or a photo. Exact retries keep the same body and key. A stale revision or changed retry body returns `409`. Saved drafts never replace the last published report for customers. Corrections preserve prior publications and their photos. Publishing does not create measurements, actual ingredient usage, payment records or a safety certification.

## Advanced batch records

Each physical preparation has a stable batchId and recipeId. The customer receives
all shared records in GET /api/v2/orders/{id} → order.snapshot.results, or directly
with GET /api/v2/orders/{id}/results. Native MCP exposes the same owned order and result records; payment simulation remains an explicit REST/browser action.
The Requests page shows the report and provides Download records
(JSON). Refresh the page to retrieve newly shared records. No automatic email is sent.

The report includes the requested versus actual ingredient masses (grams), lot/container
IDs, maker and preparation time, actual process, instrument readings with units,
method, calibration and measurement time, labeled private photos, appearance,
one maker's tasting notes, deviations, next recommendation and correction history.
It covers every selected version and every repeat; unrecorded batches remain visible.
Photo URLs require the same owner session or API key and are never public URLs.

status is not_started, partial, reported, or reported_with_omissions. “Reported”
means records were shared, not independently verified, safe, shipped, or all tests
passed. Missing measurements have value:null and a reason; do not substitute zero.
Historical correction records remain in history; batches[].record is the current record.
Withdrawn requests have no readable report or photos.

### Operator workflow

In Admin, open an order, mark it reviewed, and choose Share a batch record.
Enter the actual ingredients (including water), method and evidence; planned
amounts are shown separately and are never copied into actual amounts.
Save and share record immediately publishes to that customer's order.
This records work already performed; it does not start physical work or charge.

Advanced records with tasting notes must include `internalTastingReview` with
`status: "tasted"`, `approvedForTasting: true`, and a recorded `assessmentReference`
for that exact preparation. This reference remains private. Otherwise use empty
`tastingNotes` and explain `tastingOmittedReason`; no tasting approval is implied.
Tasted records, tasting-based assessments and physical completion recheck actual
ingredient doses, finished mass, supported process, current exact-product qualification,
and the received lots' identity, status and documented expiry/use-by date.
Untasted factual reports can still record missing measurements and limitations,
but those omissions cannot be used to mark physical work complete.
A correction to a completed preparation returns it to reporting for revalidation.

Signed-in authorized operators can POST /api/v2/admin/orders/{id}/results using
the RecordBatch schema in /openapi.json, current order revision and Idempotency-Key.
Account for all requested tests plus mass, with readings or explicit missing reasons.
Correct a record using its current id as supersedesId and a correctionReason.
Stale revisions or conflicting keys return 409; invalid batch/ingredient/photo IDs
return 422; unauthorized operators return 403. Repeat the unchanged request and
key after an uncertain response. Withdrawn orders return 404.

Photos: POST raw JPEG/PNG/WebP bytes to /api/v2/admin/orders/{id}/photos with its
Content-Type, Idempotency-Key and X-Batch-Id. Limit 2 MiB, four photos per advanced batch record,
20 unused uploads and 420 retained uploads per order. Attach returned photo.id and a caption to that exact batch.
An unattached upload is operator-only. Oversized photos return 413; incompatible
formats return 415; unavailable storage returns 503. No remote URL fetching is supported.

## Lab availability

Read `GET /api/v2/lab-status` without authentication to check `acceptingOrders`. A paused lab rejects new checkout attempts, orders, experiments and shared requests with `409 LAB_PAUSED` (`retryable: false`). Keep the idea and quote; wait for the lab to reopen. Existing orders, results and identical idempotent replays remain available.

### Fixed order quote

`GET /orders/{id}/offers` returns optional `lockedPrice` for a fully priced, unaccepted order with its original scope. The operator offer defaults use this price. Issuing or accepting an unchanged-scope offer with different trial, shipping, tax or total amounts returns `409 ORDER_QUOTE_LOCKED`; issue a corrected offer at the saved price. Invalid or inconsistent saved pricing returns `409 SAVED_QUOTE_INVALID` instead of inventing a price. Older orders with a real revised offer retain that separate explicit acceptance path. New unsupported or null-priced recipes cannot be submitted. Changed scope requires a disclosed alternative and a new quote for the customer to accept. Existing accepted agreements retain their agreed prices. This is a price lock, not a payment or credit: test checkout still captures no funds.
