SL-5 — Contract-level conflict with after-the-fact reconciliation¶
Date: 2026-06-19
Status: Draft — needs approval before implementation
Plugin: anvil
Tracks: roadmap integrity-track SL-5 (Wave 3: "earn the reframe")
Depends on: SL-3 (DiffProof — the typed diff this spec's drift check compares against). SL-3 must ship first.
Breaking: YES (model + conflict-detection semantics change). Task gains an OutputContract; ConflictGroup is keyed on contract overlap, not expected_files/likely_files overlap. Schema version bumps 6 → 7 (after SL-3 takes 6).
1. Goal¶
Lift conflict detection out of the advisory-only class. Today a ConflictGroup
is "a named set of tasks whose expected_files overlap"
(bin/src/anvil/state/models.py:618-629), computed by file-set intersection in
infer_conflict_groups (bin/src/anvil/planning/inference.py:175-263). Two
problems:
- False conflicts. Two tasks that both touch
models.pyare grouped even if one adds a class and the other edits an unrelated function — they could run in parallel safely, but the file-overlap heuristic forbids it. - It depends on an honest up-front declaration with no after-check. This is
the same gameability class the old substring gate was in (the roadmap calls
this out explicitly,
docs/roadmap.md:143-148): a task declares its files, the engine trusts the declaration, and nothing ever compares the declaration to what the task actually did.
SL-5 adds a typed OutputContract to Task (the symbols, modules, endpoints,
and tables a task promises to produce/own), keys ConflictGroup on contract
overlap, and adds a post-apply drift check that compares the declared
contract against the task's actual DiffProof (from SL-3), firing a
contract.drift_detected event when they diverge. The reconciliation step is
what makes the declaration checkable rather than merely trusted.
2. Context & root cause¶
How conflict detection works today¶
Task.conflict_groups: list[str](models.py:362) andlikely_files: list[str](models.py:367) hold the declaration.Claim.expected_files: list[str](models.py:390) holds the per-claim declaration.infer_conflict_groups(tasks)(inference.py:175-263) intersects each pair's file sets (_files_set,inference.py:199-201); a non-empty, non-subset overlap createsConflictGroup(id="CG-<sorted-task-ids>", ..., reason="Tasks A and B share overlapping files: ...")(inference.py:236-247).- Groups persist via
conflict_group.upsertedevents (mcp_server.py:2145-2156,ConflictGroupUpsertedPayloadatpayloads.py:220) into theconflict_groupstable (schema.py:219-224). - At claim time,
ClaimManager._check_group_conflictswarns (does not block) when a task shares aconflict_groupwith an active claim (conflicts.py:11-13doc; enforcement seam atsqlite.py:3032). anvil conflicts(cli/conflicts.py:54-139) reads them back.
Root cause¶
The unit of conflict is the file, and the file declaration is never
reconciled against reality. A file is too coarse (whole-file overlap ≠ real
conflict) and too trusting (no after-check). The correct unit is the contract:
the named outputs a task owns. Two tasks editing the same file but owning
disjoint symbols do not conflict; a task that declares it owns symbol foo but
its actual diff touches bar has drifted from its contract — and SL-3's
DiffProof (files_changed, diff_sha256) is exactly the observed artifact to
check against.
3. Proposed design¶
3.1 The OutputContract model (state/models.py)¶
Mirror the embedded-value-object style (Score, Verification:
models.py:249-270): Pydantic BaseModel, _MODEL_CONFIG
(models.py:225-229).
class OutputContract(BaseModel):
"""What a Task promises to produce / own. The unit of conflict detection
and the target of the post-apply drift check (SL-5)."""
model_config = _MODEL_CONFIG
symbols: list[str] = Field(default_factory=list) # "module.path:func_or_class"
modules: list[str] = Field(default_factory=list) # importable module paths / file paths
endpoints: list[str] = Field(default_factory=list) # "METHOD /route/path"
tables: list[str] = Field(default_factory=list) # DB table / migration names
def overlaps(self, other: OutputContract) -> set[str]:
"""Return the set of contract items this contract shares with other."""
return (
(set(self.symbols) & set(other.symbols))
| (set(self.modules) & set(other.modules))
| (set(self.endpoints) & set(other.endpoints))
| (set(self.tables) & set(other.tables))
)
Task (models.py:346-375) gains:
output_contract: OutputContract = Field(default_factory=OutputContract)
default_factory=OutputContract (an empty contract) means every pre-SL-5 task
deserialises with an empty contract — full backward compatibility, exactly the
pattern task_type: TaskType = TaskType.feature used (models.py:357-360).
3.2 ConflictGroup keyed on contract overlap¶
Update the ConflictGroup docstring and infer_conflict_groups. The model
fields (id, name, task_ids, reason — models.py:618-629) are unchanged;
only the meaning and the grouping key change:
class ConflictGroup(BaseModel):
"""A named set of tasks whose OutputContracts overlap (SL-5).
Two tasks conflict when they declare ownership of the same contract item
(symbol, module, endpoint, or table) — NOT merely when they touch the same
file. Claiming one while another is active is allowed but warned."""
infer_conflict_groups (inference.py:175-263) is rewritten to intersect
OutputContracts instead of file sets:
- Replace
file_sets[t.id] = _files_set(t)(inference.py:199-201) with the task'soutput_contract. - Replace
overlap = set_a & set_b(inference.py:226) withoverlap = contract_a.overlaps(contract_b). - The strict-subset "this is a dependency, not a conflict" rule
(
inference.py:230-233) is dropped for contracts: contract ownership is not subset-structured the way file scope was; any shared contract item is a real conflict regardless of the rest of each contract. reasonbecomes"Tasks A and B both declare contract items: <sorted overlap>".
3.3 Back-compat with file-overlap (the fallback)¶
A task that declares an empty output_contract (every pre-SL-5 task, and any
task a planner has not yet given a contract) must not silently lose conflict
protection. infer_conflict_groups therefore runs a two-tier rule per pair:
- If both tasks have a non-empty
output_contract, group on contract overlap (the new, precise rule). - If either task has an empty
output_contract, fall back to the legacylikely_filesoverlap rule (_files_set, the currentinference.pylogic), so an un-migrated task is never less protected than today.
This makes the upgrade non-regressive: a project with zero declared contracts behaves byte-identically to today (all conflicts via file overlap); a project where the planner has filled in contracts gets the precise contract-overlap grouping. The two-tier rule is documented in the function docstring and is the explicit acceptance test "two tasks touch the same file but declare non-overlapping contracts and run in parallel" (§4).
3.4 Post-apply drift check + reconciliation (cli/packet_apply.py)¶
After a successful apply --approve transitions a task to accepted/done
(cli/packet_apply.py, the task.applied path; TaskAppliedPayload at
payloads.py:327-335), run a reconciliation step:
- Load the task's
output_contractand the accepted evidence'sDiffProof(SL-3 —Evidence.proofs, theDiffProof.files_changed). - Compute drift via a new pure function in
review/gates.py:
@dataclass(frozen=True)
class ContractDrift:
task_id: str
declared_modules: list[str] # contract.modules
actual_files: list[str] # diff_proof.files_changed
undeclared_files: list[str] # touched but not in contract.modules
unfulfilled_modules: list[str] # declared but not touched
def contract_drift(task: Task, diff_proof: DiffProof) -> ContractDrift | None:
"""Pure. None when the declared contract.modules exactly covers the diff's
files_changed; otherwise a ContractDrift describing the divergence."""
Module-level granularity is the practical drift check (symbols/endpoints/tables
require language-aware diff parsing — deferred, §10). contract.modules lists
the file paths the task promised to change; DiffProof.files_changed lists what
it actually changed.
- If
contract_driftreturns a non-Noneresult, append acontract.drift_detectedevent (additive, replayable):
class ContractDriftDetectedPayload(BaseModel): # state/payloads.py
model_config = ConfigDict(extra="forbid")
task_id: str
declared_modules: list[str]
actual_files: list[str]
undeclared_files: list[str]
unfulfilled_modules: list[str]
detected_at: str # ISO 8601 UTC
The drift event is advisory by default but recorded: it does not block the
apply (the task is already accepted on its evidence gate), but it lands in
events.jsonl as a permanent, queryable fact. A config flag
contract_drift_enforcing (mirroring the existing advisory/enforcing evidence
gate toggle at config.py:217-231) can promote it to a hard gate that blocks
apply --approve until the contract is reconciled. anvil conflicts (or a new
anvil drift view, reusing cli/drift.py's existing structure) surfaces tasks
with recorded drift.
3.5 Storage¶
taskstable:output_contract TEXT NOT NULL DEFAULT '{}'JSON column (schema.py:110-128), exactly like theverification/scoresJSON columns.TaskCreatedPayload/TaskScoredPayload(payloads.py:115, 145) carry the contract through the create/score path so a replanned task's contract is a logged fact.contract.drift_detectedis a new event action; no new table (it is read by scanning events, like other audit-style facts).
4. Acceptance¶
OutputContractexists;Task.output_contractis added withdefault_factory=OutputContract.ConflictGroupis keyed on contract overlap when both tasks declare contracts; falls back tolikely_filesoverlap when either is empty.- Parallel-safe test: two tasks whose
likely_filesboth containmodels.pybut whoseoutput_contract.symbols/modulesare disjoint produce noConflictGroupand can both be claimed concurrently without a warning. - Drift test: a task whose
output_contract.modules = ["a.py"]but whose acceptedDiffProof.files_changed = ["a.py", "b.py"]produces acontract.drift_detectedevent withundeclared_files = ["b.py"]. - Back-compat test: a project with zero declared contracts produces the
identical
ConflictGroupset it produced before SL-5 (file-overlap path). contract.drift_detectedreplays deterministically (P4).- Schema version
6 → 7;migrations.mddocuments the v6→v7 auto-upgrade. plugins/anvilversion bumped;registry/regenerated.
5. Migration¶
Schema: bump SCHEMA_VERSION = 7 (schema.py:39); add tasks.output_contract
to the DDL; add a v6 → v7 branch in _check_schema_version
(sqlite.py:1195-1307) calling a new _ensure_output_contract_column helper
shaped exactly like _ensure_task_type_column (sqlite.py:1322-1339):
duplicate-column-tolerant ALTER TABLE tasks ADD COLUMN output_contract TEXT NOT
NULL DEFAULT '{}'. Chain it into every existing upgrade branch (now → 7).
Purely additive — the '{}' default backfills every existing task to an empty
contract, which the §3.3 two-tier rule treats exactly like a pre-SL-5 task.
Data: no data migration is required. Empty contracts are the correct legacy
meaning; the file-overlap fallback preserves old behaviour. A planner populates
contracts going forward via anvil plan (the planner agent infers a task's
output_contract from its acceptance criteria / likely_files; that inference is
itself an SL-6-adjacent planner improvement, out of scope here).
6. Backward-compat / replay implications (fakoli-style P4)¶
Task.output_contractdefaults to an emptyOutputContract, so atask.createdpayload from a pre-SL-5 log deserialises cleanly (the new field is absent → default applied). No lenient validator needed beyond the default.contract.drift_detectedis purely additive — appending it never invalidates a prior event, andreplay_from_emptyre-applies it as a no-op projection write (it is an audit fact; the drift view derives from the event stream).- Regenerate the replay golden to include one task with a non-empty
output_contractand onecontract.drift_detectedevent;test_replay_equivalencestays green (same P4 discipline as SL1-RR-1 §8C and SL-3 §6).
7. Risks¶
- Contract quality depends on the planner. A precise
output_contractrequires the planner to know a task's symbols/modules. Mitigation: the file-overlap fallback (§3.3) means a bad/empty contract degrades gracefully to today's behaviour, never worse. - Module-only drift granularity. Symbol/endpoint/table drift is not checked
(only
modulesvsfiles_changed). This is honest scoping — file-level drift is the high-value, language-agnostic check; finer granularity needs AST/route parsing (§10). - Advisory drift may be ignored. A recorded-but-non-blocking drift event can
be overlooked. Mitigation: the
contract_drift_enforcingconfig toggle and theanvil drift/conflictssurface make it visible and optionally blocking. - Dropping the subset rule (§3.2) could over-group contracts that nest. In practice contract items are flat ownership claims, not nested scopes, so any shared item is a genuine co-ownership conflict — the subset rule was a file-scope artifact.
8. Implementation steps¶
- Add
OutputContract(withoverlaps) tostate/models.py; addTask.output_contract; export from__all__; update theConflictGroupdocstring. - Bump
SCHEMA_VERSION = 7; addtasks.output_contractDDL;_ensure_output_contract_column+v6→v7branch; chain into prior branches. - Thread
output_contractthroughTaskCreatedPayload/TaskScoredPayloadand the task INSERT/SELECT insqlite.py. - Rewrite
infer_conflict_groupsto the two-tier (contract-then-file) rule; updatereasontext. - Add
contract_drift+ContractDrifttoreview/gates.py(pure). - Add
ContractDriftDetectedPayload; register thecontract.drift_detectedaction (_check_*/_write_*per SL1-RR-1's dispatch contract). - Wire the post-
applyreconciliation intocli/packet_apply.py'stask.appliedpath; add thecontract_drift_enforcingconfig flag. - Surface drift in
anvil drift/anvil conflicts. - Regenerate the replay golden; bump version; regen
registry/.
9. Test plan¶
| Test | Asserts |
|---|---|
| Parallel-safe | Same likely_files, disjoint contracts → no ConflictGroup; both claimable concurrently (no warning) |
| Contract conflict | Same output_contract.symbols item → one ConflictGroup with a contract-item reason |
| File fallback | Both tasks empty contract, overlapping likely_files → identical grouping to pre-SL-5 (golden) |
| Mixed tier | One empty + one populated contract, overlapping files → file-overlap fallback fires |
| Drift fires | Declared modules=["a.py"], actual files_changed=["a.py","b.py"] → contract.drift_detected with undeclared_files=["b.py"] |
| No drift | Declared exactly covers diff → contract_drift returns None, no event |
| Enforcing toggle | contract_drift_enforcing=true blocks apply --approve on drift; default advisory does not |
| Replay (P4) | Golden with a non-empty contract + a drift event replays byte-equal |
| Schema upgrade | v6 db auto-upgrades to v7 with output_contract defaulting '{}' |
CI: full suite via .github/workflows/anvil.yml.
10. Out of scope¶
- Symbol/endpoint/table-level drift parsing (needs AST + route + migration introspection); SL-5 checks module/file-level drift only.
- Planner inference of
output_contractfrom acceptance criteria (an SL-6-class planning improvement). - Cross-project / distributed contract coordination.
- Auto-remediation of drift (reconciliation records; the human decides — same
posture as
state-keeperdrift reporting).
11. References¶
bin/src/anvil/state/models.py:346-375(Task),:362(conflict_groups),:367(likely_files),:390(Claim.expected_files),:618-629(ConflictGroup)bin/src/anvil/planning/inference.py:175-263(infer_conflict_groups,_files_set)bin/src/anvil/cli/conflicts.py:54-139;bin/src/anvil/cli/drift.pybin/src/anvil/state/payloads.py:115, 145, 220, 327(TaskCreated/TaskScored/ConflictGroupUpserted/TaskApplied)bin/src/anvil/state/schema.py:39, 110-128, 219-224;bin/src/anvil/state/sqlite.py:1195-1339(migration pattern),:3032(group-conflict claim seam)bin/src/anvil/config.py:217-231(advisory/enforcing gate-toggle precedent)docs/specs/2026-06-19-sl3-proofartifact.md(DiffProof,Evidence.proofs)