Task: One lifecycle controller for all feed kinds

Table of Contents

This page documents a task in the Feed lifecycle harmonization: asset-class-agnostic feed control-plane story. It captures the goal, current status, acceptance, and any notes or results.

Goal

One lifecycle controller replaces feed_controller and curve_feed_controller: a running map keyed by source_name, one thread per feed, start/stop/list/join. The per-kind extras are expressed on the common interface (the feed's own conflict key for qualifier+role semantics) or become uniform behaviour — not a second class.

Status

Field Value
State DONE
Parent story Feed lifecycle harmonization: asset-class-agnostic feed control-plane
Now Nothing.
Waiting on Nothing.
Next Nothing.
Last touched 2026-08-11

Acceptance

  • One controller class serves both kinds; feed_controller and curve_feed_controller are deleted.
  • Qualifier+role conflict semantics come from the feed's conflict key on the common interface; the conflict is reported with the running source name.
  • FX-only extras become uniform: feed-binding auto-creation, sandbox subject derivation, and the status loop run for every feed kind (or are explicitly dropped as FX specifics, with the difference documented on the interface).
  • start/stop/list/running_count behaviour is unchanged for existing callers.

Plan

One controller: feed_controller absorbs curve_feed_controller (deleted). The running map stays keyed by source_name, one thread per feed, start/stop/list/running_count/join — the shared shape. The two per-kind differences are resolved as follows.

  1. Conflict semantics: both add() and start() reject a feed whose conflict key — the feed's own IFeed::conflict_key() (feed_conflict_key(qualifier, role); empty role for FX, so the key is qualifier-only) — is already held by a different running feed, reporting the holder's source name. The pure ir_curve_feeds_conflict helper becomes feeds_conflict and now covers FX too: two FX feeds on the same pair cannot both run, because publishing into the same observation series is the same hazard the IR rule guards against (the old controller's permissiveness was the pre-seam behaviour; the IFeed contract from the seam task already declares the uniform rule). A feed with an empty qualifier (an unparseable ORE key) has no published key to protect and conflicts with nothing. The conflict is reported with the running source name, via running_source_name_for_conflict_key().
  2. FX extras:
    • Feed-binding auto-creation and vintage availability stay only in the client-supplied-params start() overload — the FX-only ad-hoc surface that the per-config control-plane task deletes. The config-driven add() path never auto-created bindings and keeps that behaviour.
    • The status loop becomes uniform: it logs source_name and publish_count per feed (the ore_key/subject columns were FX-shaped; the publish subject is per-kind and lives in the feed).
    • Dead code dropped: resolve_series() and its members (md_client_, tenant_id_, uuid_gen_) are never called.
  3. Callers collapse to one ctrl: registrar/application pass one controller; the folder handler's two loops keep their per-kind build inputs (the accepted cost) but share one add() pattern with the conflicting-source-name out param — the IR pre-check (running_source_name_for_qualifier) is dropped because add()'s out param distinguishes the same cases; the IR on-demand handler uses start() plus running_source_name_for_conflict_key() for the "stop it first" message.

Notes

Test Scenarios

Manual QA scenarios (scaffolded via compass add test_scenario, run through the QA Validation Runner panel) that verify this task. Link new ones here as they're created; the scenario doc itself links back via its "Verifies task" field.

Scenario State Notes
     

PRs

PR Title
#1969 [synthetic] Collapse curve_feed_controller into the unified feed_controller

Review

Round 1 (2026-08-11): three Claude review passes, no line comments. All six findings accepted; no correctness bug found by any reviewer.

# Comment summary File Decision Notes
1 list() no longer scoped by feed kind — a shared controller leaks both kinds into each handler's list feed_controller.hpp Accepted IFeed gains kind() (the factory kind string, defined in each producer header); list(kind) filters; both handlers pass their own kind, restoring the per-kind list behaviour
2 FX auto-start silently drops a conflict (no conflicting-source-name capture, no log) application.cpp Accepted Mirrors the IR walk's error log; an already-running same-source row stays silent as before
3 Class doc still says several feeds on the same pair run concurrently, contradicting feeds_conflict feed_controller.hpp Accepted Reworded: at most one per (qualifier, role) pair — two FX feeds included
4 Doc says conflicts flow through conflict_key(); find_conflict() compares the pair via feeds_conflict feed_controller.hpp Accepted Clarified: the pair is compared, not the key string, because the empty-qualifier carve-out is key-string-blind
5 running_source_name_for_conflict_key() has no empty-qualifier carve-out (string equality can false-positive) feed_controller.hpp Accepted Skips empty-qualifier feeds; unreachable today, guard makes the lookup agree with feeds_conflict
6 No end-to-end test drives the qualifier_conflict rejection folder_feed_control_handler_tests.cpp Accepted Cascade test seeds a second enabled config on the same EUR/USD pair (own container — the schema forbids two on the same pair per container); only one starts, the other is skipped

Result

Implemented per the Plan, with one deviation noted below.

One feed_controller now owns every feed kind; curve_feed_controller and its test file are deleted. The shared shape (running map keyed by source_name, thread-per-feed, start/stop/list/running_count/join) is unchanged; the IR qualifier+role conflict semantics flow through the feed's own IFeed::conflict_key() on all three registration paths (add(), start(IFeed), and the FX client-supplied-params start()), reported with the holding source_name via running_source_name_for_conflict_key(). ir_curve_feeds_conflict became the uniform feeds_conflict helper with the empty-qualifier guard; it now also rejects two FX feeds on the same pair. The FX extras stayed FX-shaped: binding auto-creation and vintage availability only in the ad-hoc start() overload, and a uniform status loop logging source_name + publish_count. Dead code dropped: resolve_series() and its members (md_client_, tenant_id_, uuid_gen_); the ctor now takes (nats, auth_nats) only.

Callers collapsed to one ctrl: registrar and application pass a single controller (auto-start walk now runs both kinds through it); the folder handler's two loops share one add() pattern with the conflicting-source-name out param (the IR pre-check is gone — add()'s out param distinguishes the same cases); the IR on-demand handler uses start() plus running_source_name_for_conflict_key(); the FX ad-hoc handler's start gained the qualifier_conflict case with the same out param. Stale comments naming the deleted class were updated.

Deviation from the Plan: add() keeps the binding_mode parameter (as two overloads) instead of dropping it. The Plan's "drops binding_mode" reading would have stored the bound default for every config-driven feed, changing the ad-hoc start()'s should_ensure_feed_binding gate for sandboxed feeds (a later ad-hoc restart would create a feed_binding for a sandboxed feed) — a behaviour change, violating the acceptance criterion "behaviour is unchanged for existing callers". The parameter is stored purely as that restart gate.

Verification: full build green; DB recreated (schema 0.0.25); rat green on the fresh DB. Component tests: 198 assertions in 39 test cases, including the conflict tests moved from the deleted test file and the review-round conflict-rejection coverage added to the folder cascade.

Emacs 29.3 (Org mode 9.6.15)