Prepare custody observation adapter pending authenticated owner delivery
Assistant: codex Assistant-Model: gpt-6.1-sol Assistant-Session: 01a1029d-2c93-7500-a0e0-e3e089668b29
This commit is contained in:
parent
22eef7f902
commit
c11c290290
3 changed files with 290 additions and 4 deletions
119
src/user_engine/custody.py
Normal file
119
src/user_engine/custody.py
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
"""Proposed NK-ADR-0017 observation seam; deliberately absent from runtime.
|
||||
|
||||
Reader and baseline implementations are deployment-owner responsibilities.
|
||||
No claims, account roles, fixture identities or local cache can create a grant.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from copy import deepcopy
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Any, Mapping, Protocol
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CustodyRead:
|
||||
source_id: str
|
||||
document: Mapping[str, Any]
|
||||
read_at: datetime
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CustodyBaseline:
|
||||
source_id: str
|
||||
document: Mapping[str, Any]
|
||||
|
||||
|
||||
class CustodyReader(Protocol):
|
||||
def read(self) -> CustodyRead:
|
||||
"""Authenticated fresh owner read, bounded by timeout and response size.
|
||||
|
||||
source_id is trusted transport configuration, never response metadata.
|
||||
Return actual successful owner read time; raise on failure or cache hits.
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
class CustodyBaselineStore(Protocol):
|
||||
def load(self) -> CustodyBaseline | None: ...
|
||||
|
||||
def compare_and_store(
|
||||
self, previous: CustodyBaseline | None, current: CustodyBaseline,
|
||||
) -> bool:
|
||||
"""Atomically persist only if previous still matches; survive restart.
|
||||
|
||||
Return false on conflict or write failure. Never reset on source changes.
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
class CustodyPolicy(Protocol):
|
||||
"""NetKingdom's version-pinned validator and observe_root_grant functions."""
|
||||
def validate(self, record: Any) -> list[str]: ...
|
||||
def successor_errors(self, previous: dict, current: dict) -> list[str]: ...
|
||||
def observe_root_grant(
|
||||
self, record: dict, issuer: str, subject: str, tenant: str,
|
||||
read_at: datetime, now: datetime, previous_record: dict | None = None,
|
||||
) -> dict | None: ...
|
||||
|
||||
|
||||
class CustodyObservationAdapter:
|
||||
def __init__(
|
||||
self, *, reader: CustodyReader, baselines: CustodyBaselineStore,
|
||||
policy: CustodyPolicy, source_id: str, record_id: str,
|
||||
allow_initial_baseline: bool = False,
|
||||
):
|
||||
if not source_id or not record_id:
|
||||
raise ValueError("trusted source and record bindings required")
|
||||
self.reader, self.baselines, self.policy = reader, baselines, policy
|
||||
self.source_id, self.record_id = source_id, record_id
|
||||
self.allow_initial_baseline = allow_initial_baseline
|
||||
|
||||
def observe(
|
||||
self, *, issuer: str, subject: str, tenant: str, now: datetime,
|
||||
) -> dict | None:
|
||||
"""Always re-read; failures never reuse or refresh an old observation.
|
||||
|
||||
Diagnostics are suppressed because transport/validator errors may contain
|
||||
credentials or rejected data. This supplies a fact, never authorization.
|
||||
"""
|
||||
if tenant != "platform" or now.utcoffset() is None:
|
||||
return None
|
||||
try:
|
||||
previous = self.baselines.load()
|
||||
if previous is None and not self.allow_initial_baseline:
|
||||
return None
|
||||
if previous is not None and (
|
||||
previous.source_id != self.source_id
|
||||
or previous.document.get("record_id") != self.record_id
|
||||
or self.policy.validate(previous.document)
|
||||
):
|
||||
return None
|
||||
read = self.reader.read()
|
||||
document = deepcopy(dict(read.document))
|
||||
if (read.source_id != self.source_id
|
||||
or document.get("record_id") != self.record_id
|
||||
or document.get("owner_system") != "net-kingdom-custody"
|
||||
or read.read_at.utcoffset() is None
|
||||
or read.read_at > now
|
||||
or self.policy.validate(document)):
|
||||
return None
|
||||
old = deepcopy(dict(previous.document)) if previous else None
|
||||
if old is not None and self.policy.successor_errors(old, document):
|
||||
return None
|
||||
# Reject stale/future documents even when they contain no grant.
|
||||
updated = datetime.fromisoformat(document["updated_at"].replace("Z", "+00:00"))
|
||||
age = (now - read.read_at).total_seconds()
|
||||
if updated > read.read_at or not 0 <= age <= document["freshness"]["max_cache_seconds"]:
|
||||
return None
|
||||
result = self.policy.observe_root_grant(
|
||||
document, issuer, subject, tenant, read.read_at, now,
|
||||
previous_record=old,
|
||||
)
|
||||
# Keep revocation/empty revisions too; absence does not reset history.
|
||||
current = CustodyBaseline(self.source_id, deepcopy(document))
|
||||
if not self.baselines.compare_and_store(previous, current):
|
||||
return None
|
||||
return deepcopy(result)
|
||||
except Exception:
|
||||
return None
|
||||
130
tests/test_custody_adapter.py
Normal file
130
tests/test_custody_adapter.py
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
import copy
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from user_engine.custody import CustodyBaseline, CustodyObservationAdapter, CustodyRead
|
||||
|
||||
CANON = Path(__file__).resolve().parents[2] / "net-kingdom/tools/custody-record"
|
||||
try:
|
||||
spec = importlib.util.spec_from_file_location("custody_canon", CANON / "validate.py")
|
||||
policy = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(policy)
|
||||
except (FileNotFoundError, ModuleNotFoundError):
|
||||
policy = None
|
||||
|
||||
|
||||
class Baselines:
|
||||
def __init__(self):
|
||||
self.value = None
|
||||
self.conflict = False
|
||||
|
||||
def load(self):
|
||||
return copy.deepcopy(self.value)
|
||||
|
||||
def compare_and_store(self, previous, current):
|
||||
if self.conflict or previous != self.value:
|
||||
return False
|
||||
self.value = copy.deepcopy(current)
|
||||
return True
|
||||
|
||||
|
||||
@unittest.skipIf(policy is None, "optional sibling NetKingdom validator/jsonschema absent")
|
||||
class CustodyAdapterTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.document = json.loads((CANON / "fixtures/valid/grant.json").read_text())
|
||||
self.now = datetime(2026, 10, 1, 10, tzinfo=timezone.utc)
|
||||
self.read_at = self.now
|
||||
self.source = "fixture-authenticated-owner"
|
||||
self.baselines = Baselines()
|
||||
self.reads = 0
|
||||
self.failure = False
|
||||
self.adapter = CustodyObservationAdapter(
|
||||
reader=self, baselines=self.baselines, policy=policy,
|
||||
source_id=self.source, record_id=self.document["record_id"],
|
||||
allow_initial_baseline=True,
|
||||
)
|
||||
self.subject = dict(self.document["entries"][0]["subject"])
|
||||
|
||||
def read(self):
|
||||
self.reads += 1
|
||||
if self.failure:
|
||||
raise RuntimeError("sensitive error must never escape")
|
||||
return CustodyRead(self.source, self.document, self.read_at)
|
||||
|
||||
def observe(self, **overrides):
|
||||
return self.adapter.observe(**{**self.subject, "tenant": "platform", "now": self.now, **overrides})
|
||||
|
||||
def test_exact_mapping_and_fresh_read_each_time(self):
|
||||
result = self.observe()
|
||||
self.assertEqual(result["subject"], self.subject)
|
||||
self.assertEqual(result["source"], {"record_id": self.document["record_id"], "revision": 1})
|
||||
result["subject"]["subject"] = "changed"
|
||||
self.assertEqual(self.observe()["subject"], self.subject)
|
||||
self.assertEqual(self.reads, 2)
|
||||
self.assertIsNone(self.observe(issuer=self.subject["issuer"] + "/"))
|
||||
self.assertIsNone(self.observe(subject="unknown"))
|
||||
self.assertIsNone(self.observe(tenant="other"))
|
||||
|
||||
def test_failure_and_stale_read_never_refresh_old_grant(self):
|
||||
self.assertIsNotNone(self.observe())
|
||||
baseline = self.baselines.load()
|
||||
self.failure = True
|
||||
self.assertIsNone(self.observe())
|
||||
self.failure = False
|
||||
self.now += timedelta(seconds=901)
|
||||
self.assertIsNone(self.observe())
|
||||
self.assertEqual(self.baselines.load(), baseline)
|
||||
|
||||
def test_revocation_retained_and_rollback_denied_across_restart(self):
|
||||
grant = copy.deepcopy(self.document)
|
||||
self.assertIsNotNone(self.observe())
|
||||
self.document = json.loads((CANON / "fixtures/valid/revocation.json").read_text())
|
||||
self.now = self.read_at = datetime(2026, 10, 3, 12, tzinfo=timezone.utc)
|
||||
self.assertIsNone(self.observe())
|
||||
self.assertEqual(self.baselines.value.document["revision"], 2)
|
||||
self.adapter = CustodyObservationAdapter(reader=self, baselines=self.baselines, policy=policy,
|
||||
source_id=self.source, record_id=grant["record_id"])
|
||||
self.document = grant
|
||||
self.assertIsNone(self.observe())
|
||||
self.assertEqual(self.baselines.value.document["revision"], 2)
|
||||
|
||||
def test_same_revision_mutation_and_source_change_denied(self):
|
||||
self.assertIsNotNone(self.observe())
|
||||
self.document["entries"][0]["granted"]["reason"] = "rewritten"
|
||||
self.assertIsNone(self.observe())
|
||||
self.source = "other-owner"
|
||||
self.assertIsNone(self.observe())
|
||||
|
||||
def test_invalid_projection_and_future_clock_denied(self):
|
||||
self.now -= timedelta(seconds=1)
|
||||
self.assertIsNone(self.observe())
|
||||
self.now = self.read_at
|
||||
self.document = json.loads((CANON / "fixtures/invalid/projection-only-grant.json").read_text())
|
||||
self.assertIsNone(self.observe())
|
||||
self.assertIsNone(self.baselines.value)
|
||||
|
||||
def test_bootstrap_and_persistence_conflict_deny(self):
|
||||
self.adapter.allow_initial_baseline = False
|
||||
self.assertIsNone(self.observe())
|
||||
self.assertEqual(self.reads, 0)
|
||||
self.adapter.allow_initial_baseline = True
|
||||
self.baselines.conflict = True
|
||||
self.assertIsNone(self.observe())
|
||||
|
||||
def test_empty_and_supersession_revisions_are_retained(self):
|
||||
self.document["entries"] = []
|
||||
self.assertIsNone(self.observe())
|
||||
self.assertEqual(self.baselines.value.document["revision"], 1)
|
||||
# Separate approved bootstrap to the grant fixture, then handover.
|
||||
self.baselines.value = None
|
||||
self.document = json.loads((CANON / "fixtures/valid/grant.json").read_text())
|
||||
self.assertIsNotNone(self.observe())
|
||||
self.document = json.loads((CANON / "fixtures/valid/supersession.json").read_text())
|
||||
self.now = self.read_at = datetime(2026, 10, 3, 12, tzinfo=timezone.utc)
|
||||
self.assertIsNone(self.observe())
|
||||
successor = self.document["entries"][1]["subject"]
|
||||
self.assertIsNotNone(self.observe(**successor))
|
||||
self.assertEqual(self.baselines.value.document["revision"], 2)
|
||||
|
|
@ -49,10 +49,9 @@ production deployment was added. T02/T03 retain those outstanding owner gates.
|
|||
```task
|
||||
id: USER-WP-0037-T02
|
||||
status: wait
|
||||
depends_on: [NK-WP-0045-T01, NK-WP-0045-T04]
|
||||
needs_human: true
|
||||
decision_id: cef97ccd-4ee7-40fe-859e-1df9c9360ecb
|
||||
blocking_reason: "Mapping specified in proposed NK-ADR-0017; awaits founder acceptance (NK-WP-0045-T01) and the custody-record read surface (NK-WP-0045-T04)."
|
||||
depends_on: [NK-WP-0045-T05]
|
||||
blocked_on: repo:railiance-platform
|
||||
blocking_reason: "ADR and OpenBao design approved; awaits NK-WP-0045-T05 authenticated delivery plus consumer identity admission, durable baseline, canon policy packaging and live mapping/integration receipts."
|
||||
priority: high
|
||||
state_hub_task_id: "23a5c211-277b-561e-a4b3-0f8e99537cac"
|
||||
```
|
||||
|
|
@ -85,6 +84,44 @@ accepts or amends NK-ADR-0017) and NK-WP-0045-T04 (read-surface decision), both
|
|||
routed to hub decision cef97ccd (packet D10). No code changed: the field is
|
||||
added once the read surface exists; until then no root grant is returned.
|
||||
|
||||
2026-10-03 source-side progress (handoff `d6efcf93`): added transport-neutral
|
||||
`src/user_engine/custody.py`. Injected source binding, owner read timestamp,
|
||||
NetKingdom validation/transition/observation policy and atomic persistent baseline
|
||||
interfaces; every observation performs a fresh owner read, including writes.
|
||||
Read failures, stale/future data, rollback, same-revision mutations and persistence
|
||||
conflicts yield no grant. Empty/revoked revisions retain the monotonic baseline.
|
||||
Initial baseline establishment defaults denied and needs an approved bootstrap
|
||||
procedure. No runtime binding or AccountAuthority root field is activated.
|
||||
|
||||
Approved OpenBao design envelope supplied by railiance-platform is
|
||||
`data.data.document` containing the complete canon document. This is compatible
|
||||
with the reader seam; the design approval does not establish an authenticated live source. This
|
||||
repo contains no Kubernetes deployment/SA identity to use as an admission receipt.
|
||||
|
||||
Remaining owner requirements: version-pinned canon policy packaging, bounded
|
||||
authenticated reader, atomic durable baseline store with controlled rebootstrap,
|
||||
real read identity/provenance and denial/outage/recovery acceptance; then add the
|
||||
optional AccountAuthority mapping and downstream integration receipts. T02 remains
|
||||
`wait`, workplan remains `blocked`; local fixtures do not establish live delivery.
|
||||
|
||||
Founder approval received during this session: ADR-0017 and the proposed
|
||||
OpenBao delivery design are approved; NK-WP-0045-T01/T04 no longer block T02.
|
||||
This does not authorize live provisioning/deployment or create a custody entry.
|
||||
The remaining dependency is NK-WP-0045-T05 owner delivery plus user-engine's
|
||||
runtime admission, durable baseline, policy packaging and integration receipts.
|
||||
|
||||
Validation: `PYTHONDONTWRITEBYTECODE=1 make test` passed all 284 tests
|
||||
(eight optional skips), including seven new cross-repo canon fixture checks;
|
||||
layer conformance and `git diff --check` passed. The fixture tests optionally
|
||||
load the sibling validator and skip if it or jsonschema is absent; production
|
||||
has no dependency on a sibling checkout and no policy loader is configured.
|
||||
|
||||
Security orientation: emergent knowledge 0.2.3/start digest
|
||||
`60f8811c6b94b30d357a0d9e5022f2a8ea52e69b47138d3a536755f3295310b5`,
|
||||
withheld due to changed informed-decision source. Reviewed current local source
|
||||
README plus current NetKingdom companion and custodian build guidance; restricted
|
||||
work to offline source/tests, with no authority or live-readiness inference.
|
||||
|
||||
## Authenticated workload transport and private acceptance
|
||||
|
||||
```task
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue