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

@ -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

View file

@ -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.

View file

@ -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.

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():

View file

@ -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

View file

@ -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.