Files
lda-wf/docs/historical/superpowers/plans/2026-06-04-source-config-ownership-policy.md
T

723 lines
23 KiB
Markdown

# 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.