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
This commit is contained in:
tegwick 2026-10-03 18:40:41 +02:00
parent 579e4bc59e
commit add86c116f
5 changed files with 240 additions and 9 deletions

View file

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

View file

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

View file

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

View file

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

View file

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