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:
parent
579e4bc59e
commit
add86c116f
5 changed files with 240 additions and 9 deletions
99
docs/contracts/root-grant-read-contract_v0.1.md
Normal file
99
docs/contracts/root-grant-read-contract_v0.1.md
Normal 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.
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue