Repository navigation
docs: regenerate openapi.json from the app and add a drift guard - #83
numarasSigmaSoftware wants to merge 7 commits into
Conversation
The committed OpenAPI document had fallen behind the application. Regenerated with scripts/generate_openapi.py; no code change. What the regeneration picks up: - the operator key route POST /auth/api-keys/operator and its request schema (IABTechLab#59) - operator-auth header parameters on 17 admin paths (IABTechLab#39) - description-only updates on 7 paths, several of them buyer-facing: quotes, deals, change requests, negotiation messages, order transition - the required idempotency_key on change-request creation (IABTechLab#64) and on quotes (IABTechLab#50) - exclusiveMinimum on RateCardEntry.base_cpm (IABTechLab#78)
…ct the redacted guardrails The status route's rounds were serialized whole as raw dicts, and each internal round carries cumulative_concession_pct, computed as (base_price - counter_price) / base_price — so seller_price / (1 - cumulative_concession_pct) reconstructed the seller's base_price EXACTLY, handing back the anchor the top level had just dropped. Round rationale on a below-floor offer also states the floor in prose. - NegotiationStatusResponse.rounds is now list[NegotiationRoundView], a whitelist of round_number / buyer_price / seller_price / action / timestamp. concession_pct, cumulative_concession_pct and rationale do not cross the wire. Excluding rationale here is boundary filtering on this read endpoint only; whether the engine should put the floor into rationale text at all remains an open engine-side decision. - The service keeps projecting full rounds: terminal_round_response reads rationale from them internally, so the filter belongs at the response model, which is the boundary. - New test drives the REAL engine (build_negotiation_engine -> start_negotiation -> evaluate_buyer_offer -> record_round) with a below-floor offer so the round genuinely carries the nonzero concession and the floor-naming rationale, then asserts neither survives serialization. The original fixture's default 0.0 concession and hand-written rationale could not exhibit either leak. Negative control: reverting rounds to list[dict] fails this test naming cumulative_concession_pct and rationale. - NegotiationStatusResponse declares quote_id (Optional, default None) so the projection #96 adds is passed through instead of silently stripped by the pinned response model. - docs/api/openapi.json regenerated via scripts/generate_openapi.py so the drift guard arriving in #83 passes over the merged pair.
…ct the redacted guardrails The status route's rounds were serialized whole as raw dicts, and each internal round carries cumulative_concession_pct, computed as (base_price - counter_price) / base_price — so seller_price / (1 - cumulative_concession_pct) reconstructed the seller's base_price EXACTLY, handing back the anchor the top level had just dropped. Round rationale on a below-floor offer also states the floor in prose. - NegotiationStatusResponse.rounds is now list[NegotiationRoundView], a whitelist of round_number / buyer_price / seller_price / action / timestamp. concession_pct, cumulative_concession_pct and rationale do not cross the wire. Excluding rationale here is boundary filtering on this read endpoint only; whether the engine should put the floor into rationale text at all remains an open engine-side decision. - The service keeps projecting full rounds: terminal_round_response reads rationale from them internally, so the filter belongs at the response model, which is the boundary. - New test drives the REAL engine (build_negotiation_engine -> start_negotiation -> evaluate_buyer_offer -> record_round) with a below-floor offer so the round genuinely carries the nonzero concession and the floor-naming rationale, then asserts neither survives serialization. The original fixture's default 0.0 concession and hand-written rationale could not exhibit either leak. Negative control: reverting rounds to list[dict] fails this test naming cumulative_concession_pct and rationale. - NegotiationStatusResponse declares quote_id (Optional, default None) so the projection #96 adds is passed through instead of silently stripped by the pinned response model. - docs/api/openapi.json regenerated via scripts/generate_openapi.py so the drift guard arriving in #83 passes over the merged pair.
…egotiation reads (#95) * fix: stop leaking the seller's floor price and concession budget on negotiation reads GET /proposals/{proposal_id}/negotiation had no auth dependency and no response model, and answered with whatever negotiation_service. get_negotiation_status produced. That projection carried the internal NegotiationHistory's strategy, base_price, floor_price and limits.max_rounds, so any caller who knew or guessed a proposal id learned the seller's floor and its remaining concession budget - the two things a negotiating seller must never reveal. The shared Negotiation primitive excludes those same four fields deliberately. Three changes: - get_negotiation_status now projects only buyer-visible facts. Dropping the fields at the service rather than at the route means no consumer of the projection can re-expose them; the one internal consumer (apply_terminal_action -> contract_mappers.terminal_round_response) reads only negotiation_id and rounds, so nothing needed them. - The route pins its wire shape with a new NegotiationStatusResponse, so a later addition to the history dict cannot silently reach the wire. - The route resolves a verified buyer context the way its siblings (POST /proposals/{id}/counter, POST /api/v1/negotiations/messages) do, via _get_optional_api_key_record + _verified_buyer_context. A GET carries no body identity to self-assert, so an unverifiable caller cannot be established as a party to the negotiation and gets 401. Not fixed here: the response is authentication-scoped, not buyer-scoped. NegotiationHistory records no buyer identity, so there is nothing to compare a requesting buyer against - any authenticated buyer can still read any proposal's negotiation. Scoping needs a stored party identity, which is a schema change beyond this defect. * fix: type the negotiation rounds on the wire so they cannot reconstruct the redacted guardrails The status route's rounds were serialized whole as raw dicts, and each internal round carries cumulative_concession_pct, computed as (base_price - counter_price) / base_price — so seller_price / (1 - cumulative_concession_pct) reconstructed the seller's base_price EXACTLY, handing back the anchor the top level had just dropped. Round rationale on a below-floor offer also states the floor in prose. - NegotiationStatusResponse.rounds is now list[NegotiationRoundView], a whitelist of round_number / buyer_price / seller_price / action / timestamp. concession_pct, cumulative_concession_pct and rationale do not cross the wire. Excluding rationale here is boundary filtering on this read endpoint only; whether the engine should put the floor into rationale text at all remains an open engine-side decision. - The service keeps projecting full rounds: terminal_round_response reads rationale from them internally, so the filter belongs at the response model, which is the boundary. - New test drives the REAL engine (build_negotiation_engine -> start_negotiation -> evaluate_buyer_offer -> record_round) with a below-floor offer so the round genuinely carries the nonzero concession and the floor-naming rationale, then asserts neither survives serialization. The original fixture's default 0.0 concession and hand-written rationale could not exhibit either leak. Negative control: reverting rounds to list[dict] fails this test naming cumulative_concession_pct and rationale. - NegotiationStatusResponse declares quote_id (Optional, default None) so the projection #96 adds is passed through instead of silently stripped by the pinned response model. - docs/api/openapi.json regenerated via scripts/generate_openapi.py so the drift guard arriving in #83 passes over the merged pair.
…oute Main gained GET /api/v1/deals, so the committed document fell one operation behind and the drift test would fail. Regenerate it and move the hand-written totals from 88 to 89. The README group table also catches up: Authentication counts the operator-key route (5) and Deal Booking counts the list route (12), so the rows sum to the total. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Main updated the list_products and get_product docstrings to describe the stored inventory-type override, so the committed document's two descriptions fell behind and the drift test failed after merging main. No operations changed; the total stays 89. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Thanks @numarasSigmaSoftware. The drift guard is a good addition. It caught every real drift I tried, ignored formatting-only differences, and gave the same result across BlockingThe drift test fails once merged with
|
Change
Regenerate
docs/api/openapi.jsonwithscripts/generate_openapi.pyagainst current main, and add a drift test so it cannot fall behind again.What the regeneration picks up, classified from the JSON diff rather than by hand:
POST /auth/api-keys/operatorand its request schema (Cleans up operator key management #59).idempotency_keyon change-request creation (fix: enforce idempotency for change requests #64) and on quotes (fix: enforce idempotency on POST /api/v1/quotes, add conflict detection #50).exclusiveMinimumonRateCardEntry.base_cpm(feat: wire the operator rate card into pricing (issue #69) #78).GET /api/v1/deals(feat: list stored deals over REST and make export read them #84), picked up by merging main after that PR landed.The regenerated document counts 89 operations. The six hand-written pages that said 87 now say 89; the delta is the operator-key route and the deal list route above. The README group table had not moved with the total (its rows summed to 87); Authentication now counts 5 and Deal Booking 12, so the rows sum to 89.
tests/unit/test_openapi_drift.pybuilds the document from the app the same way the script does and compares parsed JSON to the committed file, with the regeneration command in the failure message. Modeled ontest_docs_inventory_drift.py, which already guards the endpoint and tool inventories. The absence of this guard is why the file fell behind.Not in this PR, observed while regenerating
securitySchemesand nosecurityarrays, so OpenAPI consumers see every endpoint as anonymous. That is a faithful report of how the app declares auth, as optional headers; the fix belongs in the app and is tracked as feat: declare security schemes in the OpenAPI document #89.POST /deals,/agentic-audience/match,/api/v1/deals/curated,/api/v1/deals/{id}/deprecate,/sessions/{id}/messages,/sessions/{id}/close. Pre-existing on main; tracked as fix: six mutating routes accept unauthenticated callers #86.