Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]

### Added
- `ContractRelations` model — computed at contract build time from ADR frontmatter; provides forward indexes (`depends_on`, `related_to`, `supersedes`), reverse indexes (`required_by`, `related_from`, `superseded_by`), supersession chains (`[oldest → newest]`), and clause lookup tables (`clause_to_adr`, `adr_to_clauses`); attached as `relations` field on `ConstraintsContract` and excluded from the content hash
- Relationship surfacing in `adr_planning_context` — each ADR in `relevant_adrs` now includes a `relations` dict with its per-ADR relationship context; the response also includes a top-level `relations_summary` with the full global relationship indexes for the contract
- Scenario taxonomy for `adr_planning_context` — four named scenarios (`strategic_planning`, `focused_implementation`, `pre_decision`, `supersession_impact`) replace the previous free-text `context_type` field; each scenario produces a different projection of the architecture contract
- `ContextRequest` model — structured request contract with typed `scope_hints`, `change_mode`, `detail_level`, and `known_targets`; existing callers passing only `task_description` continue to work unchanged (defaults to `strategic_planning`)
- `ScenarioContextPacket` response model — scenario-aware response shape with `overview`, ranked `constraints`, `warnings`, and `inspect_deeper` references to ADRs and clauses for progressive disclosure
Expand Down
115 changes: 114 additions & 1 deletion adr_kit/contract/builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
from ..core.parse import ParseError, find_adr_files, parse_adr_file
from .cache import ContractCache
from .merger import PolicyMerger
from .models import ConstraintsContract, ContractMetadata
from .models import ConstraintsContract, ContractMetadata, ContractRelations


class ConstraintsContractBuilder:
Expand Down Expand Up @@ -83,6 +83,9 @@ def build_contract(self, force_rebuild: bool = False) -> ConstraintsContract:
f"{[c.description for c in merge_result.conflicts if c.resolution is None]}"
)

# Compute relationship indexes from all parsed ADRs
relations = self._compute_relations(all_adrs, merge_result.provenance)

# Create contract metadata
metadata = ContractMetadata(
version="1.0", # Explicit default for mypy
Expand All @@ -97,6 +100,7 @@ def build_contract(self, force_rebuild: bool = False) -> ConstraintsContract:
constraints=merge_result.constraints,
provenance=merge_result.provenance,
approved_adrs=accepted_adrs,
relations=relations,
)

# Cache the contract
Expand Down Expand Up @@ -124,6 +128,115 @@ def _validate_relation_references(self, adrs: list, all_ids: set) -> list:
)
return warnings

def _compute_relations(self, all_adrs: list, provenance: dict) -> ContractRelations:
"""Compute forward and reverse relationship indexes from ADR frontmatter.

Uses all parsed ADRs (not only accepted) so that edges to proposed or
deprecated ADRs are still recorded. Unresolved references are silently
dropped — they have already been warned about by _validate_relation_references.

Also derives clause lookup tables from the contract provenance mapping.
"""
all_ids: set[str] = {a.front_matter.id for a in all_adrs}

depends_on: dict[str, list[str]] = {}
related_to: dict[str, list[str]] = {}
supersedes_fwd: dict[str, list[str]] = {}
required_by: dict[str, list[str]] = {}
related_from: dict[str, list[str]] = {}
superseded_by_rev: dict[str, list[str]] = {}

for adr in all_adrs:
src = adr.front_matter.id
fm = adr.front_matter

# depends_on / required_by (reverse)
resolved_deps = [r for r in (fm.depends_on or []) if r in all_ids]
if resolved_deps:
depends_on[src] = resolved_deps
for dep in resolved_deps:
if src not in required_by.setdefault(dep, []):
required_by[dep].append(src)

# related_to / related_from (reverse, symmetric)
resolved_rel = [r for r in (fm.related_to or []) if r in all_ids]
if resolved_rel:
related_to[src] = resolved_rel
for rel in resolved_rel:
if src not in related_from.setdefault(rel, []):
related_from[rel].append(src)

# supersedes / superseded_by (reverse)
resolved_sup = [r for r in (fm.supersedes or []) if r in all_ids]
if resolved_sup:
supersedes_fwd[src] = resolved_sup
for old in resolved_sup:
if src not in superseded_by_rev.setdefault(old, []):
superseded_by_rev[old].append(src)

chains = self._build_supersession_chains(supersedes_fwd, superseded_by_rev)

# Build clause lookup tables from provenance
clause_to_adr: dict[str, str] = {}
adr_to_clauses: dict[str, list[str]] = {}
for prov in provenance.values():
clause_to_adr[prov.clause_id] = prov.adr_id
if prov.clause_id not in adr_to_clauses.setdefault(prov.adr_id, []):
adr_to_clauses[prov.adr_id].append(prov.clause_id)

return ContractRelations(
depends_on=depends_on,
related_to=related_to,
supersedes=supersedes_fwd,
required_by=required_by,
related_from=related_from,
superseded_by=superseded_by_rev,
supersession_chains=chains,
clause_to_adr=clause_to_adr,
adr_to_clauses=adr_to_clauses,
)

def _build_supersession_chains(
self,
supersedes_fwd: dict[str, list[str]],
superseded_by_rev: dict[str, list[str]],
) -> list[list[str]]:
"""Walk supersession edges to build full lineage chains (oldest → newest).

A chain starts at the newest ADR in a lineage (one that supersedes something
but is not itself superseded) and follows the supersedes edges backward to the
oldest. The chain is then reversed so it reads oldest-to-newest.

Circular references are broken by a visited set — nodes in a cycle produce
a truncated chain rather than an infinite loop.
"""
# Roots: ADRs that supersede something but are not themselves superseded
all_superseding = set(supersedes_fwd.keys())
has_predecessor = set(superseded_by_rev.keys())
roots = all_superseding - has_predecessor

chains: list[list[str]] = []
for root in sorted(roots):
chain: list[str] = []
visited: set[str] = set()
current: str | None = root
while current and current not in visited:
visited.add(current)
chain.append(current)
nexts = supersedes_fwd.get(current, [])
if len(nexts) == 1:
current = nexts[0]
else:
# Fan-out: append all unvisited targets and stop
chain.extend(n for n in nexts if n not in visited)
break
# Reverse so chain reads oldest → newest
chain.reverse()
if len(chain) > 1:
chains.append(chain)

return chains

def get_contract_summary(self) -> dict:
"""Get a summary of the current contract state."""
try:
Expand Down
66 changes: 66 additions & 0 deletions adr_kit/contract/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,65 @@ def is_empty(self) -> bool:
)


class ContractRelations(BaseModel):
"""Computed relationship indexes derived from ADR frontmatter at build time.

All forward indexes are keyed by ADR ID. Reverse indexes are computed
automatically from forward declarations. Unresolved references (IDs not found
in the parsed corpus) are silently dropped — they have already been surfaced
as warnings during _validate_relation_references.

Not included in the contract content hash (computed state, not authored policy).
"""

# Forward indexes
depends_on: dict[str, list[str]] = Field(
default_factory=dict,
description="Maps ADR-ID → list of ADR-IDs it explicitly depends on",
)
related_to: dict[str, list[str]] = Field(
default_factory=dict,
description="Maps ADR-ID → list of ADR-IDs it is related to",
)
supersedes: dict[str, list[str]] = Field(
default_factory=dict,
description="Maps ADR-ID → list of (older) ADR-IDs it supersedes",
)

# Reverse indexes
required_by: dict[str, list[str]] = Field(
default_factory=dict,
description="Reverse of depends_on: ADR-ID → list of ADRs that depend on it",
)
related_from: dict[str, list[str]] = Field(
default_factory=dict,
description="Reverse of related_to: ADR-ID → list of ADRs that relate to it",
)
superseded_by: dict[str, list[str]] = Field(
default_factory=dict,
description="Reverse of supersedes: ADR-ID → list of (newer) ADRs that supersede it",
)

# Derived supersession lineage
supersession_chains: list[list[str]] = Field(
default_factory=list,
description=(
"Each entry is a chronological chain [oldest, ..., newest] "
"tracing a complete supersession lineage"
),
)

# Clause lookup tables (derived from contract provenance)
clause_to_adr: dict[str, str] = Field(
default_factory=dict,
description="Maps clause_id → adr_id for direct clause lookup",
)
adr_to_clauses: dict[str, list[str]] = Field(
default_factory=dict,
description="Maps adr_id → [clause_ids] for all clauses from that ADR",
)


class ConstraintsContract(BaseModel):
"""The complete constraints contract - the definitive source of truth for agents.

Expand All @@ -130,6 +189,13 @@ class ConstraintsContract(BaseModel):
default_factory=list,
description="List of all approved ADRs that contributed to this contract",
)
relations: "ContractRelations" = Field(
default_factory=ContractRelations,
description=(
"Computed relationship indexes (not included in content hash). "
"Built at compile time from ADR frontmatter."
),
)

@classmethod
def create_empty(cls, adr_directory: Path) -> "ConstraintsContract":
Expand Down
5 changes: 5 additions & 0 deletions adr_kit/mcp/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -412,6 +412,10 @@ def adr_planning_context(request: PlanningContextRequest) -> dict[str, Any]:

if result.success:
context = result.data["architectural_context"]
contract_relations = result.data.get("contract_relations")
relations_summary = (
contract_relations.model_dump() if contract_relations else {}
)
return success_response(
message=f"Planning context provided with {len(context.relevant_adrs)} relevant ADRs",
data={
Expand All @@ -427,6 +431,7 @@ def adr_planning_context(request: PlanningContextRequest) -> dict[str, Any]:
"patterns": context.architecture_patterns,
"checklist": context.compliance_checklist,
"related_decisions": context.related_decisions,
"relations_summary": relations_summary,
},
next_steps=[
"Review relevant ADRs before implementation",
Expand Down
20 changes: 20 additions & 0 deletions adr_kit/workflows/planning.py
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,7 @@ def execute(self, **kwargs: Any) -> WorkflowResult:
self.result.data = {
"architectural_context": context,
"task_analysis": task_analysis,
"contract_relations": contract.relations,
}
self.result.guidance = (
f"Architectural context generated for {input_data.context_type} task"
Expand Down Expand Up @@ -381,6 +382,7 @@ def _find_relevant_adrs(
if len(adr.decision) > 200
else adr.decision
),
"relations": self._get_adr_relations(adr.id, contract),
}
)

Expand Down Expand Up @@ -714,3 +716,21 @@ def _identify_related_decisions(
decisions.append(f"{adr_info['adr_id']}: {first_sentence}")

return decisions

def _get_adr_relations(
self, adr_id: str, contract: ConstraintsContract
) -> dict[str, Any]:
"""Return a filtered relationship summary for a single ADR.

Pulls from the precomputed contract.relations indexes — no extra computation.
All lists default to empty when the ADR has no relationships of that type.
"""
rel = contract.relations
return {
"depends_on": rel.depends_on.get(adr_id, []),
"required_by": rel.required_by.get(adr_id, []),
"related_to": rel.related_to.get(adr_id, []),
"related_from": rel.related_from.get(adr_id, []),
"supersedes": rel.supersedes.get(adr_id, []),
"superseded_by": rel.superseded_by.get(adr_id, []),
}
Loading
Loading