Server P1: one curve owner, price-by-ID with configured curves, ApiError

Merged
SU
11d ago
+1703-1676

Follows #135 (P0), now merged; rebased onto main.

What changes

Curves now have one owner, the durable recipe CurveStore (redb). Before, there were three: the in-memory engine CurveBuilder, the recipe store, and curves embedded in runtime plans.

  • Store. CurveStore keeps immutable, versioned SnapshotCurveRecipe records. New methods:

    • recipe_at(id, known_at) returns the highest revision recorded by the cutoff. It scans at most 10,000 versions.
    • recipe_heads(after, limit ≤ 256) lists each curve's latest revision.

    CurveConfig, CurveSnapshot and their eight unused methods are deleted.

  • Engine. The CurveBuilder DashMap cache and PricingEngine::curve_builder() are deleted; BuiltCurve stays. New curve_resolution::resolve_curves:

    • loads each root curve at known_at, then each dependency whose stored native key is exactly the one the recipe names (the revision with that number when the label is decimal, otherwise the revision current at the cutoff; nothing else is searched);
    • applies the same freshness rule as core snapshot assembly;
    • builds the result with rebuild_curve_recipes.

    PricingEngine::pricing_curves picks the pricing configuration that applies to the bond (select_pricing_config: active, applies_to matches, highest priority, ties refused). It then checks each curve's role and currency with the same check snapshot valuation uses.

  • Curve routes.

    RouteBehaviour
    POST /api/v1/curvesStores a recipe. Same handler as /api/v1/analytics/curves, which now works without the Unix-only core workflow. Returns 204, 409 on changed content, 400 on an invalid record.
    GET /api/v1/curvesLists curve heads, bounded and paged.
    GET /api/v1/curves/{id}?known_at=Returns the revision current at the cutoff and sets x-convex-curve-version.
    …/zero, …/discount, …/forwardBuild that revision and query it. A missing or stale curve gives 422 MISSING_INPUT.
    DELETE /api/v1/curves/{id}Removed: revisions are history.
    POST /api/v1/curves/bootstrapStateless: returns points and residuals and stores nothing. Rejects unknown or incomplete instruments, which were previously skipped silently, and non-finite or non-positive tenors and reversed FRAs. At most 128 instruments, the calibrators' bound. Now a Calculate action in the access table; its curve_id is an echoed label, not a scoped curve.
  • Price by ID. GET /api/v1/quotes/{id}?market_price=&known_at=:

    • reads the reference at known_at;
    • uses the configured discount and swap-benchmark curves current at known_at;
    • reports what it used in x-convex-reference-version, x-convex-pricing-config, x-convex-curve-versions and x-convex-known-at;
    • refuses with 422 MISSING_INPUT instead of pricing without curves, and when market_price is absent;
    • broadcasts to WebSocket subscribers only when known_at is omitted, so a historical quote is never sent as live.
  • Errors. ApiError (error.rs) is the single body shape, {error, code, retryable}, for curve and quote handlers. reference_error now delegates to it. Store scan bounds and missing adapter support on reads are 503, not client errors; parse and serialization failures are 500 and not retryable.

  • CORS. The new provenance headers are exposed to browser clients.

  • Pricing configuration specificity now also counts is_floating, is_inflation_linked and text_search, so a configuration using them outranks a generic fallback instead of tying with it.

  • Showcase.

    • Caddy allows POST /api/v1/curves/bootstrap again, so the curve calibration page works. POST /api/v1/curves stays private.
    • The seed adds a SHOWCASE-USD pricing configuration: USD bonds use D.OIS as the discount curve and P.SOFR as the swap benchmark.
    • convex-core-inputs accepts optional pricing_configs.
    • The seed is dated 2026-10-07, so the showcase prices with known_at=2026-10-07T12:00:00Z. Nothing is re-stamped. At the current time the same request is refused as stale.
  • Demo frontend. The calibration page no longer sends Bond rows, which the server now rejects.

Known limits

  • The pricing configuration is read as current; only its version is reported. A historical known_at is paired with today's configuration.
  • Pricing configurations still have no HTTP write route; they are loaded by the importer. Management belongs with P8.
  • The government and FRN-reference curve roles are not used by price-by-ID, which matches snapshot valuation.
  • REST-triggered bond_quote WebSocket messages remain for live requests. They are retired in P3.
  • Includes the flaky-test fix from #135's review (cancelled_waiter_retains_both_admissions_until_blocking_work_finishes waits for the permit as well as the admission).

Test plan

  • WSL: cargo test --no-fail-fast --all-features -p convex-server -p convex-ports -p convex-ext-redb -p convex-engine. All pass except convex-ext-redb --test recovery, which fails the same way locally on the unchanged #135 head. This PR changes that test's fixture, so CI is its first real run.
  • WSL: cargo clippy --all-features --all-targets on the four crates with -D warnings, cargo fmt --all -- --check, and tools/assurance/check.py plus --base main all pass.
  • Windows: cargo test -p convex-server --test application_path passes, and api_integration_tests passes 163 of 164. production_readiness_environment_only_binary_startup fails on Windows only: that test clears the process environment, and the binary starts normally outside it. The other Windows failures need the Unix-only snapshot repository and also fail without this change.
  • tests/application_path.rs:
    • imports the seed the way the container entrypoint does;
    • retries the reference and curve writes over HTTP, which are idempotent;
    • prices at the capture time with the expected version headers;
    • gets 422 at the current time;
    • lists curves and queries one;
    • restarts and finds the same versions;
    • gets 409 for a changed recipe.
  • New api_integration_tests:
    • cutoff reads and paging;
    • immutability and validation;
    • the discount factor matches exp(-z·t);
    • stale and missing curves are refused;
    • bootstrap stores nothing and rejects bad rows.
  • Showcase simulation under dash with Linux Caddy 2.8.4:
    • bootstrap returns 200 and POST /api/v1/curves returns 403;
    • a quote at the capture time returns 200 with D.OIS@1,P.SOFR@1 and SHOWCASE-USD@1;
    • a quote at the current time returns MISSING_INPUT;
    • the WebSocket upgrade returns 101;
    • the crash and SIGTERM supervision behaviour is unchanged.
  • Review follow-ups:
    • curve_recipe_replay::stored_dependencies_resolve_by_native_key_label: a dependency labelled supplied.recipe.v7 resolves from the store, and an unknown label is Missing;
    • access_control_tests::bootstrap_curve_label_is_not_a_scoped_curve: bootstrap with an ungranted label succeeds, and reading that curve stays forbidden;
    • WSL: cargo test on convex-server, convex-ports and convex-engine has 0 failures; clippy -D warnings and fmt are clean.
  • demo/frontend: npx tsc --noEmit passes.

🤖 Generated with Claude Code