NK-WP-0048-T15 source: adapter-owned original-intent reconcile

build() accepts paired holder/observe read-only capabilities and installs
Adapter.reconcile_original; verify accepts the published reconcile record.
Fixture now exercises the adapter's own path. No add replay, no undo after
restart; missing/ambiguous/changed state stays uncertain. Holder choice
remains an owner gate.

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

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 903097@bnt-lap001
Assistant-Session: aa320c8f-c3fa-46e7-8541-0b58e1e45d23
This commit is contained in:
tegwick 2026-10-17 08:02:25 +02:00
parent ece7b0a2a8
commit 061461e4a4
4 changed files with 115 additions and 73 deletions

View file

@ -85,7 +85,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):
revoke_session, 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 +94,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.
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
authenticated original created-only record whose post-creation revision and
exact client entry still hold. It never adds or removes; anything missing,
ambiguous or changed raises so Warden keeps the step uncertain. Choosing the
real authenticated holder is an owner gate outside this source.
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.
@ -125,6 +133,9 @@ 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 (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')
with contained_output():
case = yaml.safe_load(case_source)
client = helper.client_from_case(case)
@ -213,5 +224,67 @@ def build(*, admitted: Invocation, case_source: bytes, expected_uid: str,
with contained_output():
return authenticate(result.evidence, record[1]) is True
def contained(fn, *args):
# Callback output is discarded; refusals below stay this adapter's own.
with contained_output():
return fn(*args)
def reconcile_original(ctx):
"""Read-only confirmation of a restarted/lost-receipt add; never replays or undoes."""
o, attempt = ctx.original.invocation, ctx.attempt
try:
if (o.operation != 'run' or attempt.operation != 'reconcile'
or o.attempt_id == attempt.attempt_id
or not IDENTIFIER.fullmatch(o.attempt_id)
or not IDENTIFIER.fullmatch(attempt.attempt_id)):
raise Refused('original intent not separate from reconcile attempt')
for inv in (o, attempt):
if replace(inv, attempt_id='', operation='admission') != admitted:
raise Refused('invocation differs from admitted case')
if ctx.original.source != reviewed_source or source_digest(check_config_digest) != reviewed_source:
raise Refused('original source differs')
try:
found = list(contained(holder, o.attempt_id))
except Exception:
raise Refused('original-attempt holder unavailable') from None
if len(found) != 1:
raise Refused('original record missing or ambiguous')
ref, raw = found[0]
if type(raw) is not bytes or contained(authenticate, ref, raw) is not True:
raise Refused('original record unauthenticated')
record = json.loads(raw)
if (record.get('schema') != 'netkingdom.keycape-registration.attempt.v1'
or record.get('invocation') != json.loads(json.dumps(asdict(o)))
or record.get('source') != reviewed_source):
raise Refused('original record belongs to another attempt/case/source/principal')
if record.get('outcome') != Outcome.SUCCESS.value or record.get('created') != ['keycape-client']:
raise Refused('original record is not created-only provenance')
secret = contained(observe)
meta = secret['metadata']
if {'uid': meta['uid'], 'resource_version': meta['resourceVersion']} != record['after']:
raise Refused('native revision changed since creation')
if contained(helper.replacement, secret, client)[1] is not False:
raise Refused('exact client entry absent')
finally:
with contained_output():
revocation = revoke_session(attempt)
if not isinstance(revocation, str) or not IDENTIFIER.fullmatch(revocation):
raise Refused('session revocation evidence missing')
payload = json.dumps({'schema': 'netkingdom.keycape-registration.reconcile.v1',
'invocation': asdict(attempt), 'original': ref, 'source': reviewed_source,
'outcome': Outcome.SUCCESS.value, 'created': ('keycape-client',),
'after': record['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.SUCCESS, evidence, ('keycape-client',), True, revocation,
attempt_id=attempt.attempt_id, operation='reconcile')
# Recorded for verify only; never a creation this process may roll back.
records[evidence] = (attempt, payload, result)
return result
return Adapter(REFERENCE, reviewed_source, lambda inv: execute(inv, 'add'),
verify, lambda inv, created: execute(inv, 'remove', created))
verify, lambda inv, created: execute(inv, 'remove', created),
reconcile_original=reconcile_original if holder is not None else None)

View file

@ -59,6 +59,25 @@ 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.
## Original-intent reconciliation (NK-WP-0048-T15 source)
`build(..., holder=, observe=)` takes two optional read-only capabilities,
injected together or not at all. With them the Adapter installs
`reconcile_original` for Warden's `ReconcileContext` (ops-warden `a45cf47`): the
original `run` intent must be separate from the `reconcile` attempt and both must
match the admitted case and reviewed source; `holder(original_attempt_id)` must
return exactly one record that `authenticate` accepts, naming that invocation and
source with a created-only `success`; and `observe()` must show the recorded
post-creation uid/resourceVersion with the exact client entry. The parent session
is revoked either way, and a `netkingdom.keycape-registration.reconcile.v1`
record is published; `verify` accepts that result like any other owner record.
It never adds or removes: holder outage, missing/ambiguous/unauthenticated or
foreign records, a no-change original, and changed revision or entry all raise,
so Warden keeps the step uncertain. Rollback after restart stays refused, since a
reconciled creation is not a creation this process verified, until an owner
rollback contract exists. Which authenticated holder backs `holder` is an owner
gate; the tests use an in-memory stand-in only.
Remaining work belongs in live work records before T12 closure:
- Warden owns global evidence admission and exact native evidence/session

View file

@ -80,6 +80,15 @@ class RegistrationTests(unittest.TestCase):
return copy.deepcopy(self.secret)
raise AssertionError('unexpected tooling operation')
def test_original_reconcile_capabilities_are_optional_but_paired(self):
self.assertIsNone(a.build(**self.kw).reconcile_original)
for extra in ({'holder': lambda attempt: []}, {'observe': lambda: {}},
{'holder': 'cmd', 'observe': lambda: {}}):
with self.subTest(extra=sorted(extra)), self.assertRaisesRegex(Refused, 'together'):
a.build(**dict(self.kw, **extra))
adapter = a.build(**dict(self.kw, holder=lambda attempt: [], observe=lambda: {}))
self.assertTrue(callable(adapter.reconcile_original))
def test_actual_add_and_created_only_rollback_preserve_original_bytes(self):
adapter = a.build(**self.kw)
original = copy.deepcopy(self.secret['data'])

View file

@ -2,7 +2,8 @@
adapter against Warden's original-intent interface (ops-warden a45cf47:
OriginalIntent, Journal.original_intent, Adapter.reconcile_original).
Fixture-only. The original-attempt holder below is the test's own in-memory
T15 source: reconciliation now runs through the adapter's own
reconcile_original (build(holder=..., observe=...)). The original-attempt holder below is the test's own in-memory
evidence store standing in for an owner-authenticated holder; Warden's intent
row is Warden's dispatch record, not created-only provenance. Selecting the
actual authenticated holder remains a T15 owner gate. Synthetic Kubernetes
@ -27,74 +28,18 @@ spec.loader.exec_module(base)
a = base.a
def original_reconciler(adapter, admitted, client, *, holder, authenticate,
observe, publish, revoke_session, reasons):
"""Fixture owner reconciler: read-only, never adds, never removes.
def capture_reasons(adapter, reasons):
"""Fixture-only: Warden contains the owner's refusal, so keep the message."""
reconcile = adapter.reconcile_original
holder(original_attempt_id) -> [(evidence_ref, payload_bytes)] is the
authenticated original-attempt readback; observe() -> current secret.
Confirms creation only from one authenticated original created-only record
whose post-creation uid/resourceVersion and exact client entry still hold.
Anything else raises, so Warden keeps the step uncertain.
"""
confirmed = {}
def reconcile(ctx):
def wrapped(ctx):
try:
return confirm(ctx)
except Exception as exc: # Warden contains the message; keep it for fixtures
return reconcile(ctx)
except Exception as exc:
reasons.append(f'{type(exc).__name__}: {exc}')
raise
def confirm(ctx):
o, attempt = ctx.original.invocation, ctx.attempt
try:
if (o.operation != 'run' or attempt.operation != 'reconcile'
or o.attempt_id == attempt.attempt_id
or not IDENTIFIER.fullmatch(o.attempt_id)):
raise Refused('original intent not separate from reconcile attempt')
for inv in (o, attempt):
if replace(inv, attempt_id='', operation='admission') != admitted:
raise Refused('invocation differs from admitted case')
if ctx.original.source != adapter.source:
raise Refused('original source differs')
try:
records = holder(o.attempt_id)
except Exception:
raise Refused('original-attempt holder unavailable') from None
if len(records) != 1:
raise Refused('original record missing or ambiguous')
ref, raw = records[0]
if authenticate(ref, raw) is not True:
raise Refused('original record unauthenticated')
record = json.loads(raw)
if (record.get('schema') != 'netkingdom.keycape-registration.attempt.v1'
or record.get('invocation') != json.loads(json.dumps(base.asdict(o)))
or record.get('source') != adapter.source):
raise Refused('original record belongs to another attempt/case/source/principal')
if record.get('outcome') != Outcome.SUCCESS.value or record.get('created') != ['keycape-client']:
raise Refused('original record is not created-only provenance')
secret = observe()
meta = secret['metadata']
if {'uid': meta['uid'], 'resource_version': meta['resourceVersion']} != record['after']:
raise Refused('native revision changed since creation')
if a.helper.replacement(secret, client)[1] is not False:
raise Refused('exact client entry absent')
finally:
revocation = revoke_session(attempt)
evidence = publish(json.dumps({'schema': 'netkingdom.keycape-registration.reconcile.v1',
'original': ref, 'attempt': attempt.attempt_id}, sort_keys=True).encode())
result = OwnerResult(Outcome.SUCCESS, evidence, ('keycape-client',), True, revocation,
attempt_id=attempt.attempt_id, operation='reconcile')
confirmed[evidence] = (attempt, result)
return result
def verify(inv, result):
if confirmed.get(getattr(result, 'evidence', None)) == (inv, result):
return True
return adapter.verify(inv, result)
return replace(adapter, reconcile_original=reconcile, verify=verify)
return replace(adapter, reconcile_original=wrapped)
class OriginalIntentReconcileTests(unittest.TestCase):
@ -116,14 +61,10 @@ class OriginalIntentReconcileTests(unittest.TestCase):
"""Fresh process: new journal handle, new adapter (empty records), readmission."""
executor.journal.db.close()
self.reasons = []
# The adapter's own reconcile_original path, given test-only holder/readback.
self.kw = dict(self.kw, holder=self.holder, observe=lambda: copy.deepcopy(self.secret))
fresh, admitted = self.kernel_fixture()
adapter = fresh.adapters[a.REFERENCE]
client = a.helper.client_from_case(base.yaml.safe_load(self.case_source(admitted)))
fresh.adapters[a.REFERENCE] = original_reconciler(
adapter, admitted[1], client, holder=self.holder,
authenticate=lambda ref, raw: self.evidence.get(ref) == raw,
observe=lambda: copy.deepcopy(self.secret), publish=self.publish,
revoke_session=self.revoke, reasons=self.reasons)
fresh.adapters[a.REFERENCE] = capture_reasons(fresh.adapters[a.REFERENCE], self.reasons)
return fresh, admitted
def case_source(self, admitted):