From add86c116f23b7dcb40414047c5184a913f5648b Mon Sep 17 00:00:00 2001 From: tegwick Date: Sat, 3 Oct 2026 18:40:41 +0200 Subject: [PATCH] Harden custody record reads and define blocked owner delivery requirements Assistant: codex Assistant-Model: gpt-6.1-sol Assistant-Session: 01a1029d-2c93-7500-a0e0-e3e089668b29 --- .../root-grant-read-contract_v0.1.md | 99 +++++++++++++++++++ tools/custody-record/README.md | 13 +++ .../tests/test_custody_record.py | 59 +++++++++++ tools/custody-record/validate.py | 34 ++++++- .../NK-WP-0045-root-grant-custody-record.md | 44 ++++++++- 5 files changed, 240 insertions(+), 9 deletions(-) create mode 100644 docs/contracts/root-grant-read-contract_v0.1.md diff --git a/docs/contracts/root-grant-read-contract_v0.1.md b/docs/contracts/root-grant-read-contract_v0.1.md new file mode 100644 index 0000000..069af9a --- /dev/null +++ b/docs/contracts/root-grant-read-contract_v0.1.md @@ -0,0 +1,99 @@ +# Root-grant read contract v0.1 + +Owner: net-kingdom. Status: proposed implementation contract, 2026-10-03. +Tracks NK-WP-0045 and proposed [ADR-0017](../adr/ADR-0017-root-grant-custody-and-account-authority.md). +Schema: [custody-record v0.1](../../canon/schemas/custody-record_v0.1.schema.json). +Reference implementation and fixtures: [validator](../../tools/custody-record/README.md). + +This contract prepares delivery; it does not accept the ADR, choose a live +endpoint, assign a custodian, or establish a real root grant. Fixtures are +synthetic and must never seed the authoritative record. + +## Read-surface decision (T04) + +The founder's open decision `cef97ccd-4ee7-40fe-859e-1df9c9360ecb` +must accept/amend ADR-0017 and select one delivery surface and accountable repo. +user-engine has already confirmed the exact subject and envelope mapping +(message `60acf176`, recorded in USER-WP-0037-T02). + +| Option | Delivering repo | Requirements and tradeoff | +| --- | --- | --- | +| OpenBao-held non-secret document | railiance-platform | Governed exact-path workload read, separate custody write lane, atomic revision updates; uses existing custody infrastructure but needs consumer identity admission. Recommended for review, subject to owner confirmation. | +| Private platform service | Named platform runtime owner | Authenticated bounded read, atomic document, custody write/audit lane, availability and recovery; adds a runtime to operate. NetKingdom supplies canon, not a new runtime by default. | +| Repository artifact delivered to workloads | Named artifact/distribution owner | Authenticated provenance, atomic publication, revision rollback protection and a refresh route for sensitive writes; an indefinitely cached checkout is insufficient. | + +Decision receipt must name the surface, delivering repo, trusted record ID, +consumer identity mechanism, writer/custody approval lane and bootstrap +provenance. No endpoint, OpenBao path or access grant is invented here. If the +ADR changes, schema, validator and this contract must be reconciled before use. + +## Producer requirements (T05) + +The selected owner must implement these requirements in its own repository, +link its workplan/task in NK-WP-0045-T05, and return non-secret delivery evidence: + +1. Publish a complete schema-valid JSON document as one atomic revision. Bind + its `record_id` and `owner_system` to the trusted configured source. A schema + pass alone cannot authenticate its authority. +2. Admit a real reader identity through the existing owner routing procedure; + deny unauthenticated readers and deny custody writes by reader identities. + Keep credential material out of the document and delivery receipts. +3. Populate real entries only from human-controlled custody ceremony evidence, + with exact issuer/subject and actor/time/reason. A directory or JWT projection + cannot supply the grant. NetKingdom does not create a real entry from fixtures. +4. Validate every candidate document and its transition from the previous + document before publication. Retain all entries and immutable grant facts; + new grants, revocations and supersessions use revisions above the previous + revision. Never reuse a revision for changed content. `updated_at` must not + move backwards. Preserve revision history through backup/restore. +5. On read failure, incomplete publication or invalid data, report failure + without returning a last-good document as a newly read authoritative result. + Suppress raw documents, rejected values and credential errors in logs. +6. Document endpoint/path, authentication, timeouts/size limits, deployment + revision, revocation procedure, recovery and audit owner. Supply positive and + denial/outage/restore receipts before declaring the surface live. + +## Consumer requirements (T06 / USER-WP-0037-T02) + +user-engine owns the adapter and optional `AccountAuthority.root_grant` mapping. +Downstream workloads read that fact through user-engine's authenticated account +authority surface; they do not mint it from claims or configured root subjects. + +1. Authenticate the source and bind the expected record ID before validating. + Read with bounded timeout/size and preserve the actual successful owner read + time. File mtime, document `updated_at`, HTTP proxy/cache hits, and viewing a + cached observation do not establish a new authoritative read time. +2. Validate schema and semantics; compare with the last trusted document using + `successor_errors`. Keep the last trusted revision/content across cache + replacement and restart. Reject rollback and same-revision mutation; do not + silently reset that baseline after a source/record change. The runtime owner + must define the controlled rebootstrap procedure. +3. Call `observe_root_grant` with timezone-aware read/check times and the last + trusted document (`previous_record`) when present. Match exact issuer and + subject, without username, email, case folding, or issuer normalization. + Only tenant `platform` and active `platform-root-custodian` entries qualify. + Future records/grants, revoked/superseded entries and malformed reads yield + no grant. An empty valid record also means no grant. +4. Cache read observations no longer than the record's `max_cache_seconds` + (0–900 seconds). Re-check the owner for every root-grant-dependent write. + Failed reads, stale data or rollback yield no grant for that action, even if + a previous read granted it. Never extend expiry by re-reading local cache. +5. Retain only `subject`, fixed `grant`, `source.record_id/revision`, + `observed_at`, and expiry/revocation time when known. No root grant for other + tenants, unknown subjects, tenant admins, directory-only or JWT-only roots. + The fact does not replace flex-auth authorization for the requested action. + +## Acceptance receipts + +| Receipt | Responsible repo | Unblocks | +| --- | --- | --- | +| ADR acceptance/amendment and surface/owner selection | net-kingdom records the founder's decision | T01, T04 | +| Owner workplan/task, authenticated read location, real ceremony provenance, atomic revision and denial/outage/recovery/audit evidence | Selected delivery repo; railiance-platform for OpenBao option | T05 | +| Adapter commit, exact mapping, retained source/time, and positive/negative integration tests against the delivered surface | user-engine, USER-WP-0037-T02 | T06 | + +Integration evidence must cover grant, revocation, supersession, unknown/wrong +issuer, wrong tenant, projection-only input, expired cache, failed read, +rollback, same-revision mutation and no cache renewal on local reads. Local +fixtures establish algorithm behavior; they cannot satisfy live delivery or +real custody provenance. USER-WP-0037-T03's separate Hub credential admission +remains owned by user-engine/state-hub and is not delivery evidence for T05. diff --git a/tools/custody-record/README.md b/tools/custody-record/README.md index 69808b4..0d557d0 100644 --- a/tools/custody-record/README.md +++ b/tools/custody-record/README.md @@ -17,6 +17,19 @@ a removed entry is not a revocation), and secret-looking values. for tenant `platform`, from a valid record read at most 15 minutes ago, otherwise no grant (fail closed). +Pass the last trusted document as `previous_record` to `observe_root_grant` +to reject rollback or same-revision mutation. New custody actions cannot be +inserted into already observed revisions. Read/check times must be timezone +aware; future document/grant timestamps yield no grant. The CLI emits only +acceptance or error counts, because schema diagnostics can contain rejected +secret values. Library error strings are internal diagnostics and must not +be logged against untrusted inputs. + +Runtime owners must also authenticate the source, bind the expected record ID, +retain the revision baseline, and re-read for sensitive writes. See the +[proposed read contract](../../docs/contracts/root-grant-read-contract_v0.1.md) +for delivery requirements and cross-repo acceptance receipts. + Fixtures: `valid/` covers grant, revocation and supersession; `invalid/` covers projection-only grants, tenant-admin implication, other tenants, secret-bearing fields and values, username keys, an over-long cache bound, diff --git a/tools/custody-record/tests/test_custody_record.py b/tools/custody-record/tests/test_custody_record.py index 900e91b..917148a 100644 --- a/tools/custody-record/tests/test_custody_record.py +++ b/tools/custody-record/tests/test_custody_record.py @@ -3,9 +3,12 @@ from __future__ import annotations import copy import datetime as dt import importlib.util +import contextlib +import io import json import pathlib import unittest +import tempfile import jsonschema @@ -79,6 +82,22 @@ class SchemaTests(unittest.TestCase): class SuccessorTests(unittest.TestCase): + def test_new_actions_cannot_be_inserted_into_observed_history(self) -> None: + previous = load("valid/grant.json") + previous["revision"] = 2 + previous["updated_at"] = "2026-10-02T09:00:00Z" + for name in ("revocation", "supersession"): + with self.subTest(name=name): + current = load(f"valid/{name}.json") + current["revision"] = 3 + self.assertIn("previous record revision", "\n".join(V.successor_errors(previous, current))) + + def test_invalid_records_and_backwards_update_time_are_rejected(self) -> None: + self.assertTrue(V.successor_errors({}, load("valid/grant.json"))) + current = load("valid/revocation.json") + current["updated_at"] = "2026-09-30T09:00:00Z" + self.assertIn("updated_at went backwards", V.successor_errors(load("valid/grant.json"), current)) + def test_revocation_is_a_newer_append_only_revision(self) -> None: self.assertEqual(V.successor_errors(load("valid/grant.json"), load("valid/revocation.json")), []) self.assertEqual(V.successor_errors(load("valid/grant.json"), load("valid/supersession.json")), []) @@ -100,6 +119,29 @@ class SuccessorTests(unittest.TestCase): class ObservationTests(unittest.TestCase): + def test_rollback_and_changed_same_revision_fail_closed(self) -> None: + current = load("valid/grant.json") + for previous in (load("valid/revocation.json"), load("valid/grant.json")): + previous["freshness"]["max_cache_seconds"] = 60 + self.assertIsNone(V.observe_root_grant( + current, ISS, KING_1, "platform", READ_AT, READ_AT, + previous_record=previous, + )) + + def test_future_record_or_grant_and_naive_clocks_fail_closed(self) -> None: + for field in ("updated_at", "granted"): + record = load("valid/grant.json") + if field == "granted": + record["entries"][0][field]["at"] = "2026-10-04T09:00:00Z" + else: + record[field] = "2026-10-04T09:00:00Z" + self.assertIsNone(self.observe(record)) + for read_at, now in ((READ_AT.replace(tzinfo=None), READ_AT), + (READ_AT, READ_AT.replace(tzinfo=None))): + self.assertIsNone(V.observe_root_grant( + load("valid/grant.json"), ISS, KING_1, "platform", read_at, now + )) + def observe(self, record, subject=KING_1, tenant="platform", age=0, issuer=ISS): return V.observe_root_grant( record, issuer, subject, tenant, READ_AT, READ_AT + dt.timedelta(seconds=age) @@ -136,5 +178,22 @@ class ObservationTests(unittest.TestCase): self.assertIsNone(self.observe(tighter, age=61)) +class CommandLineTests(unittest.TestCase): + def test_rejected_values_and_malformed_input_never_reach_output(self) -> None: + sentinel = "sensitive-value-must-stay-private" + record = load("valid/grant.json") + record["owner_system"] = sentinel + with tempfile.TemporaryDirectory() as directory: + path = pathlib.Path(directory) / "record.json" + for payload in (json.dumps(record), '{"reason":"' + sentinel): + path.write_text(payload, encoding="utf-8") + output = io.StringIO() + with contextlib.redirect_stdout(output), contextlib.redirect_stderr(output): + self.assertEqual(V.main([str(path)]), 1) + self.assertIn("rejected", output.getvalue()) + self.assertNotIn(sentinel, output.getvalue()) + self.assertNotIn("Traceback", output.getvalue()) + + if __name__ == "__main__": unittest.main() diff --git a/tools/custody-record/validate.py b/tools/custody-record/validate.py index 51ec93b..ad12fb2 100644 --- a/tools/custody-record/validate.py +++ b/tools/custody-record/validate.py @@ -133,6 +133,8 @@ def validate(record: Any, schema: dict[str, Any] | None = None) -> list[str]: def successor_errors(previous: dict[str, Any], current: dict[str, Any]) -> list[str]: """A later read must be a strictly newer, append-only revision.""" + if validate(previous) or validate(current): + return ["invalid record in revision comparison"] errors: list[str] = [] if previous["record_id"] != current["record_id"]: return ["record_id changed between revisions"] @@ -143,6 +145,15 @@ def successor_errors(previous: dict[str, Any], current: dict[str, Any]) -> list[ errors.append("same revision with different content") return errors now = {entry["entry_id"]: entry for entry in current["entries"]} + before = {entry["entry_id"]: entry for entry in previous["entries"]} + if parse_time(current["updated_at"]) < parse_time(previous["updated_at"]): + errors.append("updated_at went backwards") + for entry in current["entries"]: + earlier = before.get(entry["entry_id"], {}) + for field in ("granted", "revoked", "superseded_by"): + if field in entry and field not in earlier: + if entry[field]["revision"] <= previous["revision"]: + errors.append(f"{entry['entry_id']}: new {field} must follow the previous record revision") for entry in previous["entries"]: later = now.get(entry["entry_id"]) if later is None: @@ -164,6 +175,7 @@ def observe_root_grant( tenant: str, read_at: dt.datetime, now: dt.datetime, + previous_record: dict[str, Any] | None = None, ) -> dict[str, Any] | None: """Return the ADR-0017 section 3 observation, or None (fail closed). @@ -173,11 +185,18 @@ def observe_root_grant( """ if tenant != "platform" or record is None or validate(record): return None + if previous_record is not None and successor_errors(previous_record, record): + return None + if read_at.utcoffset() is None or now.utcoffset() is None: + return None + if parse_time(record["updated_at"]) > read_at: + return None bound = min(record["freshness"]["max_cache_seconds"], MAX_CACHE_SECONDS) if not dt.timedelta(0) <= now - read_at <= dt.timedelta(seconds=bound): return None for entry in record["entries"]: - if key(entry) == (issuer, subject) and is_active(entry): + if (key(entry) == (issuer, subject) and is_active(entry) + and parse_time(entry["granted"]["at"]) <= read_at): return { "subject": dict(entry["subject"]), "grant": dict(entry["grant"]), @@ -194,12 +213,17 @@ def main(argv: list[str] | None = None) -> int: schema = load_schema() failed = False for path in args.paths: - errors = validate(json.loads(path.read_text(encoding="utf-8")), schema) - for error in errors: - print(f"{path}: {error}", file=sys.stderr) + try: + errors = validate(json.loads(path.read_text(encoding="utf-8")), schema) + except (OSError, ValueError): + errors = ["unreadable input"] + # jsonschema messages may contain the rejected secret value. Never + # expose those messages (or input paths) through the command line. + if errors: + print(f"custody record: rejected ({len(errors)} errors)", file=sys.stderr) failed = failed or bool(errors) if not errors: - print(f"{path}: ok") + print("custody record: ok") return 1 if failed else 0 diff --git a/workplans/NK-WP-0045-root-grant-custody-record.md b/workplans/NK-WP-0045-root-grant-custody-record.md index 3a177f4..d7c3bbd 100644 --- a/workplans/NK-WP-0045-root-grant-custody-record.md +++ b/workplans/NK-WP-0045-root-grant-custody-record.md @@ -4,7 +4,7 @@ type: workplan title: "Make the platform-root custody record readable as a non-secret root-grant fact" domain: infotech repo: net-kingdom -status: proposed +status: blocked flavor: implementation owner: claude-code topic_slug: netkingdom @@ -118,7 +118,8 @@ answer fixes T05. id: NK-WP-0045-T05 status: wait depends_on: [NK-WP-0045-T04] -blocking_reason: "Depends on the read-surface decision and on the delivering owner." +wait_kind: both +blocking_reason: "Open decision cef97ccd must name the read surface and delivering repo; that owner must supply authenticated atomic publication, custody provenance and delivery receipts under docs/contracts/root-grant-read-contract_v0.1.md." priority: medium state_hub_task_id: "628e8a3b-17a4-5f97-a484-c564a87d650d" ``` @@ -131,8 +132,10 @@ the runtime. Include the fail-closed and staleness rules from ADR-0017. ```task id: NK-WP-0045-T06 status: wait -depends_on: [NK-WP-0045-T05] -blocking_reason: "Needs the delivered read path." +depends_on: [NK-WP-0045-T01, NK-WP-0045-T05, USER-WP-0037-T02] +wait_kind: dependency +blocked_on: message-from:user-engine +blocking_reason: "Needs T05 delivery and user-engine USER-WP-0037-T02 adapter/mapping acceptance against the delivered source, including revocation, freshness and rollback evidence." priority: medium state_hub_task_id: "eb71faf7-d6c7-5a3a-bdb6-17281cc5a01a" ``` @@ -146,3 +149,36 @@ Tell user-engine the read path is live so USER-WP-0037-T02 can derive projection-only inputs. - A workload can read the root-grant fact from an owner-delivered surface. - user-engine derives the root grant without inferring it from claims. + + +## Implementation and dependency review — 2026-10-03 + +Local implementation is complete for the current draft. Workplan moved to +`blocked`: T01/T04 require the founder's open decision; T05 needs the selected +runtime owner; T06 needs user-engine integration. Task status `wait` expresses +these blockers under the repository convention. No live source, custody entry, +ADR acceptance or runtime delivery is claimed. + +- Added [read contract](../docs/contracts/root-grant-read-contract_v0.1.md) + with three concrete surface options, a review recommendation, producer and + consumer requirements, and owner-specific acceptance receipts. T04's choice + remains human-owned. T05's delivery repo cannot be bound until that choice. +- Hardened T03 implementation: reject retroactive actions in revision + transitions, invalid comparison documents and backwards update times; + observations can check a previous document and fail closed on rollback, + future timestamps and timezone-naive clocks. CLI diagnostics suppress raw + validation errors and malformed input so rejected secrets cannot be echoed. +- Required external work: selected delivery owner creates its implementation + workplan and supplies the T05 receipts; user-engine implements its adapter in + USER-WP-0037-T02 and supplies T06 receipts. The contract is the handoff artifact; + runtime work remains in owner repos. +- Validation: `make custody-record-test` — 18 passed, including regression tests + for the newly found transition, clock and diagnostic gaps. +- Security knowledge: Emergent package 0.2.3, `start` and `root`, source status + `verified-files`, orientation-only; digest + `60f8811c6b94b30d357a0d9e5022f2a8ea52e69b47138d3a536755f3295310b5`. + Local source/test/contract work only; no control relaxation or live mutation. + +Resume when decision `cef97ccd-4ee7-40fe-859e-1df9c9360ecb` is answered: +reconcile any amendments, name the delivering repo/task on T05, verify its +receipts, then obtain user-engine acceptance before closing T06 and the plan.