From 1b5d77b6a8e234be011297ba6aebfd58a7b41e55 Mon Sep 17 00:00:00 2001 From: kschlt Date: Fri, 3 Apr 2026 00:43:17 +0200 Subject: [PATCH 1/6] test(enforcement): verify clause_id survives exclude_none serialization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Confirms the already-implemented clause_id field is not dropped when model_dump(exclude_none=True) is called — the path used by ConstraintsContract.to_json_file(). Closes the end-to-end serialization gap in the existing TestClauseIdGeneration coverage. --- tests/unit/test_enforcement_pipeline.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tests/unit/test_enforcement_pipeline.py b/tests/unit/test_enforcement_pipeline.py index 51102c7..de9b7ab 100644 --- a/tests/unit/test_enforcement_pipeline.py +++ b/tests/unit/test_enforcement_pipeline.py @@ -78,6 +78,21 @@ def test_provenance_has_clause_id_via_merger(self): assert len(prov.clause_id) == 12 assert prov.clause_id != "" + def test_clause_id_survives_exclude_none_serialization(self): + """clause_id must survive model_dump(exclude_none=True) used by to_json_file.""" + prov = PolicyProvenance( + adr_id="ADR-0001", + adr_title="Test", + rule_path="imports.disallow.axios", + effective_date=datetime(2024, 1, 1, tzinfo=timezone.utc), + clause_id=PolicyProvenance.make_clause_id( + "ADR-0001", "imports.disallow.axios" + ), + ) + dumped = prov.model_dump(exclude_none=True) + assert "clause_id" in dumped + assert len(dumped["clause_id"]) == 12 + # --------------------------------------------------------------------------- # Topological sort tests From 1b4815b55b08429e3cdfdd908791f363a971c613 Mon Sep 17 00:00:00 2001 From: kschlt Date: Fri, 3 Apr 2026 00:45:08 +0200 Subject: [PATCH 2/6] feat(core): add depends_on and related_to relation fields to ADRFrontMatter Establishes the schema foundation for inter-ADR relationship tracking. These fields enable ADRs to declare explicit dependencies and non-hierarchical relationships, which downstream tooling (relationship computation, impact analysis) can consume. Empty lists are normalised to None by extending the existing ensure_list_or_none validator rather than adding a new one. --- adr_kit/core/model.py | 16 ++++++++- tests/unit/test_policy_validation.py | 54 ++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+), 1 deletion(-) diff --git a/adr_kit/core/model.py b/adr_kit/core/model.py index 478cfb0..50f90b2 100644 --- a/adr_kit/core/model.py +++ b/adr_kit/core/model.py @@ -320,11 +320,25 @@ class ADRFrontMatter(BaseModel): superseded_by: list[str] | None = Field( None, description="List of ADR IDs that supersede this one" ) + depends_on: list[str] | None = Field( + None, description="ADR IDs this decision depends on" + ) + related_to: list[str] | None = Field( + None, description="ADR IDs with a non-hierarchical relationship to this one" + ) policy: PolicyModel | None = Field( None, description="Structured policy for enforcement" ) - @field_validator("deciders", "tags", "supersedes", "superseded_by", mode="before") + @field_validator( + "deciders", + "tags", + "supersedes", + "superseded_by", + "depends_on", + "related_to", + mode="before", + ) @classmethod def ensure_list_or_none(cls, v: Any) -> list[str] | None: """Ensure array fields are lists or None, not empty lists.""" diff --git a/tests/unit/test_policy_validation.py b/tests/unit/test_policy_validation.py index 7020997..e276ad9 100644 --- a/tests/unit/test_policy_validation.py +++ b/tests/unit/test_policy_validation.py @@ -377,5 +377,59 @@ def test_has_extractable_policy_with_new_types(self): assert extractor.has_extractable_policy(adr_config) is True +class TestRelationFields: + """Test depends_on and related_to fields on ADRFrontMatter.""" + + def _make_fm(self, **kwargs: object) -> ADRFrontMatter: + from datetime import date + + return ADRFrontMatter( + id="ADR-0002", + title="Test ADR", + status=ADRStatus.ACCEPTED, + date=date(2024, 1, 1), + **kwargs, + ) + + def test_depends_on_accepted(self) -> None: + fm = self._make_fm(depends_on=["ADR-0001"]) + assert fm.depends_on == ["ADR-0001"] + + def test_related_to_accepted(self) -> None: + fm = self._make_fm(related_to=["ADR-0003"]) + assert fm.related_to == ["ADR-0003"] + + def test_empty_list_normalised_to_none(self) -> None: + fm = self._make_fm(depends_on=[], related_to=[]) + assert fm.depends_on is None + assert fm.related_to is None + + def test_omitted_fields_are_none(self) -> None: + fm = self._make_fm() + assert fm.depends_on is None + assert fm.related_to is None + + def test_multiple_ids_accepted(self) -> None: + fm = self._make_fm(depends_on=["ADR-0001", "ADR-0003"]) + assert len(fm.depends_on) == 2 # type: ignore[arg-type] + + def test_both_fields_together(self) -> None: + fm = self._make_fm(depends_on=["ADR-0001"], related_to=["ADR-0003", "ADR-0004"]) + assert fm.depends_on == ["ADR-0001"] + assert fm.related_to == ["ADR-0003", "ADR-0004"] + + def test_relation_fields_excluded_from_dump_when_none(self) -> None: + fm = self._make_fm() + dumped = fm.model_dump(exclude_none=True) + assert "depends_on" not in dumped + assert "related_to" not in dumped + + def test_relation_fields_in_dump_when_set(self) -> None: + fm = self._make_fm(depends_on=["ADR-0001"]) + dumped = fm.model_dump(exclude_none=True) + assert "depends_on" in dumped + assert dumped["depends_on"] == ["ADR-0001"] + + if __name__ == "__main__": pytest.main([__file__, "-v"]) From 8eac48c29c26d12e6c85b4d249ed2d096552c9ab Mon Sep 17 00:00:00 2001 From: kschlt Date: Fri, 3 Apr 2026 00:46:18 +0200 Subject: [PATCH 3/6] feat(schema): add depends_on and related_to as explicit array-of-string properties Schema validators and documentation tooling need explicit property definitions to understand relation fields; relying on additionalProperties: true would accept the fields silently but provide no type information or docs. Explicit definitions follow the same pattern as superseded_by. --- schemas/adr.schema.json | 12 ++++++++++ tests/unit/test_adr_schema.py | 45 +++++++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+) create mode 100644 tests/unit/test_adr_schema.py diff --git a/schemas/adr.schema.json b/schemas/adr.schema.json index e716457..38292c1 100644 --- a/schemas/adr.schema.json +++ b/schemas/adr.schema.json @@ -53,6 +53,18 @@ "type": "string" } }, + "depends_on": { + "type": "array", + "items": { + "type": "string" + } + }, + "related_to": { + "type": "array", + "items": { + "type": "string" + } + }, "policy": { "type": "object", "properties": { diff --git a/tests/unit/test_adr_schema.py b/tests/unit/test_adr_schema.py new file mode 100644 index 0000000..4ab7bc3 --- /dev/null +++ b/tests/unit/test_adr_schema.py @@ -0,0 +1,45 @@ +"""Unit tests for schemas/adr.schema.json — relation fields.""" + +import json +from pathlib import Path + +import jsonschema +import pytest + +SCHEMA_PATH = Path(__file__).parents[2] / "schemas" / "adr.schema.json" +SCHEMA = json.loads(SCHEMA_PATH.read_text()) + + +def _minimal_adr(**extra: object) -> dict: + return { + "id": "ADR-0001", + "title": "Use FastAPI", + "status": "accepted", + "date": "2024-01-01", + **extra, + } + + +class TestRelationFieldsInSchema: + def test_depends_on_valid(self) -> None: + jsonschema.validate(_minimal_adr(depends_on=["ADR-0002"]), SCHEMA) + + def test_related_to_valid(self) -> None: + jsonschema.validate(_minimal_adr(related_to=["ADR-0003"]), SCHEMA) + + def test_both_fields_valid(self) -> None: + jsonschema.validate( + _minimal_adr(depends_on=["ADR-0001"], related_to=["ADR-0002", "ADR-0003"]), + SCHEMA, + ) + + def test_depends_on_wrong_type_fails(self) -> None: + with pytest.raises(jsonschema.ValidationError): + jsonschema.validate(_minimal_adr(depends_on="ADR-0002"), SCHEMA) + + def test_related_to_wrong_type_fails(self) -> None: + with pytest.raises(jsonschema.ValidationError): + jsonschema.validate(_minimal_adr(related_to="ADR-0003"), SCHEMA) + + def test_adr_without_relation_fields_valid(self) -> None: + jsonschema.validate(_minimal_adr(), SCHEMA) From d94267fe7ae38e4ce8373021c476ae7acc798134 Mon Sep 17 00:00:00 2001 From: kschlt Date: Fri, 3 Apr 2026 00:47:51 +0200 Subject: [PATCH 4/6] feat(contract): warn on unresolved depends_on/related_to references MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit During contract compilation, ConstraintsContractBuilder now collects all parsed ADRs (not just accepted ones) and calls _validate_relation_references() to surface depends_on/related_to entries that point to unknown ADR IDs. Warnings are printed alongside the existing malformed-ADR warnings, keeping the strict=False philosophy — contract output is unchanged, only advisory messages are added. all_ids is built from the full parse set so that references to proposed ADRs do not produce false positives. --- adr_kit/contract/builder.py | 28 ++++++++- tests/unit/test_contract_builder.py | 91 +++++++++++++++++++++++++++++ 2 files changed, 117 insertions(+), 2 deletions(-) create mode 100644 tests/unit/test_contract_builder.py diff --git a/adr_kit/contract/builder.py b/adr_kit/contract/builder.py index ae1287f..fae8b6f 100644 --- a/adr_kit/contract/builder.py +++ b/adr_kit/contract/builder.py @@ -50,16 +50,24 @@ def build_contract(self, force_rebuild: bool = False) -> ConstraintsContract: return empty_contract # Parse and filter to only accepted ADRs + all_adrs = [] accepted_adrs = [] for file_path in adr_files: try: adr = parse_adr_file(file_path, strict=False) - if adr and adr.front_matter.status == ADRStatus.ACCEPTED: - accepted_adrs.append(adr) + if adr: + all_adrs.append(adr) + if adr.front_matter.status == ADRStatus.ACCEPTED: + accepted_adrs.append(adr) except ParseError as e: print(f"Warning: Skipping malformed ADR {file_path}: {e}") continue + # Warn on depends_on/related_to references that don't resolve + all_ids = {a.front_matter.id for a in all_adrs} + for warning in self._validate_relation_references(accepted_adrs, all_ids): + print(f"Warning: {warning}") + if not accepted_adrs: # No accepted ADRs, return empty contract empty_contract = ConstraintsContract.create_empty(self.adr_dir) @@ -100,6 +108,22 @@ def build(self, force_rebuild: bool = False) -> ConstraintsContract: """Alias for build_contract() for compatibility with existing workflows.""" return self.build_contract(force_rebuild=force_rebuild) + def _validate_relation_references(self, adrs: list, all_ids: set) -> list: + """Return warning strings for depends_on/related_to refs to unknown ADR IDs.""" + warnings = [] + for adr in adrs: + fm = adr.front_matter + for field_name, refs in [ + ("depends_on", fm.depends_on or []), + ("related_to", fm.related_to or []), + ]: + for ref in refs: + if ref not in all_ids: + warnings.append( + f"{fm.id}: {field_name} references unknown ADR '{ref}'" + ) + return warnings + def get_contract_summary(self) -> dict: """Get a summary of the current contract state.""" try: diff --git a/tests/unit/test_contract_builder.py b/tests/unit/test_contract_builder.py new file mode 100644 index 0000000..bf2e9b2 --- /dev/null +++ b/tests/unit/test_contract_builder.py @@ -0,0 +1,91 @@ +"""Unit tests for ConstraintsContractBuilder — relation reference validation.""" + +from datetime import date +from pathlib import Path + +import pytest + +from adr_kit.contract.builder import ConstraintsContractBuilder +from adr_kit.core.model import ADR, ADRFrontMatter, ADRStatus + + +def _make_adr( + adr_id: str, + depends_on: list[str] | None = None, + related_to: list[str] | None = None, + status: ADRStatus = ADRStatus.ACCEPTED, +) -> ADR: + fm = ADRFrontMatter( + id=adr_id, + title=f"ADR {adr_id}", + status=status, + date=date(2024, 1, 1), + depends_on=depends_on, + related_to=related_to, + ) + return ADR(front_matter=fm, content="## Decision\nContent.") + + +class TestRelationReferenceValidation: + @pytest.fixture() + def builder(self, tmp_path: Path) -> ConstraintsContractBuilder: + return ConstraintsContractBuilder(adr_dir=tmp_path) + + def test_no_warnings_when_refs_resolve( + self, builder: ConstraintsContractBuilder + ) -> None: + adrs = [ + _make_adr("ADR-0001"), + _make_adr("ADR-0002", depends_on=["ADR-0001"]), + ] + all_ids = {a.front_matter.id for a in adrs} + warnings = builder._validate_relation_references(adrs, all_ids) + assert warnings == [] + + def test_warning_for_unknown_depends_on( + self, builder: ConstraintsContractBuilder + ) -> None: + adrs = [_make_adr("ADR-0001", depends_on=["ADR-9999"])] + all_ids = {"ADR-0001"} + warnings = builder._validate_relation_references(adrs, all_ids) + assert len(warnings) == 1 + assert "ADR-9999" in warnings[0] + assert "depends_on" in warnings[0] + assert "ADR-0001" in warnings[0] + + def test_warning_for_unknown_related_to( + self, builder: ConstraintsContractBuilder + ) -> None: + adrs = [_make_adr("ADR-0001", related_to=["ADR-9999"])] + all_ids = {"ADR-0001"} + warnings = builder._validate_relation_references(adrs, all_ids) + assert len(warnings) == 1 + assert "ADR-9999" in warnings[0] + assert "related_to" in warnings[0] + + def test_none_fields_produce_no_warnings( + self, builder: ConstraintsContractBuilder + ) -> None: + adrs = [_make_adr("ADR-0001")] + all_ids = {"ADR-0001"} + warnings = builder._validate_relation_references(adrs, all_ids) + assert warnings == [] + + def test_multiple_unknown_refs_each_warn( + self, builder: ConstraintsContractBuilder + ) -> None: + adrs = [_make_adr("ADR-0001", depends_on=["ADR-8888", "ADR-9999"])] + all_ids = {"ADR-0001"} + warnings = builder._validate_relation_references(adrs, all_ids) + assert len(warnings) == 2 + + def test_proposed_adr_ref_resolves_via_all_ids( + self, builder: ConstraintsContractBuilder + ) -> None: + """depends_on referencing a proposed (not accepted) ADR should not warn.""" + proposed = _make_adr("ADR-0001", status=ADRStatus.PROPOSED) + accepted = _make_adr("ADR-0002", depends_on=["ADR-0001"]) + # all_ids includes all parsed ADRs, not just accepted ones + all_ids = {"ADR-0001", "ADR-0002"} + warnings = builder._validate_relation_references([accepted], all_ids) + assert warnings == [] From a484db0cac58dc17c39b786ce1499bdded871eaa Mon Sep 17 00:00:00 2001 From: kschlt Date: Fri, 3 Apr 2026 00:48:24 +0200 Subject: [PATCH 5/6] docs: update CHANGELOG for CID task --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f2362e..ce7ab6f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- `depends_on` and `related_to` optional fields on ADR frontmatter — declare inter-ADR dependencies and relationships directly in the ADR file; the contract builder warns when referenced ADR IDs cannot be resolved - `ConflictDetector` — detects two classes of enforcement conflicts: (1) policy-contract conflicts (new ADR policy contradicts existing contract, e.g. one ADR allows Flask while another bans it) and (2) fragment-config conflicts (adapter-generated fragment contradicts existing user config on disk). Policy conflict detection is reusable by decision-plane workflows for pre-approval validation - Guided fallback for unroutable policies — when no adapter can handle a policy key, the pipeline generates a structured promptlet instructing the agent to create a validation script, rather than silently dropping the policy. Scripts placed in `scripts/adr-validations/` are treated as first-class enforcement artifacts - Enforcement metadata in `_build_policy_reference()` — creation workflow now shows agents which policy keys have native adapter coverage (and which tool), which fall back to scripts, and which have no enforcement path yet. Metadata is derived live from the adapter registry so creation guidance stays in sync as adapters are added From af04fae9b2bbb027257caa5d603614ef4cfceabe Mon Sep 17 00:00:00 2001 From: kschlt Date: Fri, 3 Apr 2026 01:02:39 +0200 Subject: [PATCH 6/6] fix(core): add depends_on and related_to defaults in ADR creation workflow ADRFrontMatter gained two new required fields (depends_on, related_to) but the construction site in creation.py was not updated, causing mypy errors. --- adr_kit/decision/workflows/creation.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/adr_kit/decision/workflows/creation.py b/adr_kit/decision/workflows/creation.py index 69657f1..036c30c 100644 --- a/adr_kit/decision/workflows/creation.py +++ b/adr_kit/decision/workflows/creation.py @@ -485,6 +485,8 @@ def _build_adr_structure(self, adr_id: str, input_data: CreationInput) -> ADR: tags=input_data.tags or [], supersedes=[], superseded_by=[], + depends_on=[], + related_to=[], policy=( PolicyModel.model_validate(input_data.policy) if input_data.policy