NK-WP-0048-T12: register-client restarts the issuer and waits for readiness after a changed registry

Per coordinator r6. New register-client descriptor sha256:434c53e8...43cb;
1.2/1.3 compositions regenerated; 1.3 map and status-only ready_check updated.

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

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 696317@bnt-lap001
Assistant-Session: bab9e511-0915-45b3-926a-2fab552edff6
This commit is contained in:
tegwick 2026-10-23 07:06:48 +02:00
parent 3a68d05eb9
commit fad2c72750
6 changed files with 159 additions and 12 deletions

View file

@ -313,6 +313,10 @@ spec:
warden_executes: false
attended: true
value_handling: none
ready_check: after a changed registry the adapter stamps one restart of the single-replica
Recreate issuer and its status-only readiness verdict is ready (rollout 1/1/1, one restarted
Ready pod, discovery serves https://kc.coulomb.social with all endpoints); failed or uncertain
keeps the step uncertain for attended owner rollback or reconciliation (NK-WP-0048-T12)
attended_justification: 'The live KeyCape client registry is a Kubernetes Secret,
and agents do not read or write Secrets. Until KeyCape loads clients from a
non-secret registry with secrets referenced from custody, one attended operator
@ -495,4 +499,4 @@ spec:
ensure-groups: sha256:ebc9d5b9167d18e6015f4bbeb9699e8d396d64fbfaf195142781f84f4f47a70a
hand-over: sha256:1bce07a3229b7d0c7c730f35ce5b40fe471009a82de8dd53788ed970a9be1224
mint-secret: sha256:c92957227235db04953809d92b260d32c8b947472a8e8fd17a258ade3ae8da70
register-client: sha256:4ecc769f75433b66c7161f13c0fe7e5599633cf466d46cb6d8031f0456f426e7
register-client: sha256:434c53e88d4dd86a0325603468c3d6618570f38155484a76ed37d2f1550d43cb

View file

@ -150,11 +150,11 @@
"type_owner": "net-kingdom"
},
{
"candidate_descriptor_digest": "sha256:4ecc769f75433b66c7161f13c0fe7e5599633cf466d46cb6d8031f0456f426e7",
"candidate_descriptor_digest": "sha256:434c53e88d4dd86a0325603468c3d6618570f38155484a76ed37d2f1550d43cb",
"descriptor": {
"actor": "human",
"adapter": "net-kingdom/keycape-registration@v1",
"adapter_source": "sha256:b30f7abfbb5bf439c5ce067325b01c8b711076a38a6fb8484f55d5c8912921f3",
"adapter_source": "sha256:212172e34155d1852375b4c6c2868c3da82f5f8e7c7957f6c9eab1430880f6d3",
"allowed_created": [
"keycape-client"
],

View file

@ -15,7 +15,7 @@
"ensure-groups": "sha256:ebc9d5b9167d18e6015f4bbeb9699e8d396d64fbfaf195142781f84f4f47a70a",
"hand-over": "sha256:1bce07a3229b7d0c7c730f35ce5b40fe471009a82de8dd53788ed970a9be1224",
"mint-secret": "sha256:c92957227235db04953809d92b260d32c8b947472a8e8fd17a258ade3ae8da70",
"register-client": "sha256:4ecc769f75433b66c7161f13c0fe7e5599633cf466d46cb6d8031f0456f426e7"
"register-client": "sha256:434c53e88d4dd86a0325603468c3d6618570f38155484a76ed37d2f1550d43cb"
},
"owner_review": false,
"params": {
@ -154,11 +154,11 @@
"type_owner": "net-kingdom"
},
{
"candidate_descriptor_digest": "sha256:4ecc769f75433b66c7161f13c0fe7e5599633cf466d46cb6d8031f0456f426e7",
"candidate_descriptor_digest": "sha256:434c53e88d4dd86a0325603468c3d6618570f38155484a76ed37d2f1550d43cb",
"descriptor": {
"actor": "human",
"adapter": "net-kingdom/keycape-registration@v1",
"adapter_source": "sha256:b30f7abfbb5bf439c5ce067325b01c8b711076a38a6fb8484f55d5c8912921f3",
"adapter_source": "sha256:212172e34155d1852375b4c6c2868c3da82f5f8e7c7957f6c9eab1430880f6d3",
"allowed_created": [
"keycape-client"
],

View file

@ -24,13 +24,20 @@ from warden.case_receipt_state import Outcome, Refused
ROOT = Path(__file__).resolve().parent
SOURCE_FILES = ('keycape-registration-adapter.py', 'keycape-client.py',
'portal-client-rollout.py', 'openbao-client-config.py',
'register-user-engine-portal.py')
'register-user-engine-portal.py', 'restart-readiness.py')
REFERENCE = 'net-kingdom/keycape-registration@v1'
# Importing the maintained helper performs no tooling operation.
_spec = importlib.util.spec_from_file_location('registration_owner_helper', ROOT / 'keycape-client.py')
helper = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(helper)
_spec = importlib.util.spec_from_file_location('registration_restart', ROOT / 'restart-readiness.py')
restart_readiness = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(restart_readiness)
RESTART_KEYS = frozenset({'read', 'write', 'observe', 'sleep', 'issuer'})
# KeyCape loads clients at startup: a changed registry is not in effect until the
# issuer restarts. Bounded wait: 60 x 5 s.
RESTART_ATTEMPTS, RESTART_INTERVAL = 60, 5
@contextmanager
@ -85,7 +92,7 @@ def descriptor(check_config_digest):
def build(*, admitted: Invocation, case_source: bytes, expected_uid: str,
expected_resource_version: str, case_acceptance: str,
check_config: Path, check_config_digest: str, publish, authenticate,
revoke_session, holder=None, observe=None):
revoke_session, restart, holder=None, observe=None):
"""Bind one admitted case and observed revision; return a closed kernel Adapter.
publish(bytes)->identifier durably retains only the closed metadata payload.
@ -94,6 +101,14 @@ def build(*, admitted: Invocation, case_source: bytes, expected_uid: str,
session; Warden independently authenticates it using its own evidence verifier.
These callbacks are capabilities, never command strings or case parameters.
restart is a mapping of read()->Deployment, write(Deployment, expected_rv)->rv
(CAS), observe()->(Deployment, pods, discovery), sleep(seconds) and the
expected issuer URL. After any apply that changed the registry, the step
stamps one restart of the single-replica issuer and waits for the reviewed
readiness verdict (restart-readiness.py, NK-WP-0048-T12). Anything but
`ready` raises, so the kernel keeps the step uncertain for attended owner
rollback or reconciliation; it is never retried here.
holder(original_attempt_id)->[(identifier, bytes)] and observe()->secret are
optional read-only capabilities, injected together or not at all. With them
the Adapter gains reconcile_original: it confirms a creation only from one
@ -133,6 +148,10 @@ def build(*, admitted: Invocation, case_source: bytes, expected_uid: str,
raise Refused('acceptance evidence binding differs')
if not all(callable(x) for x in (publish, authenticate, revoke_session)):
raise Refused('owner evidence and revocation capabilities required')
if (not isinstance(restart, dict) or set(restart) != RESTART_KEYS
or not all(callable(restart[k]) for k in RESTART_KEYS - {'issuer'})
or not isinstance(restart['issuer'], str) or not restart['issuer'].startswith('https://')):
raise Refused('issuer restart and readiness capabilities required')
if (holder is None) != (observe is None) or not all(
x is None or callable(x) for x in (holder, observe)):
raise Refused('original-attempt holder and readback must be injected together')
@ -152,6 +171,21 @@ def build(*, admitted: Invocation, case_source: bytes, expected_uid: str,
if source_digest(check_config_digest) != reviewed_source:
raise Refused('owner source changed')
def restart_issuer(inv):
stamp = 'case-' + digest(inv.attempt_id.encode()).split(':', 1)[1][:32]
deployment = restart['read']()
revision = deployment.get('metadata', {}).get('resourceVersion')
planned = restart_readiness.plan_restart(deployment, stamp, revision)
written = restart['write'](planned, revision)
if not isinstance(written, str) or not 0 < len(written) <= 192:
raise Refused('restart write ambiguous; owner reconciliation required')
receipt = restart_readiness.wait_ready(restart['observe'], restart['sleep'], stamp=stamp,
expected_issuer=restart['issuer'], attempts=RESTART_ATTEMPTS, interval=RESTART_INTERVAL)
if receipt['verdict'] != 'ready':
raise Refused(f"issuer restart {receipt['verdict']}; owner reconciliation required")
return {'stamp': stamp, 'before': revision, 'after': written,
'verdict': receipt['verdict'], 'attempts': receipt['attempts']}
def execute(inv, operation, created=()):
nonlocal creation
try:
@ -192,6 +226,7 @@ def build(*, admitted: Invocation, case_source: bytes, expected_uid: str,
outcome = (Outcome.ROLLED_BACK if operation == 'remove' else
Outcome.SUCCESS if changed else Outcome.NO_CHANGE)
objects = ('keycape-client',) if operation == 'add' and changed else ()
restarted = restart_issuer(inv) if changed else None
finally:
# Even helper failure must end the parent session. Failures propagate
# contained to the kernel and become uncertain, never safe to replay.
@ -202,7 +237,7 @@ def build(*, admitted: Invocation, case_source: bytes, expected_uid: str,
payload = json.dumps({'schema': 'netkingdom.keycape-registration.attempt.v1',
'invocation': asdict(inv), 'source': reviewed_source,
'outcome': outcome.value, 'created': objects, 'before': receipt['before'],
'after': after, 'session_revocation': revocation},
'after': after, 'issuer_restart': restarted, 'session_revocation': revocation},
sort_keys=True, separators=(',', ':')).encode()
with contained_output():
evidence = publish(payload)

View file

@ -22,6 +22,49 @@ f = importlib.util.module_from_spec(spec)
spec.loader.exec_module(f)
ISSUER = 'https://kc.coulomb.social'
class FakeIssuer:
"""Single-replica Recreate issuer; becomes Ready once a restart stamp is written."""
def __init__(self, outcome='ready'):
self.outcome, self.writes, self.sleeps = outcome, [], []
self.deployment = {'kind': 'Deployment',
'metadata': {'name': 'keycape', 'namespace': 'sso', 'resourceVersion': '40', 'generation': 3},
'spec': {'replicas': 1, 'strategy': {'type': 'Recreate'},
'template': {'spec': {'containers': [{'name': 'keycape'}]}}},
'status': {'observedGeneration': 3, 'updatedReplicas': 1, 'readyReplicas': 1, 'availableReplicas': 1}}
def read(self):
return copy.deepcopy(self.deployment)
def write(self, deployment, expected):
assert expected == self.deployment['metadata']['resourceVersion']
self.writes.append(copy.deepcopy(deployment))
self.deployment = copy.deepcopy(deployment)
self.deployment['metadata']['resourceVersion'] = str(int(expected) + 1)
self.deployment['metadata']['generation'] += 1
self.deployment['status']['observedGeneration'] += 1
return self.deployment['metadata']['resourceVersion']
def observe(self):
stamp = self.deployment['spec']['template'].get('metadata', {}).get('annotations', {}).get(
a.restart_readiness.ANNOTATION)
state = {'waiting': {'reason': 'CrashLoopBackOff'}} if self.outcome == 'crash' else {'running': {}}
ready = 'False' if self.outcome in ('crash', 'stuck') else 'True'
pod = {'metadata': {'annotations': {a.restart_readiness.ANNOTATION: stamp}},
'status': {'phase': 'Running', 'conditions': [{'type': 'Ready', 'status': ready}],
'containerStatuses': [{'name': 'keycape', 'state': state}]}}
discovery = {'issuer': ISSUER, 'authorization_endpoint': ISSUER + '/authorize',
'token_endpoint': ISSUER + '/token', 'jwks_uri': ISSUER + '/jwks'}
return copy.deepcopy(self.deployment), [pod], discovery if ready == 'True' else None
def capabilities(self):
return {'read': self.read, 'write': self.write, 'observe': self.observe,
'sleep': self.sleeps.append, 'issuer': ISSUER}
class RegistrationTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
@ -44,7 +87,9 @@ class RegistrationTests(unittest.TestCase):
expected_uid=f.UID, expected_resource_version='7', case_acceptance='case-acceptance:test',
check_config=self.binary, check_config_digest=digest(self.binary.read_bytes()),
publish=self.publish, authenticate=lambda ref, payload: self.evidence.get(ref) == payload,
revoke_session=self.revoke)
revoke_session=self.revoke, restart=None)
self.issuer = FakeIssuer()
self.kw['restart'] = self.issuer.capabilities()
self.check_patch = patch.object(a.helper, 'check_config', return_value={'valid': True})
self.kube_patch = patch.object(a.helper.lane, 'kubectl', side_effect=self.kube)
self.check_patch.start()
@ -106,6 +151,47 @@ class RegistrationTests(unittest.TestCase):
self.assertNotIn(b'config.yaml', payload)
self.assertNotIn(b'signing-key', payload)
def test_changed_registry_restarts_issuer_and_records_ready_verdict(self):
adapter = a.build(**self.kw)
result = adapter.invoke(self.inv)
self.assertEqual(result.outcome, Outcome.SUCCESS)
self.assertEqual(len(self.issuer.writes), 1)
payload = json.loads(self.evidence[result.evidence])
self.assertEqual(payload['issuer_restart']['verdict'], 'ready')
self.assertEqual(payload['issuer_restart']['before'], '40')
undone = adapter.rollback(replace(self.inv, attempt_id='attempt-two', operation='rollback'), result.created)
self.assertEqual(undone.outcome, Outcome.ROLLED_BACK)
self.assertEqual(len(self.issuer.writes), 2)
def test_no_change_does_not_restart_issuer(self):
self.secret, _ = f.fixture(f.EXISTING + [f.FORGEJO])
result = a.build(**self.kw).invoke(self.inv)
self.assertEqual(result.outcome, Outcome.NO_CHANGE)
self.assertFalse(self.issuer.writes)
self.assertIsNone(json.loads(self.evidence[result.evidence])['issuer_restart'])
def test_failed_or_stuck_restart_is_uncertain_and_revokes_session(self):
for outcome in ('crash', 'stuck'):
with self.subTest(outcome=outcome):
self.setUp()
self.issuer = FakeIssuer(outcome)
adapter = a.build(**dict(self.kw, restart=self.issuer.capabilities()))
with self.assertRaises(Refused):
adapter.invoke(self.inv)
self.assertEqual(len(self.issuer.writes), 1)
self.assertFalse(self.evidence)
self.assertEqual(self.revocations, [self.inv])
if outcome == 'stuck':
self.assertEqual(len(self.issuer.sleeps), a.RESTART_ATTEMPTS - 1)
def test_restart_capabilities_are_required_and_bounded(self):
caps = self.issuer.capabilities()
for bad in (None, {}, dict(caps, issuer='http://kc.coulomb.social'),
dict(caps, write='not-callable'), dict(caps, extra=print)):
with self.subTest(bad=bad if not isinstance(bad, dict) else sorted(bad)):
with self.assertRaisesRegex(Refused, 'restart'):
a.build(**dict(self.kw, restart=bad))
def test_adapter_refuses_symlink_fifo_and_oversized_source_before_helper(self):
symlink = self.binary.with_name('symlink')
symlink.symlink_to(self.binary)

View file

@ -630,7 +630,7 @@ publication receipt: Custodian
id: NK-WP-0048-T12
status: wait
priority: high
blocking_reason: "Restart/readiness source candidate delivered 2026-10-23 (sso-mfa/k8s/keycape/restart-readiness.py, 11 synthetic tests). Awaits the coordinator's 1.3 placement decision (ready_check vs NetKingdom step vs carried limit) at the T16 re-freeze, then owner review and descriptor wiring."
blocking_reason: "Wired into register-client 2026-10-23 per coordinator r6 recommendation (descriptor sha256:434c53e8..43cb; 1.3 map + ready_check updated). Awaits coordinator r7 together with the F3 fix; key-cape to confirm readiness criteria."
state_hub_task_id: "7ce4eee3-cd47-55a9-bfa5-4f1835d4d6fd"
```
@ -682,6 +682,28 @@ Separately: `test_adapter_source_closure.py` already fails 7 probes in this
workstation environment, with or without this change, because the probe
subprocess cannot import `warden`.
2026-10-23 wiring. Coordinator r6 (Custodian `README-r6.md`, a870092)
recommended putting the restart inside register-client rather than as a bare
ready_check or an eighth step. Done:
- `keycape-registration-adapter.py` now requires a `restart` capability bundle
(read, CAS write, observe, sleep, https issuer). After any apply that changed
the registry, in either direction, it stamps one restart and waits for
readiness (at most 60 x 5 s). `failed` or `uncertain` raises, so the kernel
keeps the step uncertain; the session is still revoked.
- The attempt payload records `issuer_restart`, and `restart-readiness.py` joins
the source closure.
- 4 new tests: ready, no-change without restart, crash/stuck uncertain, and
bounded capabilities.
- The register-client descriptor is now `sha256:434c53e8…43cb` (was
`4ecc769f…26e7`). The 1.2/1.3 compositions are regenerated, and the 1.3 map
plus a status-only `ready_check` on register-client are updated.
- The keycape and type suites pass: 363 passed, 5 skipped.
The source-closure failure noted above was a relative-PYTHONPATH artifact; fixed
in 3a68d05. Open, from r6: readiness does not prove the new client was loaded
(key-cape to confirm the criteria), and bind-env's own restart has no readiness
check. Coordinator r7 reviews this together with the F3 fix.
## T13 — Prepare the fixed registration adapter source candidate
```task
@ -870,7 +892,7 @@ id: NK-WP-0048-T16
status: wait
depends_on: [NK-WP-0048-T24, NK-WP-0048-T25, NK-WP-0048-T26, NK-WP-0048-T27, NK-WP-0048-T28, NK-WP-0048-T29, NK-WP-0048-T30]
priority: high
blocking_reason: "Owner re-check of the 1.3 candidate cannot start: coordinator r5 (001a38c) left NK48-R5-F1..F3 open and their fixes changed 1.3 type.yaml (now ef02dd.., re-check froze e75fe9..). Needs re-freeze + coordinator r6 with no open findings; kernel review r2 is clean."
blocking_reason: "Coordinator r6 (the-custodian a870092) rejected: NK48-R5-F1/F2 resolved, F3 (consumer config inverse) open via T34 / RAPPS-WP-0016-T05. After F3 + T12 integration: re-freeze, coordinator r7, then owner re-check."
state_hub_task_id: "200bf500-5084-5ae7-be26-3033fe1f296e"
```