Skip to content

    Forward Deployed Engineer

    Capstone Architecture and API Contract

    The Product Discovery Copilot service and its HTTP contract: endpoints, models, headers, one error body, idempotency and the discovery request sequence.

    By Published 7 min read

    Share

    This lesson is part of Forward Deployed Engineer: Evaluate and Capstone. Enroll for free, then sign in, to read it in full.

    What you'll learn: the shape of the capstone service, every endpoint with its request and response models, the headers it reads and writes, the single error body, how idempotency behaves, and the exact sequence one POST /api/v1/discovery call goes through.

    The service in one view

    Composite scenario, not a real company. The service below is the Lumen Industrial Supply Product Discovery Copilot. The contract on this page is what a client, such as the capstone UI or a sales engineer's script, can rely on. It is read from the reference build's app/main.py, app/models.py, app/errors.py and app/idempotency.py.

    Figure 1. Components of the capstone service

    Capstone service componentsClient app to Orchestrator (HTTPS JSON); Orchestrator to Policy engine (who is asking?); Orchestrator to Idempotency store (key lookup); Orchestrator to Retriever (filtered query); Retriever to Document store; Orchestrator to Model gateway (prompt, schema); Model gateway to LLM provider; Orchestrator to Tool server (validated call); Orchestrator to Approval service (sensitive action); Orchestrator to Trace store (usage, steps)Client app: React UI, curlClient appReact UI, curlOrchestrator: FastAPI /api/v1OrchestratorFastAPI /api/v1Policy engine: token, rolePolicy enginetoken, roleIdempotency store: in memoryIdempotencystorein memoryRetriever: BM25, filter insideRetrieverBM25, filterinsideDocument store: files on diskDocument storefiles on diskModel gateway: provider protocolModel gatewayprovider protocolLLM provider: mock by defaultLLM providermock by defaultTool server: 3 read-only toolsTool server3 read-only toolsApproval service: library onlyApprovalservicelibrary onlyTrace store: logs, usageTrace storelogs, usageHTTPS JSONwho is asking?key lookupfiltered queryprompt, schemavalidated callsensitive actionusage, steps

    How to read this: the Orchestrator is the only component the client talks to, and the Policy engine is asked who is calling before anything else runs.

    Two details matter for what you promise a customer. The Approval service is a library the code can call, not an HTTP endpoint, and the model cannot call it. The LLM provider is the mock unless the operator sets LLM_PROVIDER=anthropic.

    Endpoints

    All paths sit under /api/v1, and every body is JSON.

    Method and path Purpose Auth
    GET /health Liveness and the active provider, for example {"status": "ok", "provider": "mock"} none
    POST /discovery Run the discovery pipeline on a free-text request Bearer token required
    GET /products/search Keyword search with q (up to 200 characters), category, max_price (greater than 0) optional: no header means an anonymous public caller; a bad token is still a 401
    POST /dev/token Development only: {"role": "public" or "partner"} returns a signed token none; returns 404 when ALLOW_DEV_TOKENS is not true
    WARNING

    POST /dev/token hands a token to anyone who asks. It exists so the UI's role selector works locally. Switch it off with ALLOW_DEV_TOKENS=false anywhere else, and replace the HMAC dev token with your identity provider's tokens.

    Request and response models

    The request is one field. The response has eight, and refused, confidence and message carry the three behaviours the customer cares about.

    Model Fields
    DiscoveryRequest request: string, 3 to 2,000 characters
    DiscoveryResponse requirements, recommendations, open_questions, refused, confidence, trace_id, usage, message
    Requirements category (sensors, gateways, controllers, enclosures, software, or null), quantity, budget_eur, protocols, environment, must_have, nice_to_have, keywords, skus
    Recommendation sku, name, why, citations (each doc_id#chunk, validated as a citation id)
    Usage input_tokens, output_tokens, llm_calls, workflow_steps, estimated_cost_eur (placeholder prices)

    Rules a client can rely on:

    • confidence is high, medium or low.
    • When refused is true, message is exactly "I cannot answer this from the documents." and recommendations is empty.
    • A clarifying question is a normal 200 response with the questions in open_questions, no recommendations and confidence low.
    • Every citation in a recommendation was in the context given to the model and either names the product or belongs to that product's own document, because code checks it before the response is built.

    At the customer: "Can I trust the citations?" A good answer separates the model's draft from the response: the model proposes RecommendationDraft objects, and code keeps only a known SKU with citation ids that exist and speak about it.

    Headers

    Header Direction Meaning
    Authorization: Bearer <token> request Identity: user, tenant and role come from the verified token, never from the body
    Idempotency-Key request, optional Up to 100 characters; scoped to tenant and user
    X-Trace-Id response, every response The same value as trace_id in the body and in the structured log line
    Idempotent-Replay: true response The body was returned from the store, nothing was recomputed

    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"}}
    Code Status When
    unauthorized 401 Missing, malformed, wrongly signed or expired token
    forbidden 403 The role may not perform the action (a second 403 code, approval_required, is raised only by the approval library, never by an endpoint)
    not_found 404 Unknown path, or the dev token endpoint switched off
    method_not_allowed 405 Wrong method on a known path
    idempotency_conflict 409 Same key, different request
    validation_error 422 Bad input; the message names the field, for example Invalid input: request, never the value
    llm_invalid_output 502 The model's output stayed invalid after one repair
    config_error 500 A provider is selected but not configured
    internal_error 500 Anything unexpected, with the fixed message "Something went wrong."

    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.

    Outcome How the client recognises it What it should do
    Recommendations 200, refused false, recommendations not empty Show each with its citations, then the open questions
    Refusal 200, refused true, message set Show the message; do not retry, the answer will not change
    Clarify 200, recommendations empty, open_questions not empty Show the questions and let the user refine the request
    Replay 200 with Idempotent-Replay: true Treat as the same answer, not a new one
    Bad input 422 validation_error Fix the named field
    Not signed in 401 Get a new token, then retry once
    Conflict 409 The key was reused for a different request: use a new key
    Provider trouble 502 or 500 Retry later with the same key, and quote trace_id

    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.

    1. The discovery response contains exactly the eight documented fields.
    2. X-Trace-Id equals trace_id in the body, on success and on error.
    3. A missing, malformed and wrongly signed token each return 401 in the one error shape.
    4. The same key and body return the stored response with Idempotent-Replay: true; the same key with different text returns 409.
    5. A body with 2 characters returns 422 and the message names request without echoing the text.
    6. A public caller asking for a partner-only product gets the refusal, and a partner caller gets the recommendation.
    TIP

    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.

    1. 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.
    2. 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.
    3. 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.
    4. 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

    Enroll to keep reading

    Lesson 6 of 8 · Forward Deployed Engineer: Evaluate and Capstone

    The Product Discovery Copilot service and its HTTP contract: endpoints, models, headers, one error body, idempotency and the discovery request sequence.

    In the rest of this lesson

    • Headers
    • The discovery sequence
    • One error body
    • Idempotency, precisely
    • A real session
    • What a client does with each outcome

    Enrolling is free. It unlocks every lesson in Forward Deployed Engineer: Evaluate and Capstone, tracks your progress across devices and keeps your quiz scores. The newsletter is optional.

    Ask about this article

    Get answers grounded in this post. AI-generated — based on this article, and may be imperfect.

    Scaled AI Weekly

    Enjoyed this? Get more like it every Monday.

    Real architecture decisions, LLMOps patterns that survive production, and engineering leadership advice — from 12+ years of building at enterprise scale. Free. No spam. Unsubscribe anytime.

    Get new articles by email

    Free. We email a confirmation link first, and you can unsubscribe from any email.

    Join engineers building production AI systems

    Comments