Add lifecycle registration and revisions with atomic packet uploads

Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a10780-6d41-7952-9718-dfbc1f41236f
This commit is contained in:
tegwick 2026-10-04 19:31:39 +02:00
parent 7ce1e589f7
commit ee472acc87
10 changed files with 332 additions and 35 deletions

View file

@ -101,11 +101,15 @@ class LifecycleRepository:
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) -> str:
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")
scope, digest = self._request(context, "register", key, 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)
@ -114,12 +118,16 @@ class LifecycleRepository:
# 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) -> str:
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")
@ -127,8 +135,13 @@ class LifecycleRepository:
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")
scope, digest = self._request(context, "revise", key,
{"memo": memo, "expected_version": expected_version, "summary": summary})
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)
@ -140,6 +153,11 @@ class LifecycleRepository:
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)

View file

@ -0,0 +1,117 @@
"""Closed JSON decoding of the existing Memo model, not a second memo schema."""
import base64
from dataclasses import MISSING, fields, is_dataclass
from enum import Enum
import hashlib
import re
from types import UnionType
from typing import get_args, get_origin, get_type_hints
from .intake import check_one_question, scan_secret_markers
from .memo import Memo
def _decode(value, model, depth=0):
if depth > 16:
raise ValueError("command nesting too deep")
origin, args = get_origin(model), get_args(model)
if origin is UnionType:
if value is None and type(None) in args:
return None
return _decode(value, next(arg for arg in args if arg is not type(None)), depth + 1)
if is_dataclass(model):
if not isinstance(value, dict):
raise ValueError("object required")
definitions = {field.name: field for field in fields(model)}
if value.keys() - definitions.keys():
raise ValueError("unknown field")
required = {name for name, field in definitions.items()
if field.default is MISSING and field.default_factory is MISSING}
if not required <= value.keys():
raise ValueError("missing field")
hints = get_type_hints(model)
return model(**{key: _decode(item, hints[key], depth + 1) for key, item in value.items()})
if origin is tuple:
if not isinstance(value, list) or len(value) > 1000:
raise ValueError("bounded array required")
return tuple(_decode(item, args[0], depth + 1) for item in value)
if isinstance(model, type) and issubclass(model, Enum):
return model(value)
if model is dict:
if not isinstance(value, dict):
raise ValueError("object required")
_json(value, depth + 1)
return value
if model in (str, bool, int):
if type(value) is not model or (model is str and len(value) > 100000):
raise ValueError("invalid field type or size")
return value
raise ValueError("unsupported model field")
def _json(value, depth):
if depth > 16:
raise ValueError("command nesting too deep")
if isinstance(value, dict):
for key, item in value.items():
if not isinstance(key, str):
raise ValueError("invalid object key")
_json(item, depth + 1)
elif isinstance(value, list):
for item in value:
_json(item, depth + 1)
elif value is not None and type(value) not in (str, int, bool):
raise ValueError("unsupported JSON value")
def decode_memo(data):
scan_secret_markers(data)
memo = _decode(data, Memo)
if not re.fullmatch(r"[A-Za-z0-9._:-]{1,256}", memo.id) or memo.version < 1:
raise ValueError("invalid memo identity")
if memo.sealed or memo.approval_id is not None or memo.approval_binding_digest is not None:
raise ValueError("unbound draft required")
check_one_question(memo.question)
for value in (memo.question, memo.requested_act, memo.brief, memo.binding.principal.id,
memo.binding.principal.kind, memo.binding.principal.display_name,
memo.binding.target.id, memo.binding.target.kind, memo.binding.target.label):
if not value.strip():
raise ValueError("nonempty text required")
for items, attribute in ((memo.packet, "item_id"), (memo.highlights, "id")):
ids = [getattr(item, attribute) for item in items]
if len(set(ids)) != len(ids) or any(not ident.strip() for ident in ids):
raise ValueError("duplicate or empty identifier")
for item in memo.packet:
if not re.fullmatch(r"sha256:[a-f0-9]{64}", item.hash):
raise ValueError("invalid packet digest")
return memo
def decode_documents(documents, memo):
"""Validate command-local content; never claim custody from a supplied hash."""
if not isinstance(documents, list) or len(documents) > 100:
raise ValueError("bounded document list required")
permitted = {item.hash for item in memo.packet}
decoded, seen = [], set()
for document in documents:
if not isinstance(document, dict) or set(document) != {"digest", "media_type", "content_base64"}:
raise ValueError("invalid document fields")
digest, media = document["digest"], document["media_type"]
if (not isinstance(digest, str) or digest not in permitted or digest in seen
or not isinstance(media, str) or not media.strip() or len(media) > 256
or not isinstance(document["content_base64"], str)):
raise ValueError("invalid document")
content = base64.b64decode(document["content_base64"], validate=True)
if len(content) > 192 * 1024 or "sha256:" + hashlib.sha256(content).hexdigest() != digest:
raise ValueError("packet content mismatch")
# UTF-8 packet text receives the same bounded secret-marker check as intake.
try:
text = content.decode("utf-8")
except UnicodeDecodeError:
text = None
if text is not None:
scan_secret_markers(text)
seen.add(digest)
decoded.append((digest, media, content))
return decoded

View file

@ -10,11 +10,12 @@ import re
from urllib.parse import parse_qs
from .lifecycle_service import Caller, LifecycleError
from .lifecycle_commands import decode_memo
from .records import dumps
from .store import Conflict, EvidenceUnavailable
MAX_BODY = 256 * 1024
ROUTE = re.compile(r"/v1/decisions/([A-Za-z0-9._:-]{1,256})(?:/(events|comments|sources|evaluations))?")
ROUTE = re.compile(r"/v1/decisions/([A-Za-z0-9._:-]{1,256})(?:/(events|comments|sources|evaluations|revisions))?")
def _object(pairs):
@ -63,10 +64,12 @@ class LifecycleAPI:
except Exception:
raise LifecycleError(401, "authentication_failed") from None
self.service._caller(caller)
match = ROUTE.fullmatch(environ.get("PATH_INFO", ""))
if not match:
path = environ.get("PATH_INFO", "")
match = ROUTE.fullmatch(path)
registration = path == "/v1/decisions"
if not match and not registration:
raise LifecycleError(404, "route_not_found")
memo_id, resource = match.groups()
memo_id, resource = match.groups() if match else (None, "register")
method = environ.get("REQUEST_METHOD", "GET")
query = environ.get("QUERY_STRING", "")
if len(query) > 2048:
@ -87,7 +90,7 @@ class LifecycleAPI:
return 200, {"events": self.service.events(caller, memo_id,
after=int(params.get("after", ["0"])[0]), limit=int(params.get("limit", ["100"])[0]))}
kinds = {"comments": "comment", "sources": "source", "evaluations": "evaluation"}
if method != "POST" or resource not in kinds:
if method != "POST" or resource not in {*kinds, "register", "revisions"}:
raise LifecycleError(405, "method_not_allowed")
if params:
raise ValueError()
@ -103,11 +106,27 @@ class LifecycleAPI:
raise ValueError()
data = json.loads(raw, object_pairs_hook=_object,
parse_constant=lambda value: (_ for _ in ()).throw(ValueError()))
if not isinstance(data, dict) or set(data) != {"version", "body"}:
raise ValueError()
key = environ.get("HTTP_IDEMPOTENCY_KEY", "")
if not isinstance(key, str) or not key.strip() or len(key) > 256:
raise ValueError()
if resource in {"register", "revisions"}:
required = {"memo"} if registration else {"memo", "expected_version", "summary"}
if (not isinstance(data, dict) or not required <= data.keys()
or data.keys() - (required | {"documents"})):
raise ValueError()
memo = decode_memo(data["memo"])
documents = data.get("documents", [])
if registration:
event_id = self.service.register(caller, memo, key=key, documents=documents)
else:
if memo.id != memo_id:
raise ValueError()
event_id = self.service.revise(caller, memo, key=key,
expected_version=data["expected_version"], summary=data["summary"], documents=documents)
return 200, {"event_id": event_id, "memo_id": memo.id, "version": memo.version,
"resource": f"/v1/decisions/{memo.id}?version={memo.version}"}
if not isinstance(data, dict) or set(data) != {"version", "body"}:
raise ValueError()
event_id = self.service.annotate(caller, memo_id, data["version"], kind=kinds[resource],
body=data["body"], key=key)
return 200, {"event_id": event_id, "memo_id": memo_id, "version": data["version"]}

View file

@ -107,19 +107,21 @@ class LifecycleService:
current()
return current, guard
def register(self, caller, memo, *, key):
def register(self, caller, memo, *, key, documents=None):
# Snapshot before the PDP call: mutable caller-owned objects cannot change
# the command between authorization and persistence.
memo = memo_from(json.loads(dumps(memo)))
_, guard = self._authorize(caller, memo.id, "register", {"memo": memo, "key": key})
return self.repository.register(caller.attribution, memo, key=key, guard=guard)
documents = json.loads(dumps([] if documents is None else documents))
_, guard = self._authorize(caller, memo.id, "register", {"memo": memo, "key": key, "documents": documents})
return self.repository.register(caller.attribution, memo, key=key, guard=guard, documents=documents)
def revise(self, caller, memo, *, expected_version, summary, key):
def revise(self, caller, memo, *, expected_version, summary, key, documents=None):
memo = memo_from(json.loads(dumps(memo)))
documents = json.loads(dumps([] if documents is None else documents))
_, guard = self._authorize(caller, memo.id, "revise", {
"memo": memo, "expected_version": expected_version, "summary": summary, "key": key})
"memo": memo, "expected_version": expected_version, "summary": summary, "key": key, "documents": documents})
return self.repository.revise(caller.attribution, memo, expected_version=expected_version,
summary=summary, key=key, guard=guard)
summary=summary, key=key, guard=guard, documents=documents)
def annotate(self, caller, memo_id, version, *, kind, body, key):
body = json.loads(dumps(body))

View file

@ -156,16 +156,19 @@ class Store:
def put_document(self, content: bytes, media_type="text/plain") -> str:
if not isinstance(content, bytes) or len(content) > 16 * 1024 * 1024:
raise ValueError("packet document must be bytes, at most 16 MiB")
_text(media_type, "media type")
digest = "sha256:" + hashlib.sha256(content).hexdigest()
with self._transaction() as db:
existing = db.execute("SELECT media_type,content FROM documents WHERE digest=?", (digest,)).fetchone()
if existing and (existing["media_type"] != media_type or existing["content"] != content):
raise Conflict("document digest already has different content or media type")
if not existing:
db.execute("INSERT INTO documents VALUES (?,?,?)", (digest, media_type, content))
self._put_document(db, digest, media_type, content)
return digest
def _put_document(self, db, digest, media_type, content):
_text(media_type, "media type")
existing = db.execute("SELECT media_type,content FROM documents WHERE digest=?", (digest,)).fetchone()
if existing and (existing["media_type"] != media_type or existing["content"] != content):
raise Conflict("document digest already has different content or media type")
if not existing:
db.execute("INSERT INTO documents VALUES (?,?,?)", (digest, media_type, content))
def save_memo(self, memo: Memo):
with self._transaction() as db:
if db.execute("SELECT 1 FROM lifecycle_records WHERE memo_id=?", (memo.id,)).fetchone():