A client that reports a problem should send the X-Trace-Id. It is the one value that ties the response to the log record, and the log carries token counts and the role but not the request text.
The discovery sequence
Figure 2. One POST /api/v1/discovery call
sequenceDiagram
accTitle: Sequence of a discovery request
accDescr: The client sends a request with a bearer token and an optional idempotency key. The API verifies the identity and checks the role. It looks up the key and either replays the stored response or reports a conflict. Otherwise the pipeline extracts requirements through the provider, refuses or asks clarifying questions when needed, runs the bounded tool workflow, retrieves evidence with the visibility filter inside the query, composes a draft, validates it in code, logs usage, stores the response and returns it with a trace id.
participant C as Client app
participant A as API layer
participant I as Identity and permissions
participant S as Idempotency store
participant P as Discovery pipeline
participant M as Model gateway
participant R as Retriever and tools
C->>A: POST with Bearer token and Idempotency-Key
A->>I: verify token, check role may run discovery
I-->>C: 401 unauthorized if the token is missing or invalid
A->>S: look up tenant, user and key
S-->>C: 200 with Idempotent-Replay if key and body match
S-->>C: 409 idempotency_conflict if the body differs
A->>P: run_discovery with request and identity
P->>M: extract requirements as structured output
P-->>A: refusal or clarifying questions if scope or detail is missing
P->>R: run the bounded workflow with read-only tools
P->>R: retrieve evidence with the visibility filter inside the query
P->>M: compose recommendations from the context
P->>P: keep only known SKUs with real citations
P-->>A: response with confidence and usage
A->>S: store the response under the key
A-->>C: 200 with X-Trace-Id
How to read this: the three early exits (401, replay or 409, and refusal or clarify) all happen before any retrieval, so a bad or repeated call is cheap.
The order is a design decision. Identity is checked before the idempotency lookup, so a caller can never read another caller's stored answer, and the lookup is before the pipeline, so a retry costs no model calls.
One error body
Every failure uses the same shape, and none contains a stack trace or the submitted text.
Verified: capstone/fde-product-discovery-copilot/app/errors.py
def error_body(code: str, message: str, trace_id: str) -> dict[str, dict[str, str]]:
return {"error": {"code": code, "message": message, "trace_id": trace_id}}
A real response from a request with a missing token:
{"error": {"code": "unauthorized", "message": "Missing Authorization header.", "trace_id": "c3e1236132dc4710"}}
Idempotency, precisely
The store keys on tenant, user and the key, and keeps a SHA-256 fingerprint of the request text next to the stored response.
Verified: capstone/fde-product-discovery-copilot/app/idempotency.py
with self._lock:
entry = self._items.get((identity.tenant, identity.user_id, key))
if entry is None:
return None
if entry[0] != self._fingerprint(body):
raise IdempotencyConflict("This Idempotency-Key was already used with a different request.")
return entry[1]
Checked against a real run: the first call with key demo-1 returned 200, the second with the same body returned 200 with Idempotent-Replay: true and the same trace_id, and a third with changed text returned 409. The store holds up to 1,000 entries, drops the oldest first, lives in one process's memory and is lost on restart.
A real session
These are the reference build's own commands for trying the API (a token for the public role, then the headline request).
TOKEN=$(curl -s -X POST localhost:8000/api/v1/dev/token -H 'content-type: application/json' -d '{"role":"public"}' | python -c "import sys,json;print(json.load(sys.stdin)['token'])")
curl -s -X POST localhost:8000/api/v1/discovery \
-H "Authorization: Bearer $TOKEN" -H 'Idempotency-Key: demo-1' -H 'content-type: application/json' \
-d '{"request":"We need something to monitor vibration on 40 pumps in a humid plant, budget around 30k, must work with our existing OPC UA setup"}'
With the mock provider the response is a 200 with two recommendations (LUM-VS-100 and LUM-GW-200), confidence high, one open question about the gateway's IP20 rating, llm_calls 9 and workflow_steps 6. The mock is a rule-based stand-in, so a real model would produce different wording and possibly different call counts.
What a client does with each outcome
A contract is only useful if the caller knows what to do with every answer. This table is the client-side half of the contract.
Note the second row: a refusal is a successful answer, not an error. Mixing the two makes dashboards show a healthy refusal as an outage.
Contract tests to write
Write these before you trust the contract, using the in-process test client so they need no network or key.
- The discovery response contains exactly the eight documented fields.
X-Trace-Id equals trace_id in the body, on success and on error.
- A missing, malformed and wrongly signed token each return 401 in the one error shape.
- The same key and body return the stored response with
Idempotent-Replay: true; the same key with different text returns 409.
- A body with 2 characters returns 422 and the message names
request without echoing the text.
- A public caller asking for a partner-only product gets the refusal, and a partner caller gets the recommendation.
Check the error shape on every error test, not on one. The most common contract bug is one handler that forgets to use the shared body.
Evolving the contract
Adding a response field is safe for clients that ignore unknown fields. Renaming one, changing a type, or moving a status code is a breaking change. The /api/v1 prefix is where a future /api/v2 would live, so keep the old version running until its clients have moved.
Design choices you should be able to defend
Four choices are worth knowing, because a reviewer can fairly question each one.
- 409 for a reused key. The IETF draft for the
Idempotency-Key header (an Internet-Draft in the HTTP API working group, not an RFC as of 2026-11-02) recommends 422 for a key reused with a different payload. The capstone returns 409, which RFC 9110 defines as a conflict with the current state of the resource. Either is defensible; pick one and document it.
- 401 without
WWW-Authenticate. RFC 9110 says a 401 response must carry that header. The reference build does not send it, which is a gap to close if a standard HTTP client depends on it.
- Validation runs before authentication. FastAPI checks the body before the handler's own identity check, so an unauthenticated call with an invalid body gets 422, not 401.
- 403 is not reachable on discovery today. Both roles may run it, so the check exists for the day a role is added.
What to do with this page
Write your own contract before you compare. List your endpoints, models and error codes, then diff against this page. Every difference is either a decision you can defend or a gap you can fix.
Last verified: 2026-11-02
Next
Capstone rubric (100 points) and submission