info-tech-canon/src/info_tech_canon/service.py
tegwick e1a6314131
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 3s
Check import manifests by hash and name together (INFO-WP-0028 T01-T03)
import-review takes any partner manifest and returns, per concept, whether the
name resolves in the ownership index and to which artifact, and per entry
whether the pinned SHA-256 matches the blob at the declared source commit. Both
run in one pass so neither can be recorded without the other, which is the
failure this workplan exists to prevent. It exits non-zero on a finding, reads
JSON or YAML, needs no partner checkout, and carries its own limit: resolution
proves a name exists and names one owner, nothing more.

Accepted manifests are registered under infospace/interfaces/manifests/ as
provenance-preserving copies owned by the partner, with the partner revision and
retrieval date recorded. Editing a copy to make a check pass is forbidden in the
file itself. Validation re-resolves them and reports drift as
federation_import_drift, a warning naming the partner rather than an error,
because a stale partner pin is not this repository's file to fix.

The review kit gains an extension-boundary-review template requiring hash count,
resolution count and conflict count as three separate lines, and an operating
rule saying one is never evidence of another. Both boundary files carry the
standing-check result.

Verified live: security-canon resolves 11 of 11, interface-canon 23 of 25 with
the two known Interface and Endpoint pins. make check passes with 58 tests,
clean validation and those two warnings.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 3588@bnt-lap001
Assistant-Session: 24b80f66-e5a7-4e61-99fe-2d422e6d17da
2026-09-20 23:43:34 +02:00

484 lines
16 KiB
Python

from __future__ import annotations
from collections import Counter
from dataclasses import asdict, dataclass
from pathlib import Path
import json
from typing import Any
import yaml
from . import generation
from . import profiles
from .bench import (
Infospace,
KnowledgeArtifact,
export_mermaid,
load_infospace,
relationship_summary,
run_collection_checks,
)
from .validation import structural_checks
from .paths import infospace_root
REPO_ROOT = Path(__file__).resolve().parents[2]
DEFAULT_INFOSPACE_ROOT = infospace_root()
REVIEW_KIT_COMPONENTS = {
"manifest": "agent/review-kit/review-kit.yaml",
"workflow": "agent/review-kit/review-workflow.yaml",
"scorecard": "agent/review-kit/scorecard.yaml",
"model_selection_guide": "agent/review-kit/model-selection-guide.yaml",
"schema": "schemas/alignment-review.schema.yaml",
}
ALIGNMENT_TEMPLATE_PATH = "agent/templates/consumer-alignment-workplan.template.md"
class CanonServiceError(Exception):
def __init__(
self,
code: str,
message: str,
details: dict[str, Any] | None = None,
) -> None:
super().__init__(message)
self.code = code
self.message = message
self.details = details or {}
def to_dict(self) -> dict[str, Any]:
return {
"ok": False,
"error": {
"code": self.code,
"message": self.message,
"details": self.details,
},
}
@dataclass(frozen=True)
class CanonContext:
repo_root: Path
infospace_root: Path
infospace: Infospace
def load_context(root: Path | str | None = None) -> CanonContext:
resolved_root = infospace_root(root)
try:
infospace = load_infospace(resolved_root)
except Exception as exc:
raise CanonServiceError(
"infospace_load_failed",
f"Unable to load infospace at {resolved_root}",
{"root": str(resolved_root), "reason": str(exc)},
) from exc
return CanonContext(
repo_root=resolved_root.parent,
infospace_root=resolved_root,
infospace=infospace,
)
def inspect_canon(root: Path | str | None = None) -> dict[str, Any]:
context = load_context(root)
artifacts = context.infospace.artifacts
kinds = Counter(artifact.kind for artifact in artifacts)
return {
"ok": True,
"repo": {
"slug": "info-tech-canon",
"root": str(context.repo_root),
},
"infospace": {
"slug": context.infospace.config.slug,
"name": context.infospace.config.name,
"root": str(context.infospace_root),
"artifact_count": len(artifacts),
"kinds": dict(sorted(kinds.items())),
},
"service": {
"package": "info_tech_canon",
"contract": "cli-json-api",
},
}
def list_artifacts(
root: Path | str | None = None,
*,
kind: str | None = None,
) -> dict[str, Any]:
context = load_context(root)
artifacts = [
_artifact_to_dict(artifact, context.infospace_root)
for artifact in context.infospace.artifacts
if kind is None or artifact.kind == kind
]
return {
"ok": True,
"count": len(artifacts),
"artifacts": artifacts,
}
def list_models(root: Path | str | None = None) -> dict[str, Any]:
return list_artifacts(root, kind="model")
def list_standards(root: Path | str | None = None) -> dict[str, Any]:
return list_artifacts(root, kind="standard")
def review_kit(root: Path | str | None = None) -> dict[str, Any]:
context = load_context(root)
components = {
name: {
"path": relative,
"content": _read_yaml_component(context.infospace_root, relative),
}
for name, relative in REVIEW_KIT_COMPONENTS.items()
}
template = alignment_template(root)
return {
"ok": True,
"review_kit": components["manifest"]["content"],
"components": components,
"template": {
"path": template["path"],
"content": template["content"],
},
}
def alignment_template(root: Path | str | None = None) -> dict[str, Any]:
context = load_context(root)
path = context.infospace_root / ALIGNMENT_TEMPLATE_PATH
try:
content = path.read_text(encoding="utf-8")
except FileNotFoundError as exc:
raise CanonServiceError(
"missing_alignment_template",
"Consumer alignment workplan template not found.",
{"path": str(path)},
) from exc
return {
"ok": True,
"path": ALIGNMENT_TEMPLATE_PATH,
"content": content,
}
def validate_canon(root: Path | str | None = None) -> dict[str, Any]:
context = load_context(root)
errors: list[dict[str, Any]] = []
warnings: list[dict[str, Any]] = []
artifact_ids = {artifact.id for artifact in context.infospace.artifacts}
for artifact in context.infospace.artifacts:
artifact_path = context.infospace_root / artifact.path
if not artifact_path.is_file():
errors.append(
{
"code": "missing_artifact_path",
"artifact_id": artifact.id,
"path": artifact.path,
}
)
for relationship in artifact.relationships:
target = relationship.get("target")
if target not in artifact_ids:
errors.append(
{
"code": "missing_relationship_target",
"artifact_id": artifact.id,
"target": target,
}
)
for discipline in context.infospace.config.disciplines:
discipline_path = context.infospace_root / discipline.path
if not discipline_path.is_file():
errors.append(
{
"code": "missing_discipline_path",
"discipline": discipline.name,
"path": discipline.path,
}
)
checks = run_collection_checks(context.infospace.artifacts)
threshold_errors = _evaluate_thresholds(
checks.metrics,
context.infospace.config.viability,
)
from .import_reviews import reviewed_import_cycles
import_reviews = reviewed_import_cycles(context.infospace.artifacts, context.infospace_root)
if import_reviews:
threshold_errors = [error for error in threshold_errors
if error.get("metric") != "consistency_cycles"]
checks.details["reviewed_model_import_cycles"] = import_reviews
checks.details["cycle_threshold_exception"] = "Exact reviewed reciprocal model imports; raw metric retained"
errors.extend(threshold_errors)
structural = structural_checks(context)
errors.extend(structural["errors"])
warnings.extend(structural["warnings"])
from .contracts import bound_artifact_errors, coverage
errors.extend(bound_artifact_errors(context))
ownership = generation.concept_ownership(context)
errors.extend(dict(item, code="concept_ownership_conflict")
for item in ownership["ownership_conflicts"])
declaration = concept_declaration_checks(context, ownership)
errors.extend(declaration["errors"])
warnings.extend(declaration["warnings"])
from .federation import registered_drift
warnings.extend(registered_drift(context))
return {
"ok": not errors,
"errors": errors,
"warnings": warnings,
"metrics": checks.metrics,
"details": checks.details,
"coverage": coverage(context),
}
#: The kernel map assigns concepts to owners rather than defining them, so it is
#: the one artifact allowed to define concept names and declare none.
DECLARATION_EXEMPT = {"kernel/itc-kernel-map"}
def concept_declaration_checks(context, ownership: dict) -> dict:
"""Undeclared prose is a warning; defining concepts and declaring none is an error.
A defined name another artifact owns is an import, not a gap, so only names
no artifact declares are reported.
"""
from .maintenance import concept_candidates
owned = {generation._normalize_concept(item["concept"]) for item in ownership["concepts"]}
errors, warnings = [], []
for item in concept_candidates(context)["artifacts"]:
unowned = [name for name in item["undeclared"]
if generation._normalize_concept(name) not in owned]
if not item["declares_frontmatter"] and item["candidate_count"] \
and item["artifact"] not in DECLARATION_EXEMPT:
errors.append({"code": "concept_declaration_missing",
"artifact_id": item["artifact"], "path": item["path"],
"defined": item["candidate_count"]})
if unowned:
warnings.append({"code": "concept_defined_without_owner",
"artifact_id": item["artifact"], "path": item["path"],
"concepts": unowned})
return {"errors": errors, "warnings": warnings}
def write_validation_report(
destination: Path | str,
root: Path | str | None = None,
) -> dict[str, Any]:
payload = validate_canon(root)
from .maintenance import source_evidence
payload["evidence"] = source_evidence(infospace_root(root))
path = Path(destination)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8")
payload["report_path"] = str(path)
return payload
def artifact_graph(
root: Path | str | None = None,
*,
output_format: str = "json",
) -> dict[str, Any]:
context = load_context(root)
summary = relationship_summary(context.infospace.artifacts)
if output_format == "mermaid":
return {"ok": True, "format": "mermaid", "graph": export_mermaid(summary)}
if output_format != "json":
raise CanonServiceError(
"unsupported_graph_format",
f"Unsupported graph format: {output_format}",
{"supported": ["json", "mermaid"]},
)
return {
"ok": True,
"format": "json",
"graph": {
"node_count": summary.node_count,
"edge_count": summary.edge_count,
"nodes": summary.nodes,
"edges": [asdict(edge) for edge in summary.edges],
"relationship_types": summary.relationship_types,
},
}
def profile_inspect(
profile: str,
root: Path | str | None = None,
) -> dict[str, Any]:
context = load_context(root)
profile_path = context.infospace_root / "profiles" / profile / "profile.yaml"
if not profile_path.is_file():
raise CanonServiceError(
"missing_profile",
f"Profile not found: {profile}",
{"profile": profile, "path": str(profile_path)},
)
try:
return profiles.inspect_profile(context, profile)
except ValueError as exc:
raise CanonServiceError(
"invalid_profile",
f"Profile must be a YAML mapping: {profile}",
{"profile": profile, "path": str(profile_path)},
) from exc
def profile_validate(
profile: str,
root: Path | str | None = None,
) -> dict[str, Any]:
context = load_context(root)
profile_path = context.infospace_root / "profiles" / profile / "profile.yaml"
if not profile_path.is_file():
raise CanonServiceError(
"missing_profile",
f"Profile not found: {profile}",
{"profile": profile, "path": str(profile_path)},
)
return profiles.validate_profile(context, profile)
def profile_graph(
profile: str,
root: Path | str | None = None,
*,
output_format: str = "json",
) -> dict[str, Any]:
context = load_context(root)
profile_path = context.infospace_root / "profiles" / profile / "profile.yaml"
if not profile_path.is_file():
raise CanonServiceError(
"missing_profile",
f"Profile not found: {profile}",
{"profile": profile, "path": str(profile_path)},
)
try:
return profiles.profile_graph(context, profile, output_format=output_format)
except ValueError as exc:
raise CanonServiceError(
"unsupported_graph_format",
str(exc),
{"supported": ["json", "mermaid"]},
) from exc
def review_capability_record(
path: str | Path,
root: Path | str | None = None,
) -> dict[str, Any]:
from .capability import CapabilityReviewError, review_path
try:
return review_path(path, infospace_root(root) / "models/capability/capabilities.yaml")
except CapabilityReviewError as exc:
raise CanonServiceError(exc.code, exc.message) from exc
def generate_indexes(root: Path | str | None = None) -> dict[str, Any]:
return generation.generate_indexes(load_context(root))
def generate_tree(root: Path | str | None = None) -> dict[str, Any]:
return generation.generate_tree(load_context(root))
def generate_agent_briefs(root: Path | str | None = None) -> dict[str, Any]:
return generation.generate_agent_briefs(load_context(root))
def list_views(root: Path | str | None = None) -> dict[str, Any]:
return generation.list_generated_views(load_context(root))
def read_view(name: str, root: Path | str | None = None) -> dict[str, Any]:
try:
return generation.read_generated_view(load_context(root), name)
except FileNotFoundError as exc:
raise CanonServiceError(
"missing_view",
f"View not found: {name}",
{"view": name},
) from exc
except ValueError as exc:
raise CanonServiceError(
"invalid_view_name",
str(exc),
{"view": name},
) from exc
def _read_yaml_component(infospace_root: Path, relative: str) -> Any:
path = infospace_root / relative
try:
with path.open("r", encoding="utf-8") as handle:
return yaml.safe_load(handle) or {}
except FileNotFoundError as exc:
raise CanonServiceError(
"missing_review_kit_component",
f"Review kit component not found: {relative}",
{"path": str(path)},
) from exc
except yaml.YAMLError as exc:
raise CanonServiceError(
"invalid_review_kit_component",
f"Review kit component is not valid YAML: {relative}",
{"path": str(path), "reason": str(exc)},
) from exc
def _artifact_to_dict(
artifact: KnowledgeArtifact,
infospace_root: Path,
) -> dict[str, Any]:
data = artifact.to_dict()
data["exists"] = (infospace_root / artifact.path).is_file()
return data
def _evaluate_thresholds(
metrics: dict[str, float],
thresholds: dict[str, Any],
) -> list[dict[str, Any]]:
errors: list[dict[str, Any]] = []
for metric, threshold in thresholds.items():
value = metrics.get(metric)
if value is None:
continue
min_value = getattr(threshold, "min", None)
max_value = getattr(threshold, "max", None)
if min_value is not None and value < min_value:
errors.append(
{
"code": "metric_below_threshold",
"metric": metric,
"value": value,
"min": min_value,
}
)
if max_value is not None and value > max_value:
errors.append(
{
"code": "metric_above_threshold",
"metric": metric,
"value": value,
"max": max_value,
}
)
return errors