Stand up the Engine/PIP surface for MAT-WP-0001
Declare layer.yaml, add a Python engine over a local SQLite store, and cover deterministic assessment, the §13 gap register, stance-map inventory, claim guardrails, and the gate-house review path with tests. Assistant: grok Assistant-Session: 01a04ceb-150e-7e80-a542-ec8b1372e164
This commit is contained in:
parent
5c052ed106
commit
4cde4e489a
31 changed files with 2498 additions and 51 deletions
21
src/maturity_engine/__init__.py
Normal file
21
src/maturity_engine/__init__.py
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
"""Deterministic maturity assessment, gap register, and capability readiness."""
|
||||
|
||||
from maturity_engine.claims import Claim
|
||||
from maturity_engine.engine import Engine
|
||||
from maturity_engine.errors import GuardrailError, ModelError, UnevaluableCriterion
|
||||
from maturity_engine.models import Assessment, Criterion, Evidence, Gap, Ladder, Level, StanceMap
|
||||
|
||||
__all__ = [
|
||||
"Assessment",
|
||||
"Claim",
|
||||
"Criterion",
|
||||
"Engine",
|
||||
"Evidence",
|
||||
"Gap",
|
||||
"GuardrailError",
|
||||
"Ladder",
|
||||
"Level",
|
||||
"ModelError",
|
||||
"StanceMap",
|
||||
"UnevaluableCriterion",
|
||||
]
|
||||
3
src/maturity_engine/__main__.py
Normal file
3
src/maturity_engine/__main__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
from maturity_engine.cli import main
|
||||
|
||||
raise SystemExit(main())
|
||||
82
src/maturity_engine/claims.py
Normal file
82
src/maturity_engine/claims.py
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
"""PIP claim shape for a maturity level.
|
||||
|
||||
A claim is a fact `access-engine` may consume. This module does not decide
|
||||
whether an action is permitted.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from maturity_engine.errors import GuardrailError
|
||||
from maturity_engine.ids import digest
|
||||
from maturity_engine.models import Assessment
|
||||
from maturity_engine.timeutil import format_instant
|
||||
|
||||
ISSUER = "maturity-engine"
|
||||
CLAIM_KIND = "maturity-level"
|
||||
FRESHNESS_RULE = "assessment evaluated_at plus limiting evidence valid_until"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Claim:
|
||||
kind: str
|
||||
issuer: str
|
||||
subject: str
|
||||
model_id: str
|
||||
model_version: str
|
||||
level: int
|
||||
level_id: str
|
||||
assessed_at: str
|
||||
assessment_id: str
|
||||
freshness_rule: str
|
||||
digest: str
|
||||
|
||||
def as_dict(self) -> dict:
|
||||
return {
|
||||
"kind": self.kind,
|
||||
"issuer": self.issuer,
|
||||
"subject": self.subject,
|
||||
"model_id": self.model_id,
|
||||
"model_version": self.model_version,
|
||||
"level": self.level,
|
||||
"level_id": self.level_id,
|
||||
"assessed_at": self.assessed_at,
|
||||
"assessment_id": self.assessment_id,
|
||||
"freshness_rule": self.freshness_rule,
|
||||
"digest": self.digest,
|
||||
}
|
||||
|
||||
|
||||
def to_claim(assessment: Assessment) -> Claim:
|
||||
body = {
|
||||
"kind": CLAIM_KIND,
|
||||
"issuer": ISSUER,
|
||||
"subject": assessment.subject,
|
||||
"model_id": assessment.model_id,
|
||||
"model_version": assessment.model_version,
|
||||
"level": assessment.level,
|
||||
"level_id": assessment.level_id,
|
||||
"assessed_at": format_instant(assessment.evaluated_at),
|
||||
"assessment_id": assessment.id,
|
||||
"freshness_rule": FRESHNESS_RULE,
|
||||
}
|
||||
return Claim(**body, digest=digest(body))
|
||||
|
||||
|
||||
def compile_into_registry(claim: Claim) -> None:
|
||||
raise GuardrailError(
|
||||
"A maturity level MUST NOT be compiled into registry content "
|
||||
"(statute §9.5). Until access-engine decision provenance carries a "
|
||||
"registry-snapshot digest, a level reaching a decision through the "
|
||||
"registry is not reconstructable. Use to_claim() as a request claim "
|
||||
"or a versioned policy rule."
|
||||
)
|
||||
|
||||
|
||||
def gate_on_level(claim: Claim, *, minimum: int) -> None:
|
||||
raise GuardrailError(
|
||||
"A maturity level MUST NOT gate a decision directly (statute §6.1 / "
|
||||
f"§9.5). Refusing to proceed because {claim.level_id} < {minimum} is "
|
||||
"a second decision point. The level reaches access-engine as a claim."
|
||||
)
|
||||
110
src/maturity_engine/cli.py
Normal file
110
src/maturity_engine/cli.py
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from maturity_engine.engine import Engine
|
||||
from maturity_engine.timeutil import parse_instant
|
||||
|
||||
|
||||
def _db(args: argparse.Namespace) -> Path:
|
||||
if args.db:
|
||||
return Path(args.db)
|
||||
env = os.environ.get("MATURITY_ENGINE_DB")
|
||||
if env:
|
||||
return Path(env)
|
||||
return Path("data/maturity-engine.sqlite")
|
||||
|
||||
|
||||
def _engine(args: argparse.Namespace) -> Engine:
|
||||
return Engine.open(_db(args))
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="maturity-engine",
|
||||
description="Compute levels, remember gaps. Does not decide.",
|
||||
)
|
||||
parser.add_argument("--db", help="sqlite path (default data/maturity-engine.sqlite)")
|
||||
sub = parser.add_subparsers(dest="cmd", required=True)
|
||||
|
||||
boot = sub.add_parser("bootstrap", help="register seed ladders, §13 snapshot, stance inventory")
|
||||
boot.add_argument("--at", required=True, help="timezone-aware instant")
|
||||
|
||||
assess = sub.add_parser("assess", help="compute a level from recorded evidence")
|
||||
assess.add_argument("--subject", required=True)
|
||||
assess.add_argument("--model", required=True)
|
||||
assess.add_argument("--at", required=True)
|
||||
assess.add_argument("--version")
|
||||
|
||||
hist = sub.add_parser("history", help="progression history for a subject")
|
||||
hist.add_argument("--subject", required=True)
|
||||
hist.add_argument("--model")
|
||||
|
||||
sub.add_parser("gaps", help="query the gap register")
|
||||
ready = sub.add_parser("readiness", help="pending | declared-gap | surface-exists")
|
||||
ready.add_argument("capability")
|
||||
sub.add_parser("stance", help="PEP stance-map inventory")
|
||||
|
||||
review = sub.add_parser("review", help="compute-and-remember surface for gate-house")
|
||||
review.add_argument("--subject", required=True)
|
||||
review.add_argument("--at", required=True)
|
||||
|
||||
sub.add_parser("outbox", help="list locally queued evidence events")
|
||||
sub.add_parser("models", help="list registered ladders")
|
||||
|
||||
args = parser.parse_args(argv)
|
||||
engine = _engine(args)
|
||||
try:
|
||||
return _dispatch(engine, args)
|
||||
finally:
|
||||
engine.close()
|
||||
|
||||
|
||||
def _dispatch(engine: Engine, args: argparse.Namespace) -> int:
|
||||
if args.cmd == "bootstrap":
|
||||
engine.bootstrap(at=parse_instant(args.at))
|
||||
print("bootstrapped")
|
||||
return 0
|
||||
if args.cmd == "assess":
|
||||
assessment = engine.assess(
|
||||
args.subject, args.model, at=parse_instant(args.at), version=args.version
|
||||
)
|
||||
print(json.dumps(assessment.as_explanation(), indent=2))
|
||||
return 0
|
||||
if args.cmd == "history":
|
||||
print(json.dumps([item.as_explanation() for item in engine.history(args.subject, args.model)], indent=2))
|
||||
return 0
|
||||
if args.cmd == "gaps":
|
||||
print(json.dumps([gap.as_dict() for gap in engine.gaps()], indent=2))
|
||||
return 0
|
||||
if args.cmd == "readiness":
|
||||
print(json.dumps(engine.readiness(args.capability), indent=2))
|
||||
return 0
|
||||
if args.cmd == "stance":
|
||||
print(json.dumps([item.as_dict() for item in engine.stance_maps()], indent=2))
|
||||
return 0
|
||||
if args.cmd == "review":
|
||||
print(json.dumps(engine.review(args.subject, at=parse_instant(args.at)).as_dict(), indent=2))
|
||||
return 0
|
||||
if args.cmd == "outbox":
|
||||
print(json.dumps(list(engine.pending_events()), indent=2))
|
||||
return 0
|
||||
if args.cmd == "models":
|
||||
print(
|
||||
json.dumps(
|
||||
[
|
||||
{"id": item.id, "version": item.version, "owner": item.owner, "name": item.name}
|
||||
for item in engine.models()
|
||||
],
|
||||
indent=2,
|
||||
)
|
||||
)
|
||||
return 0
|
||||
return 2
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
83
src/maturity_engine/compute.py
Normal file
83
src/maturity_engine/compute.py
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from maturity_engine.ids import canonical_json, named_id
|
||||
from maturity_engine.models import (
|
||||
Assessment,
|
||||
Evidence,
|
||||
Ladder,
|
||||
MetCriterion,
|
||||
UnmetCriterion,
|
||||
)
|
||||
from maturity_engine.timeutil import format_instant
|
||||
|
||||
|
||||
def compute_level(
|
||||
ladder: Ladder,
|
||||
evidence: tuple[Evidence, ...],
|
||||
*,
|
||||
subject: str,
|
||||
instant: datetime,
|
||||
) -> Assessment:
|
||||
"""Highest consecutive level whose criteria all have valid evidence.
|
||||
|
||||
Evaluation instant is an input. Same ladder, same evidence, same instant,
|
||||
same assessment.
|
||||
"""
|
||||
ladder.validate()
|
||||
valid = [item for item in evidence if item.valid_at(instant)]
|
||||
expired = tuple(
|
||||
item.id
|
||||
for item in evidence
|
||||
if not item.valid_at(instant) and item.valid_until is not None and instant >= item.valid_until
|
||||
)
|
||||
by_kind: dict[str, list[Evidence]] = {}
|
||||
for item in valid:
|
||||
by_kind.setdefault(item.kind, []).append(item)
|
||||
|
||||
met: list[MetCriterion] = []
|
||||
achieved = -1
|
||||
unmet: tuple[UnmetCriterion, ...] = ()
|
||||
next_level_id: str | None = None
|
||||
|
||||
for level in ladder.levels:
|
||||
missing = [
|
||||
UnmetCriterion(criterion.id, criterion.evidence_kind, criterion.description)
|
||||
for criterion in level.criteria
|
||||
if criterion.evidence_kind not in by_kind
|
||||
]
|
||||
if missing:
|
||||
unmet = tuple(missing)
|
||||
next_level_id = level.id
|
||||
break
|
||||
for criterion in level.criteria:
|
||||
ids = tuple(item.id for item in by_kind[criterion.evidence_kind])
|
||||
met.append(MetCriterion(criterion.id, criterion.evidence_kind, ids))
|
||||
achieved = level.index
|
||||
|
||||
if achieved < 0:
|
||||
raise RuntimeError("ladder has no floor level 0")
|
||||
|
||||
level = ladder.level_by_index(achieved)
|
||||
material = {
|
||||
"subject": subject,
|
||||
"model_id": ladder.id,
|
||||
"model_version": ladder.version,
|
||||
"evaluated_at": format_instant(instant),
|
||||
"evidence_ids": sorted(item.id for item in evidence),
|
||||
}
|
||||
assessment_id = named_id("assessment", canonical_json(material))
|
||||
return Assessment(
|
||||
id=assessment_id,
|
||||
subject=subject,
|
||||
model_id=ladder.id,
|
||||
model_version=ladder.version,
|
||||
level=achieved,
|
||||
level_id=level.id,
|
||||
evaluated_at=instant,
|
||||
met=tuple(met),
|
||||
unmet=unmet,
|
||||
next_level_id=next_level_id,
|
||||
expired_evidence_ids=expired,
|
||||
)
|
||||
183
src/maturity_engine/engine.py
Normal file
183
src/maturity_engine/engine.py
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from maturity_engine.claims import Claim, compile_into_registry, gate_on_level, to_claim
|
||||
from maturity_engine.compute import compute_level
|
||||
from maturity_engine.errors import UnknownModel
|
||||
from maturity_engine.ids import named_id
|
||||
from maturity_engine.models import Assessment, Evidence, Gap, Ladder, StanceMap
|
||||
from maturity_engine.scoring import (
|
||||
capability_readiness,
|
||||
score,
|
||||
states_from_gaps,
|
||||
worst_state,
|
||||
)
|
||||
from maturity_engine.seed import (
|
||||
asm_ladder,
|
||||
pep_stance_publication_ladder,
|
||||
section_13_1_stances,
|
||||
section_13_gaps,
|
||||
)
|
||||
from maturity_engine.store import Store
|
||||
from maturity_engine.timeutil import format_instant
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ConformanceReview:
|
||||
subject: str
|
||||
evaluated_at: datetime
|
||||
aggregate_state: str
|
||||
capabilities: tuple[dict, ...]
|
||||
assessments: tuple[dict, ...]
|
||||
note: str = (
|
||||
"gate-house judges and proposes; maturity-engine computes and remembers. "
|
||||
"This review is a computation, not a judgment."
|
||||
)
|
||||
|
||||
def as_dict(self) -> dict:
|
||||
return {
|
||||
"subject": self.subject,
|
||||
"evaluated_at": format_instant(self.evaluated_at),
|
||||
"aggregate_state": self.aggregate_state,
|
||||
"capabilities": list(self.capabilities),
|
||||
"assessments": list(self.assessments),
|
||||
"note": self.note,
|
||||
}
|
||||
|
||||
|
||||
class Engine:
|
||||
"""Deterministic API for graded progression, the gap register, and readiness.
|
||||
|
||||
No authorize / decide / may method exists. A maturity level leaves this
|
||||
process only as a Claim.
|
||||
"""
|
||||
|
||||
def __init__(self, store: Store) -> None:
|
||||
self.store = store
|
||||
|
||||
@classmethod
|
||||
def open(cls, path: Path) -> Engine:
|
||||
return cls(Store(path))
|
||||
|
||||
def close(self) -> None:
|
||||
self.store.close()
|
||||
|
||||
def bootstrap(self, *, at: datetime) -> None:
|
||||
self.register_model(asm_ladder())
|
||||
self.register_model(pep_stance_publication_ladder())
|
||||
for gap in section_13_gaps():
|
||||
self.store.put_gap(gap, emit_at=at)
|
||||
for stance in section_13_1_stances():
|
||||
self.store.put_stance(stance)
|
||||
|
||||
def register_model(self, ladder: Ladder) -> None:
|
||||
self.store.put_ladder(ladder)
|
||||
|
||||
def models(self) -> tuple[Ladder, ...]:
|
||||
return self.store.list_ladders()
|
||||
|
||||
def submit_evidence(self, evidence: Evidence) -> None:
|
||||
self.store.put_evidence(evidence, emit_at=evidence.submitted_at)
|
||||
|
||||
def assess(self, subject: str, model_id: str, *, at: datetime, version: str | None = None) -> Assessment:
|
||||
ladder = self.store.get_ladder(model_id, version)
|
||||
if ladder is None:
|
||||
raise UnknownModel(f"{model_id}@{version or 'latest'}")
|
||||
evidence = self.store.evidence_for(subject)
|
||||
assessment = compute_level(ladder, evidence, subject=subject, instant=at)
|
||||
self.store.put_assessment(assessment)
|
||||
return assessment
|
||||
|
||||
def history(self, subject: str, model_id: str | None = None) -> tuple[Assessment, ...]:
|
||||
return self.store.assessments_for(subject, model_id)
|
||||
|
||||
def claim(self, assessment: Assessment) -> Claim:
|
||||
return to_claim(assessment)
|
||||
|
||||
def gaps(self) -> tuple[Gap, ...]:
|
||||
return self.store.list_gaps()
|
||||
|
||||
def upsert_gap(self, gap: Gap, *, at: datetime) -> None:
|
||||
self.store.put_gap(gap, emit_at=at)
|
||||
|
||||
def readiness(self, capability_id: str) -> dict:
|
||||
gap = self.store.get_gap(capability_id)
|
||||
if gap is None:
|
||||
return {"id": capability_id, "readiness": "pending", "note": "no register row"}
|
||||
return {
|
||||
"id": gap.id,
|
||||
"capability": gap.capability,
|
||||
"readiness": capability_readiness(gap),
|
||||
"mark": gap.mark,
|
||||
"state": gap.state,
|
||||
"owner_status": gap.owner_status,
|
||||
"intended_owner": gap.intended_owner,
|
||||
"owns_actuation": False,
|
||||
}
|
||||
|
||||
def stance_maps(self) -> tuple[StanceMap, ...]:
|
||||
return self.store.list_stances()
|
||||
|
||||
def review(self, subject: str, *, at: datetime) -> ConformanceReview:
|
||||
touching = tuple(
|
||||
gap
|
||||
for gap in self.gaps()
|
||||
if _touches(subject, gap.declared_by) or _touches(subject, gap.intended_owner)
|
||||
)
|
||||
capabilities = tuple(
|
||||
{
|
||||
"id": gap.id,
|
||||
"readiness": capability_readiness(gap),
|
||||
"state": gap.state,
|
||||
"owner_status": gap.owner_status,
|
||||
"mark": gap.mark,
|
||||
"score": score(
|
||||
{
|
||||
"pending": "blocked-clean",
|
||||
"declared-gap": "declared-gap",
|
||||
"surface-exists": "conforming",
|
||||
}.get(capability_readiness(gap), "blocked-clean")
|
||||
),
|
||||
}
|
||||
for gap in touching
|
||||
)
|
||||
states = states_from_gaps(touching)
|
||||
assessments = tuple(item.as_explanation() for item in self.history(subject))
|
||||
return ConformanceReview(
|
||||
subject=subject,
|
||||
evaluated_at=at,
|
||||
aggregate_state=worst_state(states),
|
||||
capabilities=capabilities,
|
||||
assessments=assessments,
|
||||
)
|
||||
|
||||
def pending_events(self) -> tuple[dict, ...]:
|
||||
return self.store.pending_outbox()
|
||||
|
||||
def drain_events(self, *, at: datetime) -> int:
|
||||
"""Mark queued events drained. Does not call audit-core."""
|
||||
pending = self.store.pending_outbox()
|
||||
for item in pending:
|
||||
self.store.drain_outbox(item["id"], at)
|
||||
return len(pending)
|
||||
|
||||
def heartbeat(self, *, at: datetime) -> None:
|
||||
self.store.heartbeat(at)
|
||||
|
||||
def evidence_id(self, subject: str, kind: str, submitted_at: datetime) -> str:
|
||||
return named_id("evidence", subject, kind, format_instant(submitted_at))
|
||||
|
||||
|
||||
def _touches(subject: str, field: str | None) -> bool:
|
||||
if not field:
|
||||
return False
|
||||
return subject == field or subject in {part.strip() for part in field.replace(";", ",").split(",")}
|
||||
|
||||
|
||||
# Guardrail helpers re-exported so tests import one place, and so nobody
|
||||
# adds a decide() here thinking it is convenient.
|
||||
reject_registry_compile = compile_into_registry
|
||||
reject_consumer_branch = gate_on_level
|
||||
25
src/maturity_engine/errors.py
Normal file
25
src/maturity_engine/errors.py
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
"""Domain errors. None of these is an authorization decision."""
|
||||
|
||||
|
||||
class MaturityError(Exception):
|
||||
"""Base error for this engine."""
|
||||
|
||||
|
||||
class ModelError(MaturityError):
|
||||
"""A ladder cannot be registered as specified."""
|
||||
|
||||
|
||||
class UnevaluableCriterion(ModelError):
|
||||
"""A criterion has no rule; it is not yet a criterion."""
|
||||
|
||||
|
||||
class GuardrailError(MaturityError):
|
||||
"""A caller asked this engine to decide or to compile a level into a registry."""
|
||||
|
||||
|
||||
class UnknownModel(MaturityError):
|
||||
"""No ladder is registered under that id and version."""
|
||||
|
||||
|
||||
class UnknownSubject(MaturityError):
|
||||
"""No facts are recorded for that subject."""
|
||||
20
src/maturity_engine/ids.py
Normal file
20
src/maturity_engine/ids.py
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from typing import Any
|
||||
from uuid import UUID, uuid5
|
||||
|
||||
NAMESPACE = uuid5(UUID("6ba7b811-9dad-11d1-80b4-00c04fd430c8"), "net-kingdom/maturity-engine")
|
||||
|
||||
|
||||
def canonical_json(value: Any) -> str:
|
||||
return json.dumps(value, sort_keys=True, separators=(",", ":"), default=str)
|
||||
|
||||
|
||||
def digest(value: Any) -> str:
|
||||
return hashlib.sha256(canonical_json(value).encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def named_id(*parts: str) -> str:
|
||||
return str(uuid5(NAMESPACE, "|".join(parts)))
|
||||
180
src/maturity_engine/models.py
Normal file
180
src/maturity_engine/models.py
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
|
||||
from maturity_engine.errors import UnevaluableCriterion
|
||||
from maturity_engine.timeutil import format_instant
|
||||
|
||||
KIND_RE = re.compile(r"^[a-z][a-z0-9-]*$")
|
||||
UNEVALUABLE = {"interpret", "judgment", "opinion", "human", "discretion", "review-discretion"}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Criterion:
|
||||
id: str
|
||||
evidence_kind: str
|
||||
description: str
|
||||
|
||||
def validate(self) -> None:
|
||||
kind = self.evidence_kind.strip()
|
||||
if kind in UNEVALUABLE or not KIND_RE.match(kind):
|
||||
raise UnevaluableCriterion(
|
||||
f"criterion {self.id!r} is not evaluable by rule "
|
||||
f"(evidence_kind={self.evidence_kind!r})"
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Level:
|
||||
index: int
|
||||
id: str
|
||||
name: str
|
||||
criteria: tuple[Criterion, ...]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Ladder:
|
||||
id: str
|
||||
version: str
|
||||
owner: str
|
||||
name: str
|
||||
levels: tuple[Level, ...]
|
||||
|
||||
def validate(self) -> None:
|
||||
if not self.levels:
|
||||
raise UnevaluableCriterion(f"ladder {self.id} has no levels")
|
||||
indexes = [level.index for level in self.levels]
|
||||
if indexes != list(range(len(self.levels))):
|
||||
raise UnevaluableCriterion(
|
||||
f"ladder {self.id} levels must be consecutive from 0, got {indexes}"
|
||||
)
|
||||
for level in self.levels:
|
||||
for criterion in level.criteria:
|
||||
criterion.validate()
|
||||
|
||||
def level_by_index(self, index: int) -> Level:
|
||||
return self.levels[index]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Evidence:
|
||||
id: str
|
||||
subject: str
|
||||
kind: str
|
||||
submitted_by: str
|
||||
submitted_at: datetime
|
||||
valid_from: datetime
|
||||
valid_until: datetime | None
|
||||
payload: dict = field(default_factory=dict)
|
||||
|
||||
def valid_at(self, instant: datetime) -> bool:
|
||||
if instant < self.valid_from:
|
||||
return False
|
||||
if self.valid_until is not None and instant >= self.valid_until:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MetCriterion:
|
||||
criterion_id: str
|
||||
evidence_kind: str
|
||||
evidence_ids: tuple[str, ...]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class UnmetCriterion:
|
||||
criterion_id: str
|
||||
evidence_kind: str
|
||||
description: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Assessment:
|
||||
id: str
|
||||
subject: str
|
||||
model_id: str
|
||||
model_version: str
|
||||
level: int
|
||||
level_id: str
|
||||
evaluated_at: datetime
|
||||
met: tuple[MetCriterion, ...]
|
||||
unmet: tuple[UnmetCriterion, ...]
|
||||
next_level_id: str | None
|
||||
expired_evidence_ids: tuple[str, ...]
|
||||
|
||||
def as_explanation(self) -> dict:
|
||||
return {
|
||||
"assessment_id": self.id,
|
||||
"subject": self.subject,
|
||||
"model_id": self.model_id,
|
||||
"model_version": self.model_version,
|
||||
"level": self.level,
|
||||
"level_id": self.level_id,
|
||||
"evaluated_at": format_instant(self.evaluated_at),
|
||||
"met": [
|
||||
{
|
||||
"criterion_id": item.criterion_id,
|
||||
"evidence_kind": item.evidence_kind,
|
||||
"evidence_ids": list(item.evidence_ids),
|
||||
}
|
||||
for item in self.met
|
||||
],
|
||||
"unmet": [
|
||||
{
|
||||
"criterion_id": item.criterion_id,
|
||||
"evidence_kind": item.evidence_kind,
|
||||
"description": item.description,
|
||||
}
|
||||
for item in self.unmet
|
||||
],
|
||||
"next_level_id": self.next_level_id,
|
||||
"expired_evidence_ids": list(self.expired_evidence_ids),
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Gap:
|
||||
id: str
|
||||
capability: str
|
||||
intended_owner: str | None
|
||||
blocked_on: str | None
|
||||
review: str | None
|
||||
state: str
|
||||
owner_status: str
|
||||
declared_by: str
|
||||
mark: str | None
|
||||
notes: str | None = None
|
||||
|
||||
def as_dict(self) -> dict:
|
||||
return {
|
||||
"id": self.id,
|
||||
"capability": self.capability,
|
||||
"intended_owner": self.intended_owner,
|
||||
"blocked_on": self.blocked_on,
|
||||
"review": self.review,
|
||||
"state": self.state,
|
||||
"owner_status": self.owner_status,
|
||||
"declared_by": self.declared_by,
|
||||
"mark": self.mark,
|
||||
"notes": self.notes,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class StanceMap:
|
||||
consumer: str
|
||||
published: bool
|
||||
path: str | None
|
||||
shape: str
|
||||
|
||||
def as_dict(self) -> dict:
|
||||
return {
|
||||
"consumer": self.consumer,
|
||||
"published": self.published,
|
||||
"path": self.path,
|
||||
"shape": self.shape,
|
||||
"absence": not self.published,
|
||||
}
|
||||
87
src/maturity_engine/scoring.py
Normal file
87
src/maturity_engine/scoring.py
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
"""Four conformance states and the scoring rule from statute §11.
|
||||
|
||||
blocked-clean MUST NOT rank below conforming. The two are equal. Declared
|
||||
gap is tracked non-conformance. Undeclared violation is worse.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from maturity_engine.models import Gap
|
||||
|
||||
CONFORMING = "conforming"
|
||||
BLOCKED_CLEAN = "blocked-clean"
|
||||
DECLARED_GAP = "declared-gap"
|
||||
UNDECLARED_VIOLATION = "undeclared-violation"
|
||||
|
||||
STATES = (CONFORMING, BLOCKED_CLEAN, DECLARED_GAP, UNDECLARED_VIOLATION)
|
||||
|
||||
# Higher is better. Equal scores mean neither ranks below the other.
|
||||
SCORE = {
|
||||
CONFORMING: 2,
|
||||
BLOCKED_CLEAN: 2,
|
||||
DECLARED_GAP: 1,
|
||||
UNDECLARED_VIOLATION: 0,
|
||||
}
|
||||
|
||||
ASSIGNED_STATUSES = {"assigned", "assented", "resolved"}
|
||||
|
||||
|
||||
def score(state: str) -> int:
|
||||
if state not in SCORE:
|
||||
raise ValueError(f"unknown conformance state {state!r}")
|
||||
return SCORE[state]
|
||||
|
||||
|
||||
def ranks_below(left: str, right: str) -> bool:
|
||||
return score(left) < score(right)
|
||||
|
||||
|
||||
def mark_for(gap: Gap) -> str | None:
|
||||
return gap.mark
|
||||
|
||||
|
||||
def capability_readiness(gap: Gap) -> str:
|
||||
"""pending | declared-gap | surface-exists. Per capability, not per repo."""
|
||||
if gap.owner_status in ASSIGNED_STATUSES or gap.state == "resolved":
|
||||
return "surface-exists"
|
||||
if gap.mark == "declared-gap" or gap.state == "declared-contact":
|
||||
return "declared-gap"
|
||||
if gap.mark == "pending" or gap.state == "unowned-capability":
|
||||
return "pending"
|
||||
return "pending"
|
||||
|
||||
|
||||
def states_from_gaps(gaps: tuple[Gap, ...]) -> tuple[str, ...]:
|
||||
if not gaps:
|
||||
return (CONFORMING,)
|
||||
found: list[str] = []
|
||||
for gap in gaps:
|
||||
readiness = capability_readiness(gap)
|
||||
if gap.state == "undeclared-violation" or gap.mark == "undeclared-violation":
|
||||
found.append(UNDECLARED_VIOLATION)
|
||||
elif readiness == "declared-gap":
|
||||
found.append(DECLARED_GAP)
|
||||
elif readiness == "pending":
|
||||
found.append(BLOCKED_CLEAN)
|
||||
else:
|
||||
found.append(CONFORMING)
|
||||
return tuple(found)
|
||||
|
||||
|
||||
def worst_state(states: tuple[str, ...]) -> str:
|
||||
"""Aggregate that never ranks blocked-clean below conforming.
|
||||
|
||||
A repo that is only blocked-clean stays blocked-clean, which scores equal
|
||||
to conforming. Declared-gap or undeclared-violation still surface.
|
||||
"""
|
||||
if not states:
|
||||
return CONFORMING
|
||||
if UNDECLARED_VIOLATION in states:
|
||||
return UNDECLARED_VIOLATION
|
||||
if DECLARED_GAP in states:
|
||||
return DECLARED_GAP
|
||||
if BLOCKED_CLEAN in states and CONFORMING not in states:
|
||||
return BLOCKED_CLEAN
|
||||
if BLOCKED_CLEAN in states and CONFORMING in states:
|
||||
return CONFORMING
|
||||
return CONFORMING
|
||||
299
src/maturity_engine/seed.py
Normal file
299
src/maturity_engine/seed.py
Normal file
|
|
@ -0,0 +1,299 @@
|
|||
"""Seed data: statute §13 snapshot, §13.1 stance inventory, first two ladders.
|
||||
|
||||
Ladder *content* is not authored here. ASM-0…ASM-6 is gate-house's
|
||||
(Active Secrets Management Canon v0.3 §38). PEP-stance publication is
|
||||
ops-warden's (ADR-0009). This module registers those ladders as data.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from maturity_engine.models import Criterion, Gap, Ladder, Level, StanceMap
|
||||
|
||||
REVIEW = "2026-11-28"
|
||||
|
||||
|
||||
def _c(criterion_id: str, kind: str, description: str) -> Criterion:
|
||||
return Criterion(id=criterion_id, evidence_kind=kind, description=description)
|
||||
|
||||
|
||||
def asm_ladder() -> Ladder:
|
||||
"""ASM-0…ASM-6. Criteria kinds are mechanical; doctrine stays gate-house's."""
|
||||
levels = (
|
||||
Level(0, "ASM-0", "Embedded", ()),
|
||||
Level(
|
||||
1,
|
||||
"ASM-1",
|
||||
"Stored",
|
||||
(
|
||||
_c("asm-1-manager", "central-secret-manager", "Central secret manager exists"),
|
||||
_c("asm-1-rbac", "secret-rbac", "RBAC on secrets exists"),
|
||||
_c("asm-1-scan", "basic-secret-scanning", "Basic secret scanning exists"),
|
||||
_c("asm-1-rotate", "basic-rotation", "Basic rotation exists"),
|
||||
),
|
||||
),
|
||||
Level(
|
||||
2,
|
||||
"ASM-2",
|
||||
"Managed",
|
||||
(
|
||||
_c("asm-2-owner", "secret-ownership", "Ownership is established"),
|
||||
_c("asm-2-auto", "automated-rotation", "Automated rotation is established"),
|
||||
_c("asm-2-inv", "secret-inventory", "Inventory is established"),
|
||||
_c("asm-2-detect", "exposure-detection", "Detection is established"),
|
||||
_c("asm-2-remediate", "measurable-remediation", "Measurable remediation is established"),
|
||||
),
|
||||
),
|
||||
Level(
|
||||
3,
|
||||
"ASM-3",
|
||||
"Dynamic",
|
||||
(
|
||||
_c("asm-3-fed", "workload-federation", "Workload federation reduces standing credentials"),
|
||||
_c("asm-3-jit", "jit-access", "JIT access reduces standing credentials"),
|
||||
_c("asm-3-dyn", "dynamic-secrets", "Dynamic secrets reduce standing credentials"),
|
||||
_c("asm-3-ci", "secretless-cicd", "Secretless CI/CD reduces standing credentials"),
|
||||
),
|
||||
),
|
||||
Level(
|
||||
4,
|
||||
"ASM-4",
|
||||
"Agent-Aware",
|
||||
(
|
||||
_c("asm-4-split", "principal-actor-distinction", "Human principal and agent actor are distinguished"),
|
||||
_c("asm-4-asst", "assistant-mode-modeled", "Assistant mode is explicitly modeled"),
|
||||
_c("asm-4-auto", "autonomous-mode-modeled", "Autonomous mode is explicitly modeled"),
|
||||
),
|
||||
),
|
||||
Level(
|
||||
5,
|
||||
"ASM-5",
|
||||
"Governed Autonomy",
|
||||
(
|
||||
_c("asm-5-id", "agent-identities", "Autonomous agents have identities"),
|
||||
_c("asm-5-man", "agent-mandates", "Autonomous agents have mandates"),
|
||||
_c("asm-5-budget", "agent-budgets", "Autonomous agents have budgets"),
|
||||
_c("asm-5-ceil", "authority-ceilings", "Authority ceilings are in force"),
|
||||
_c("asm-5-env", "change-envelopes", "Change envelopes are in force"),
|
||||
_c("asm-5-cb", "circuit-breakers", "Deterministic circuit breakers are in force"),
|
||||
),
|
||||
),
|
||||
Level(
|
||||
6,
|
||||
"ASM-6",
|
||||
"Closed-Loop Authority",
|
||||
(
|
||||
_c("asm-6-id", "identity-join", "Identity is in the reconciled authority lifecycle"),
|
||||
_c("asm-6-del", "delegation-join", "Delegation is in the reconciled authority lifecycle"),
|
||||
_c("asm-6-iss", "issuance-join", "Credential issuance is in the reconciled authority lifecycle"),
|
||||
_c("asm-6-exec", "execution-join", "Execution is in the reconciled authority lifecycle"),
|
||||
_c("asm-6-ev", "evidence-join", "Evidence is in the reconciled authority lifecycle"),
|
||||
_c("asm-6-exp", "exposure-join", "Exposure detection is in the reconciled authority lifecycle"),
|
||||
_c("asm-6-rev", "revocation-join", "Revocation is in the reconciled authority lifecycle"),
|
||||
_c("asm-6-rem", "remediation-join", "Remediation is in the reconciled authority lifecycle"),
|
||||
),
|
||||
),
|
||||
)
|
||||
return Ladder(
|
||||
id="asm",
|
||||
version="0.3",
|
||||
owner="gate-house",
|
||||
name="Active Secrets Management",
|
||||
levels=levels,
|
||||
)
|
||||
|
||||
|
||||
def pep_stance_publication_ladder() -> Ladder:
|
||||
"""Publication of unreachable-engine stance maps. Doctrine is ops-warden's."""
|
||||
levels = (
|
||||
Level(0, "PSP-0", "Unpublished", ()),
|
||||
Level(
|
||||
1,
|
||||
"PSP-1",
|
||||
"Published",
|
||||
(_c("psp-1-file", "stance-map-published", "Stance map published at a named path"),),
|
||||
),
|
||||
Level(
|
||||
2,
|
||||
"PSP-2",
|
||||
"Tested",
|
||||
(
|
||||
_c(
|
||||
"psp-2-test",
|
||||
"stance-map-equality-test",
|
||||
"A test asserts the published map equals shipped behaviour",
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
return Ladder(
|
||||
id="pep-stance-publication",
|
||||
version="0.1",
|
||||
owner="ops-warden",
|
||||
name="PEP stance-map publication",
|
||||
levels=levels,
|
||||
)
|
||||
|
||||
|
||||
def section_13_gaps() -> tuple[Gap, ...]:
|
||||
"""Statute §13 snapshot. state and owner_status survive the migration."""
|
||||
return (
|
||||
Gap(
|
||||
id="ssh-ca-signing-write",
|
||||
capability="SSH-CA signing write (VaultCA, bao kv put)",
|
||||
intended_owner="secrets-engine",
|
||||
blocked_on="No engine exposes an SSH certificate signing surface",
|
||||
review=REVIEW,
|
||||
state="declared-contact",
|
||||
owner_status="proposed",
|
||||
declared_by="ops-warden",
|
||||
mark="declared-gap",
|
||||
),
|
||||
Gap(
|
||||
id="authentication-assurance-evidence",
|
||||
capability="Authentication / assurance evidence",
|
||||
intended_owner="identity layer + audit-core",
|
||||
blocked_on="access-engine declined (FLEX-DEC-2026-002); no identity-layer evidence surface",
|
||||
review=REVIEW,
|
||||
state="unowned-capability",
|
||||
owner_status="declined",
|
||||
declared_by="kings-guard",
|
||||
mark="pending",
|
||||
notes="access-engine declined; reproposed, not assented",
|
||||
),
|
||||
Gap(
|
||||
id="secret-use-evidence",
|
||||
capability="Secret-use evidence",
|
||||
intended_owner="secrets-engine",
|
||||
blocked_on="No engine exposes secret-use evidence; OpenBao is Tooling",
|
||||
review=REVIEW,
|
||||
state="unowned-capability",
|
||||
owner_status="proposed",
|
||||
declared_by="kings-guard",
|
||||
mark="pending",
|
||||
),
|
||||
Gap(
|
||||
id="actuation-containment-surface",
|
||||
capability="Reduce authority, require step-up, isolate a workload — as a deterministic engine API",
|
||||
intended_owner="access-engine + runtime PEPs",
|
||||
blocked_on="Ruled in v0.7 §9.2 to be an Engine concept, unowned and held at zero",
|
||||
review=REVIEW,
|
||||
state="unowned-capability",
|
||||
owner_status="proposed",
|
||||
declared_by="gate-house (estate-wide)",
|
||||
mark="pending",
|
||||
notes="kings-guard proposes containment and does not own it",
|
||||
),
|
||||
Gap(
|
||||
id="identity-and-secret-observation",
|
||||
capability="Identity and secret observation",
|
||||
intended_owner="identity layer + secrets-engine + audit-core",
|
||||
blocked_on="No engine exposes the observation surface; kings-guard makes no Tooling contact",
|
||||
review=REVIEW,
|
||||
state="unowned-capability",
|
||||
owner_status="proposed",
|
||||
declared_by="kings-guard",
|
||||
mark="pending",
|
||||
),
|
||||
Gap(
|
||||
id="stance-map-register",
|
||||
capability="Stance-map register had no implementation",
|
||||
intended_owner="gate-house",
|
||||
blocked_on=None,
|
||||
review=REVIEW,
|
||||
state="resolved",
|
||||
owner_status="resolved",
|
||||
declared_by="ops-warden, access-engine",
|
||||
mark=None,
|
||||
notes="resolved in statute §13.1; inventory now held here",
|
||||
),
|
||||
Gap(
|
||||
id="registry-snapshot-digest",
|
||||
capability="Registry-snapshot digest in decision provenance",
|
||||
intended_owner="flex-auth",
|
||||
blocked_on="Decision provenance holds no snapshot digest; a registry compile of a level would be unfalsifiable",
|
||||
review=REVIEW,
|
||||
state="declared-contact",
|
||||
owner_status="self-declared",
|
||||
declared_by="flex-auth",
|
||||
mark="declared-gap",
|
||||
),
|
||||
Gap(
|
||||
id="approval-storage-lifecycle",
|
||||
capability="Approval storage and lifecycle",
|
||||
intended_owner="approval-engine",
|
||||
blocked_on=None,
|
||||
review=REVIEW,
|
||||
state="assigned",
|
||||
owner_status="assigned",
|
||||
declared_by="flex-auth",
|
||||
mark=None,
|
||||
notes="assigned (§9.4)",
|
||||
),
|
||||
Gap(
|
||||
id="approval-evidence",
|
||||
capability="Approval evidence",
|
||||
intended_owner="audit-core",
|
||||
blocked_on=None,
|
||||
review=REVIEW,
|
||||
state="assigned",
|
||||
owner_status="assented",
|
||||
declared_by="gate-house",
|
||||
mark=None,
|
||||
notes="assented (AUDIT-IN-0001)",
|
||||
),
|
||||
Gap(
|
||||
id="approval-evidence-custody-stronger",
|
||||
capability="Approval evidence custody stronger than the shipped bound — WORM, object lock, transparency log",
|
||||
intended_owner=None,
|
||||
blocked_on="Doctrine work not yet done; approval evidence carries the same bound as every other source",
|
||||
review=REVIEW,
|
||||
state="unowned-capability",
|
||||
owner_status="unassigned",
|
||||
declared_by="audit-core",
|
||||
mark="pending",
|
||||
),
|
||||
Gap(
|
||||
id="approval-emission-atomicity",
|
||||
capability="Emission atomicity for approval state changes",
|
||||
intended_owner="approval-engine",
|
||||
blocked_on=None,
|
||||
review=REVIEW,
|
||||
state="assigned",
|
||||
owner_status="assigned",
|
||||
declared_by="audit-core",
|
||||
mark=None,
|
||||
notes="assigned (§9.4)",
|
||||
),
|
||||
Gap(
|
||||
id="ssh-signing-non-atomic-audit",
|
||||
capability="Non-atomic audit emission on the SSH signing lane",
|
||||
intended_owner="ops-warden",
|
||||
blocked_on="Declared trade so an audit-store failure cannot remove production host access",
|
||||
review=REVIEW,
|
||||
state="declared-contact",
|
||||
owner_status="self-declared",
|
||||
declared_by="ops-warden",
|
||||
mark="declared-gap",
|
||||
notes="attributive (§9.6)",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def section_13_1_stances() -> tuple[StanceMap, ...]:
|
||||
return (
|
||||
StanceMap(
|
||||
consumer="ops-warden",
|
||||
published=True,
|
||||
path="ops-warden/pep-stance.yaml",
|
||||
shape=(
|
||||
"total per-zone; open z0–z2 and unknown, closed z3-critical; "
|
||||
"test asserts the published map equals the shipped default (ADR-0009)"
|
||||
),
|
||||
),
|
||||
StanceMap(
|
||||
consumer="ops-mason",
|
||||
published=False,
|
||||
path=None,
|
||||
shape="catalogued PEP-shaped in §4; map not published",
|
||||
),
|
||||
)
|
||||
470
src/maturity_engine/store.py
Normal file
470
src/maturity_engine/store.py
Normal file
|
|
@ -0,0 +1,470 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from maturity_engine.models import (
|
||||
Assessment,
|
||||
Criterion,
|
||||
Evidence,
|
||||
Gap,
|
||||
Ladder,
|
||||
Level,
|
||||
MetCriterion,
|
||||
StanceMap,
|
||||
UnmetCriterion,
|
||||
)
|
||||
from maturity_engine.timeutil import format_instant, parse_instant
|
||||
|
||||
SCHEMA = """
|
||||
CREATE TABLE IF NOT EXISTS models (
|
||||
id TEXT NOT NULL,
|
||||
version TEXT NOT NULL,
|
||||
owner TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
body TEXT NOT NULL,
|
||||
PRIMARY KEY (id, version)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS evidence (
|
||||
id TEXT PRIMARY KEY,
|
||||
subject TEXT NOT NULL,
|
||||
kind TEXT NOT NULL,
|
||||
submitted_by TEXT NOT NULL,
|
||||
submitted_at TEXT NOT NULL,
|
||||
valid_from TEXT NOT NULL,
|
||||
valid_until TEXT,
|
||||
payload TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS assessments (
|
||||
id TEXT PRIMARY KEY,
|
||||
subject TEXT NOT NULL,
|
||||
model_id TEXT NOT NULL,
|
||||
model_version TEXT NOT NULL,
|
||||
level INTEGER NOT NULL,
|
||||
level_id TEXT NOT NULL,
|
||||
evaluated_at TEXT NOT NULL,
|
||||
explanation TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS gaps (
|
||||
id TEXT PRIMARY KEY,
|
||||
capability TEXT NOT NULL,
|
||||
intended_owner TEXT,
|
||||
blocked_on TEXT,
|
||||
review TEXT,
|
||||
state TEXT NOT NULL,
|
||||
owner_status TEXT NOT NULL,
|
||||
declared_by TEXT NOT NULL,
|
||||
mark TEXT,
|
||||
notes TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS stance_maps (
|
||||
consumer TEXT PRIMARY KEY,
|
||||
published INTEGER NOT NULL,
|
||||
path TEXT,
|
||||
shape TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS outbox (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
event_type TEXT NOT NULL,
|
||||
payload TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
drained_at TEXT
|
||||
);
|
||||
"""
|
||||
|
||||
|
||||
class Store:
|
||||
"""This PIP's own transactional store. The outbox lives here, not in audit-core."""
|
||||
|
||||
def __init__(self, path: Path) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self.path = path
|
||||
self.fail_outbox = False
|
||||
self._conn = sqlite3.connect(path)
|
||||
self._conn.row_factory = sqlite3.Row
|
||||
self._conn.execute("PRAGMA foreign_keys=ON")
|
||||
self._conn.executescript(SCHEMA)
|
||||
self._conn.commit()
|
||||
|
||||
def close(self) -> None:
|
||||
self._conn.close()
|
||||
|
||||
def _commit_or_rollback(self) -> None:
|
||||
if self.fail_outbox:
|
||||
self._conn.rollback()
|
||||
raise RuntimeError("outbox insert refused — state change rolled back")
|
||||
self._conn.commit()
|
||||
|
||||
def enqueue(self, event_type: str, payload: dict, created_at: datetime) -> None:
|
||||
if self.fail_outbox:
|
||||
raise RuntimeError("outbox insert refused")
|
||||
self._conn.execute(
|
||||
"INSERT INTO outbox(event_type, payload, created_at) VALUES (?, ?, ?)",
|
||||
(event_type, json.dumps(payload, sort_keys=True), format_instant(created_at)),
|
||||
)
|
||||
|
||||
def put_ladder(self, ladder: Ladder) -> None:
|
||||
ladder.validate()
|
||||
body = _ladder_body(ladder)
|
||||
self._conn.execute(
|
||||
"""
|
||||
INSERT INTO models(id, version, owner, name, body)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id, version) DO UPDATE SET
|
||||
owner=excluded.owner, name=excluded.name, body=excluded.body
|
||||
""",
|
||||
(ladder.id, ladder.version, ladder.owner, ladder.name, json.dumps(body, sort_keys=True)),
|
||||
)
|
||||
self._conn.commit()
|
||||
|
||||
def get_ladder(self, model_id: str, version: str | None = None) -> Ladder | None:
|
||||
if version is None:
|
||||
row = self._conn.execute(
|
||||
"SELECT body FROM models WHERE id = ? ORDER BY version DESC LIMIT 1",
|
||||
(model_id,),
|
||||
).fetchone()
|
||||
else:
|
||||
row = self._conn.execute(
|
||||
"SELECT body FROM models WHERE id = ? AND version = ?",
|
||||
(model_id, version),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
return _ladder_from_body(json.loads(row["body"]))
|
||||
|
||||
def list_ladders(self) -> tuple[Ladder, ...]:
|
||||
rows = self._conn.execute("SELECT body FROM models ORDER BY id, version").fetchall()
|
||||
return tuple(_ladder_from_body(json.loads(row["body"])) for row in rows)
|
||||
|
||||
def put_evidence(self, evidence: Evidence, *, emit_at: datetime) -> None:
|
||||
self._conn.execute(
|
||||
"""
|
||||
INSERT INTO evidence(
|
||||
id, subject, kind, submitted_by, submitted_at, valid_from, valid_until, payload
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
subject=excluded.subject,
|
||||
kind=excluded.kind,
|
||||
submitted_by=excluded.submitted_by,
|
||||
submitted_at=excluded.submitted_at,
|
||||
valid_from=excluded.valid_from,
|
||||
valid_until=excluded.valid_until,
|
||||
payload=excluded.payload
|
||||
""",
|
||||
(
|
||||
evidence.id,
|
||||
evidence.subject,
|
||||
evidence.kind,
|
||||
evidence.submitted_by,
|
||||
format_instant(evidence.submitted_at),
|
||||
format_instant(evidence.valid_from),
|
||||
None if evidence.valid_until is None else format_instant(evidence.valid_until),
|
||||
json.dumps(evidence.payload, sort_keys=True),
|
||||
),
|
||||
)
|
||||
try:
|
||||
self.enqueue(
|
||||
"evidence.submitted",
|
||||
{
|
||||
"evidence_id": evidence.id,
|
||||
"subject": evidence.subject,
|
||||
"kind": evidence.kind,
|
||||
"class": "attributive",
|
||||
},
|
||||
emit_at,
|
||||
)
|
||||
except RuntimeError:
|
||||
self._conn.rollback()
|
||||
raise
|
||||
self._commit_or_rollback()
|
||||
|
||||
def evidence_for(self, subject: str) -> tuple[Evidence, ...]:
|
||||
rows = self._conn.execute(
|
||||
"SELECT * FROM evidence WHERE subject = ? ORDER BY submitted_at, id",
|
||||
(subject,),
|
||||
).fetchall()
|
||||
return tuple(_evidence_from_row(row) for row in rows)
|
||||
|
||||
def put_assessment(self, assessment: Assessment) -> None:
|
||||
explanation = assessment.as_explanation()
|
||||
existed = self._conn.execute(
|
||||
"SELECT 1 FROM assessments WHERE id = ?", (assessment.id,)
|
||||
).fetchone()
|
||||
self._conn.execute(
|
||||
"""
|
||||
INSERT INTO assessments(
|
||||
id, subject, model_id, model_version, level, level_id, evaluated_at, explanation
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
level=excluded.level,
|
||||
level_id=excluded.level_id,
|
||||
explanation=excluded.explanation
|
||||
""",
|
||||
(
|
||||
assessment.id,
|
||||
assessment.subject,
|
||||
assessment.model_id,
|
||||
assessment.model_version,
|
||||
assessment.level,
|
||||
assessment.level_id,
|
||||
format_instant(assessment.evaluated_at),
|
||||
json.dumps(explanation, sort_keys=True),
|
||||
),
|
||||
)
|
||||
if existed is None:
|
||||
try:
|
||||
self.enqueue(
|
||||
"assessment.recorded",
|
||||
{
|
||||
"assessment_id": assessment.id,
|
||||
"subject": assessment.subject,
|
||||
"model_id": assessment.model_id,
|
||||
"level_id": assessment.level_id,
|
||||
"class": "load-bearing",
|
||||
"bound": (
|
||||
"archive proves records were not altered or truncated after arrival; "
|
||||
"it does not prove an event never sent"
|
||||
),
|
||||
},
|
||||
assessment.evaluated_at,
|
||||
)
|
||||
except RuntimeError:
|
||||
self._conn.rollback()
|
||||
raise
|
||||
self._commit_or_rollback()
|
||||
|
||||
def assessments_for(self, subject: str, model_id: str | None = None) -> tuple[Assessment, ...]:
|
||||
if model_id is None:
|
||||
rows = self._conn.execute(
|
||||
"SELECT explanation FROM assessments WHERE subject = ? ORDER BY evaluated_at, id",
|
||||
(subject,),
|
||||
).fetchall()
|
||||
else:
|
||||
rows = self._conn.execute(
|
||||
"""
|
||||
SELECT explanation FROM assessments
|
||||
WHERE subject = ? AND model_id = ?
|
||||
ORDER BY evaluated_at, id
|
||||
""",
|
||||
(subject, model_id),
|
||||
).fetchall()
|
||||
return tuple(_assessment_from_explanation(json.loads(row["explanation"])) for row in rows)
|
||||
|
||||
def put_gap(self, gap: Gap, *, emit_at: datetime) -> None:
|
||||
self._conn.execute(
|
||||
"""
|
||||
INSERT INTO gaps(
|
||||
id, capability, intended_owner, blocked_on, review, state,
|
||||
owner_status, declared_by, mark, notes
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
capability=excluded.capability,
|
||||
intended_owner=excluded.intended_owner,
|
||||
blocked_on=excluded.blocked_on,
|
||||
review=excluded.review,
|
||||
state=excluded.state,
|
||||
owner_status=excluded.owner_status,
|
||||
declared_by=excluded.declared_by,
|
||||
mark=excluded.mark,
|
||||
notes=excluded.notes
|
||||
""",
|
||||
(
|
||||
gap.id,
|
||||
gap.capability,
|
||||
gap.intended_owner,
|
||||
gap.blocked_on,
|
||||
gap.review,
|
||||
gap.state,
|
||||
gap.owner_status,
|
||||
gap.declared_by,
|
||||
gap.mark,
|
||||
gap.notes,
|
||||
),
|
||||
)
|
||||
try:
|
||||
self.enqueue(
|
||||
"gap.mutated",
|
||||
{"gap_id": gap.id, "state": gap.state, "owner_status": gap.owner_status, "class": "attributive"},
|
||||
emit_at,
|
||||
)
|
||||
except RuntimeError:
|
||||
self._conn.rollback()
|
||||
raise
|
||||
self._commit_or_rollback()
|
||||
|
||||
def list_gaps(self) -> tuple[Gap, ...]:
|
||||
rows = self._conn.execute("SELECT * FROM gaps ORDER BY id").fetchall()
|
||||
return tuple(_gap_from_row(row) for row in rows)
|
||||
|
||||
def get_gap(self, gap_id: str) -> Gap | None:
|
||||
row = self._conn.execute("SELECT * FROM gaps WHERE id = ?", (gap_id,)).fetchone()
|
||||
return None if row is None else _gap_from_row(row)
|
||||
|
||||
def put_stance(self, stance: StanceMap) -> None:
|
||||
self._conn.execute(
|
||||
"""
|
||||
INSERT INTO stance_maps(consumer, published, path, shape)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(consumer) DO UPDATE SET
|
||||
published=excluded.published, path=excluded.path, shape=excluded.shape
|
||||
""",
|
||||
(stance.consumer, 1 if stance.published else 0, stance.path, stance.shape),
|
||||
)
|
||||
self._conn.commit()
|
||||
|
||||
def list_stances(self) -> tuple[StanceMap, ...]:
|
||||
rows = self._conn.execute("SELECT * FROM stance_maps ORDER BY consumer").fetchall()
|
||||
return tuple(
|
||||
StanceMap(
|
||||
consumer=row["consumer"],
|
||||
published=bool(row["published"]),
|
||||
path=row["path"],
|
||||
shape=row["shape"],
|
||||
)
|
||||
for row in rows
|
||||
)
|
||||
|
||||
def pending_outbox(self) -> tuple[dict, ...]:
|
||||
rows = self._conn.execute(
|
||||
"SELECT id, event_type, payload, created_at FROM outbox WHERE drained_at IS NULL ORDER BY id"
|
||||
).fetchall()
|
||||
return tuple(
|
||||
{
|
||||
"id": row["id"],
|
||||
"event_type": row["event_type"],
|
||||
"payload": json.loads(row["payload"]),
|
||||
"created_at": row["created_at"],
|
||||
}
|
||||
for row in rows
|
||||
)
|
||||
|
||||
def drain_outbox(self, row_id: int, drained_at: datetime) -> None:
|
||||
self._conn.execute(
|
||||
"UPDATE outbox SET drained_at = ? WHERE id = ?",
|
||||
(format_instant(drained_at), row_id),
|
||||
)
|
||||
self._conn.commit()
|
||||
|
||||
def heartbeat(self, instant: datetime) -> None:
|
||||
self.enqueue(
|
||||
"maturity-engine.heartbeat",
|
||||
{
|
||||
"class": "load-bearing",
|
||||
"form": "heartbeat",
|
||||
"note": "positive claim that can itself go missing; not a rate",
|
||||
},
|
||||
instant,
|
||||
)
|
||||
self._commit_or_rollback()
|
||||
|
||||
|
||||
def _ladder_body(ladder: Ladder) -> dict:
|
||||
return {
|
||||
"id": ladder.id,
|
||||
"version": ladder.version,
|
||||
"owner": ladder.owner,
|
||||
"name": ladder.name,
|
||||
"levels": [
|
||||
{
|
||||
"index": level.index,
|
||||
"id": level.id,
|
||||
"name": level.name,
|
||||
"criteria": [
|
||||
{
|
||||
"id": criterion.id,
|
||||
"evidence_kind": criterion.evidence_kind,
|
||||
"description": criterion.description,
|
||||
}
|
||||
for criterion in level.criteria
|
||||
],
|
||||
}
|
||||
for level in ladder.levels
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _ladder_from_body(body: dict) -> Ladder:
|
||||
levels = []
|
||||
for level in body["levels"]:
|
||||
criteria = tuple(
|
||||
Criterion(
|
||||
id=item["id"],
|
||||
evidence_kind=item["evidence_kind"],
|
||||
description=item["description"],
|
||||
)
|
||||
for item in level["criteria"]
|
||||
)
|
||||
levels.append(Level(index=level["index"], id=level["id"], name=level["name"], criteria=criteria))
|
||||
return Ladder(
|
||||
id=body["id"],
|
||||
version=body["version"],
|
||||
owner=body["owner"],
|
||||
name=body["name"],
|
||||
levels=tuple(levels),
|
||||
)
|
||||
|
||||
|
||||
def _evidence_from_row(row: sqlite3.Row) -> Evidence:
|
||||
until = row["valid_until"]
|
||||
return Evidence(
|
||||
id=row["id"],
|
||||
subject=row["subject"],
|
||||
kind=row["kind"],
|
||||
submitted_by=row["submitted_by"],
|
||||
submitted_at=parse_instant(row["submitted_at"]),
|
||||
valid_from=parse_instant(row["valid_from"]),
|
||||
valid_until=None if until is None else parse_instant(until),
|
||||
payload=json.loads(row["payload"]),
|
||||
)
|
||||
|
||||
|
||||
def _gap_from_row(row: sqlite3.Row) -> Gap:
|
||||
return Gap(
|
||||
id=row["id"],
|
||||
capability=row["capability"],
|
||||
intended_owner=row["intended_owner"],
|
||||
blocked_on=row["blocked_on"],
|
||||
review=row["review"],
|
||||
state=row["state"],
|
||||
owner_status=row["owner_status"],
|
||||
declared_by=row["declared_by"],
|
||||
mark=row["mark"],
|
||||
notes=row["notes"],
|
||||
)
|
||||
|
||||
|
||||
def _assessment_from_explanation(body: dict) -> Assessment:
|
||||
return Assessment(
|
||||
id=body["assessment_id"],
|
||||
subject=body["subject"],
|
||||
model_id=body["model_id"],
|
||||
model_version=body["model_version"],
|
||||
level=body["level"],
|
||||
level_id=body["level_id"],
|
||||
evaluated_at=parse_instant(body["evaluated_at"]),
|
||||
met=tuple(
|
||||
MetCriterion(
|
||||
criterion_id=item["criterion_id"],
|
||||
evidence_kind=item["evidence_kind"],
|
||||
evidence_ids=tuple(item["evidence_ids"]),
|
||||
)
|
||||
for item in body["met"]
|
||||
),
|
||||
unmet=tuple(
|
||||
UnmetCriterion(
|
||||
criterion_id=item["criterion_id"],
|
||||
evidence_kind=item["evidence_kind"],
|
||||
description=item["description"],
|
||||
)
|
||||
for item in body["unmet"]
|
||||
),
|
||||
next_level_id=body["next_level_id"],
|
||||
expired_evidence_ids=tuple(body["expired_evidence_ids"]),
|
||||
)
|
||||
19
src/maturity_engine/timeutil.py
Normal file
19
src/maturity_engine/timeutil.py
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
|
||||
def parse_instant(value: str) -> datetime:
|
||||
text = value.strip()
|
||||
if text.endswith("Z"):
|
||||
text = text[:-1] + "+00:00"
|
||||
instant = datetime.fromisoformat(text)
|
||||
if instant.tzinfo is None:
|
||||
raise ValueError("evaluation instant must be timezone-aware")
|
||||
return instant.astimezone(timezone.utc)
|
||||
|
||||
|
||||
def format_instant(instant: datetime) -> str:
|
||||
if instant.tzinfo is None:
|
||||
raise ValueError("evaluation instant must be timezone-aware")
|
||||
return instant.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
Loading…
Add table
Add a link
Reference in a new issue