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
484 lines
16 KiB
Python
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
|