Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a10780-6d41-7952-9718-dfbc1f41236f
233 lines
12 KiB
Python
233 lines
12 KiB
Python
"""Internal lifecycle persistence, independent of browser and HTTP adapters.
|
|
|
|
Like Store, this repository is NOT an authenticated service. Its caller must
|
|
authenticate identity and obtain current access-engine entitlement before any
|
|
read or write. Attribution is imported context, never proof of authentication.
|
|
No production route exposes this repository yet.
|
|
"""
|
|
|
|
from dataclasses import dataclass
|
|
from datetime import datetime
|
|
import hashlib
|
|
import json
|
|
import re
|
|
import uuid
|
|
|
|
from .disposition import Actor, ActorKind
|
|
from .memo import Memo
|
|
from .records import dumps
|
|
from .store import Conflict, EvidenceUnavailable, Store, _now, _text
|
|
|
|
|
|
SCHEMA = """
|
|
CREATE TABLE lifecycle_records (
|
|
memo_id TEXT PRIMARY KEY, tenant TEXT NOT NULL,
|
|
registered_at TEXT NOT NULL);
|
|
CREATE TABLE lifecycle_events (
|
|
sequence INTEGER PRIMARY KEY AUTOINCREMENT, id TEXT NOT NULL UNIQUE,
|
|
tenant TEXT NOT NULL, memo_id TEXT NOT NULL REFERENCES lifecycle_records(memo_id),
|
|
version INTEGER NOT NULL, kind TEXT NOT NULL, actor TEXT NOT NULL,
|
|
at TEXT NOT NULL, body TEXT NOT NULL,
|
|
FOREIGN KEY(memo_id,version) REFERENCES memos(id,version));
|
|
CREATE INDEX lifecycle_events_record ON lifecycle_events(tenant,memo_id,sequence);
|
|
CREATE TABLE lifecycle_operations (
|
|
tenant TEXT NOT NULL, actor TEXT NOT NULL, operation TEXT NOT NULL,
|
|
key TEXT NOT NULL, digest TEXT NOT NULL,
|
|
event_id TEXT NOT NULL REFERENCES lifecycle_events(id),
|
|
PRIMARY KEY(tenant,actor,operation,key));
|
|
CREATE TABLE lifecycle_pending (
|
|
event_id TEXT PRIMARY KEY REFERENCES lifecycle_events(id));
|
|
"""
|
|
# Pending events are intentionally not sent to audit-core under invented types.
|
|
# A later delivery adapter must retain these until a receiver contract is admitted.
|
|
for _table in ("lifecycle_records", "lifecycle_events", "lifecycle_operations"):
|
|
for _operation in ("UPDATE", "DELETE"):
|
|
SCHEMA += (f"CREATE TRIGGER immutable_{_table}_{_operation} BEFORE {_operation} ON {_table} "
|
|
"BEGIN SELECT RAISE(ABORT,'immutable lifecycle record'); END;\n")
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Attribution:
|
|
tenant: str
|
|
actor: Actor
|
|
|
|
def __post_init__(self):
|
|
_text(self.tenant, "tenant")
|
|
_text(self.actor.sub, "actor")
|
|
if not isinstance(self.actor.kind, ActorKind):
|
|
raise ValueError("invalid actor kind")
|
|
|
|
|
|
class LifecycleRepository:
|
|
"""Tenant-scoped storage primitives; authorization belongs to the caller."""
|
|
|
|
def __init__(self, store: Store):
|
|
self.store = store
|
|
|
|
@staticmethod
|
|
def _record(db, tenant, memo_id):
|
|
if not db.execute("SELECT 1 FROM lifecycle_records WHERE tenant=? AND memo_id=?",
|
|
(tenant, memo_id)).fetchone():
|
|
raise EvidenceUnavailable("lifecycle record unavailable")
|
|
|
|
@staticmethod
|
|
def _request(context, operation, key, payload):
|
|
_text(key, "idempotency key")
|
|
actor = dumps(context.actor)
|
|
encoded = dumps(payload).encode()
|
|
if len(encoded) > 256 * 1024:
|
|
raise ValueError("lifecycle command too large")
|
|
return (context.tenant, actor, operation, key), hashlib.sha256(encoded).hexdigest()
|
|
|
|
@staticmethod
|
|
def _replay(db, scope, digest):
|
|
row = db.execute("SELECT digest,event_id FROM lifecycle_operations "
|
|
"WHERE tenant=? AND actor=? AND operation=? AND key=?", scope).fetchone()
|
|
if row:
|
|
if row["digest"] != digest:
|
|
raise Conflict("idempotency key has different command content")
|
|
return row["event_id"]
|
|
return None
|
|
|
|
@staticmethod
|
|
def _append(db, scope, digest, memo, kind, body, policy_id=None):
|
|
event_id = str(uuid.uuid4())
|
|
if policy_id is not None:
|
|
body = {**body, "policy_observation_id": policy_id}
|
|
db.execute("INSERT INTO lifecycle_events "
|
|
"(id,tenant,memo_id,version,kind,actor,at,body) VALUES (?,?,?,?,?,?,?,?)",
|
|
(event_id, scope[0], memo.id, memo.version, kind, scope[1], _now(), dumps(body)))
|
|
db.execute("INSERT INTO lifecycle_pending VALUES (?)", (event_id,))
|
|
db.execute("INSERT INTO lifecycle_operations VALUES (?,?,?,?,?,?)", (*scope, digest, event_id))
|
|
return event_id
|
|
|
|
def register(self, context: Attribution, memo: Memo, *, key: str, guard=None, documents=None) -> str:
|
|
"""Register a complete, unbound version-one memo without creating approval."""
|
|
if memo.version != 1 or memo.sealed or memo.approval_id is not None:
|
|
raise ValueError("registration requires an unbound version-one memo")
|
|
from .lifecycle_commands import decode_documents
|
|
documents = [] if documents is None else documents
|
|
decoded = decode_documents(documents, memo)
|
|
payload = {"memo": memo, "documents": documents} if documents else memo
|
|
scope, digest = self._request(context, "register", key, payload)
|
|
with self.store._transaction() as db:
|
|
policy_id = guard(db) if guard is not None else None
|
|
replay = self._replay(db, scope, digest)
|
|
if replay:
|
|
return replay
|
|
# Never take over an existing intake/imported memo, even with identical content.
|
|
if db.execute("SELECT 1 FROM memos WHERE id=?", (memo.id,)).fetchone():
|
|
raise Conflict("memo identifier already exists")
|
|
if {item.hash for item in memo.packet} - {item[0] for item in decoded}:
|
|
raise EvidenceUnavailable("registration requires packet content in the command")
|
|
for document in decoded:
|
|
self.store._put_document(db, *document)
|
|
self.store._save_memo(db, memo)
|
|
db.execute("INSERT INTO lifecycle_records VALUES (?,?,?)", (memo.id, context.tenant, _now()))
|
|
return self._append(db, scope, digest, memo, "registered", {"memo_version": memo.version}, policy_id)
|
|
|
|
def revise(self, context: Attribution, memo: Memo, *, expected_version: int,
|
|
summary: str, key: str, guard=None, documents=None) -> str:
|
|
"""Compare-and-append; retain existing Store guards on outstanding entries."""
|
|
if type(expected_version) is not int or expected_version < 1:
|
|
raise ValueError("expected version must be a positive integer")
|
|
if not isinstance(summary, str) or not summary.strip() or len(summary) > 4096:
|
|
raise ValueError("revision summary required, at most 4096 characters")
|
|
if memo.version != expected_version + 1 or memo.sealed or memo.approval_id is not None:
|
|
raise ValueError("revision requires the next unbound memo version")
|
|
from .lifecycle_commands import decode_documents
|
|
documents = [] if documents is None else documents
|
|
decoded = decode_documents(documents, memo)
|
|
payload = {"memo": memo, "expected_version": expected_version, "summary": summary}
|
|
if documents:
|
|
payload["documents"] = documents
|
|
scope, digest = self._request(context, "revise", key, payload)
|
|
with self.store._transaction() as db:
|
|
policy_id = guard(db) if guard is not None else None
|
|
self._record(db, context.tenant, memo.id)
|
|
replay = self._replay(db, scope, digest)
|
|
if replay:
|
|
return replay
|
|
current = self.store._memo(db, memo.id)
|
|
if current.version != expected_version:
|
|
raise Conflict("memo changed since it was retrieved")
|
|
if current.sealed or current.approval_id is not None:
|
|
raise Conflict("bound memo requires the review integration path")
|
|
available = {item.hash for item in current.packet} | {item[0] for item in decoded}
|
|
if {item.hash for item in memo.packet} - available:
|
|
raise EvidenceUnavailable("revision requires new packet content in the command")
|
|
for document in decoded:
|
|
self.store._put_document(db, *document)
|
|
self.store._save_memo(db, memo)
|
|
return self._append(db, scope, digest, memo, "revised", {"summary": summary}, policy_id)
|
|
|
|
def annotate(self, context: Attribution, memo_id: str, version: int, *, kind: str,
|
|
body: dict, key: str, guard=None) -> str:
|
|
"""Append a source, advisory evaluation or discussion; never a disposition."""
|
|
self._validate_annotation(kind, body)
|
|
if type(version) is not int or version < 1:
|
|
raise ValueError("version must be a positive integer")
|
|
scope, digest = self._request(context, kind, key,
|
|
{"memo_id": memo_id, "version": version, "body": body})
|
|
with self.store._transaction() as db:
|
|
policy_id = guard(db) if guard is not None else None
|
|
self._record(db, context.tenant, memo_id)
|
|
replay = self._replay(db, scope, digest)
|
|
if replay:
|
|
return replay
|
|
memo = self.store._memo(db, memo_id, version)
|
|
if kind == "evaluation":
|
|
for source in body["source_ids"]:
|
|
if not db.execute("SELECT 1 FROM lifecycle_events WHERE id=? AND tenant=? "
|
|
"AND memo_id=? AND version=? AND kind='source'",
|
|
(source, context.tenant, memo_id, version)).fetchone():
|
|
raise EvidenceUnavailable("evaluation source unavailable for this revision")
|
|
return self._append(db, scope, digest, memo, kind, body, policy_id)
|
|
|
|
@staticmethod
|
|
def _validate_annotation(kind, body):
|
|
fields = {
|
|
"comment": ({"text"}, {"text", "locator"}),
|
|
"source": ({"reference", "retrieved_at", "digest", "summary"},
|
|
{"reference", "retrieved_at", "digest", "summary"}),
|
|
"evaluation": ({"assessment", "source_ids", "uncertainty"},
|
|
{"assessment", "source_ids", "uncertainty"}),
|
|
}
|
|
if kind not in fields or not isinstance(body, dict):
|
|
raise ValueError("unsupported annotation")
|
|
required, allowed = fields[kind]
|
|
if not required <= body.keys() or body.keys() - allowed:
|
|
raise ValueError("invalid annotation fields")
|
|
for field, value in body.items():
|
|
if field == "source_ids":
|
|
if not isinstance(value, list) or len(value) > 100:
|
|
raise ValueError("source_ids must be a bounded list")
|
|
for source in value:
|
|
_text(source, "source id")
|
|
elif not isinstance(value, str) or not value.strip() or len(value) > 32768:
|
|
raise ValueError("annotation fields must be bounded non-empty text")
|
|
if kind == "source":
|
|
if not re.fullmatch(r"sha256:[a-f0-9]{64}", body["digest"]):
|
|
raise ValueError("invalid source digest")
|
|
try:
|
|
retrieved_at = datetime.fromisoformat(body["retrieved_at"].replace("Z", "+00:00"))
|
|
except ValueError:
|
|
raise ValueError("invalid source retrieval time") from None
|
|
if retrieved_at.tzinfo is None:
|
|
raise ValueError("source retrieval time requires timezone")
|
|
|
|
def retrieve(self, tenant: str, memo_id: str, version: int | None = None) -> Memo:
|
|
with self.store._connection() as db:
|
|
self._record(db, tenant, memo_id)
|
|
return self.store._memo(db, memo_id, version)
|
|
|
|
def events(self, tenant: str, memo_id: str, *, after: int = 0, limit: int = 100) -> list[dict]:
|
|
if type(after) is not int or after < 0 or type(limit) is not int or not 1 <= limit <= 100:
|
|
raise ValueError("invalid event page")
|
|
with self.store._connection() as db:
|
|
self._record(db, tenant, memo_id)
|
|
rows = db.execute("SELECT * FROM lifecycle_events WHERE tenant=? AND memo_id=? "
|
|
"AND sequence>? ORDER BY sequence LIMIT ?",
|
|
(tenant, memo_id, after, limit)).fetchall()
|
|
return [{**dict(row), "actor": json.loads(row["actor"]), "body": json.loads(row["body"])}
|
|
for row in rows]
|