Skip to content

Testing plan: the CLI config is editable from the Control Plane UI

Derived from the approved requirements.md and design.md, before tasks.md. Authored at the test-planning node and completed at the verification node.

This file is executable content. It names commands an agent will run, so review it like code. No credentials of any kind are involved: every command below runs a test suite or a linter over this repository, against temp directories.

What this work item has to prove

Three claims carry the risk, and each gets its own kind of test rather than a shared "it works":

  1. A save does not damage the file. Text-level splicing is the one novel mechanism here, so it is tested against the real, heavily commented shipped template — comment count, byte-for-byte equality of every untouched line, and a property-style round trip over many key paths — not against a three-line fixture that would pass whatever we wrote.
  2. An invalid config never reaches disk. Every rejection path is asserted twice: the response is 400, and the file is byte-identical to what it was.
  3. The validator is not a comforting stub. It is compared against real jsonschema (a dev dependency) over a corpus, and a keyword guard fails if the schema grows a construct it does not implement.

Test matrix

#TypeApplies?Scope / what it provesWhere it runs
T1Unit — yamlpatchyessplice fidelity and comment preservation: existing scalar, block sequence, flow sequence, empty container, missing leaf, missing parent chain, a null leaf that removes a key, absent file; every untouched byte of the shipped template survives; an unverifiable splice raises rather than returning text (R2.1–R2.4, R2.8)uv run pytest cli/tests/test_yamlpatch.py
T2Unit — configschemayes$ref resolution against the packaged collaborators schema; type/enum/range/unknown-key violations are reported with their key path; the keyword guard; differential agreement with jsonschema over valid and invalid corpora (R3.1, NFR4)uv run pytest cli/tests/test_configschema.py
T3Unit — core.configyesread of a missing file, a present file and an unparseable file; merge + changed-path computation; restartRequired selection; atomic write leaves no temp file; failure paths leave the file byte-identical (R1.1–R1.5, R2.5, R2.7, R3.4, R3.5, R4.4, R4.5)uv run pytest cli/tests/test_core_config.py
T4Integration — the three routesyesGET /config, GET /config/schema, POST /config through FastAPI's TestClient against a temp config: 200/400 mapping, the CORS pairing refusal, the migration-gate refusal, the event-log record naming key paths and not values (R1, R2, R3, security design)uv run pytest cli/tests/test_api_config_integration.py
T5Integration — hot reloadyesa POST is visible to the next request without a restart; a Reloader baselined on the file — the one the poller and receiver hold — rebuilds after an API write; a hand-edit is picked up too; a file that becomes unparseable keeps the previous config (R4.1, R4.2, R4.3)uv run pytest cli/tests/test_api_config_integration.py
T6Contract (OpenAPI)yesthe three operations are authored in docs/api-specs/openapi/the-loop.v1.yaml and the served schema matches it exactly (NFR3)uv run pytest cli/tests/test_api_contract_parity.py
T7Parity — packaged schemayesthe_loop/schemas/*.json are byte-identical to .the-loop/*.schema.json, and resolve from the package with no repository checkout (design §Where the schema comes from)uv run pytest cli/tests/test_config_schema_parity.py
T8Unit — UI field modelyesfieldsOf derives sections and leaf kinds from the schema (string/number/boolean/enum/string-array/structured); getIn/setIn; diff emits a sparse patch and nothing for an unchanged draft (R5.1–R5.4, R5.6)cd ui && bun run test
T9UI / component renderyesthe editor renders a section per top-level property with the schema's prose, an unset field shows its default as a placeholder rather than a value, an unsupported subtree renders a structured field, and Save posts the diff (R5.1, R5.5, R5.6); demo mode renders with no network call (R5.7)cd ui && bun run test
T10Security / abuse caseyesthe five abuse cases: no request field names a path; a write emits key paths and no values; the un-bootable CORS pairing is refused at write time; every rejection leaves the file untouched; no schema key holds a secretasserted in T3/T4 + review against requirements.md §Security considerations
T11Accessibilityyes, partialevery control has a programmatic label and its description is associated with it; the structured field reports a parse error in text, not colour aloneassertion in T9 (getByLabelText) + manual read-through; no axe run — the repo has no accessibility harness, and adding one is its own work item
T12Snapshotn/a — the one byte-exact comparison that matters (the template survives a save) is an explicit assertion in T1, which says what must not change; a snapshot would only say that something did
T13Performance / loadn/a — one sha256 of a ~10 KB file per API request, and one file write per save. No load dimension; the cost is stated in design.md rather than measured
T14Migration / upgradeyesan operator's existing config keeps working: assert_current still gates reads, and a config below CURRENT_CONFIG_VERSION is refused by the write path with the message that names the fix (R3.2)assertion in T3/T4 + uv run python scripts/validate_config.py
T15End-to-endn/a — cli/tests/test_pdlc_e2e/ drives the process graph against a mocked harness; config editing is not a graph node and has no place in that suite
T16Manual exploratoryyesrun the-loop service start against a copy of the shipped config, open the built UI, change a value, confirm the file changed and kept its comments. The daemon side is not run by hand — no poller or receiver is started in this session; R4.1 is carried by T5's reloader assertion instead, and this row says so rather than implying a run that did not happenrecorded in evidence with the diff of the touched file

Scenarios & requirement trace

RowRequirement(s)Scenario / case
T1R2.1, R2.2Scenario: a save rewrites one value and leaves every comment
T1R2.3Scenario: a key absent from the file is inserted under its parent
T1R2.4Scenario: a config file that does not exist is created with its modeline
T1R2.6Scenario: a patch leaf of null removes the key (in the shape-parametrised case)
T1R2.8Scenario: a splice that does not re-parse to the intended document raises
T2R3.1Scenario: an unknown key, a wrong type and an out-of-enum value are each named
T2NFR4Scenario: the packaged schema resolves with no repository checkout
T3R1.1, R1.2Scenario: a workstation with no config file reads as empty, not as an error
T3R1.5Scenario: an unparseable config is an error, not an empty config
T3R2.5, R2.7Scenario: an empty patch writes nothing / Scenario: the write is atomic
T3R3.5Scenario: a refused save leaves the file byte-identical
T3R4.4, R4.5Scenario: a bind change is reported as restart-required, a routing change is not
T4R1.3, R3.1–R3.3Scenario: the schema route serves a resolved schema / Scenario: the un-bootable CORS pairing cannot be saved
T4security designScenario: a config write is recorded as key paths, never values
T5R4.1Scenario: a daemon watching the file sees a saved change
T5R4.2, R4.3Scenario: a saved change is live on the next request / Scenario: a hand-edit is picked up too
T6NFR3existing: Scenario: the served schema drifts from the authored contract
T7design §schema homeScenario: the packaged schema is the authored schema
T8, T9R5.1–R5.7sections mirror the schema · an unset field offers its default · an unsupported subtree stays editable · save sends only what changed
T14R3.2Scenario: a config below the current version is refused with the upgrade command

Verification environment

  • Repositories: this one. The API tests point a whole app at a tmp_path config, so no test reads or writes the operator's real ~/.the-loop/cli-config.yaml or this repository's .the-loop/cli-config.yaml.
  • Services / containers: none for T1–T15. T16 runs the-loop service start on loopback in this checkout.
  • Fixtures & data: skills/the-loop/templates/cli-config.yaml is used as the realistic input for T1 (it is the file operators actually have); tmp_path for everything else.
  • Credentials: none. No token, secret or environment variable is read by any test.
  • Bring-up: uv sync; cd ui && bun install.
  • Tear-down: none (temp dirs are pytest's).
  • If bring-up fails: record it under Verification results, leave the dependent activities unticked, and escalate — an activity that could not run is never ticked.

Evidence plan

RowEvidencePath under evidence/
T1–T7, T14pytest output per row plus the full suite (counts, duration)verification.md
T8, T9, T11vitest output; the rendered editorverification.md, settings-config.png
T16the before/after diff of the touched config file, showing the changed value and the surviving commentsverification.md
allquality gates: make lint, make format-check, make typecheck, make validate, UI typecheck/lintverification.md

Redaction: T16's diff is of a config in this public repository and holds no secret; any absolute path that would reveal a home directory is replaced with ~.

Verification activities

  • [x] T1 — uv run pytest cli/tests/test_yamlpatch.py
  • [x] T2 — uv run pytest cli/tests/test_configschema.py
  • [x] T3 — uv run pytest cli/tests/test_core_config.py
  • [x] T4, T5, T10 — uv run pytest cli/tests/test_api_config_integration.py
  • [x] T6 — uv run pytest cli/tests/test_api_contract_parity.py
  • [x] T7 — uv run pytest cli/tests/test_config_schema_parity.py
  • [x] T8, T9, T11 — cd ui && bun run test
  • [x] T14 — uv run python scripts/validate_config.py
  • [x] full suite — make test
  • [x] quality gates — make lint format-check typecheck validate + cd ui && bun run typecheck && bun run lint
  • [x] T16 — manual: service + UI, edit a value, inspect the file and the daemon

Released under the MIT License.