Skip to content
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]

### Added
- `OutputMode` enum (`native_config`, `native_rules`, `generated_checker`, `policy_file`, `script_fallback`) and `EnforcementStage` enum (`commit`, `push`, `ci`) — enforcement adapters now declare what kind of artifact they emit and at which gate it is evaluated, making the enforcement plane's output taxonomy explicit and inspectable
- `FallbackAdapter` — unroutable policy keys now flow through a standard adapter interface rather than a pipeline side path; fallback promptlets appear in `EnforcementResult.fragments_applied` with `output_mode="script_fallback"` alongside native-config fragments, making the full enforcement picture visible in one place
- `output_mode` field on `EnforcementResult.fragments_applied` entries — each applied fragment now reports its output mode, enabling callers to distinguish native enforcement artifacts from agent-authored validation scripts
- `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
- Mypy enforcement adapter — approving an ADR with `config_enforcement.python.mypy` constraints automatically generates a `.mypy-adr.ini` configuration file
- TypeScript tsconfig enforcement adapter — approving an ADR with `config_enforcement.typescript.tsconfig` constraints automatically generates a `tsconfig.adr.json` file that can be extended via tsconfig's `extends`
Expand Down
19 changes: 11 additions & 8 deletions adr_kit/enforcement/adapters/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from dataclasses import dataclass, field

from ...contract.models import MergedConstraints
from ..clause_kinds import EnforcementStage, OutputMode


@dataclass
Expand All @@ -33,6 +34,9 @@ class ConfigFragment:
policy_keys: list[str] = field(default_factory=list)
"""Contract policy keys covered by this fragment, e.g. ['imports.disallow.axios']."""

output_mode: OutputMode = field(default=OutputMode.NATIVE_CONFIG)
"""Output mode this fragment represents."""


class BaseAdapter(ABC):
"""Abstract base class for all enforcement adapters.
Expand Down Expand Up @@ -92,21 +96,20 @@ def supported_clause_kinds(self) -> list[str]:
return []

@property
def output_modes(self) -> list[str]:
def output_modes(self) -> list[OutputMode]:
"""Kinds of artifacts this adapter emits.

Values: native_config, native_rules, generated_checker, policy_file, script_fallback.
ENF-MODE will formalise these as a first-class enum. Defaults to native_config.
Defaults to [OutputMode.NATIVE_CONFIG].
"""
return ["native_config"]
return [OutputMode.NATIVE_CONFIG]

@property
def supported_stages(self) -> list[str]:
"""Enforcement stages this adapter targets (commit, push, ci).
def supported_stages(self) -> list[EnforcementStage]:
"""Enforcement stages this adapter targets.

Defaults to ['ci'].
Defaults to [EnforcementStage.CI].
"""
return ["ci"]
return [EnforcementStage.CI]

# ------------------------------------------------------------------
# Core method
Expand Down
10 changes: 5 additions & 5 deletions adr_kit/enforcement/adapters/eslint.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
from ...core.model import ADR, ADRStatus
from ...core.parse import ParseError, find_adr_files, parse_adr_file
from ...core.policy_extractor import PolicyExtractor
from ..clause_kinds import ClauseKind
from ..clause_kinds import ClauseKind, EnforcementStage, OutputMode
from .base import BaseAdapter, ConfigFragment


Expand Down Expand Up @@ -466,12 +466,12 @@ def supported_clause_kinds(self) -> list[str]:
return [ClauseKind.FORBIDDEN_IMPORT, ClauseKind.ALLOWED_IMPORT_SURFACE]

@property
def output_modes(self) -> list[str]:
return ["native_config"]
def output_modes(self) -> list[OutputMode]:
return [OutputMode.NATIVE_CONFIG]

@property
def supported_stages(self) -> list[str]:
return ["commit", "ci"]
def supported_stages(self) -> list[EnforcementStage]:
return [EnforcementStage.COMMIT, EnforcementStage.CI]

def generate_fragments(
self, constraints: MergedConstraints
Expand Down
147 changes: 147 additions & 0 deletions adr_kit/enforcement/adapters/fallback.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
"""Fallback adapter for unroutable policy keys.

FallbackAdapter handles policy keys that no other adapter can enforce by
emitting script-authoring promptlets as ConfigFragment objects. This makes
the fallback path a first-class output mode (SCRIPT_FALLBACK) rather than
a special side path in the pipeline.
"""

from __future__ import annotations

import json
from typing import TYPE_CHECKING, Any

from ...contract.models import MergedConstraints
from ..clause_kinds import EnforcementStage, OutputMode
from .base import BaseAdapter, ConfigFragment

if TYPE_CHECKING:
from ...contract.models import ConstraintsContract


class FallbackAdapter(BaseAdapter):
"""Handles unroutable policy keys by emitting script-authoring promptlets.

One ConfigFragment is produced per unroutable policy key. The fragment's
content is a JSON promptlet that instructs the calling agent to write a
validation script. The fragment is never written to disk (target_file="").

output_modes = [OutputMode.SCRIPT_FALLBACK]
"""

@property
def name(self) -> str:
return "fallback"

@property
def supported_policy_keys(self) -> list[str]:
# Not used for normal routing — pipeline calls this adapter directly.
return []

@property
def supported_languages(self) -> list[str]:
# Not language-filtered.
return []

@property
def config_targets(self) -> list[str]:
# No files written to disk.
return []

@property
def output_modes(self) -> list[OutputMode]:
return [OutputMode.SCRIPT_FALLBACK]

@property
def supported_stages(self) -> list[EnforcementStage]:
return [EnforcementStage.CI]

def generate_fragments(
self,
constraints: MergedConstraints,
*,
policy_keys: list[str] | None = None,
contract: ConstraintsContract | None = None,
) -> list[ConfigFragment]:
"""Generate one promptlet fragment per unroutable policy key.

Args:
constraints: Merged policy constraints (required by BaseAdapter interface).
policy_keys: Unroutable policy keys to generate promptlets for.
If None or empty, returns an empty list.
contract: Full contract used to collect provenance and constraint values.

Returns:
One ConfigFragment per key, with fragment_type='promptlet_json'
and output_mode=OutputMode.SCRIPT_FALLBACK. target_file is empty
— these fragments are never written to disk.
"""
if not policy_keys:
return []

fragments = []
for key in policy_keys:
content = self._build_promptlet(key, constraints, contract)
fragments.append(
ConfigFragment(
adapter=self.name,
target_file="",
content=content,
fragment_type="promptlet_json",
policy_keys=[key],
output_mode=OutputMode.SCRIPT_FALLBACK,
)
)
return fragments

def _build_promptlet(
self,
policy_key: str,
constraints: MergedConstraints,
contract: ConstraintsContract | None,
) -> str:
"""Build a JSON promptlet for one unroutable policy key."""
constraint_value: Any = None
source_adrs: list[str] = []

if contract is not None:
constraints_dump = contract.constraints.model_dump(exclude_none=True)
constraint_value = constraints_dump.get(policy_key)
source_adrs = sorted(
{
prov.adr_id
for rule_path, prov in contract.provenance.items()
if rule_path == policy_key or rule_path.startswith(policy_key + ".")
}
)

promptlet = {
"unenforceable_policy": {
"policy_key": policy_key,
"constraint": constraint_value,
"source_adrs": source_adrs,
},
"instruction": (
f"No enforcement adapter exists for policy key '{policy_key}'. "
"Create a validation script that checks for this policy constraint."
),
"script_requirements": {
"input": "list of file paths to check",
"output": "EnforcementReport JSON (schema: {passed, violations: [{file, message, severity}]})",
"integration": (
"Place in scripts/adr-validations/ — "
"the enforcement pipeline picks up all scripts in that directory"
),
},
"example_script_structure": (
"#!/usr/bin/env python3\n"
"import sys, json\n"
"violations = []\n"
"for path in sys.argv[1:]:\n"
" # ... check constraint ...\n"
" pass\n"
'print(json.dumps({"passed": not violations, "violations": violations}))'
),
}

return json.dumps(promptlet, indent=2, default=str)
10 changes: 5 additions & 5 deletions adr_kit/enforcement/adapters/import_linter.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@

from ...contract.models import MergedConstraints
from ...core.model import LayerBoundaryRule
from ..clause_kinds import ClauseKind
from ..clause_kinds import ClauseKind, EnforcementStage, OutputMode
from .base import BaseAdapter, ConfigFragment


Expand Down Expand Up @@ -111,12 +111,12 @@ def supported_clause_kinds(self) -> list[str]:
return [ClauseKind.LAYER_BOUNDARY]

@property
def output_modes(self) -> list[str]:
return ["native_config"]
def output_modes(self) -> list[OutputMode]:
return [OutputMode.NATIVE_RULES]

@property
def supported_stages(self) -> list[str]:
return ["commit", "ci"]
def supported_stages(self) -> list[EnforcementStage]:
return [EnforcementStage.COMMIT, EnforcementStage.CI]

def generate_fragments(
self, constraints: MergedConstraints
Expand Down
10 changes: 5 additions & 5 deletions adr_kit/enforcement/adapters/mypy.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
from typing import Any

from ...contract.models import MergedConstraints
from ..clause_kinds import ClauseKind
from ..clause_kinds import ClauseKind, EnforcementStage, OutputMode
from .base import BaseAdapter, ConfigFragment


Expand Down Expand Up @@ -75,12 +75,12 @@ def supported_clause_kinds(self) -> list[str]:
return [ClauseKind.CONFIG_INVARIANT]

@property
def output_modes(self) -> list[str]:
return ["native_config"]
def output_modes(self) -> list[OutputMode]:
return [OutputMode.NATIVE_CONFIG]

@property
def supported_stages(self) -> list[str]:
return ["commit", "ci"]
def supported_stages(self) -> list[EnforcementStage]:
return [EnforcementStage.COMMIT, EnforcementStage.CI]

def generate_fragments(
self, constraints: MergedConstraints
Expand Down
10 changes: 5 additions & 5 deletions adr_kit/enforcement/adapters/ruff.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
from ...contract.models import MergedConstraints
from ...core.model import ADR, ADRStatus
from ...core.parse import ParseError, find_adr_files, parse_adr_file
from ..clause_kinds import ClauseKind
from ..clause_kinds import ClauseKind, EnforcementStage, OutputMode
from .base import BaseAdapter, ConfigFragment


Expand Down Expand Up @@ -425,12 +425,12 @@ def supported_clause_kinds(self) -> list[str]:
return [ClauseKind.FORBIDDEN_IMPORT]

@property
def output_modes(self) -> list[str]:
return ["native_config"]
def output_modes(self) -> list[OutputMode]:
return [OutputMode.NATIVE_CONFIG]

@property
def supported_stages(self) -> list[str]:
return ["commit", "ci"]
def supported_stages(self) -> list[EnforcementStage]:
return [EnforcementStage.COMMIT, EnforcementStage.CI]

def generate_fragments(
self, constraints: MergedConstraints
Expand Down
10 changes: 5 additions & 5 deletions adr_kit/enforcement/adapters/tsconfig.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
from typing import Any

from ...contract.models import MergedConstraints
from ..clause_kinds import ClauseKind
from ..clause_kinds import ClauseKind, EnforcementStage, OutputMode
from .base import BaseAdapter, ConfigFragment


Expand Down Expand Up @@ -70,12 +70,12 @@ def supported_clause_kinds(self) -> list[str]:
return [ClauseKind.CONFIG_INVARIANT]

@property
def output_modes(self) -> list[str]:
return ["native_config"]
def output_modes(self) -> list[OutputMode]:
return [OutputMode.NATIVE_CONFIG]

@property
def supported_stages(self) -> list[str]:
return ["commit", "ci"]
def supported_stages(self) -> list[EnforcementStage]:
return [EnforcementStage.COMMIT, EnforcementStage.CI]

def generate_fragments(
self, constraints: MergedConstraints
Expand Down
23 changes: 23 additions & 0 deletions adr_kit/enforcement/clause_kinds.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,29 @@
from enum import Enum


class OutputMode(str, Enum):
"""How an adapter emits its enforcement artifact.

Each value represents a distinct artifact family the enforcement plane
can produce. Adapters declare which modes they support; the pipeline
and reporting use these to distinguish native enforcement from fallbacks.
"""

NATIVE_CONFIG = "native_config"
NATIVE_RULES = "native_rules"
GENERATED_CHECKER = "generated_checker"
POLICY_FILE = "policy_file"
SCRIPT_FALLBACK = "script_fallback"


class EnforcementStage(str, Enum):
"""Enforcement gate at which an adapter's output is evaluated."""

COMMIT = "commit"
PUSH = "push"
CI = "ci"


class ClauseKind(str, Enum):
"""Canonical clause families for architectural enforcement.

Expand Down
Loading
Loading