diff --git a/src/user_engine/custody.py b/src/user_engine/custody.py new file mode 100644 index 0000000..61bfa7f --- /dev/null +++ b/src/user_engine/custody.py @@ -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 diff --git a/tests/test_custody_adapter.py b/tests/test_custody_adapter.py new file mode 100644 index 0000000..1037183 --- /dev/null +++ b/tests/test_custody_adapter.py @@ -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) diff --git a/workplans/USER-WP-0037-hub-authority-lookup.md b/workplans/USER-WP-0037-hub-authority-lookup.md index e144b79..0405905 100644 --- a/workplans/USER-WP-0037-hub-authority-lookup.md +++ b/workplans/USER-WP-0037-hub-authority-lookup.md @@ -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