the-custodian/docs/changes/netkingdom-target-2026-10-16/browser-adapter-next/implementation.patch
codex b53eed4073
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 2s
Reject generated-only commits / generated-commit-guard (push) Successful in 13s
docs: preserve reviewed NetKingdom session artifacts
2026-11-01 22:46:55 +01:00

669 lines
38 KiB
Diff

--- /dev/null
+++ b/sso-mfa/k8s/keycape/keycape-registration-adapter.py
@@ -0,0 +1,180 @@
+"""Unexercised, injection-only attended registration seam for Warden's v1 kernel.
+
+No CLI, credentials, evidence trust root or live registration is installed here.
+Owner publication/authentication and parent-session revocation are mandatory
+injected capabilities. The kernel must independently admit both evidence kinds.
+"""
+from __future__ import annotations
+
+import argparse
+from dataclasses import asdict, replace
+import json
+import importlib.util
+from pathlib import Path
+
+import yaml
+
+from warden.case_run import Adapter, Descriptor, Invocation, OwnerResult, contained_output, digest, IDENTIFIER, DIGEST
+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')
+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)
+
+
+def source_digest(check_config_digest):
+ """Pin adapter plus the complete local helper import closure, in filename order."""
+ if not isinstance(check_config_digest, str) or not DIGEST.fullmatch(check_config_digest):
+ raise Refused('exact reviewed checker digest required')
+ composition = {'files': {name: digest((ROOT / name).read_bytes()) for name in SOURCE_FILES},
+ 'check_config_binary': check_config_digest}
+ return digest(json.dumps(composition, sort_keys=True, separators=(',', ':')).encode())
+
+
+def descriptor(check_config_digest):
+ return Descriptor('warden.case.execution.v1', 'register-client', 'net-kingdom',
+ REFERENCE, source_digest(check_config_digest), 'human', 'key-cape-oidc-login',
+ 'client_id', 'change', ('keycape-client',), False,
+ ('parent-session',))
+
+
+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):
+ """Bind one admitted case and observed revision; return a closed kernel Adapter.
+
+ publish(bytes)->identifier durably retains only the closed metadata payload.
+ authenticate(identifier, bytes)->True must verify independent owner custody.
+ revoke_session(invocation)->identifier must revoke this attempt's parent
+ session; Warden independently authenticates it using its own evidence verifier.
+ These callbacks are capabilities, never command strings or case parameters.
+
+ Only a verified creation in this process may be rolled back. Process loss,
+ write/readback ambiguity, publication/revocation failure and source drift
+ require owner reconciliation; none are converted to retry-safe failures.
+ """
+ reviewed_source = source_digest(check_config_digest)
+ desc = descriptor(check_config_digest)
+ if type(admitted) is not Invocation or not all(
+ isinstance(value, str) and IDENTIFIER.fullmatch(value)
+ for value in (admitted.step, admitted.owner, admitted.target,
+ admitted.actor, admitted.principal, admitted.lane)):
+ raise Refused('bounded invocation required')
+ if desc.adapter_source != reviewed_source:
+ raise Refused('owner source changed during binding')
+ if (admitted.step, admitted.owner, admitted.actor, admitted.lane) != (
+ desc.step, desc.owner, desc.actor, desc.lane):
+ raise Refused('registration invocation boundary differs')
+ if admitted.operation != 'admission' or admitted.attempt_id:
+ raise Refused('expected admission invocation')
+ if type(case_source) is not bytes or len(case_source) > 262144 or digest(case_source) != admitted.pins.case:
+ raise Refused('case source pin differs')
+ # Pins.adapter is the descriptor digest, not the owner source digest.
+ descriptor_bytes = json.dumps(asdict(desc), sort_keys=True, separators=(',', ':')).encode()
+ if admitted.pins.adapter != digest(descriptor_bytes):
+ raise Refused('descriptor pin differs')
+ if not all(isinstance(x, str) and 0 < len(x) <= 192 for x in
+ (expected_uid, expected_resource_version, case_acceptance)):
+ raise Refused('bounded attended binding required')
+ if digest(case_acceptance.encode()) != admitted.pins.acceptance:
+ 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')
+ with contained_output():
+ case = yaml.safe_load(case_source)
+ client = helper.client_from_case(case)
+ if admitted.target != client['clientId']:
+ raise Refused('case target differs')
+ check_config = Path(check_config)
+ records = {}
+ creation = None
+
+ def binding(inv, operation):
+ if (replace(inv, attempt_id='', operation='admission') != admitted
+ or inv.operation != operation or not IDENTIFIER.fullmatch(inv.attempt_id)):
+ raise Refused('attempt binding differs')
+ if source_digest(check_config_digest) != reviewed_source:
+ raise Refused('owner source changed')
+ if digest(check_config.read_bytes()) != check_config_digest:
+ raise Refused('check-config binary changed')
+
+ def execute(inv, operation, created=()):
+ nonlocal creation
+ try:
+ with contained_output():
+ binding(inv, 'run' if operation == 'add' else 'rollback')
+ uid, revision = expected_uid, expected_resource_version
+ if operation == 'remove':
+ if (created != ('keycape-client',) or creation is None
+ or not verify(*creation)):
+ raise Refused('no authenticated case-created client')
+ original_payload = json.loads(records[creation[1].evidence][1])
+ uid, revision = original_payload['after']['uid'], original_payload['after']['resource_version']
+ args = argparse.Namespace(mode='apply', operation=operation,
+ expected_uid=uid, expected_resource_version=revision,
+ case_acceptance=case_acceptance, check_config=str(check_config))
+ receipt = helper.run(args, client)
+ # The helper has completed CAS, whole-data readback and preservation.
+ # Never retain check-config diagnostics or arbitrary helper fields.
+ if not (receipt['schema'] == 'netkingdom.keycape-client.v1'
+ and receipt['target'] == 'sso/keycape-config'
+ and receipt['client_id'] == admitted.target
+ and receipt['mode'] == 'apply' and receipt['operation'] == operation
+ and receipt['values_emitted'] is False
+ and type(receipt['changed']) is bool
+ and receipt['unrelated_config_bytes_preserved'] is True
+ and receipt['other_secret_data_unchanged'] is True
+ and receipt['before'] == {'uid': uid, 'resource_version': revision}
+ and receipt['after']['uid'] == uid
+ and receipt['check_config']['proposed']['valid'] is True):
+ raise Refused('owner readback contract differs')
+ after = receipt['after']
+ if (set(after) != {'uid', 'resource_version'} or
+ not all(isinstance(v, str) and 0 < len(v) <= 192 for v in after.values())):
+ raise Refused('owner revision contract differs')
+ changed = receipt['changed']
+ 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 ()
+ finally:
+ # Even helper failure must end the parent session. Failures propagate
+ # contained to the kernel and become uncertain, never safe to replay.
+ with contained_output():
+ revocation = revoke_session(inv)
+ if not isinstance(revocation, str) or not IDENTIFIER.fullmatch(revocation):
+ raise Refused('session revocation evidence missing')
+ 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},
+ sort_keys=True, separators=(',', ':')).encode()
+ with contained_output():
+ evidence = publish(payload)
+ if not isinstance(evidence, str) or not IDENTIFIER.fullmatch(evidence) or evidence in records:
+ raise Refused('owner evidence identifier invalid or reused')
+ result = OwnerResult(outcome, evidence, objects, True, revocation,
+ attempt_id=inv.attempt_id, operation=inv.operation)
+ records[evidence] = (inv, payload, result)
+ if operation == 'add' and changed:
+ creation = (inv, result)
+ elif operation == 'remove':
+ creation = None
+ return result
+
+ def verify(inv, result):
+ record = records.get(result.evidence) if type(result) is OwnerResult else None
+ if record is None or record[0] != inv or record[2] != result:
+ return False
+ with contained_output():
+ return authenticate(result.evidence, record[1]) is True
+
+ return Adapter(REFERENCE, reviewed_source, lambda inv: execute(inv, 'add'),
+ verify, lambda inv, created: execute(inv, 'remove', created))
--- /dev/null
+++ b/sso-mfa/k8s/keycape/test_keycape_registration_adapter.py
@@ -0,0 +1,289 @@
+"""Actual helper/CAS execution against synthetic Kubernetes objects only."""
+from dataclasses import asdict, replace
+import copy
+import importlib.util
+import json
+from pathlib import Path
+import tempfile
+import unittest
+from unittest.mock import patch
+
+from warden.case_run import Invocation, digest
+from warden.case_receipt_state import Outcome, Pins, Refused
+import yaml
+
+ROOT = Path(__file__).parent
+spec = importlib.util.spec_from_file_location('registration_adapter', ROOT / 'keycape-registration-adapter.py')
+a = importlib.util.module_from_spec(spec)
+spec.loader.exec_module(a)
+spec = importlib.util.spec_from_file_location('registration_fixture', ROOT / 'test_keycape_client.py')
+f = importlib.util.module_from_spec(spec)
+spec.loader.exec_module(f)
+
+
+class RegistrationTests(unittest.TestCase):
+ def setUp(self):
+ self.temp = tempfile.TemporaryDirectory()
+ self.binary = Path(self.temp.name) / 'keycape'
+ self.binary.write_bytes(b'synthetic checker binary')
+ self.case = yaml.safe_dump({'spec': {'type': 'oidc-browser-relying-party@1.1',
+ 'parameters': f.FORGEJO_PARAMS}}).encode()
+ desc = a.descriptor(digest(self.binary.read_bytes()))
+ self.desc_bytes = json.dumps(asdict(desc), sort_keys=True, separators=(',', ':')).encode()
+ pins = Pins(digest(self.case), digest(b'type'), digest(self.desc_bytes),
+ digest(b'catalog'), digest(b'case-acceptance:test'), digest(b'run sheet'))
+ self.admitted = Invocation('register-client', 'net-kingdom', 'forgejo', 'human',
+ 'founder', 'key-cape-oidc-login', pins)
+ self.inv = replace(self.admitted, attempt_id='attempt-one', operation='run')
+ self.secret, self.raw = f.fixture()
+ self.effects = []
+ self.evidence = {}
+ self.revocations = []
+ self.kw = dict(admitted=self.admitted, case_source=self.case,
+ 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)
+ 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()
+ self.kube_patch.start()
+ self.addCleanup(self.check_patch.stop)
+ self.addCleanup(self.kube_patch.stop)
+ self.addCleanup(self.temp.cleanup)
+
+ def publish(self, payload):
+ ref = 'owner-evidence:attempt-' + str(len(self.evidence))
+ self.evidence[ref] = payload
+ return ref
+
+ def revoke(self, inv):
+ self.revocations.append(inv)
+ return 'session-revoked:' + inv.operation + ':' + inv.attempt_id
+
+ def kube(self, args, payload=None):
+ if args[:3] == ['get', 'namespace', 'kube-system']:
+ return {'metadata': {'uid': a.helper.lane.CLUSTER_UID}}
+ if args[:5] == ['-n', 'sso', 'get', 'secret', 'keycape-config']:
+ return copy.deepcopy(self.secret)
+ if args[:5] == ['-n', 'sso', 'get', 'deployment', 'keycape']:
+ return {'spec': {'template': {'spec': {'containers': [{'name': 'keycape',
+ 'env': [a.helper.env_binding(f.FORGEJO)]}]}}}}
+ if args[:5] == ['-n', 'sso', 'patch', 'secret', 'keycape-config']:
+ self.assertEqual(payload[:2], [
+ {'op': 'test', 'path': '/metadata/uid', 'value': self.secret['metadata']['uid']},
+ {'op': 'test', 'path': '/metadata/resourceVersion', 'value': self.secret['metadata']['resourceVersion']}])
+ self.effects.append(copy.deepcopy(payload))
+ self.secret['data']['config.yaml'] = payload[2]['value']
+ self.secret['metadata']['resourceVersion'] = str(int(self.secret['metadata']['resourceVersion']) + 1)
+ return copy.deepcopy(self.secret)
+ raise AssertionError('unexpected tooling operation')
+
+ def test_actual_add_and_created_only_rollback_preserve_original_bytes(self):
+ adapter = a.build(**self.kw)
+ original = copy.deepcopy(self.secret['data'])
+ result = adapter.invoke(self.inv)
+ self.assertEqual(result.outcome, Outcome.SUCCESS)
+ self.assertEqual(result.created, ('keycape-client',))
+ self.assertTrue(adapter.verify(self.inv, result))
+ undone = adapter.rollback(replace(self.inv, attempt_id='attempt-two', operation='rollback'), result.created)
+ self.assertEqual(undone.outcome, Outcome.ROLLED_BACK)
+ self.assertEqual(self.secret['data'], original)
+ self.assertEqual(len(self.effects), 2)
+ self.assertEqual(len(self.revocations), 2)
+ for payload in self.evidence.values():
+ self.assertNotIn(b'synthetic-marker', payload)
+ self.assertNotIn(b'config.yaml', payload)
+ self.assertNotIn(b'signing-key', payload)
+
+ def test_identical_client_is_no_change_and_cannot_be_removed(self):
+ self.secret, _ = f.fixture(f.EXISTING + [f.FORGEJO])
+ adapter = a.build(**self.kw)
+ result = adapter.invoke(self.inv)
+ self.assertEqual(result.outcome, Outcome.NO_CHANGE)
+ self.assertEqual(result.created, ())
+ with self.assertRaises(Refused):
+ adapter.rollback(replace(self.inv, operation='rollback'), ('keycape-client',))
+ self.assertFalse(self.effects)
+
+ def test_existing_drift_refused_and_session_revoked(self):
+ self.secret, _ = f.fixture(f.EXISTING + [dict(f.FORGEJO, displayName='Drift')])
+ with self.assertRaises(Refused):
+ a.build(**self.kw).invoke(self.inv)
+ self.assertFalse(self.effects)
+ self.assertEqual(self.revocations, [self.inv])
+
+ def test_invocation_rebinding_refused_before_tooling(self):
+ for changes in ({'target': 'other'}, {'principal': 'other'}, {'actor': 'agent'},
+ {'lane': 'other'}, {'operation': 'rollback'}, {'attempt_id': ''},
+ {'pins': replace(self.inv.pins, case=digest(b'other'))}):
+ with self.subTest(changes=changes), self.assertRaises(Refused):
+ a.build(**self.kw).invoke(replace(self.inv, **changes))
+ self.assertFalse(self.effects)
+
+ def test_initial_case_descriptor_and_target_pins_are_required(self):
+ for changes in ({'case_source': b'{}'}, {'case_acceptance': 'other'}, {'admitted': replace(self.admitted, target='other')},
+ {'admitted': replace(self.admitted, actor='agent')},
+ {'admitted': replace(self.admitted, pins=replace(self.admitted.pins, adapter=digest(b'wrong')))}):
+ with self.subTest(changes=changes), self.assertRaises(Refused):
+ a.build(**dict(self.kw, **changes))
+ self.assertFalse(self.effects)
+
+ def test_binary_or_source_drift_refused_before_tooling(self):
+ adapter = a.build(**self.kw)
+ self.binary.write_bytes(b'changed')
+ with self.assertRaises(Refused):
+ adapter.invoke(self.inv)
+ self.binary.write_bytes(b'synthetic checker binary')
+ with patch.object(a, 'source_digest', return_value=digest(b'changed')):
+ with self.assertRaises(Refused):
+ adapter.invoke(self.inv)
+ self.assertFalse(self.effects)
+
+ def test_substituting_caller_selected_checker_under_same_descriptor_is_refused(self):
+ substitute = Path(self.temp.name) / 'other-checker'
+ substitute.write_bytes(b'caller-selected executable')
+ with self.assertRaisesRegex(Refused, 'descriptor pin differs'):
+ a.build(**dict(self.kw, check_config=substitute,
+ check_config_digest=digest(substitute.read_bytes())))
+ self.assertFalse(self.effects)
+
+ def test_stale_revision_refuses_mutation(self):
+ adapter = a.build(**self.kw)
+ self.secret['metadata']['resourceVersion'] = '8'
+ with self.assertRaises(Refused):
+ adapter.invoke(self.inv)
+ self.assertFalse(self.effects)
+
+ def test_rollback_requires_exact_provenance_and_postcreation_revision(self):
+ adapter = a.build(**self.kw)
+ result = adapter.invoke(self.inv)
+ rollback = replace(self.inv, attempt_id='attempt-two', operation='rollback')
+ for created in ((), ('other',), ('keycape-client', 'other')):
+ with self.subTest(created=created), self.assertRaises(Refused):
+ adapter.rollback(rollback, created)
+ self.secret['metadata']['resourceVersion'] = '9'
+ with self.assertRaises(Refused):
+ adapter.rollback(rollback, result.created)
+ self.assertEqual(len(self.effects), 1)
+
+ def test_missing_or_tampered_evidence_never_verifies_or_authorizes_undo(self):
+ adapter = a.build(**self.kw)
+ result = adapter.invoke(self.inv)
+ self.assertFalse(adapter.verify(replace(self.inv, attempt_id='other'), result))
+ self.assertFalse(adapter.verify(self.inv, replace(result, revoked=False)))
+ self.evidence[result.evidence] = b'forged'
+ self.assertFalse(adapter.verify(self.inv, result))
+ with self.assertRaises(Refused):
+ adapter.rollback(replace(self.inv, operation='rollback'), result.created)
+ self.assertEqual(len(self.effects), 1)
+
+ def test_process_loss_does_not_reconstruct_created_authority(self):
+ result = a.build(**self.kw).invoke(self.inv)
+ fresh = a.build(**self.kw)
+ with self.assertRaises(Refused):
+ fresh.rollback(replace(self.inv, operation='rollback'), result.created)
+ self.assertIsNone(fresh.reconcile)
+ self.assertEqual(len(self.effects), 1)
+
+ def test_failed_readback_is_uncertain_and_never_provides_creation(self):
+ original_kube = self.kube
+ def lost(args, payload=None):
+ if self.effects and args[:5] == ['-n', 'sso', 'get', 'secret', 'keycape-config']:
+ raise RuntimeError('synthetic sensitive detail')
+ return original_kube(args, payload)
+ with patch.object(a.helper.lane, 'kubectl', side_effect=lost):
+ adapter = a.build(**self.kw)
+ with self.assertRaisesRegex(Refused, '^owner callback failed$'):
+ adapter.invoke(self.inv)
+ self.assertEqual(len(self.effects), 1)
+ self.assertFalse(self.evidence)
+ self.assertEqual(self.revocations, [self.inv])
+
+ def test_revocation_or_publication_failure_cannot_produce_verified_success(self):
+ for capability in ('publish', 'revoke_session'):
+ self.secret, _ = f.fixture()
+ with self.subTest(capability=capability):
+ kw = dict(self.kw, **{capability: lambda *args: (_ for _ in ()).throw(RuntimeError('private'))})
+ adapter = a.build(**kw)
+ with self.assertRaisesRegex(Refused, '^owner callback failed$'):
+ adapter.invoke(self.inv)
+ self.assertFalse(self.evidence)
+
+ def kernel_fixture(self):
+ # Fixture-only proposed type/case; canonical files are never edited.
+ # Warden's published producer pins apply unchanged. Owner trust and
+ # proposed descriptor/type/case are fixture-only, never native admission.
+ import warden.case_inputs as inputs
+ from warden.case_run import Executor, Journal
+ root = ROOT.parents[2]
+ case = yaml.safe_load((root / 'security-cases/forgejo-netkingdom-oidc.yaml').read_bytes())
+ case['spec']['status'] = 'materialized'
+ case_source = yaml.safe_dump(case).encode()
+ typ = yaml.safe_load((root / 'security-case-types/oidc-browser-relying-party/type.yaml').read_bytes())
+ typ['spec']['execution_descriptors'] = {'register-client': digest(self.desc_bytes)}
+ type_source = yaml.safe_dump(typ).encode()
+ catalog = b'fixture-only-catalog'
+ sheet = json.dumps(['register-client', 'net-kingdom', 'forgejo', 'human',
+ 'founder', 'key-cape-oidc-login', 'fixture-acceptance', 'fixture-materialization',
+ 'fixture-descriptor', 'fixture-session'], separators=(',', ':')).encode()
+ pins = Pins(digest(case_source), digest(type_source), digest(self.desc_bytes),
+ digest(catalog), digest(b'fixture-acceptance'), digest(sheet))
+ invocation = replace(self.admitted, pins=pins)
+ adapter = a.build(**dict(self.kw, admitted=invocation, case_source=case_source,
+ case_acceptance='fixture-acceptance'))
+ def fixture_verifier(purpose, source, evidence):
+ if purpose.startswith('session-revocation:'):
+ _, lane, operation, attempt = purpose.split(':')
+ return source == digest(case_source) and lane == invocation.lane and evidence == (
+ 'session-revoked:' + operation + ':' + attempt)
+ return evidence in {'fixture-acceptance', 'fixture-materialization',
+ 'fixture-descriptor', 'fixture-session'}
+ executor = Executor(Journal(Path(self.temp.name) / 'private'),
+ {adapter.reference: adapter}, fixture_verifier, browser_inputs=inputs.BrowserInputs(root))
+ admitted = executor.admit(case_source, type_source, self.desc_bytes, catalog,
+ principal='founder', actor='human', lane=invocation.lane,
+ acceptance_evidence='fixture-acceptance', materialization_evidence='fixture-materialization',
+ descriptor_evidence='fixture-descriptor', session_evidence='fixture-session')
+ self.assertEqual(admitted[1], invocation)
+ return executor, admitted
+
+ def test_actual_kernel_admission_dispatch_and_rollback_with_fixture_only_trust(self):
+ executor, admitted = self.kernel_fixture()
+ result = executor.run(*admitted)
+ self.assertEqual(result.last.outcome, Outcome.SUCCESS)
+ self.assertEqual(result.last.created, ('keycape-client',))
+ undone = executor.rollback(*admitted)
+ self.assertEqual(undone.last.outcome, Outcome.ROLLED_BACK)
+ self.assertEqual(len(self.effects), 2)
+ self.assertEqual(len(self.revocations), 2)
+
+ def test_kernel_lost_readback_retains_uncertain_and_refuses_replay_and_undo(self):
+ executor, admitted = self.kernel_fixture()
+ original_kube = self.kube
+ def lost(args, payload=None):
+ if self.effects and args[:5] == ['-n', 'sso', 'get', 'secret', 'keycape-config']:
+ raise RuntimeError('synthetic sensitive detail')
+ return original_kube(args, payload)
+ with patch.object(a.helper.lane, 'kubectl', side_effect=lost):
+ result = executor.run(*admitted)
+ self.assertEqual(result.last.outcome, Outcome.UNCERTAIN)
+ self.assertFalse(result.last.created)
+ with self.assertRaises(Refused):
+ executor.run(*admitted, retry=True)
+ with self.assertRaises(Refused):
+ executor.rollback(*admitted)
+ self.assertEqual(len(self.effects), 1)
+ self.assertEqual(len(self.revocations), 1)
+
+ def test_kernel_external_revision_change_retains_uncertain_rollback(self):
+ executor, admitted = self.kernel_fixture()
+ self.assertEqual(executor.run(*admitted).last.outcome, Outcome.SUCCESS)
+ self.secret['metadata']['resourceVersion'] = '9'
+ self.assertEqual(executor.rollback(*admitted).last.outcome, Outcome.ROLLBACK_UNCERTAIN)
+ self.assertEqual(len(self.effects), 1)
+ self.assertEqual(len(self.revocations), 2)
+
+
+if __name__ == '__main__':
+ unittest.main()
--- /dev/null
+++ b/sso-mfa/k8s/keycape/registration-adapter.md
@@ -0,0 +1,59 @@
+# Attended registration adapter candidate
+
+Exercise status: unexercised
+
+This is a source-only, injection-only adapter for the existing Warden closed
+kernel. It installs no command, runtime dispatcher, credential provider, evidence
+trust root or new service. NetKingdom type/case status and bytes remain unchanged.
+The generated descriptor is a proposal; it is not owner acknowledgment.
+
+`keycape-registration-adapter.py` wraps maintained `keycape-client.run` in apply
+mode. A fixed invocation binds principal, human actor, lane, case/type/descriptor/
+catalog/acceptance/run-sheet digests and target. The descriptor pins this adapter
+and its complete local helper import closure together with the exact reviewed
+`check-config` executable digest. Changing that digest changes the descriptor
+source pin and descriptor bytes, requiring independently reviewed admission.
+The fixed executable is rehashed before each attempt. The caller constructs the
+proposed invocation from exact source inputs, installs the fixed adapter once,
+and calls normal `Executor.admit`; independently derived admission must match.
+No callable replacement after admission is required or supported by this recipe.
+
+The operation uses existing cluster/issuer checks, byte-preserving merge,
+Deployment secret-env prerequisite, config validation, UID/resourceVersion CAS
+and whole-data readback. Only closed metadata is passed to the injected durable
+owner publisher. The owner authenticator must independently authenticate those
+exact bytes. The session-revocation capability must end the same attempt's
+parent session; Warden separately verifies its exact revocation evidence.
+These capabilities are owner-selected callables, never case-supplied commands.
+No implementation of these native capabilities is supplied or implied here.
+
+An identical existing client returns no change and creates no rollback authority.
+Removal requires this process's independently authenticated successful creation
+receipt, exact created-object label, unchanged postcreation UID/resourceVersion,
+and the original exact client record. External drift, a lost readback, receipt
+publication/revocation failure or process loss requires owner reconciliation.
+No uncertainty is labeled retry-safe. No automatic reconciliation is installed.
+Rollback after restart intentionally remains unavailable until an owner supplies
+independently authenticated durable creation provenance and reviews recovery.
+
+The source tests execute actual maintained helper CAS/readback against synthetic
+Kubernetes objects. Three checks exercise actual Warden admission/run/rollback
+and uncertainty transitions with synthetic owner trust. Warden d97652a's exact
+published producer source pins apply unchanged through actual BrowserInputs.
+This is a source compatibility fixture, not acknowledgment, owner provenance,
+normal native admission or deployment acceptance.
+
+Remaining work belongs in live work records before T12 closure:
+
+- Warden owns global evidence admission and exact native evidence/session
+ capabilities. Published d97652a refreshes BrowserInputs producer pins for
+ NetKingdom 015694ee; that source assent does not attest this new adapter.
+- NetKingdom owns separately reviewed descriptor/type pin selection, the env
+ binding/config/restart steps, and group ensure/revoke provenance. Current group
+ ensure does not report newly added memberships; current revoke can remove
+ memberships that predate the case. Wrapping these as reversible creation would
+ be unsound. Preserve them until a reviewed created-only contract exists.
+- Platform owns structural/mint/broker and secret delivery evidence; Apps owns
+ actual consumer login and group enforcement. No assent is supplied here.
+- Attended use still requires existing owner authorization, current caller
+ identity, native source/config/image pins, native receipts and cleanup proof.
--- a/workplans/NK-WP-0048-security-case-types.md
+++ b/workplans/NK-WP-0048-security-case-types.md
@@ -323,7 +323,7 @@
```task
id: NK-WP-0048-T06
status: wait
-depends_on: [NK-WP-0048-T05, NK-WP-0048-T08, NK-WP-0048-T09, NK-WP-0048-T12, MASON-WP-0010-T03, WARDEN-WP-0044-T03]
+depends_on: [NK-WP-0048-T05, NK-WP-0048-T08, NK-WP-0048-T09, NK-WP-0048-T12, NK-WP-0048-T13, NK-WP-0048-T14, NK-WP-0048-T15, NK-WP-0048-T16, MASON-WP-0010-T03, WARDEN-WP-0044-T03]
blocking_reason: "Type1.2 architecture has a confirmed source-only founder acknowledgment; canonical1.1 remains unchanged/default and the1.2 reference case stays proposed. Needs explicit version selection/reference-source integration without normal admission bypass, closed owner adapters plus global/native receipt verifier (WARDEN-WP-0044-T03), independently verified six-object structural materialization and CCR42 claims/created-only provenance, exact broker/case/source reconciliation, then separately authorized native mint/delivery and attended registry/consumer verification. Source acknowledgment, a bundle or local tests do not prove implemented/materialized/live status."
priority: high
state_hub_task_id: "d9b26bcf-a7c6-59a2-92a0-f913d76a203c"
@@ -631,17 +631,106 @@
state_hub_task_id: "7ce4eee3-cd47-55a9-bfa5-4f1835d4d6fd"
```
-Owner: NetKingdom. Next action: wrap the existing contained `keycape-client.py`
-registration/config/env and identity-provisioner ensure/revoke entry points in
-bounded owner adapters or receipt-only attended steps for Warden's closed kernel.
-Reuse the existing caller/session boundary, CAS/readback and status-only outputs;
-never acquire user-engine's runtime identity or expose registry/client secrets.
-Completion requires exact adapter/source descriptors, positive and refusal source
-checks, uncertainty/created-only rollback behavior and a separate owner review of
-the proposed machine seam. Source preparation can begin without credentials.
-Warden independently owns global provenance/admission integration; Platform owns
-mint/broker and native structural receipts; Apps owns consumer delivery/group
-acceptance. Their assent is not supplied by this task. Pinning descriptors into
-an acknowledged type remains a separately reviewed type-source change. Actual
-attended execution still requires its existing owner authorization and native
-receipts. T06 retains all native/reference lifecycle acceptance scope.
+Owner: NetKingdom. Retained scope: prepare bounded attended Deployment env,
+config verification and restart/readiness steps around the existing helpers.
+Next action: identify exact current entry points and their revision/readback
+contracts, then implement fixed source-pinned callbacks or receipt-only attended
+steps. Completion requires positive/refusal checks, exact target/source pins and
+uncertainty behavior without acquiring user-engine runtime identity or exposing
+registry/client secrets. Existing caller/session, cluster/issuer, CAS/readback
+and status-only output boundaries remain mandatory.
+
+2026-10-16 task refinement: the original broad adapter scope is split into T13
+(completed registration source candidate), T14 (group creation provenance), T15
+(durable registration recovery) and T16 (descriptor/artifact selection and owner
+review). This preserves T12's task ID and Hub UUID while giving each remaining
+outcome an owner and completion gate. T06 now names every source dependency;
+its native/reference lifecycle acceptance scope remains unchanged. Completion
+of any source task supplies no Warden, Platform or Apps assent and no native
+execution authority.
+
+## T13 — Prepare the fixed registration adapter source candidate
+
+```task
+id: NK-WP-0048-T13
+status: done
+priority: high
+```
+
+Completed 2026-10-16, source implementation and synthetic fixture proof only.
+Added an injection-only adapter around actual `keycape-client.run`: fixed
+case/principal/human/lane/source/acceptance binding, existing CAS/readback,
+metadata-only owner evidence and per-attempt parent-session revocation boundary.
+The descriptor source composition pins the exact checker executable digest
+alongside adapter/helper source. Creation-only rollback authenticates its
+creation receipt and refuses changed postcreation UID/resourceVersion;
+pre-existing clients confer no rollback authority. Missing readback, evidence,
+revocation or process-local creation provenance cannot become retry-safe success.
+
+Evidence: Custodian `docs/changes/netkingdom-target-2026-10-16/browser-adapter-next/`
+R3 candidate, explicit patch and postimages, retained R1 rejected custody finding
+and R2 revision, and 36 passing source checks (16 new, 20 existing). Three checks
+use actual Warden kernel admission/dispatch/rollback with published Warden
+`d97652a3bb5fa733b063d71a1b74068efcccf572` producer pins unchanged. Owner trust,
+descriptor/type/case and Kubernetes objects remain synthetic fixtures. This
+records a completed source deliverable, not independent review, publication,
+type acknowledgment, exercised procedure, identity admission or native browser
+acceptance. Exact root review/publication receipts are required on integration.
+
+## T14 — Preserve created-only provenance for group ensure and revoke
+
+```task
+id: NK-WP-0048-T14
+status: todo
+priority: high
+```
+
+Owner: NetKingdom. Next action: extend or wrap the existing identity-provisioner
+ensure/revoke contract with independently retained prestate and exact newly
+created groups/memberships before exposing reversible owner callbacks. Current
+ensure returns no membership-creation inventory; current revoke may remove a
+membership that predated this case. Completion requires actual helper-backed
+positive/refusal fixtures, all validation before mutation, per-attempt evidence
+and revocation, readback, ambiguity handling, and rollback that preserves every
+pre-existing/shared group and membership. No existing endpoint or service token
+is treated as new authorization.
+
+## T15 — Define authenticated recovery for lost registration process state
+
+```task
+id: NK-WP-0048-T15
+status: todo
+priority: high
+```
+
+Owner: NetKingdom for registration state, with Warden owning kernel evidence
+admission. Next action: select the exact existing owner evidence holder/readback
+contract and define authenticated lookup for the original creation attempt.
+Completion requires restart/lost-receipt fixtures that bind case/source/principal,
+original created-only provenance and current native revision before any undo;
+uncertain outcomes remain non-replayable. The T13 adapter deliberately refuses
+post-restart rollback and installs no reconciliation callback. A new broker or
+unverified JSON receipt is not a recovery contract. Actual evidence-holder
+acceptance and attended recovery remain separately authorized native work.
+
+## T16 — Select and review the actual machine descriptor composition
+
+```task
+id: NK-WP-0048-T16
+status: todo
+priority: high
+```
+
+Owner: NetKingdom for descriptor/type source and exact checker artifact; Warden
+independently owns global provenance/admission. Next action: identify the exact
+reviewed checker executable artifact and native owner evidence/session callback
+contracts, then assemble the complete step descriptor set and a proposed explicit
+type version selection. T13's retained descriptor pins a synthetic checker only.
+Completion requires separate owner source review of exact machine descriptors,
+artifact/source/config pins, normal type/version admission and independent
+evidence-verification boundaries. Warden d97652a refreshes producer validator
+pins for NetKingdom 015694ee; it does not attest T13's new adapter. Pinning any
+machine descriptor into an acknowledged type is a separately reviewed source
+change. Platform owns structural/mint/broker/delivery evidence; Apps owns actual
+consumer login/group enforcement. Neither their assent nor native acceptance is
+supplied by this task.