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:
parent
7ce1e589f7
commit
ee472acc87
10 changed files with 332 additions and 35 deletions
4
SCOPE.md
4
SCOPE.md
|
|
@ -18,8 +18,8 @@ comments and event retrieval using the existing Memo and Store (schema 4). Its
|
|||
local event queue is durable. `LifecycleService` now wraps these operations
|
||||
with fresh exact-policy observations, expiry checks inside write transactions,
|
||||
and a verified human-session adapter. An unmounted WSGI
|
||||
`LifecycleAPI` now provides retrieval, history and annotation routes through that
|
||||
service; production HTTP exposure, workload identity composition and external
|
||||
`LifecycleAPI` now provides registration, optimistic revisions with atomic packet
|
||||
uploads, retrieval, history and annotation routes through that service; production HTTP exposure, workload identity composition and external
|
||||
event delivery remain unimplemented. Unpublished lifecycle records
|
||||
are excluded from the legacy approver overview, legacy import cannot bypass their
|
||||
revision history, and pending lifecycle events close runtime readiness. See
|
||||
|
|
|
|||
|
|
@ -23,8 +23,8 @@ or runtime configuration exposes these operations in this increment.
|
|||
|
||||
| Method | Contract |
|
||||
| --- | --- |
|
||||
| `register(context, memo, key=...)` | Version 1, no approval binding or seal; rejects ids already present in the memo store; returns durable event id |
|
||||
| `revise(context, memo, expected_version=..., summary=..., key=...)` | Exact current revision precondition and next version; nonempty change summary; returns durable event id |
|
||||
| `register(context, memo, key=..., documents=...)` | Version 1, no approval binding or seal; rejects ids already present in the memo store; returns durable event id |
|
||||
| `revise(context, memo, expected_version=..., summary=..., key=..., documents=...)` | Exact current revision precondition and next version; nonempty change summary; returns durable event id |
|
||||
| `annotate(context, memo_id, version, kind=..., body=..., key=...)` | Append source/evaluation/comment to an existing exact revision; returns durable event id |
|
||||
| `retrieve(tenant, memo_id, version=None)` | Retrieve current or historical memo only within its registered tenant |
|
||||
| `events(tenant, memo_id, after=0, limit=100)` | Ordered cursor page of attributed immutable events, maximum 100 |
|
||||
|
|
@ -93,3 +93,9 @@ legacy record enrollment, source fetching/retention, independent evidence delive
|
|||
and non-approval binding integration. T03 is started with this persistence slice;
|
||||
it is not complete. T04/T05 continue to own HTTP and UI exposure. Native runtime
|
||||
intake and admission remain with `INFD-WP-0005`.
|
||||
|
||||
Packet command extension: optional `documents` carries digest/media_type/base64
|
||||
content, verified and written in the same transaction as the memo/event. New
|
||||
registration requires packet bytes; revisions may retain their current packet's
|
||||
digests. The details and limits are in `LifecycleHTTPContract.md`. Idempotency
|
||||
digests for prior commands with no documents remain compatible.
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# Lifecycle HTTP adapter — initial subset
|
||||
# Lifecycle HTTP adapter — registration, revisions and collaboration
|
||||
|
||||
2026-10-04; INFD-WP-0007-T04, in progress. `LifecycleAPI` is an unmounted WSGI
|
||||
adapter over `LifecycleService`. It starts no listener and adds no route to the
|
||||
|
|
@ -6,6 +6,8 @@ existing browser app or public ingress.
|
|||
|
||||
| Method and path | Input | Result |
|
||||
| --- | --- | --- |
|
||||
| `POST /v1/decisions` | `memo`, optional `documents` | Register an unbound version-one memo and return event/record/revision/resource receipt |
|
||||
| `POST /v1/decisions/{id}/revisions` | `memo`, `expected_version`, `summary`, optional `documents` | Compare-and-append successor revision; same receipt shape |
|
||||
| `GET /v1/decisions/{id}` | Optional positive integer `version` | Existing Memo JSON for the current or specified revision |
|
||||
| `GET /v1/decisions/{id}/events` | Optional `after` sequence cursor, `limit` 1–100 | `{"events": [...]}` ordered by sequence; use last returned sequence for next page |
|
||||
| `POST /v1/decisions/{id}/comments` | `{"version": 1, "body": {"text": "...", "locator": "optional"}}` | Durable event receipt |
|
||||
|
|
@ -17,8 +19,7 @@ Ids are 1–256 ASCII letters/digits or `.`, `_`, `:`, `-`. POST requires exact
|
|||
nonempty `Idempotency-Key` up to 256 characters. Mutation receipts are HTTP 200
|
||||
for both first submission and replay, with `event_id`, `memo_id`, `version`.
|
||||
Unknown fields, duplicate JSON/query keys, non-finite JSON values, boolean
|
||||
revision numbers and truncated bodies are rejected. Version-one registration,
|
||||
revision, search, publication and disposition routes are not yet exposed.
|
||||
revision numbers and truncated bodies are rejected. Search, publication and disposition routes are not yet exposed.
|
||||
|
||||
Authentication runs before routing or reading a mutation body. The composition
|
||||
must explicitly supply a bearer-token verifier returning an authenticated
|
||||
|
|
@ -39,9 +40,45 @@ identity, 403 policy denial, 404 unavailable record/route, 405 unsupported metho
|
|||
service or unusable policy evidence. Raw exception text and credentials are never
|
||||
returned. Upstream raw diagnostics are not forwarded.
|
||||
|
||||
T04 remains in progress: complete closed memo mutation schemas, canonical URLs,
|
||||
search/publication contracts, admitted authenticators and runtime wiring. Current
|
||||
T04 remains in progress: search/publication contracts, admitted authenticators,
|
||||
canonical browser URLs and runtime wiring. Current
|
||||
tests use the real service/repository/PolicyClient with synthetic authenticator
|
||||
and PDP transport; they establish adapter behavior, not live identity admission.
|
||||
Runtime must not expose this adapter before lifecycle policy and independent
|
||||
event-delivery contracts are accepted and readiness can be satisfied.
|
||||
|
||||
## Memo commands and packet custody
|
||||
|
||||
`memo` uses the existing application Memo JSON representation returned by GET,
|
||||
with the model's defaults available on input. `lifecycle_commands.decode_memo`
|
||||
derives closed fields and types from the existing dataclasses; it does not add
|
||||
fields to or replace the governed portable Decision Memo schema. This is the
|
||||
current application's projection, not a claim to accept every portable schema
|
||||
field. Nested unknown fields, wrong scalar types (including booleans as version
|
||||
numbers), duplicate packet/highlight ids, invalid digests, excessive nesting and
|
||||
secret markers are rejected. Approval pins and seals cannot be submitted. The
|
||||
question must pass the existing one-question check. Both L0 and L3 use the same
|
||||
model/decoder. The memo's proposed binding principal is not caller identity.
|
||||
|
||||
Registration is `{ "memo": <Memo JSON>, "documents": [...] }`. Revision adds
|
||||
`expected_version` and a nonempty `summary`; its memo id must match the path and
|
||||
its version must be exactly expected_version + 1. Every mutation requires
|
||||
Idempotency-Key. Register/revise receipts include a relative API resource link
|
||||
`/v1/decisions/{id}?version={version}`. This does not claim that an unpublished
|
||||
draft has a human review URL or that the API is publicly mounted.
|
||||
|
||||
Each document has exactly `digest`, `media_type`, `content_base64`. At most 100
|
||||
documents are allowed; the total JSON request remains bounded at 256 KiB and
|
||||
each decoded document at 192 KiB. SHA-256 must match the supplied bytes and a
|
||||
packet entry. Duplicate/orphan documents are refused. UTF-8 packet content uses
|
||||
the existing secret-marker scan. Supplied hashes are never proof of custody.
|
||||
Registration must include all packet content. Revision may retain content from
|
||||
the current revision's packet, and must supply bytes for any new digest. Knowing
|
||||
a digest stored under a different record grants no reuse capability.
|
||||
|
||||
Memo, new documents, lifecycle event, pending delivery and idempotency receipt
|
||||
commit together. A write failure rolls them all back. Policy authorization covers
|
||||
the command including documents; identity and decision freshness are rechecked
|
||||
in the write transaction. Replays are reauthorized and return the original event.
|
||||
Conflicting content for a key, stale revision preconditions and attempts to
|
||||
replace an existing digest's media type produce 409 without a partial write.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
117
informed_decision/lifecycle_commands.py
Normal file
117
informed_decision/lifecycle_commands.py
Normal 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
|
||||
|
|
@ -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"]}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -87,6 +87,85 @@ def test_body_limits_and_idempotency_conflict(api):
|
|||
assert call(api, path, method="POST", body={"version": 1, "body": {"text": "second"}})[0] == 409
|
||||
|
||||
|
||||
def test_unimplemented_binding_and_creation_routes_are_unavailable(api):
|
||||
assert call(api, "/v1/decisions", method="POST", body={})[0] == 404
|
||||
def test_unimplemented_binding_route_is_unavailable(api):
|
||||
assert call(api, "/v1/decisions", method="POST", body={})[0] == 400
|
||||
assert call(api, "/v1/decisions/memo-1/accept", method="POST", body={})[0] == 404
|
||||
|
||||
|
||||
def packet_command(api, ident="new-record"):
|
||||
import base64
|
||||
import hashlib
|
||||
memo = call(api, "/v1/decisions/memo-1")[1]
|
||||
content = b"New decision supporting evidence"
|
||||
digest = "sha256:" + hashlib.sha256(content).hexdigest()
|
||||
memo.update(id=ident, packet=[{"item_id": "doc", "label": "Evidence", "hash": digest}])
|
||||
return {"memo": memo, "documents": [{"digest": digest, "media_type": "text/plain",
|
||||
"content_base64": base64.b64encode(content).decode()}]}
|
||||
|
||||
|
||||
def test_register_and_revise_packet_through_api_without_operator_load(api):
|
||||
command = packet_command(api)
|
||||
status, receipt, _ = call(api, "/v1/decisions", method="POST", body=command)
|
||||
assert status == 200 and receipt["resource"] == "/v1/decisions/new-record?version=1"
|
||||
assert call(api, "/v1/decisions", method="POST", body=command)[1] == receipt
|
||||
status, stored, _ = call(api, "/v1/decisions/new-record")
|
||||
assert status == 200 and stored == command["memo"]
|
||||
stored.update(version=2, brief="Revised assessment")
|
||||
revision = {"memo": stored, "expected_version": 1, "summary": "Updated assessment"}
|
||||
status, receipt2, _ = call(api, "/v1/decisions/new-record/revisions", method="POST", body=revision)
|
||||
assert status == 200 and receipt2["version"] == 2
|
||||
assert call(api, "/v1/decisions/new-record/revisions", method="POST", body=revision)[1] == receipt2
|
||||
assert call(api, "/v1/decisions/new-record", query="version=1")[1]["brief"] != "Revised assessment"
|
||||
assert call(api, "/v1/decisions/new-record/revisions", method="POST", body=revision,
|
||||
HTTP_IDEMPOTENCY_KEY="different-writer")[0] == 409
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mutate", [
|
||||
lambda c: c["memo"].update(version=True),
|
||||
lambda c: c["memo"].update(sealed="false"),
|
||||
lambda c: c["memo"].update(unknown="not allowed"),
|
||||
lambda c: c["memo"]["binding"]["target"].update(requires_new_bind="false"),
|
||||
lambda c: c["memo"]["binding"]["principal"].update(identity_verified=True),
|
||||
lambda c: c["memo"]["packet"].append(dict(c["memo"]["packet"][0])),
|
||||
lambda c: c["documents"][0].update(content_base64="not base64"),
|
||||
lambda c: c["documents"][0].update(content_base64="bm90IHRoZSBzYW1l"),
|
||||
lambda c: c["memo"].update(approval_id="invented"),
|
||||
])
|
||||
def test_closed_memo_and_content_validation(api, mutate):
|
||||
command = packet_command(api)
|
||||
mutate(command)
|
||||
assert call(api, "/v1/decisions", method="POST", body=command)[0] == 400
|
||||
assert api.service.repository.store.lifecycle_counts()["records"] == 1
|
||||
|
||||
|
||||
def test_known_digest_alone_does_not_grant_custody_of_other_record(api):
|
||||
command = packet_command(api)
|
||||
assert call(api, "/v1/decisions", method="POST", body=command)[0] == 200
|
||||
command["memo"]["id"] = "another-record"
|
||||
command.pop("documents")
|
||||
assert call(api, "/v1/decisions", method="POST", body=command,
|
||||
HTTP_IDEMPOTENCY_KEY="another")[0] == 404
|
||||
|
||||
|
||||
def test_packet_write_rolls_back_when_event_queue_fails(api):
|
||||
command = packet_command(api)
|
||||
store = api.service.repository.store
|
||||
with store._connection() as db:
|
||||
db.execute("CREATE TRIGGER fail_packet_event BEFORE INSERT ON lifecycle_pending "
|
||||
"BEGIN SELECT RAISE(ABORT,'injected'); END")
|
||||
assert call(api, "/v1/decisions", method="POST", body=command)[0] == 503
|
||||
with store._connection() as db:
|
||||
assert db.execute("SELECT COUNT(*) FROM documents").fetchone()[0] == 0
|
||||
assert store.lifecycle_counts()["records"] == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize("act,step,level", [("login", "acknowledge", "acknowledgment"),
|
||||
("approve", "approve", "organizational")])
|
||||
def test_shared_command_decoder_preserves_l0_and_l3_binding_fields(api, act, step, level):
|
||||
command = packet_command(api)
|
||||
command["memo"].update(requested_act=act, step_kind=step, binding_level=level)
|
||||
command["memo"]["binding"]["target"]["requires_new_bind"] = True
|
||||
assert call(api, "/v1/decisions", method="POST", body=command)[0] == 200
|
||||
result = call(api, "/v1/decisions/new-record")[1]
|
||||
assert result["binding"]["target"]["requires_new_bind"] is True
|
||||
assert result["requested_act"] == act and result["binding_level"] == level
|
||||
|
|
|
|||
|
|
@ -188,3 +188,19 @@ identity/runtime work: `docs/specs/LifecycleHTTPContract.md`.
|
|||
HTTP continuation validation: cross-repo suite 585 passed, 5 skipped; a subsequent
|
||||
focused lifecycle/HTTP run also covers content-free admin inspection.
|
||||
`git diff --check` passed. Runtime remains unmounted pending the named contracts.
|
||||
|
||||
## Registration and revision API — 2026-10-04
|
||||
|
||||
Continued T04 with POST /v1/decisions and POST /v1/decisions/{id}/revisions,
|
||||
using closed typed decoding of the existing Memo dataclasses. Added bounded
|
||||
base64 packet uploads, verified digests and atomic packet/memo/event writes.
|
||||
New records must supply packet bytes; revisions can reuse only their current
|
||||
packet's digests. The shared service snapshots and authorizes the whole command.
|
||||
Receipts include stable relative API revision links. Tests cover end-to-end
|
||||
registration/replay/revision, malformed nested values, forged approval pins,
|
||||
packet corruption, cross-record digest reuse and rollback on queue failure.
|
||||
The adapter remains unmounted; T02/T03/T04 remain in progress.
|
||||
|
||||
Validation: cross-repository suite 598 passed, 5 skipped; subsequent HTTP tests
|
||||
include shared L0/L3 decoding and preservation of requires_new_bind.
|
||||
`git diff --check` passed. No live deployment or authentication admission.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue