# Source Config Ownership Policy Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Replace implicit "config always shadows registry" behavior with explicit config ownership policy for MCP source entries: `locked` vs `seed`. **Architecture:** Keep v1 safety by making `locked` the default for existing config connections. Add `seed` as an explicit opt-in that materializes a missing store entry, then lets the store-backed registry own future admin changes. Do this in the MCP broker config path first; neutral `wf_config.server.sources` only models built-in stdlib sources today and should not grow MCP transport config in this slice. **Tech Stack:** Python 3.14, Pydantic v2, wf_mcp broker config/service, wf_mcp source registry, pytest, ruff, basedpyright. --- ## Current Context Relevant current behavior: - `src/wf_mcp/broker/service/connection_service.py::ConnectionService.sync_connections_from_config()` loads config connections first, then ignores same-id registry entries. - Same-id registry entries emit `source_registry_ignored_config_shadow`. - `src/wf_mcp/source_registry.py::McpSourceRegistryEntry` is the persisted desired source entry. - `src/wf_mcp/models.py::ConnectionConfig` is the broker runtime config model used by legacy MCP config. - `src/wf_mcp/control.py::BrokerConfigFile.to_runtime()` converts config-file connection declarations into `ConnectionConfig`. Intended new policy: - `locked`: config owns the source id. Same-id registry entries remain shadowed. - `seed`: config bootstraps a missing registry entry. Once the registry entry exists, registry owns later runtime state for that id. - Backward compatibility: existing config with no policy behaves as `locked`. Out of scope: - Do not add MCP source transports to neutral `wf_config.server.sources`. - Do not add auth/catalog cleanup. - Do not implement live remount without reload. - Do not alter built-in reserved ids (`wf.std`, `wf.recipes`, `wf.admin`). --- ### Task 1: Add Policy Field to Broker Runtime and Config Models **Files:** - Modify: `src/wf_mcp/models.py` - Modify: `src/wf_mcp/control.py` - Test: `tests/wf_mcp/test_broker_config.py` or nearest existing broker config test file - [ ] **Step 1: Inspect existing model shape** Run: ```bash rg -n "class ConnectionConfig|class .*Connection" src/wf_mcp/models.py src/wf_mcp/control.py tests/wf_mcp -g '*.py' ``` Expected: locate `ConnectionConfig` and the Pydantic config-file model that constructs it. - [ ] **Step 2: Add a policy type and field to runtime config** In `src/wf_mcp/models.py`, add a type alias near `ConnectionConfig`: ```python SourceConfigOwnership = Literal["locked", "seed"] ``` Add to `ConnectionConfig`: ```python source_config_ownership: SourceConfigOwnership = "locked" ``` If `Literal` is not imported, import it from `typing`. - [ ] **Step 3: Add field to config-file connection model** In `src/wf_mcp/control.py`, add the same field to the config-file connection model: ```python source_config_ownership: SourceConfigOwnership = "locked" ``` When converting to `ConnectionConfig`, pass: ```python source_config_ownership=self.source_config_ownership ``` If the config-file model is named differently, update the exact class that owns `id`, `server`, and `account`. - [ ] **Step 4: Add config parsing tests** In the existing broker config test file, add: ```python def test_broker_config_connection_defaults_to_locked(tmp_path: Path) -> None: config_path = tmp_path / "wf_mcp.config.json" config_path.write_text( json.dumps( { "store_root": str(tmp_path / "store"), "connections": [ {"id": "demo.default", "server": "demo", "account": "default"} ], } ), encoding="utf-8", ) config = load_broker_config(config_path) assert config.connections[0].source_config_ownership == "locked" def test_broker_config_connection_accepts_seed_policy(tmp_path: Path) -> None: config_path = tmp_path / "wf_mcp.config.json" config_path.write_text( json.dumps( { "store_root": str(tmp_path / "store"), "connections": [ { "id": "demo.default", "server": "demo", "account": "default", "source_config_ownership": "seed", } ], } ), encoding="utf-8", ) config = load_broker_config(config_path) assert config.connections[0].source_config_ownership == "seed" ``` Import `json`, `Path`, and `load_broker_config` as needed. - [ ] **Step 5: Run focused tests** Run: ```bash uv run pytest tests/wf_mcp/test_broker_config.py -q ``` Expected: all tests pass. --- ### Task 2: Convert Seed Config Connections to Registry Entries **Files:** - Modify: `src/wf_mcp/source_registry.py` - Test: `tests/wf_mcp/test_source_registry.py` - [ ] **Step 1: Add conversion helper** In `src/wf_mcp/source_registry.py`, add: ```python def connection_config_to_registry_entry( connection: ConnectionConfig, ) -> McpSourceRegistryEntry: """Materialize a seed config connection into persisted registry state. Seed config is bootstrap-only. The registry entry must carry enough source identity to become the future desired-state owner after first startup. """ transport = connection.metadata.get("transport") if not isinstance(transport, dict): raise ValueError( f"seed connection {connection.id!r} requires metadata.transport" ) profile = connection.metadata.get("profile") auth_ref = connection.metadata.get("auth_ref") return McpSourceRegistryEntry.model_validate( { "id": connection.id, "enabled": connection.enabled, "provider": connection.server, "account": connection.account, "profile": profile if isinstance(profile, str) else None, "transport": transport, "auth_ref": auth_ref if isinstance(auth_ref, str) else None, "metadata": { key: value for key, value in connection.metadata.items() if key not in {"transport", "profile", "auth_ref", "source_registry"} }, } ) ``` Also export it in `__all__`. - [ ] **Step 2: Add conversion test** In `tests/wf_mcp/test_source_registry.py`, add: ```python def test_connection_config_to_registry_entry_preserves_transport_metadata() -> None: connection = ConnectionConfig( id="github.work", server="github", account="work", enabled=False, metadata={ "transport": {"kind": "stdio", "command": "npx", "args": ["server"]}, "profile": "corp", "auth_ref": "secret://github/work", "region": "us", }, ) entry = connection_config_to_registry_entry(connection) assert entry.id == "github.work" assert entry.provider == "github" assert entry.account == "work" assert entry.enabled is False assert entry.profile == "corp" assert entry.auth_ref == "secret://github/work" assert entry.transport.kind == "stdio" assert entry.metadata["region"] == "us" ``` Add imports for `ConnectionConfig` and `connection_config_to_registry_entry`. - [ ] **Step 3: Add missing transport failure test** ```python def test_connection_config_to_registry_entry_requires_transport_metadata() -> None: connection = ConnectionConfig(id="github.work", server="github", account="work") with pytest.raises(ValueError, match="requires metadata.transport"): connection_config_to_registry_entry(connection) ``` - [ ] **Step 4: Run source registry tests** Run: ```bash uv run pytest tests/wf_mcp/test_source_registry.py -q ``` Expected: all tests pass. --- ### Task 3: Implement Locked vs Seed Startup Merge **Files:** - Modify: `src/wf_mcp/broker/service/connection_service.py` - Test: `tests/wf_mcp/service/test_connection_service.py` - [ ] **Step 1: Update imports** In `src/wf_mcp/broker/service/connection_service.py`, import: ```python from ...source_registry import ( SourceRegistryFile, SourceRegistryStore, connection_config_to_registry_entry, registry_entry_to_connection_config, ) ``` If `SourceRegistryStore` and `registry_entry_to_connection_config` are already imported, extend the existing import. - [ ] **Step 2: Rewrite registry merge block** Inside `sync_connections_from_config`, replace the current registry loop with this logic: ```python connections = list(config.connections) config_by_id = {connection.id: connection for connection in connections} registry_entries = {} registry_changed = False if source_registry_store is not None: registry = source_registry_store.load_registry() registry_entries = registry.source_map() for connection in connections: if connection.source_config_ownership != "seed": continue if connection.id in registry_entries: continue seeded = connection_config_to_registry_entry(connection) registry_entries[seeded.id] = seeded registry_changed = True self.events.record_kind( "source_registry_seeded_from_config", connection_id=seeded.id, payload={"server": seeded.provider, "account": seeded.account}, ) if registry_changed: source_registry_store.save_registry( SourceRegistryFile(sources=list(registry_entries.values())) ) merged_connections: list[ConnectionConfig] = [] for connection in connections: registry_entry = registry_entries.get(connection.id) if connection.source_config_ownership == "seed" and registry_entry is not None: merged_connections.append(registry_entry_to_connection_config(registry_entry)) continue merged_connections.append(connection) merged_ids = {connection.id for connection in merged_connections} for entry in registry_entries.values(): config_connection = config_by_id.get(entry.id) if config_connection is not None: if config_connection.source_config_ownership == "locked": self.events.record_kind( "source_registry_ignored_config_shadow", connection_id=entry.id, payload={ "server": entry.provider, "account": entry.account, "reason": "locked_config_connection_takes_precedence", }, ) continue if entry.id not in merged_ids: merged_connections.append(registry_entry_to_connection_config(entry)) connections = merged_connections ``` Important notes: - `locked` keeps current behavior. - `seed` with no store entry writes a store entry, then uses that store entry. - `seed` with an existing store entry uses the store entry, not config. - Registry-only entries still hydrate as before. - [ ] **Step 3: Add locked behavior regression test** In `tests/wf_mcp/service/test_connection_service.py`, keep or add: ```python def test_connection_service_sync_locked_config_shadows_registry_entry() -> None: service = ConnectionService(events=BrokerEventRecorder(EventBus())) catalog = _source_catalog(service) store = FileSourceRegistryStore(local_temp_root() / "locked_shadow") store.save_registry( SourceRegistryFile( sources=[ _registry_entry( "demo.default", provider="registry", account="stored", ) ] ) ) config = BrokerConfig( store_root=local_temp_root(), connections=[ ConnectionConfig( id="demo.default", server="config", account="locked", source_config_ownership="locked", ) ], ) service.bind_source_catalog(catalog) service.sync_connections_from_config(config, source_registry_store=store) connection = service.get("demo.default") assert connection.server == "config" assert connection.account == "locked" assert any( event.kind == "source_registry_ignored_config_shadow" for event in service.events.list_events() ) ``` Use existing helpers if names differ. - [ ] **Step 4: Add seed materialization test** ```python def test_connection_service_sync_seed_config_materializes_registry_entry() -> None: service = ConnectionService(events=BrokerEventRecorder(EventBus())) catalog = _source_catalog(service) store_root = local_temp_root() / "seed_materialized" store = FileSourceRegistryStore(store_root) config = BrokerConfig( store_root=local_temp_root(), connections=[ ConnectionConfig( id="demo.default", server="demo", account="default", metadata={"transport": {"kind": "stdio", "command": "demo-server"}}, source_config_ownership="seed", ) ], ) service.bind_source_catalog(catalog) service.sync_connections_from_config(config, source_registry_store=store) registry = store.load_registry() assert registry.sources[0].id == "demo.default" assert registry.sources[0].provider == "demo" assert service.get("demo.default").metadata["source_registry"] is True assert any( event.kind == "source_registry_seeded_from_config" for event in service.events.list_events() ) ``` - [ ] **Step 5: Add seed existing-store-wins test** ```python def test_connection_service_sync_seed_existing_registry_entry_wins() -> None: service = ConnectionService(events=BrokerEventRecorder(EventBus())) catalog = _source_catalog(service) store = FileSourceRegistryStore(local_temp_root() / "seed_existing") store.save_registry( SourceRegistryFile( sources=[ _registry_entry( "demo.default", provider="registry", account="stored", ) ] ) ) config = BrokerConfig( store_root=local_temp_root(), connections=[ ConnectionConfig( id="demo.default", server="config", account="seed", metadata={"transport": {"kind": "stdio", "command": "config-server"}}, source_config_ownership="seed", ) ], ) service.bind_source_catalog(catalog) service.sync_connections_from_config(config, source_registry_store=store) connection = service.get("demo.default") assert connection.server == "registry" assert connection.account == "stored" ``` - [ ] **Step 6: Run connection-service tests** Run: ```bash uv run pytest tests/wf_mcp/service/test_connection_service.py -q ``` Expected: all tests pass. --- ### Task 4: Update Registry Mutation Shadow Checks **Files:** - Modify: `src/wf_mcp/broker/service/source_registry_admin.py` - Test: `tests/wf_mcp/service/test_source_registry_admin.py` - [ ] **Step 1: Add config ownership lookup helper** In `SourceRegistryAdminProvider`, add: ```python def _config_connection(self, source_id: str) -> ConnectionConfig | None: for connection in self.config_connections: if connection.id == source_id: return connection return None ``` - [ ] **Step 2: Change `config_source_ids` if needed** Keep `config_source_ids()` unchanged for read payload compatibility: ```python def config_source_ids(self) -> set[str]: return {connection.id for connection in self.config_connections} ``` - [ ] **Step 3: Update `add_registry_entry` shadow rejection** Replace the current config id check with: ```python config_connection = self._config_connection(source_id) if ( config_connection is not None and config_connection.source_config_ownership == "locked" ): raise ValueError( f"cannot add {source_id!r}: id is locked by a config connection" ) ``` For `seed`, adding remains allowed only if no registry entry already exists. - [ ] **Step 4: Add locked add rejection test** In `tests/wf_mcp/service/test_source_registry_admin.py`, add: ```python def test_add_rejects_locked_config_shadow(tmp_path: Path) -> None: provider = _provider(tmp_path, config_ids=frozenset({"github.work"})) with pytest.raises(ValueError, match="locked by a config connection"): provider.add_registry_entry(_entry_dict("github.work")) ``` If `_provider` cannot pass policy, update it to build `ConnectionConfig(..., source_config_ownership="locked")`. - [ ] **Step 5: Add seed add allowed test** Update `_provider` to accept config connections or config policy, then add: ```python def test_add_allows_seed_config_shadow_when_registry_missing(tmp_path: Path) -> None: provider = _provider( tmp_path, config_connections=[ ConnectionConfig( id="github.work", server="github", account="work", source_config_ownership="seed", ) ], ) result = provider.add_registry_entry(_entry_dict("github.work")) assert result.id == "github.work" ``` - [ ] **Step 6: Run source registry admin tests** Run: ```bash uv run pytest tests/wf_mcp/service/test_source_registry_admin.py -q ``` Expected: all tests pass. --- ### Task 5: Expose Ownership in Admin Registry Payloads **Files:** - Modify: `src/wf_api/source_registry_admin.py` - Modify: `src/wf_mcp/broker/service/source_registry_admin.py` - Test: `tests/wf_api/test_source_registry_admin_api.py` - Test: `tests/wf_mcp/service/test_source_registry_admin.py` - [ ] **Step 1: Extend provider protocol** In `WorkflowSourceRegistryProvider`, add: ```python def config_source_ownership(self) -> Mapping[str, str]: ... ``` - [ ] **Step 2: Implement provider method** In `SourceRegistryAdminProvider`, add: ```python def config_source_ownership(self) -> dict[str, str]: return { connection.id: connection.source_config_ownership for connection in self.config_connections } ``` - [ ] **Step 3: Include ownership in summary and inspect payloads** In `WorkflowSourceRegistryApi`, compute: ```python ownership = self._provider.config_source_ownership() ``` Update `_entry_summary` signature to accept `ownership: Mapping[str, str]`. Include: ```python "config_ownership": ownership.get(entry_id), "mutable": ownership.get(entry_id) != "locked", ``` For inspect payloads, include the same fields at top level: ```python "config_ownership": ownership.get(source_id), "mutable": ownership.get(source_id) != "locked", ``` Keep existing `shadowed_by_config` for compatibility. - [ ] **Step 4: Update fake providers in tests** In `tests/wf_api/test_source_registry_admin_api.py`, add: ```python def config_source_ownership(self) -> dict[str, str]: return {source_id: "locked" for source_id in self._config_ids} ``` or allow the fake to accept an ownership mapping. - [ ] **Step 5: Add API payload test** ```python def test_list_registry_entries_reports_config_ownership_and_mutability() -> None: api = WorkflowSourceRegistryApi( provider=FakeRegistryProvider( [FakeRegistryEntry(id="github.work")], config_ids={"github.work"}, ) ) payload = asyncio.run(api.list_registry_entries()) entry = payload["entries"][0] assert entry["shadowed_by_config"] is True assert entry["config_ownership"] == "locked" assert entry["mutable"] is False ``` - [ ] **Step 6: Run API/provider tests** Run: ```bash uv run pytest tests/wf_api/test_source_registry_admin_api.py tests/wf_mcp/service/test_source_registry_admin.py -q ``` Expected: all tests pass. --- ### Task 6: Update Docs and Roadmap Status **Files:** - Modify: `docs/superpowers/specs/2026-06-03-store-backed-source-registry-design.md` - Modify: `docs/superpowers/plans/2026-06-03-source-registry-next-slices.md` - Modify: `docs/current_roadmap.md` - [ ] **Step 1: Update spec Slice 6 status** In `docs/superpowers/specs/2026-06-03-store-backed-source-registry-design.md`, change Slice 6 status from planned to complete and summarize: ```markdown Status: complete. MCP broker config connections now support `source_config_ownership="locked" | "seed"`. `locked` preserves v1 shadowing. `seed` materializes missing store entries and lets existing registry entries own future runtime state. ``` - [ ] **Step 2: Update next-slices plan** In `docs/superpowers/plans/2026-06-03-source-registry-next-slices.md`, mark Slice 6 complete. - [ ] **Step 3: Update current roadmap** In `docs/current_roadmap.md`, replace the planned wording with implemented wording: ```markdown Config ownership policy is implemented for MCP broker config connections: `locked` entries stay operator-owned, while `seed` entries bootstrap missing store entries and then let the store own later admin changes. ``` - [ ] **Step 4: Run doc diff check** Run: ```bash git diff -- docs/superpowers/specs/2026-06-03-store-backed-source-registry-design.md docs/superpowers/plans/2026-06-03-source-registry-next-slices.md docs/current_roadmap.md ``` Expected: docs match the implemented slice and do not claim neutral `wf_config.server.sources` supports MCP source ownership yet. --- ## Final Verification Run: ```bash uv run pytest tests/wf_mcp/test_source_registry.py tests/wf_mcp/service/test_connection_service.py tests/wf_mcp/service/test_source_registry_admin.py tests/wf_api/test_source_registry_admin_api.py -q uv run ruff check src/wf_mcp/models.py src/wf_mcp/control.py src/wf_mcp/source_registry.py src/wf_mcp/broker/service/connection_service.py src/wf_mcp/broker/service/source_registry_admin.py src/wf_api/source_registry_admin.py tests/wf_mcp/test_source_registry.py tests/wf_mcp/service/test_connection_service.py tests/wf_mcp/service/test_source_registry_admin.py tests/wf_api/test_source_registry_admin_api.py uv run basedpyright --level error src/wf_mcp/models.py src/wf_mcp/control.py src/wf_mcp/source_registry.py src/wf_mcp/broker/service/connection_service.py src/wf_mcp/broker/service/source_registry_admin.py src/wf_api/source_registry_admin.py tests/wf_mcp/test_source_registry.py tests/wf_mcp/service/test_connection_service.py tests/wf_mcp/service/test_source_registry_admin.py tests/wf_api/test_source_registry_admin_api.py git diff --check ``` Expected: pytest exits 0, ruff exits 0, basedpyright exits 0, and `git diff --check` reports no whitespace errors. ## Self-Review - This plan intentionally keeps `locked` as the default for backward compatibility. - This plan does not add MCP source entries to neutral `wf_config` because current neutral source config only supports built-in sources. - This plan requires `seed` config connections to carry `metadata.transport`; without transport, the config cannot be materialized into a durable registry entry. - The admin payload adds `config_ownership` and `mutable` without removing `shadowed_by_config`, preserving compatibility.