- ADR: ADR 0120
- Title: CPU-only preflight engine that computes a structured SemanticDiff for any ExecutionIntent before execution — predicts exact changed objects across Ansible, OpenTofu, Docker, DNS, TLS, and firewall surfaces
- Status: merged
- Branch:
codex/adr-0120-diff-engine - Worktree:
../proxmox-host_server-diff-engine - Owner: codex
- Depends On:
adr-0048-command-catalog,adr-0085-opentofu-vm-lifecycle,adr-0090-platform-cli,adr-0112-goal-compiler,adr-0113-world-state-materializer,adr-0115-mutation-ledger,adr-0116-change-risk-scoring - Conflicts With: none
- Shared Surfaces:
platform/diff_engine/,config/diff-adapters.yaml
- create
platform/diff_engine/__init__.py - create
platform/diff_engine/engine.py—DiffEngine.compute(intent, world_state): dispatches to registered adapters, aggregatesChangedObjectlists intoSemanticDiff - create
platform/diff_engine/schema.py—ChangedObjectandSemanticDiffdataclasses from ADR 0120 - create
platform/diff_engine/registry.py— adapter registry: loads adapter classes fromconfig/diff-adapters.yaml, instantiates on demand - create
platform/diff_engine/adapters/ansible_adapter.py— runsansible-playbook --check --diff; parses JSON callback output; extractsChangedObjectper changed task - create
platform/diff_engine/adapters/opentofu_adapter.py— runstofu plan -json; parses resource change list - create
platform/diff_engine/adapters/docker_adapter.py— compares desired compose state (from compose file) against current Docker API container state - create
platform/diff_engine/adapters/dns_adapter.py— compares desired DNS entries (from NetBox/world-state) against live resolver responses - create
platform/diff_engine/adapters/cert_adapter.py— compares desired certificate config against step-ca inventory from world-state - create
config/diff-adapters.yaml— adapter registry config: maps tool IDs to adapter class paths and enabled flag - update
scripts/risk_scorer/context.pyandscripts/risk_scorer/models.py— callDiffEngine.compute()after compilation and embedSemanticDiffplus real diff counts in the compiled intent model - update
scripts/risk_scorer/dimensions.py— treatunknown_count > 0as a maximum mutation-surface penalty and escalate irreversible diffs - update
scripts/lv3_cli.py— renderSemanticDiffas the structured change summary before the approval prompt and write optional compiled-intent ledger events - write
tests/unit/test_diff_engine.py— test each adapter independently with fixture data; testunknownconfidence propagation; test empty diff - write
docs/runbooks/dry-run-semantic-diff-engine.md— operator-facing usage, adapter behavior, and verification flow
- Writing a firewall adapter in this workstream (complex nftables parsing; add as a follow-up)
- VM-level Proxmox config diffing (add as a follow-up)
- Executing any mutations — the diff engine is read-only
platform/diff_engine/__init__.pyplatform/diff_engine/engine.pyplatform/diff_engine/schema.pyplatform/diff_engine/registry.pyplatform/diff_engine/adapters/ansible_adapter.pyplatform/diff_engine/adapters/opentofu_adapter.pyplatform/diff_engine/adapters/docker_adapter.pyplatform/diff_engine/adapters/dns_adapter.pyplatform/diff_engine/adapters/cert_adapter.pyconfig/diff-adapters.yamlscripts/risk_scorer/context.pyscripts/risk_scorer/models.pyscripts/risk_scorer/dimensions.pyscripts/lv3_cli.pydocs/runbooks/dry-run-semantic-diff-engine.mddocs/adr/0120-dry-run-semantic-diff-engine.mddocs/workstreams/adr-0120-dry-run-diff-engine.md
lv3 run converge-netbox --dry-rundisplays a structured diff output showing predicted changed objects before asking for confirmationSemanticDiff.total_changesis a positive integer for a service that has pending changesSemanticDiff.total_changes == 0for a service that is already converged and up-to-dateSemanticDiff.unknown_count > 0is logged and shown in the approval prompt when a surface has no adapter
- Run
uv run --with pytest --with pyyaml pytest tests/unit/test_diff_engine.py tests/test_risk_scorer.py tests/test_lv3_cli.py tests/test_world_state_client.py -q→ all tests pass - Run
uv run --with pyyaml --with jsonschema python scripts/validate_repository_data_models.py --validate→ repository data models validate - Run
lv3 run converge-netbox --dry-runon the controller with valid Windmill metadata → inspect the structured diff output before any API submission - Run the same dry-run against a workflow without a configured adapter surface → confirm
unknown_count > 0is rendered and the mutation-surface score contribution rises to the maximum
- Focused unit and integration tests pass for all 5 initial adapters plus the CLI and risk-scoring integration path
SemanticDiffis embedded in the compiled intent model and optional ledger event path- The risk scorer (ADR 0116) now consumes real diff-engine counts when available and falls back only when a surface is unsupported or unavailable
- The initial repository implementation resolves desired Docker state from the image catalog rather than fully rendered Compose templates. If a future workflow needs field-level Compose diffing, extend the Docker adapter instead of bypassing it.
- Firewall and Proxmox VM config diffing remain separate follow-up workstreams. Keep unsupported surfaces explicit by emitting
confidence: unknownobjects rather than inventing optimistic counts.