generated from coulomb/repo-seed
Add canon reset and reingest guardrails
This commit is contained in:
@@ -9,10 +9,12 @@ from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from .canon import edge_canon_mapping, node_canon_mapping
|
||||
from .canon import DISPLAY_ONLY_EDGE_TYPES, edge_canon_mapping, node_canon_mapping
|
||||
from .loader import repo_root
|
||||
from .schema_validation import draft202012_validator
|
||||
|
||||
RESET_CONFIRMATION_TOKEN = "RESET-RAILIANCE-FABRIC-GRAPH-DATA"
|
||||
|
||||
|
||||
class RegistryError(Exception):
|
||||
def __init__(self, message: str, status_code: int = 400) -> None:
|
||||
@@ -108,6 +110,15 @@ class RegistryStore:
|
||||
|
||||
create index if not exists idx_libraries_purl
|
||||
on libraries(purl);
|
||||
|
||||
create table if not exists registry_reset_events (
|
||||
id integer primary key autoincrement,
|
||||
created_at text not null,
|
||||
reason text not null,
|
||||
archive_path text,
|
||||
archive_sha256 text not null,
|
||||
dropped_counts_json text not null
|
||||
);
|
||||
"""
|
||||
)
|
||||
|
||||
@@ -726,6 +737,7 @@ class RegistryStore:
|
||||
"discovery_snapshots": db.execute("select count(*) from discovery_snapshots").fetchone()[0],
|
||||
"artifacts": db.execute("select count(*) from artifacts").fetchone()[0],
|
||||
"libraries": db.execute("select count(*) from libraries").fetchone()[0],
|
||||
"registry_reset_events": db.execute("select count(*) from registry_reset_events").fetchone()[0],
|
||||
}
|
||||
latest = [
|
||||
{
|
||||
@@ -748,6 +760,120 @@ class RegistryStore:
|
||||
"latest_discovery_snapshots": latest_discovery,
|
||||
}
|
||||
|
||||
def reset_archive(self) -> dict[str, Any]:
|
||||
with self._connect() as db:
|
||||
snapshot_rows = db.execute(
|
||||
"""
|
||||
select id, repo_slug, commit_sha, generated_at, graph_json, created_at
|
||||
from snapshots
|
||||
order by repo_slug, id
|
||||
"""
|
||||
).fetchall()
|
||||
discovery_rows = db.execute(
|
||||
"""
|
||||
select id, repo_slug, commit_sha, profile, generated_at,
|
||||
snapshot_json, accepted_graph_snapshot_id, created_at
|
||||
from discovery_snapshots
|
||||
order by repo_slug, profile, id
|
||||
"""
|
||||
).fetchall()
|
||||
artifact_rows = db.execute(
|
||||
"""
|
||||
select id, repo_slug, target_id, target_kind, artifact_type, name, uri,
|
||||
media_type, digest, version, metadata_json, created_at
|
||||
from artifacts
|
||||
order by repo_slug, id
|
||||
"""
|
||||
).fetchall()
|
||||
library_rows = db.execute(
|
||||
"""
|
||||
select id, repo_slug, bom_ref, component_type, name, version, purl, scope,
|
||||
licenses_json, hashes_json, metadata_json, created_at
|
||||
from libraries
|
||||
order by repo_slug, id
|
||||
"""
|
||||
).fetchall()
|
||||
reset_rows = db.execute(
|
||||
"""
|
||||
select id, created_at, reason, archive_path, archive_sha256, dropped_counts_json
|
||||
from registry_reset_events
|
||||
order by id
|
||||
"""
|
||||
).fetchall()
|
||||
return {
|
||||
"apiVersion": "railiance.fabric/v1alpha1",
|
||||
"kind": "RegistryResetArchive",
|
||||
"generated_at": _utc_now(),
|
||||
"source": {"database": str(self.path)},
|
||||
"counts": self.status()["counts"],
|
||||
"combined_graph": self.combined_graph(),
|
||||
"repositories": self.list_repositories(),
|
||||
"snapshots": [_snapshot_dict(row) for row in snapshot_rows],
|
||||
"discovery_snapshots": [_discovery_snapshot_dict(row) for row in discovery_rows],
|
||||
"artifacts": [_artifact_dict(row) for row in artifact_rows],
|
||||
"libraries": [_library_dict(row) for row in library_rows],
|
||||
"reset_events": [_reset_event_dict(row) for row in reset_rows],
|
||||
"rollback": {
|
||||
"limits": (
|
||||
"This archive is a JSON evidence bundle, not an automatic SQLite restore. "
|
||||
"Use it to inspect and manually reinsert prior registry graph data if needed."
|
||||
),
|
||||
"post_reset_source_of_truth": (
|
||||
"Repository registrations remain in the registry. Graph snapshots, discovery "
|
||||
"snapshots, artifacts, and library inventory must be recreated by reingesting "
|
||||
"registered/local repositories with the canon-aligned scanner and graph model."
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
def reset_graph_data(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
confirm = _required_text(payload, "confirm")
|
||||
if confirm != RESET_CONFIRMATION_TOKEN:
|
||||
raise RegistryError(
|
||||
f"reset requires confirm={RESET_CONFIRMATION_TOKEN!r}",
|
||||
400,
|
||||
)
|
||||
reason = _required_text(payload, "reason")
|
||||
archive_sha256 = _required_text(payload, "archive_sha256")
|
||||
archive_path = _optional_text(payload, "archive_path")
|
||||
now = _utc_now()
|
||||
with self._connect() as db:
|
||||
counts = _resettable_counts(db)
|
||||
cursor = db.execute(
|
||||
"""
|
||||
insert into registry_reset_events (
|
||||
created_at, reason, archive_path, archive_sha256, dropped_counts_json
|
||||
)
|
||||
values (?, ?, ?, ?, ?)
|
||||
""",
|
||||
(now, reason, archive_path, archive_sha256, json.dumps(counts, sort_keys=True)),
|
||||
)
|
||||
event_id = int(cursor.lastrowid)
|
||||
db.execute("delete from discovery_snapshots")
|
||||
db.execute("delete from snapshots")
|
||||
db.execute("delete from artifacts")
|
||||
db.execute("delete from libraries")
|
||||
event = self.get_reset_event(event_id)
|
||||
return {
|
||||
**event,
|
||||
"confirm": confirm,
|
||||
"repositories_preserved": len(self.list_repositories()),
|
||||
}
|
||||
|
||||
def get_reset_event(self, event_id: int) -> dict[str, Any]:
|
||||
with self._connect() as db:
|
||||
row = db.execute(
|
||||
"""
|
||||
select id, created_at, reason, archive_path, archive_sha256, dropped_counts_json
|
||||
from registry_reset_events
|
||||
where id = ?
|
||||
""",
|
||||
(event_id,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise RegistryError(f"reset event not found: {event_id}", 404)
|
||||
return _reset_event_dict(row)
|
||||
|
||||
def latest_discovery_snapshots(self, profile: str | None = None) -> list[dict[str, Any]]:
|
||||
params: list[Any] = []
|
||||
where = ""
|
||||
@@ -794,6 +920,9 @@ def validate_graph_export(graph: dict[str, Any]) -> None:
|
||||
error = errors[0]
|
||||
location = ".".join(str(part) for part in error.path) or "<root>"
|
||||
raise RegistryError(f"invalid FabricGraphExport at {location}: {error.message}")
|
||||
canon_errors = _graph_canon_metadata_errors(graph)
|
||||
if canon_errors:
|
||||
raise RegistryError(f"invalid FabricGraphExport canon metadata: {canon_errors[0]}")
|
||||
|
||||
|
||||
def validate_discovery_snapshot(snapshot: dict[str, Any]) -> None:
|
||||
@@ -804,6 +933,88 @@ def validate_discovery_snapshot(snapshot: dict[str, Any]) -> None:
|
||||
error = errors[0]
|
||||
location = ".".join(str(part) for part in error.path) or "<root>"
|
||||
raise RegistryError(f"invalid FabricDiscoverySnapshot at {location}: {error.message}")
|
||||
canon_errors = _discovery_canon_metadata_errors(snapshot)
|
||||
if canon_errors:
|
||||
raise RegistryError(f"invalid FabricDiscoverySnapshot canon metadata: {canon_errors[0]}")
|
||||
|
||||
|
||||
def _graph_canon_metadata_errors(graph: dict[str, Any]) -> list[str]:
|
||||
errors: list[str] = []
|
||||
for index, node in enumerate(graph.get("nodes", [])):
|
||||
if not isinstance(node, dict):
|
||||
continue
|
||||
if _has_any(node, ("canon_category", "canon_anchor", "mapping_fit", "evidence_state")):
|
||||
_require_fields(
|
||||
errors,
|
||||
f"nodes[{index}]",
|
||||
node,
|
||||
("canon_category", "mapping_fit", "evidence_state"),
|
||||
)
|
||||
for index, edge in enumerate(graph.get("edges", [])):
|
||||
if not isinstance(edge, dict):
|
||||
continue
|
||||
_validate_edge_canon_metadata(errors, f"edges[{index}]", edge, type_field="type")
|
||||
return errors
|
||||
|
||||
|
||||
def _discovery_canon_metadata_errors(snapshot: dict[str, Any]) -> list[str]:
|
||||
errors: list[str] = []
|
||||
candidates = snapshot.get("candidates") if isinstance(snapshot.get("candidates"), dict) else {}
|
||||
for index, node in enumerate(candidates.get("nodes", [])):
|
||||
if not isinstance(node, dict):
|
||||
continue
|
||||
_require_fields(
|
||||
errors,
|
||||
f"candidates.nodes[{index}]",
|
||||
node,
|
||||
("canon_category", "mapping_fit", "evidence_state"),
|
||||
)
|
||||
for index, edge in enumerate(candidates.get("edges", [])):
|
||||
if not isinstance(edge, dict):
|
||||
continue
|
||||
_require_fields(
|
||||
errors,
|
||||
f"candidates.edges[{index}]",
|
||||
edge,
|
||||
("mapping_fit", "display_only", "evidence_state"),
|
||||
)
|
||||
_validate_edge_canon_metadata(errors, f"candidates.edges[{index}]", edge, type_field="edge_type")
|
||||
return errors
|
||||
|
||||
|
||||
def _validate_edge_canon_metadata(
|
||||
errors: list[str],
|
||||
path: str,
|
||||
edge: dict[str, Any],
|
||||
*,
|
||||
type_field: str,
|
||||
) -> None:
|
||||
edge_type = str(edge.get(type_field) or "")
|
||||
has_canon_fields = _has_any(
|
||||
edge,
|
||||
("canonical_type", "canon_anchor", "mapping_fit", "display_only", "evidence_state"),
|
||||
)
|
||||
if has_canon_fields:
|
||||
_require_fields(errors, path, edge, ("mapping_fit", "display_only", "evidence_state"))
|
||||
if edge_type in DISPLAY_ONLY_EDGE_TYPES and edge.get("display_only") is not True:
|
||||
errors.append(f"{path} uses display-only edge type {edge_type!r} without display_only=true")
|
||||
if edge.get("display_only") is True and edge_type and not has_canon_fields:
|
||||
errors.append(f"{path} is display-only but lacks canon metadata")
|
||||
|
||||
|
||||
def _has_any(item: dict[str, Any], fields: tuple[str, ...]) -> bool:
|
||||
return any(field in item for field in fields)
|
||||
|
||||
|
||||
def _require_fields(
|
||||
errors: list[str],
|
||||
path: str,
|
||||
item: dict[str, Any],
|
||||
fields: tuple[str, ...],
|
||||
) -> None:
|
||||
for field in fields:
|
||||
if field not in item or item.get(field) in (None, ""):
|
||||
errors.append(f"{path} missing required canon metadata field {field!r}")
|
||||
|
||||
|
||||
def providers(graph: dict[str, Any], capability: str) -> list[dict[str, Any]]:
|
||||
@@ -1269,6 +1480,26 @@ def _row_dict(row: sqlite3.Row) -> dict[str, Any]:
|
||||
return {key: row[key] for key in row.keys()}
|
||||
|
||||
|
||||
def _resettable_counts(db: sqlite3.Connection) -> dict[str, int]:
|
||||
return {
|
||||
"snapshots": int(db.execute("select count(*) from snapshots").fetchone()[0]),
|
||||
"discovery_snapshots": int(db.execute("select count(*) from discovery_snapshots").fetchone()[0]),
|
||||
"artifacts": int(db.execute("select count(*) from artifacts").fetchone()[0]),
|
||||
"libraries": int(db.execute("select count(*) from libraries").fetchone()[0]),
|
||||
}
|
||||
|
||||
|
||||
def _reset_event_dict(row: sqlite3.Row) -> dict[str, Any]:
|
||||
return {
|
||||
"id": row["id"],
|
||||
"created_at": row["created_at"],
|
||||
"reason": row["reason"],
|
||||
"archive_path": row["archive_path"],
|
||||
"archive_sha256": row["archive_sha256"],
|
||||
"dropped_counts": json.loads(row["dropped_counts_json"]),
|
||||
}
|
||||
|
||||
|
||||
def _artifact_dict(row: sqlite3.Row) -> dict[str, Any]:
|
||||
return {
|
||||
"id": row["id"],
|
||||
|
||||
Reference in New Issue
Block a user