CUST-WP-0075: NK-WP-0045 custody-record schema and validator; NK-WP-0042 reply
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 3775@bnt-lap001
Assistant-Session: cfacb6a5-ad5b-4f79-ac67-053e3b0d34a3
This commit is contained in:
tegwick 2026-10-03 12:56:22 +02:00
parent a8cebcfc94
commit 17f92c9f11
21 changed files with 1196 additions and 2 deletions

View file

@ -214,6 +214,9 @@ security-scenario-compose-lightweight: ## Compose plan-only KeyCape C1+C2b from
posture-feedback-test: ## Run deterministic posture-feedback tests
python3 -m pytest tools/posture-feedback/tests tools/tenancy-posture/test_validate.py
custody-record-test: ## Run custody-record schema/validator tests (NK-WP-0045-T03)
python3 -m pytest tools/custody-record/tests
posture-feedback-reference: ## Emit the proposal-only reference report at its fixed example time
python3 tools/posture-feedback/posture_feedback.py \
--as-of 2026-08-23T12:00:00Z \
@ -384,6 +387,7 @@ security-bootstrap-ui: security-bootstrap-metadata-init ## Serve local custody a
creds-agent-init creds-agent-status creds-emergency-reprint \
openbao-init-unseal openbao-init-unseal-dry-run \
iam-profile-conformance-test playbook-contract-test posture-feedback-test \
custody-record-test \
emission-cadence-profile-test \
posture-feedback-reference security-scenario-compose-lightweight \
security-bootstrap-console-test security-bootstrap-scripts-syntax \

View file

@ -0,0 +1,135 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://policy.coulomb.social/schemas/custody-record/v0.1",
"title": "NetKingdom platform-root custody record v0.1",
"description": "Machine-readable, non-secret form of the platform-root custody record (NK-ADR-0017, status proposed; NK-WP-0045-T02). The record is the single authority for the root grant. Entries are keyed by exact (issuer, subject), never by username. The root grant is relation platform-root-custodian in tenant:platform only; directory membership and IAM claims are projections and cannot ground an entry. Revocation and supersession are recorded custody actions that add a record revision; entries are never deleted. No secret material: token, key, share, recovery-code, password and reset-link fields are rejected everywhere.",
"type": "object",
"additionalProperties": false,
"required": [
"schema_version",
"kind",
"record_id",
"revision",
"updated_at",
"owner_system",
"freshness",
"entries"
],
"propertyNames": { "$ref": "#/$defs/nonSecretName" },
"properties": {
"schema_version": { "const": "0.1" },
"kind": { "const": "netkingdom-custody-record" },
"record_id": {
"description": "Stable identifier of the custody record. Revisions of one record share it.",
"type": "string",
"pattern": "^nk-custody:[a-z0-9][a-z0-9-]{0,62}$"
},
"revision": {
"description": "Monotonic per record: every custody action (grant, revocation, supersession) increments it, so a revocation is distinguishable from a stale read.",
"type": "integer",
"minimum": 1
},
"updated_at": { "type": "string", "format": "date-time" },
"owner_system": { "const": "net-kingdom-custody" },
"freshness": {
"description": "Consumer cache bound. Observations older than this are stale; root-grant-dependent actions re-check and fail closed when the record is unreadable or stale.",
"type": "object",
"additionalProperties": false,
"required": ["max_cache_seconds", "on_unreadable"],
"propertyNames": { "$ref": "#/$defs/nonSecretName" },
"properties": {
"max_cache_seconds": { "type": "integer", "minimum": 0, "maximum": 900 },
"on_unreadable": { "const": "no-grant" }
}
},
"entries": {
"type": "array",
"items": { "$ref": "#/$defs/entry" }
}
},
"$defs": {
"nonSecretName": {
"description": "Field names that would carry secret material are rejected at every level.",
"type": "string",
"not": {
"pattern": "(?i)(token|key|secret|share|recovery|password|passphrase|reset|seed|private|otp|credential)"
}
},
"timestamp": { "type": "string", "format": "date-time" },
"subject": {
"description": "Exact OIDC subject reference. No username, email, group or role is accepted as the key.",
"type": "object",
"additionalProperties": false,
"required": ["issuer", "subject"],
"propertyNames": { "$ref": "#/$defs/nonSecretName" },
"properties": {
"issuer": { "type": "string", "pattern": "^https://[^\\s/]+(/[^\\s]*)?$" },
"subject": { "type": "string", "minLength": 1, "maxLength": 255, "pattern": "^\\S+$" }
}
},
"grant": {
"description": "Membership-envelope mapping from NK-ADR-0017 section 2. Fixed: no other tenant, relation or owner is representable.",
"type": "object",
"additionalProperties": false,
"required": ["scope_type", "scope_id", "relation", "owner_system"],
"propertyNames": { "$ref": "#/$defs/nonSecretName" },
"properties": {
"scope_type": { "const": "tenant" },
"scope_id": { "const": "platform" },
"relation": { "const": "platform-root-custodian" },
"owner_system": { "const": "net-kingdom-custody" }
}
},
"action": {
"description": "A recorded custody action with actor, reason and the record revision it introduced.",
"type": "object",
"additionalProperties": false,
"required": ["at", "actor", "reason", "revision"],
"propertyNames": { "$ref": "#/$defs/nonSecretName" },
"properties": {
"at": { "$ref": "#/$defs/timestamp" },
"actor": { "$ref": "#/$defs/subject" },
"reason": { "type": "string", "minLength": 1, "maxLength": 500 },
"revision": { "type": "integer", "minimum": 1 }
}
},
"evidence": {
"description": "Non-secret pointer to the custody ceremony that grounds the entry. A projection (directory group, IAM claim) is never a basis.",
"type": "object",
"additionalProperties": false,
"required": ["basis", "ref"],
"propertyNames": { "$ref": "#/$defs/nonSecretName" },
"properties": {
"basis": { "const": "custody-ceremony" },
"ref": { "type": "string", "minLength": 1, "maxLength": 300 }
}
},
"supersession": {
"type": "object",
"additionalProperties": false,
"required": ["entry_id", "revision"],
"propertyNames": { "$ref": "#/$defs/nonSecretName" },
"properties": {
"entry_id": { "$ref": "#/$defs/entryId" },
"revision": { "type": "integer", "minimum": 1 }
}
},
"entryId": { "type": "string", "pattern": "^[a-z0-9][a-z0-9-]{0,62}$" },
"entry": {
"type": "object",
"additionalProperties": false,
"required": ["entry_id", "subject", "grant", "evidence", "granted"],
"propertyNames": { "$ref": "#/$defs/nonSecretName" },
"properties": {
"entry_id": { "$ref": "#/$defs/entryId" },
"subject": { "$ref": "#/$defs/subject" },
"grant": { "$ref": "#/$defs/grant" },
"evidence": { "$ref": "#/$defs/evidence" },
"granted": { "$ref": "#/$defs/action" },
"superseded_by": { "$ref": "#/$defs/supersession" },
"revoked": { "$ref": "#/$defs/action" }
},
"not": { "required": ["superseded_by", "revoked"] }
}
}
}

View file

@ -0,0 +1,23 @@
# Custody Record Validator
Validates the non-secret platform-root custody record defined by
`canon/schemas/custody-record_v0.1.schema.json` (NK-ADR-0017, status
proposed; NK-WP-0045-T02/T03). Offline and read-only.
```bash
uv run tools/custody-record/validate.py tools/custody-record/fixtures/valid/*.json
uv run pytest tools/custody-record/tests
```
Beyond the schema it checks: one active grant per exact `(issuer, subject)`,
revision ordering and supersession links inside a record, append-only
successors across reads (`successor_errors`: an older revision is a stale read,
a removed entry is not a revocation), and secret-looking values.
`observe_root_grant` is the consumer rule from ADR-0017 section 3: a grant only
for tenant `platform`, from a valid record read at most 15 minutes ago,
otherwise no grant (fail closed).
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,
duplicate active grants and an entry both revoked and superseded.

View file

@ -0,0 +1,40 @@
{
"schema_version": "0.1",
"kind": "netkingdom-custody-record",
"record_id": "nk-custody:platform-root",
"revision": 1,
"updated_at": "2026-10-01T09:00:00Z",
"owner_system": "net-kingdom-custody",
"freshness": {
"max_cache_seconds": 3600,
"on_unreadable": "no-grant"
},
"entries": [
{
"entry_id": "king-1",
"subject": {
"issuer": "https://kc.coulomb.social/realms/netkingdom",
"subject": "00000000-0000-4000-8000-000000000001"
},
"grant": {
"scope_type": "tenant",
"scope_id": "platform",
"relation": "platform-root-custodian",
"owner_system": "net-kingdom-custody"
},
"evidence": {
"basis": "custody-ceremony",
"ref": "docs/platform-root-custody.md#king-credential-signoff"
},
"granted": {
"at": "2026-10-01T09:00:00Z",
"actor": {
"issuer": "https://kc.coulomb.social/realms/netkingdom",
"subject": "00000000-0000-4000-8000-000000000009"
},
"reason": "King credential assigned at custody ceremony",
"revision": 1
}
}
]
}

View file

@ -0,0 +1,66 @@
{
"schema_version": "0.1",
"kind": "netkingdom-custody-record",
"record_id": "nk-custody:platform-root",
"revision": 1,
"updated_at": "2026-10-01T09:00:00Z",
"owner_system": "net-kingdom-custody",
"freshness": {
"max_cache_seconds": 900,
"on_unreadable": "no-grant"
},
"entries": [
{
"entry_id": "king-1",
"subject": {
"issuer": "https://kc.coulomb.social/realms/netkingdom",
"subject": "00000000-0000-4000-8000-000000000001"
},
"grant": {
"scope_type": "tenant",
"scope_id": "platform",
"relation": "platform-root-custodian",
"owner_system": "net-kingdom-custody"
},
"evidence": {
"basis": "custody-ceremony",
"ref": "docs/platform-root-custody.md#king-credential-signoff"
},
"granted": {
"at": "2026-10-01T09:00:00Z",
"actor": {
"issuer": "https://kc.coulomb.social/realms/netkingdom",
"subject": "00000000-0000-4000-8000-000000000009"
},
"reason": "King credential assigned at custody ceremony",
"revision": 1
}
},
{
"entry_id": "king-1b",
"subject": {
"issuer": "https://kc.coulomb.social/realms/netkingdom",
"subject": "00000000-0000-4000-8000-000000000001"
},
"grant": {
"scope_type": "tenant",
"scope_id": "platform",
"relation": "platform-root-custodian",
"owner_system": "net-kingdom-custody"
},
"evidence": {
"basis": "custody-ceremony",
"ref": "docs/platform-root-custody.md#king-credential-signoff"
},
"granted": {
"at": "2026-10-01T09:00:00Z",
"actor": {
"issuer": "https://kc.coulomb.social/realms/netkingdom",
"subject": "00000000-0000-4000-8000-000000000009"
},
"reason": "King credential assigned at custody ceremony",
"revision": 1
}
}
]
}

View file

@ -0,0 +1,40 @@
{
"schema_version": "0.1",
"kind": "netkingdom-custody-record",
"record_id": "nk-custody:platform-root",
"revision": 1,
"updated_at": "2026-10-01T09:00:00Z",
"owner_system": "net-kingdom-custody",
"freshness": {
"max_cache_seconds": 900,
"on_unreadable": "no-grant"
},
"entries": [
{
"entry_id": "king-1",
"subject": {
"issuer": "https://kc.coulomb.social/realms/netkingdom",
"subject": "00000000-0000-4000-8000-000000000001"
},
"grant": {
"scope_type": "tenant",
"scope_id": "acme",
"relation": "platform-root-custodian",
"owner_system": "net-kingdom-custody"
},
"evidence": {
"basis": "custody-ceremony",
"ref": "docs/platform-root-custody.md#king-credential-signoff"
},
"granted": {
"at": "2026-10-01T09:00:00Z",
"actor": {
"issuer": "https://kc.coulomb.social/realms/netkingdom",
"subject": "00000000-0000-4000-8000-000000000009"
},
"reason": "King credential assigned at custody ceremony",
"revision": 1
}
}
]
}

View file

@ -0,0 +1,40 @@
{
"schema_version": "0.1",
"kind": "netkingdom-custody-record",
"record_id": "nk-custody:platform-root",
"revision": 1,
"updated_at": "2026-10-01T09:00:00Z",
"owner_system": "net-kingdom-custody",
"freshness": {
"max_cache_seconds": 900,
"on_unreadable": "no-grant"
},
"entries": [
{
"entry_id": "king-1",
"subject": {
"issuer": "https://kc.coulomb.social/realms/netkingdom",
"subject": "00000000-0000-4000-8000-000000000001"
},
"grant": {
"scope_type": "tenant",
"scope_id": "platform",
"relation": "platform-root-custodian",
"owner_system": "net-kingdom-custody"
},
"evidence": {
"basis": "projection",
"ref": "lldap group platform-root"
},
"granted": {
"at": "2026-10-01T09:00:00Z",
"actor": {
"issuer": "https://kc.coulomb.social/realms/netkingdom",
"subject": "00000000-0000-4000-8000-000000000009"
},
"reason": "King credential assigned at custody ceremony",
"revision": 1
}
}
]
}

View file

@ -0,0 +1,43 @@
{
"schema_version": "0.1",
"kind": "netkingdom-custody-record",
"record_id": "nk-custody:platform-root",
"revision": 1,
"updated_at": "2026-10-01T09:00:00Z",
"owner_system": "net-kingdom-custody",
"freshness": {
"max_cache_seconds": 900,
"on_unreadable": "no-grant"
},
"entries": [
{
"entry_id": "king-1",
"subject": {
"issuer": "https://kc.coulomb.social/realms/netkingdom",
"subject": "00000000-0000-4000-8000-000000000001"
},
"grant": {
"scope_type": "tenant",
"scope_id": "platform",
"relation": "platform-root-custodian",
"owner_system": "net-kingdom-custody"
},
"evidence": {
"basis": "custody-ceremony",
"ref": "docs/platform-root-custody.md#king-credential-signoff"
},
"granted": {
"at": "2026-10-01T09:00:00Z",
"actor": {
"issuer": "https://kc.coulomb.social/realms/netkingdom",
"subject": "00000000-0000-4000-8000-000000000009"
},
"reason": "King credential assigned at custody ceremony",
"revision": 1
},
"recovery_codes": [
"<redacted-fixture>"
]
}
]
}

View file

@ -0,0 +1,53 @@
{
"schema_version": "0.1",
"kind": "netkingdom-custody-record",
"record_id": "nk-custody:platform-root",
"revision": 2,
"updated_at": "2026-10-01T09:00:00Z",
"owner_system": "net-kingdom-custody",
"freshness": {
"max_cache_seconds": 900,
"on_unreadable": "no-grant"
},
"entries": [
{
"entry_id": "king-1",
"subject": {
"issuer": "https://kc.coulomb.social/realms/netkingdom",
"subject": "00000000-0000-4000-8000-000000000001"
},
"grant": {
"scope_type": "tenant",
"scope_id": "platform",
"relation": "platform-root-custodian",
"owner_system": "net-kingdom-custody"
},
"evidence": {
"basis": "custody-ceremony",
"ref": "docs/platform-root-custody.md#king-credential-signoff"
},
"granted": {
"at": "2026-10-01T09:00:00Z",
"actor": {
"issuer": "https://kc.coulomb.social/realms/netkingdom",
"subject": "00000000-0000-4000-8000-000000000009"
},
"reason": "King credential assigned at custody ceremony",
"revision": 1
},
"revoked": {
"at": "2026-10-02T09:00:00Z",
"actor": {
"issuer": "https://kc.coulomb.social/realms/netkingdom",
"subject": "00000000-0000-4000-8000-000000000009"
},
"reason": "x",
"revision": 2
},
"superseded_by": {
"entry_id": "king-1",
"revision": 2
}
}
]
}

View file

@ -0,0 +1,41 @@
{
"schema_version": "0.1",
"kind": "netkingdom-custody-record",
"record_id": "nk-custody:platform-root",
"revision": 1,
"updated_at": "2026-10-01T09:00:00Z",
"owner_system": "net-kingdom-custody",
"freshness": {
"max_cache_seconds": 900,
"on_unreadable": "no-grant"
},
"entries": [
{
"entry_id": "king-1",
"subject": {
"issuer": "https://kc.coulomb.social/realms/netkingdom",
"subject": "00000000-0000-4000-8000-000000000001"
},
"grant": {
"scope_type": "tenant",
"scope_id": "platform",
"relation": "platform-root-custodian",
"owner_system": "net-kingdom-custody"
},
"evidence": {
"basis": "custody-ceremony",
"ref": "docs/platform-root-custody.md#king-credential-signoff"
},
"granted": {
"at": "2026-10-01T09:00:00Z",
"actor": {
"issuer": "https://kc.coulomb.social/realms/netkingdom",
"subject": "00000000-0000-4000-8000-000000000009"
},
"reason": "King credential assigned at custody ceremony",
"revision": 1
},
"root_token": "<redacted-fixture>"
}
]
}

View file

@ -0,0 +1,40 @@
{
"schema_version": "0.1",
"kind": "netkingdom-custody-record",
"record_id": "nk-custody:platform-root",
"revision": 1,
"updated_at": "2026-10-01T09:00:00Z",
"owner_system": "net-kingdom-custody",
"freshness": {
"max_cache_seconds": 900,
"on_unreadable": "no-grant"
},
"entries": [
{
"entry_id": "king-1",
"subject": {
"issuer": "https://kc.coulomb.social/realms/netkingdom",
"subject": "00000000-0000-4000-8000-000000000001"
},
"grant": {
"scope_type": "tenant",
"scope_id": "platform",
"relation": "platform-root-custodian",
"owner_system": "net-kingdom-custody"
},
"evidence": {
"basis": "custody-ceremony",
"ref": "docs/platform-root-custody.md#king-credential-signoff"
},
"granted": {
"at": "2026-10-01T09:00:00Z",
"actor": {
"issuer": "https://kc.coulomb.social/realms/netkingdom",
"subject": "00000000-0000-4000-8000-000000000009"
},
"reason": "handover hvs.FIXTURE-NOT-A-TOKEN",
"revision": 1
}
}
]
}

View file

@ -0,0 +1,40 @@
{
"schema_version": "0.1",
"kind": "netkingdom-custody-record",
"record_id": "nk-custody:platform-root",
"revision": 1,
"updated_at": "2026-10-01T09:00:00Z",
"owner_system": "net-kingdom-custody",
"freshness": {
"max_cache_seconds": 900,
"on_unreadable": "no-grant"
},
"entries": [
{
"entry_id": "king-1",
"subject": {
"issuer": "https://kc.coulomb.social/realms/netkingdom",
"subject": "00000000-0000-4000-8000-000000000001"
},
"grant": {
"scope_type": "tenant",
"scope_id": "platform",
"relation": "tenant-admin",
"owner_system": "net-kingdom-custody"
},
"evidence": {
"basis": "tenant-admin",
"ref": "tenant:platform admin role"
},
"granted": {
"at": "2026-10-01T09:00:00Z",
"actor": {
"issuer": "https://kc.coulomb.social/realms/netkingdom",
"subject": "00000000-0000-4000-8000-000000000009"
},
"reason": "King credential assigned at custody ceremony",
"revision": 1
}
}
]
}

View file

@ -0,0 +1,43 @@
{
"schema_version": "0.1",
"kind": "netkingdom-custody-record",
"record_id": "nk-custody:platform-root",
"revision": 1,
"updated_at": "2026-10-01T09:00:00Z",
"owner_system": "net-kingdom-custody",
"freshness": {
"max_cache_seconds": 900,
"on_unreadable": "no-grant"
},
"entries": [
{
"entry_id": "king-1",
"subject": {
"issuer": "https://kc.coulomb.social/realms/netkingdom",
"subject": "00000000-0000-4000-8000-000000000001"
},
"grant": {
"scope_type": "tenant",
"scope_id": "platform",
"relation": "platform-root-custodian",
"owner_system": "net-kingdom-custody"
},
"evidence": {
"basis": "custody-ceremony",
"ref": "docs/platform-root-custody.md#king-credential-signoff"
},
"granted": {
"at": "2026-10-01T09:00:00Z",
"actor": {
"issuer": "https://kc.coulomb.social/realms/netkingdom",
"subject": "00000000-0000-4000-8000-000000000009"
},
"reason": "King credential assigned at custody ceremony",
"revision": 1
}
}
],
"unseal_shares": [
"<redacted-fixture>"
]
}

View file

@ -0,0 +1,40 @@
{
"schema_version": "0.1",
"kind": "netkingdom-custody-record",
"record_id": "nk-custody:platform-root",
"revision": 1,
"updated_at": "2026-10-01T09:00:00Z",
"owner_system": "net-kingdom-custody",
"freshness": {
"max_cache_seconds": 900,
"on_unreadable": "no-grant"
},
"entries": [
{
"entry_id": "king-1",
"subject": {
"issuer": "https://kc.coulomb.social/realms/netkingdom",
"username": "tegwick"
},
"grant": {
"scope_type": "tenant",
"scope_id": "platform",
"relation": "platform-root-custodian",
"owner_system": "net-kingdom-custody"
},
"evidence": {
"basis": "custody-ceremony",
"ref": "docs/platform-root-custody.md#king-credential-signoff"
},
"granted": {
"at": "2026-10-01T09:00:00Z",
"actor": {
"issuer": "https://kc.coulomb.social/realms/netkingdom",
"subject": "00000000-0000-4000-8000-000000000009"
},
"reason": "King credential assigned at custody ceremony",
"revision": 1
}
}
]
}

View file

@ -0,0 +1,40 @@
{
"schema_version": "0.1",
"kind": "netkingdom-custody-record",
"record_id": "nk-custody:platform-root",
"revision": 1,
"updated_at": "2026-10-01T09:00:00Z",
"owner_system": "net-kingdom-custody",
"freshness": {
"max_cache_seconds": 900,
"on_unreadable": "no-grant"
},
"entries": [
{
"entry_id": "king-1",
"subject": {
"issuer": "https://kc.coulomb.social/realms/netkingdom",
"subject": "00000000-0000-4000-8000-000000000001"
},
"grant": {
"scope_type": "tenant",
"scope_id": "platform",
"relation": "platform-root-custodian",
"owner_system": "net-kingdom-custody"
},
"evidence": {
"basis": "custody-ceremony",
"ref": "docs/platform-root-custody.md#king-credential-signoff"
},
"granted": {
"at": "2026-10-01T09:00:00Z",
"actor": {
"issuer": "https://kc.coulomb.social/realms/netkingdom",
"subject": "00000000-0000-4000-8000-000000000009"
},
"reason": "King credential assigned at custody ceremony",
"revision": 1
}
}
]
}

View file

@ -0,0 +1,49 @@
{
"schema_version": "0.1",
"kind": "netkingdom-custody-record",
"record_id": "nk-custody:platform-root",
"revision": 2,
"updated_at": "2026-10-02T09:00:00Z",
"owner_system": "net-kingdom-custody",
"freshness": {
"max_cache_seconds": 900,
"on_unreadable": "no-grant"
},
"entries": [
{
"entry_id": "king-1",
"subject": {
"issuer": "https://kc.coulomb.social/realms/netkingdom",
"subject": "00000000-0000-4000-8000-000000000001"
},
"grant": {
"scope_type": "tenant",
"scope_id": "platform",
"relation": "platform-root-custodian",
"owner_system": "net-kingdom-custody"
},
"evidence": {
"basis": "custody-ceremony",
"ref": "docs/platform-root-custody.md#king-credential-signoff"
},
"granted": {
"at": "2026-10-01T09:00:00Z",
"actor": {
"issuer": "https://kc.coulomb.social/realms/netkingdom",
"subject": "00000000-0000-4000-8000-000000000009"
},
"reason": "King credential assigned at custody ceremony",
"revision": 1
},
"revoked": {
"at": "2026-10-02T09:00:00Z",
"actor": {
"issuer": "https://kc.coulomb.social/realms/netkingdom",
"subject": "00000000-0000-4000-8000-000000000009"
},
"reason": "Custodian stepped down; break-glass under king custody unaffected",
"revision": 2
}
}
]
}

View file

@ -0,0 +1,70 @@
{
"schema_version": "0.1",
"kind": "netkingdom-custody-record",
"record_id": "nk-custody:platform-root",
"revision": 2,
"updated_at": "2026-10-02T09:00:00Z",
"owner_system": "net-kingdom-custody",
"freshness": {
"max_cache_seconds": 900,
"on_unreadable": "no-grant"
},
"entries": [
{
"entry_id": "king-1",
"subject": {
"issuer": "https://kc.coulomb.social/realms/netkingdom",
"subject": "00000000-0000-4000-8000-000000000001"
},
"grant": {
"scope_type": "tenant",
"scope_id": "platform",
"relation": "platform-root-custodian",
"owner_system": "net-kingdom-custody"
},
"evidence": {
"basis": "custody-ceremony",
"ref": "docs/platform-root-custody.md#king-credential-signoff"
},
"granted": {
"at": "2026-10-01T09:00:00Z",
"actor": {
"issuer": "https://kc.coulomb.social/realms/netkingdom",
"subject": "00000000-0000-4000-8000-000000000009"
},
"reason": "King credential assigned at custody ceremony",
"revision": 1
},
"superseded_by": {
"entry_id": "king-2",
"revision": 2
}
},
{
"entry_id": "king-2",
"subject": {
"issuer": "https://kc.coulomb.social/realms/netkingdom",
"subject": "00000000-0000-4000-8000-000000000002"
},
"grant": {
"scope_type": "tenant",
"scope_id": "platform",
"relation": "platform-root-custodian",
"owner_system": "net-kingdom-custody"
},
"evidence": {
"basis": "custody-ceremony",
"ref": "docs/platform-root-custody.md#king-credential-signoff"
},
"granted": {
"at": "2026-10-02T09:00:00Z",
"actor": {
"issuer": "https://kc.coulomb.social/realms/netkingdom",
"subject": "00000000-0000-4000-8000-000000000009"
},
"reason": "Custody handed over to the successor king credential",
"revision": 2
}
}
]
}

View file

@ -0,0 +1,140 @@
from __future__ import annotations
import copy
import datetime as dt
import importlib.util
import json
import pathlib
import unittest
import jsonschema
HERE = pathlib.Path(__file__).resolve().parent
MODULE_PATH = HERE.parent / "validate.py"
SPEC = importlib.util.spec_from_file_location("custody_record_validate", MODULE_PATH)
assert SPEC and SPEC.loader
V = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(V)
FIXTURES = HERE.parent / "fixtures"
ISS = "https://kc.coulomb.social/realms/netkingdom"
KING_1 = "00000000-0000-4000-8000-000000000001"
KING_2 = "00000000-0000-4000-8000-000000000002"
READ_AT = dt.datetime(2026, 10, 3, 9, 0, tzinfo=dt.timezone.utc)
def load(name: str) -> dict:
return json.loads((FIXTURES / name).read_text(encoding="utf-8"))
class SchemaTests(unittest.TestCase):
def test_schema_is_valid_draft_2020_12(self) -> None:
jsonschema.Draft202012Validator.check_schema(V.load_schema())
def test_valid_fixtures_pass(self) -> None:
for name in ("grant", "revocation", "supersession"):
with self.subTest(name=name):
self.assertEqual(V.validate(load(f"valid/{name}.json")), [])
def test_every_invalid_fixture_fails_for_its_reason(self) -> None:
expected = {
"projection-only-grant": "custody-ceremony",
"tenant-admin-implication": "platform-root-custodian",
"other-tenant-grant": "'platform' was expected",
"secret-token-field": "root_token",
"recovery-code-field": "recovery_codes",
"unseal-share-field": "unseal_shares",
"secret-value-in-reason": "secret material",
"username-key": "username",
"cache-over-bound": "maximum of 900",
"duplicate-active-subject": "second active grant",
"revoked-and-superseded": "should not be valid",
}
found = {path.stem for path in (FIXTURES / "invalid").glob("*.json")}
self.assertEqual(found, set(expected))
for name, needle in expected.items():
with self.subTest(name=name):
errors = V.validate(load(f"invalid/{name}.json"))
self.assertTrue(errors)
self.assertIn(needle, "\n".join(errors))
def test_secret_names_rejected_at_any_depth(self) -> None:
for field in ("api_key", "privateKey", "reset_link", "otp_seed", "password"):
with self.subTest(field=field):
record = load("valid/grant.json")
record["entries"][0]["evidence"][field] = "<redacted-fixture>"
self.assertTrue(V.validate(record))
def test_revision_ordering_inside_a_record(self) -> None:
record = load("valid/revocation.json")
record["entries"][0]["revoked"]["revision"] = 1
self.assertIn("revoked.revision", "\n".join(V.validate(record)))
record = load("valid/grant.json")
record["entries"][0]["granted"]["revision"] = 2
self.assertIn("after record revision", "\n".join(V.validate(record)))
def test_supersession_must_point_at_the_successor_grant(self) -> None:
record = load("valid/supersession.json")
record["entries"][0]["superseded_by"]["entry_id"] = "missing"
self.assertIn("names no other entry", "\n".join(V.validate(record)))
class SuccessorTests(unittest.TestCase):
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")), [])
def test_older_revision_is_a_stale_read(self) -> None:
errors = V.successor_errors(load("valid/revocation.json"), load("valid/grant.json"))
self.assertIn("stale read", errors[0])
def test_same_revision_different_content_rejected(self) -> None:
later = load("valid/grant.json")
later["entries"][0]["granted"]["reason"] = "rewritten"
self.assertTrue(V.successor_errors(load("valid/grant.json"), later))
def test_deleting_an_entry_is_not_a_revocation(self) -> None:
later = load("valid/grant.json")
later["revision"] = 2
later["entries"] = []
self.assertIn("never delete", "\n".join(V.successor_errors(load("valid/grant.json"), later)))
class ObservationTests(unittest.TestCase):
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)
)
def test_active_entry_yields_adr_0017_observation(self) -> None:
observation = self.observe(load("valid/grant.json"))
self.assertEqual(observation["grant"]["scope_id"], "platform")
self.assertEqual(observation["grant"]["relation"], "platform-root-custodian")
self.assertEqual(observation["source"], {"record_id": "nk-custody:platform-root", "revision": 1})
self.assertEqual(set(observation), {"subject", "grant", "source", "observed_at"})
def test_fail_closed_cases(self) -> None:
grant = load("valid/grant.json")
cases = {
"other tenant": dict(record=grant, tenant="acme"),
"unreadable record": dict(record=None),
"stale beyond 15 minutes": dict(record=grant, age=901),
"unknown subject": dict(record=grant, subject=KING_2),
"different issuer": dict(record=grant, issuer="https://other.example"),
"revoked": dict(record=load("valid/revocation.json")),
"superseded": dict(record=load("valid/supersession.json")),
"invalid record": dict(record=load("invalid/projection-only-grant.json")),
}
for name, kwargs in cases.items():
with self.subTest(name=name):
self.assertIsNone(self.observe(**kwargs))
def test_cache_bound_edge_and_successor(self) -> None:
self.assertIsNotNone(self.observe(load("valid/grant.json"), age=900))
self.assertIsNotNone(self.observe(load("valid/supersession.json"), subject=KING_2))
tighter = copy.deepcopy(load("valid/grant.json"))
tighter["freshness"]["max_cache_seconds"] = 60
self.assertIsNone(self.observe(tighter, age=61))
if __name__ == "__main__":
unittest.main()

View file

@ -0,0 +1,207 @@
#!/usr/bin/env python3
# /// script
# dependencies = ["jsonschema>=4.10,<5"]
# ///
"""Validate platform-root custody records (NK-ADR-0017, NK-WP-0045-T03).
Checks the JSON Schema (`canon/schemas/custody-record_v0.1.schema.json`) and
the semantics the schema cannot express: unique entries, at most one active
grant per exact (issuer, subject), revision ordering, supersession links,
append-only successors, and secret-looking values. It also provides the
fail-closed read rule a consumer applies to an observation (15-minute bound).
Offline and read-only: it never contacts an issuer, OpenBao or a directory.
"""
from __future__ import annotations
import argparse
import datetime as dt
import json
import pathlib
import re
import sys
from typing import Any
import jsonschema
ROOT = pathlib.Path(__file__).resolve().parents[2]
SCHEMA = ROOT / "canon/schemas/custody-record_v0.1.schema.json"
MAX_CACHE_SECONDS = 900
# Value shapes that look like secret material (OpenBao/Vault tokens, PEM keys,
# JWTs). Field names are rejected by the schema; this catches smuggled values.
SECRET_VALUE = re.compile(
r"(\bhv[sbr]\.[A-Za-z0-9_-]{8,}|\bs\.[A-Za-z0-9]{20,}"
r"|-----BEGIN [A-Z ]*PRIVATE KEY-----"
r"|\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.)"
)
def load_schema() -> dict[str, Any]:
return json.loads(SCHEMA.read_text(encoding="utf-8"))
def parse_time(value: str) -> dt.datetime:
return dt.datetime.fromisoformat(value.replace("Z", "+00:00"))
def key(entry: dict[str, Any]) -> tuple[str, str]:
return (entry["subject"]["issuer"], entry["subject"]["subject"])
def is_active(entry: dict[str, Any]) -> bool:
return "revoked" not in entry and "superseded_by" not in entry
def schema_errors(document: Any, schema: dict[str, Any]) -> list[str]:
validator = jsonschema.Draft202012Validator(
schema, format_checker=jsonschema.FormatChecker()
)
errors = []
for error in sorted(validator.iter_errors(document), key=lambda e: list(e.path)):
where = "/".join(str(part) for part in error.path) or "<root>"
errors.append(f"schema: {where}: {error.message}")
return errors
def _walk_strings(node: Any, path: str = ""):
if isinstance(node, dict):
for name, value in node.items():
yield from _walk_strings(value, f"{path}/{name}")
elif isinstance(node, list):
for index, value in enumerate(node):
yield from _walk_strings(value, f"{path}/{index}")
elif isinstance(node, str):
yield path or "<root>", node
def semantic_errors(record: dict[str, Any]) -> list[str]:
errors: list[str] = []
for where, value in _walk_strings(record):
if SECRET_VALUE.search(value):
errors.append(f"{where}: value looks like secret material")
revision = record["revision"]
entries = record["entries"]
by_id: dict[str, dict[str, Any]] = {}
for entry in entries:
if entry["entry_id"] in by_id:
errors.append(f"{entry['entry_id']}: duplicate entry_id")
by_id[entry["entry_id"]] = entry
active: dict[tuple[str, str], str] = {}
for entry in entries:
eid = entry["entry_id"]
granted = entry["granted"]
if granted["revision"] > revision:
errors.append(f"{eid}: granted.revision is after record revision")
if is_active(entry):
if key(entry) in active:
errors.append(
f"{eid}: second active grant for {key(entry)} "
f"(already {active[key(entry)]})"
)
active[key(entry)] = eid
revoked = entry.get("revoked")
if revoked:
if not granted["revision"] < revoked["revision"] <= revision:
errors.append(f"{eid}: revoked.revision must follow the grant and not exceed the record revision")
if parse_time(revoked["at"]) < parse_time(granted["at"]):
errors.append(f"{eid}: revoked before granted")
link = entry.get("superseded_by")
if link:
successor = by_id.get(link["entry_id"])
if successor is None or link["entry_id"] == eid:
errors.append(f"{eid}: superseded_by names no other entry")
else:
if successor["granted"]["revision"] != link["revision"]:
errors.append(f"{eid}: superseded_by.revision must equal the successor's granted.revision")
if parse_time(successor["granted"]["at"]) < parse_time(granted["at"]):
errors.append(f"{eid}: successor granted before the superseded entry")
if not granted["revision"] < link["revision"] <= revision:
errors.append(f"{eid}: superseded_by.revision must follow the grant and not exceed the record revision")
return errors
def validate(record: Any, schema: dict[str, Any] | None = None) -> list[str]:
errors = schema_errors(record, schema or load_schema())
if errors:
return errors
return semantic_errors(record)
def successor_errors(previous: dict[str, Any], current: dict[str, Any]) -> list[str]:
"""A later read must be a strictly newer, append-only revision."""
errors: list[str] = []
if previous["record_id"] != current["record_id"]:
return ["record_id changed between revisions"]
if current["revision"] < previous["revision"]:
return ["revision went backwards: stale read, keep the newer observation"]
if current["revision"] == previous["revision"]:
if current != previous:
errors.append("same revision with different content")
return errors
now = {entry["entry_id"]: entry for entry in current["entries"]}
for entry in previous["entries"]:
later = now.get(entry["entry_id"])
if later is None:
errors.append(f"{entry['entry_id']}: entry removed; custody actions append, never delete")
continue
for field in ("subject", "grant", "evidence", "granted"):
if later[field] != entry[field]:
errors.append(f"{entry['entry_id']}: {field} rewritten")
for field in ("revoked", "superseded_by"):
if field in entry and later.get(field) != entry[field]:
errors.append(f"{entry['entry_id']}: {field} rewritten")
return errors
def observe_root_grant(
record: dict[str, Any] | None,
issuer: str,
subject: str,
tenant: str,
read_at: dt.datetime,
now: dt.datetime,
) -> dict[str, Any] | None:
"""Return the ADR-0017 section 3 observation, or None (fail closed).
None when: the tenant is not `platform`; the record is missing, invalid or
older than the cache bound; or no active entry matches the exact
(issuer, subject).
"""
if tenant != "platform" or record is None or validate(record):
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):
return {
"subject": dict(entry["subject"]),
"grant": dict(entry["grant"]),
"source": {"record_id": record["record_id"], "revision": record["revision"]},
"observed_at": read_at.isoformat().replace("+00:00", "Z"),
}
return None
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
parser.add_argument("paths", nargs="+", type=pathlib.Path)
args = parser.parse_args(argv)
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)
failed = failed or bool(errors)
if not errors:
print(f"{path}: ok")
return 1 if failed else 0
if __name__ == "__main__":
sys.exit(main())

View file

@ -79,6 +79,18 @@ cannot decide it unilaterally.
2026-10-03: human gate routed to hub decision cef97ccd-4ee7-40fe-859e-1df9c9360ecb (D10: Identity record and MFA: accept NK-ADR-0017, choose the record read surface, agree the MFA user experience) by CUST-WP-0075; resolving that decision raises C-40 here.
2026-10-03 (CUST-WP-0075 inbox sweep): gate re-checked, unchanged. The unread
user-engine message of 2026-10-03 (`db1b7d6e`, USER-WP-0026-T03) was about the
workload catalogue/admission and access-decision owner, not this journey; it
was answered (reply `19a0cbcd`: access-engine owns decisions, grants,
revocation and the protected-system registry; the catalogue is the onboarding
binding of access-engine protected system, key-cape OIDC client and user-engine
application record; NetKingdom only orchestrates). It does not settle T02. Our
2026-09-27 request `892fd489` (pilot workload, U06 touchpoints) is still unread
by user-engine, so `blocked_on: message-from:user-engine` stays real, and the
founder's acceptance under decision cef97ccd stays the human gate. The reply
reminds user-engine of `892fd489`.
## Infrastructure review — 2026-09-28
Build T02 on the already completed USER-WP-0033 and KEY-WP-0035 P06 work.

View file

@ -41,7 +41,7 @@ ADR to `accepted`.
```task
id: NK-WP-0045-T02
status: todo
status: done
depends_on: [NK-WP-0045-T01]
priority: high
state_hub_task_id: "49d8dd25-b555-55e9-a481-037d2b98fcc5"
@ -52,11 +52,27 @@ Add `canon/schemas/custody-record_v0.1.schema.json`: record id and revision,
granted-at, superseded-by, revoked-at with actor and reason. Non-secret only;
the schema rejects token, key, share and recovery-code fields.
**Done 2026-10-03** (CUST-WP-0075, build mode; ADR-0017 stays `proposed`,
acceptance remains T01 under decision cef97ccd). Added
`canon/schemas/custody-record_v0.1.schema.json`: `record_id` +
monotonic per-record `revision`, `owner_system: net-kingdom-custody`, and
entries keyed by exact `(issuer, subject)` (no username/email/group key), each
with the fixed grant `{scope_type: tenant, scope_id: platform, relation:
platform-root-custodian, owner_system: net-kingdom-custody}`, ceremony evidence
(`basis: custody-ceremony` only, so projections cannot ground an entry),
`granted`, optional `superseded_by` (entry id + revision) or `revoked` (at,
actor, reason, revision), never both. A `freshness` block caps consumer cache at
900 s with `on_unreadable: no-grant`. Field names matching token, key, secret,
share, recovery, password, reset, seed, private, otp or credential are rejected
at every level. Incorporates user-engine's 2026-10-03 confirmation (message
`60acf176`): exact `(issuer, subject)` keying, monotonic revision, 15-minute
cache bound, fail closed. If T01 amends the ADR, revise this schema to match.
## Add a validator and fixtures
```task
id: NK-WP-0045-T03
status: todo
status: done
depends_on: [NK-WP-0045-T02]
priority: medium
state_hub_task_id: "6372f282-a12b-54b4-b2ea-327b20e58c8f"
@ -66,6 +82,18 @@ Validator with grant, revocation and supersession fixtures, plus negative cases:
projection-only grants, tenant-admin implication, secret-bearing fields.
Follow the existing tool layout under `tools/`.
**Done 2026-10-03** (CUST-WP-0075). `tools/custody-record/validate.py` (schema
plus semantics: one active grant per `(issuer, subject)`, revision ordering,
supersession links, secret-looking values; `successor_errors` for append-only,
strictly newer revisions so a stale read is distinguishable from a revocation;
`observe_root_grant` for the ADR-0017 section 3 fail-closed read rule).
Fixtures under `tools/custody-record/fixtures/`: valid grant, revocation,
supersession; invalid projection-only grant, tenant-admin implication, other
tenant, root-token / recovery-code / unseal-share fields, secret-looking value,
username key, cache over 900 s, duplicate active grant, revoked-and-superseded.
Tests `tools/custody-record/tests/test_custody_record.py` (13 passed;
`make custody-record-test`); full `pytest tests tools`: 131 passed.
## Decide the read surface
```task