Record reviewed case source evidence (8/13)
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Reject generated-only commits / generated-commit-guard (push) Successful in 8s

This commit is contained in:
codex 2026-10-17 22:34:18 +02:00
parent 4743544938
commit 2d4401a425
20 changed files with 2445 additions and 0 deletions

View file

@ -0,0 +1,30 @@
{
"status": "passed",
"checks": [
{
"check": "existing_owner_validate_type",
"issues": []
},
{
"check": "existing_owner_validate_case/forgejo.yaml",
"allow_unimplemented": true,
"issues": []
},
{
"check": "existing_owner_validate_case/example-public-portal.yaml",
"allow_unimplemented": true,
"issues": []
},
{
"check": "bounded_structural_diff",
"changed": [
"follow_through[hand-over].entry_point",
"follow_through[hand-over].auth",
"rollback[hand-over].action",
"rollback[deliver-consumer-secret].action"
],
"seven_descriptors_parameters_proposed_status_preserved": true
}
],
"native_or_owner_acceptance": false
}

View file

@ -0,0 +1,19 @@
{
"command": [
"/home/worsch/ops-warden/.venv/bin/python",
"-m",
"pytest",
"-q",
"-p",
"no:cacheprovider",
"/home/worsch/the-custodian/docs/changes/netkingdom-target-2026-10-17/nk48-r5-f3-implementation-r2/test_owner_configuration_contract.py"
],
"exit_code": 0,
"environment": {
"PATH": "/usr/bin:/bin",
"PYTHONDONTWRITEBYTECODE": "1",
"NK_FROZEN_ROOT": "/tmp/nk48-r5-f1-ahudwokt/net-kingdom",
"PYTHONPATH": "/tmp/nk48-r5-f1-ahudwokt/warden-frozen/src:/home/worsch/the-custodian/docs/changes/netkingdom-target-2026-10-17/nk48-r5-f3-implementation-r2"
},
"scope": "synthetic corrected owner protocol through unchanged frozen adapter seams; no real backend"
}

View file

@ -0,0 +1,2 @@
................... [100%]
19 passed, 33 subtests passed in 0.19s

View file

@ -0,0 +1,16 @@
--- a/owner_configuration_contract.py
+++ b/owner_configuration_contract.py
@@ -11,7 +11,12 @@
def inverse(binding, transaction, owner):
"""Owner unwind entry, reachable without any delivery/hand-over creation."""
- with owner.exclusive(binding):
+ # This is a distinct operation: hand-over has already revoked its session.
+ # Backend must authenticate a fresh attended inverse attempt, exact operation,
+ # principal/owner/case/source/release/transaction scope and current disposition;
+ # durably retain authority + outcome and revoke this attempt on every exit.
+ # Durable configuration provenance alone is never execution authority.
+ with owner.inverse_session(binding, transaction), owner.exclusive(binding):
record = owner.authenticated_record(transaction)
if record['binding'] != binding:
raise Refused('configuration transaction binding differs')

View file

@ -0,0 +1,353 @@
F.F.FF [100%]
=================================== FAILURES ===================================
____ OwnerFlowTests.test_fresh_inverse_session_exact_scope_open_and_revoked ____
self = <test_owner_configuration_contract.OwnerFlowTests testMethod=test_fresh_inverse_session_exact_scope_open_and_revoked>
def test_fresh_inverse_session_exact_scope_open_and_revoked(self):
self.setup_flow()
transaction, _ = self.flow()
self.assertEqual(self.revoked, [self.inv])
inverse(self.binding, transaction, self.owner)
> opened, closed = self.owner.inverse_session_events
^^^^^^^^^^^^^^
E ValueError: not enough values to unpack (expected 2, got 0)
nk48-apps-f3-r2-negative/test_owner_configuration_contract.py:347: ValueError
_ OwnerFlowTests.test_fresh_inverse_session_revoked_on_guard_and_persistence_failure (failure='drift') _
self = <test_owner_configuration_contract.OwnerFlowTests testMethod=test_fresh_inverse_session_revoked_on_guard_and_persistence_failure>
def test_fresh_inverse_session_revoked_on_guard_and_persistence_failure(self):
for failure in ('drift', 'persistence'):
with self.subTest(failure=failure):
self.setup_flow(); transaction, _ = self.flow()
if failure == 'drift':
self.owner.current['revision'] += 1
else:
def refuse(ident, restored):
raise Refused('inverse persistence failed')
self.owner.confirm_inverse = refuse
with self.assertRaises(Refused):
inverse(self.binding, transaction, self.owner)
> opened, closed = self.owner.inverse_session_events
^^^^^^^^^^^^^^
E ValueError: not enough values to unpack (expected 2, got 0)
nk48-apps-f3-r2-negative/test_owner_configuration_contract.py:415: ValueError
_ OwnerFlowTests.test_fresh_inverse_session_revoked_on_guard_and_persistence_failure (failure='persistence') _
self = <test_owner_configuration_contract.OwnerFlowTests testMethod=test_fresh_inverse_session_revoked_on_guard_and_persistence_failure>
def test_fresh_inverse_session_revoked_on_guard_and_persistence_failure(self):
for failure in ('drift', 'persistence'):
with self.subTest(failure=failure):
self.setup_flow(); transaction, _ = self.flow()
if failure == 'drift':
self.owner.current['revision'] += 1
else:
def refuse(ident, restored):
raise Refused('inverse persistence failed')
self.owner.confirm_inverse = refuse
with self.assertRaises(Refused):
inverse(self.binding, transaction, self.owner)
> opened, closed = self.owner.inverse_session_events
^^^^^^^^^^^^^^
E ValueError: not enough values to unpack (expected 2, got 0)
nk48-apps-f3-r2-negative/test_owner_configuration_contract.py:415: ValueError
_____ OwnerFlowTests.test_fresh_inverse_session_revoked_on_inverse_failure _____
self = <test_owner_configuration_contract.OwnerFlowTests testMethod=test_fresh_inverse_session_revoked_on_inverse_failure>
def test_fresh_inverse_session_revoked_on_inverse_failure(self):
self.setup_flow(); transaction, _ = self.flow()
self.owner.fail_inverse = True
with self.assertRaisesRegex(Refused, 'owner rollback failed'):
inverse(self.binding, transaction, self.owner)
> opened, closed = self.owner.inverse_session_events
^^^^^^^^^^^^^^
E ValueError: not enough values to unpack (expected 2, got 0)
nk48-apps-f3-r2-negative/test_owner_configuration_contract.py:398: ValueError
_ OwnerFlowTests.test_inverse_scope_mismatches_refused_without_mutation (changed={'operation': 'hand-over'}) _
self = <test_owner_configuration_contract.OwnerFlowTests testMethod=test_inverse_scope_mismatches_refused_without_mutation>
def test_inverse_scope_mismatches_refused_without_mutation(self):
self.setup_flow(); transaction, _ = self.flow()
scope = {'operation': 'configuration-inverse',
'binding': deepcopy(self.binding), 'transaction': transaction}
before = deepcopy(self.owner.current)
cases = [{'operation': 'hand-over'}, {'transaction': 'other-transaction'}]
cases += [{'binding': {**self.binding, field: 'other'}} for field in self.binding]
for changed in cases:
with self.subTest(changed=changed):
self.owner.inverse_authority_override = {**scope, **changed}
> with self.assertRaisesRegex(Refused, 'scope/authority'):
E AssertionError: Refused not raised
nk48-apps-f3-r2-negative/test_owner_configuration_contract.py:388: AssertionError
_ OwnerFlowTests.test_inverse_scope_mismatches_refused_without_mutation (changed={'transaction': 'other-transaction'}) _
self = <test_owner_configuration_contract.OwnerFlowTests testMethod=test_inverse_scope_mismatches_refused_without_mutation>
def test_inverse_scope_mismatches_refused_without_mutation(self):
self.setup_flow(); transaction, _ = self.flow()
scope = {'operation': 'configuration-inverse',
'binding': deepcopy(self.binding), 'transaction': transaction}
before = deepcopy(self.owner.current)
cases = [{'operation': 'hand-over'}, {'transaction': 'other-transaction'}]
cases += [{'binding': {**self.binding, field: 'other'}} for field in self.binding]
for changed in cases:
with self.subTest(changed=changed):
self.owner.inverse_authority_override = {**scope, **changed}
> with self.assertRaisesRegex(Refused, 'scope/authority'):
E AssertionError: Refused not raised
nk48-apps-f3-r2-negative/test_owner_configuration_contract.py:388: AssertionError
_ OwnerFlowTests.test_inverse_scope_mismatches_refused_without_mutation (changed={'binding': {'acceptance': 'sha256:3e5028cf109dc045bc677e34aa63c6e809f0ce98a7c53888d78a5c7bdb865a73', 'attempt': 'fixture-owner-attempt', 'case': 'other', 'client': 'example-portal', ...}}) _
self = <test_owner_configuration_contract.OwnerFlowTests testMethod=test_inverse_scope_mismatches_refused_without_mutation>
def test_inverse_scope_mismatches_refused_without_mutation(self):
self.setup_flow(); transaction, _ = self.flow()
scope = {'operation': 'configuration-inverse',
'binding': deepcopy(self.binding), 'transaction': transaction}
before = deepcopy(self.owner.current)
cases = [{'operation': 'hand-over'}, {'transaction': 'other-transaction'}]
cases += [{'binding': {**self.binding, field: 'other'}} for field in self.binding]
for changed in cases:
with self.subTest(changed=changed):
self.owner.inverse_authority_override = {**scope, **changed}
> with self.assertRaisesRegex(Refused, 'scope/authority'):
E AssertionError: Refused not raised
nk48-apps-f3-r2-negative/test_owner_configuration_contract.py:388: AssertionError
_ OwnerFlowTests.test_inverse_scope_mismatches_refused_without_mutation (changed={'binding': {'acceptance': 'other', 'attempt': 'fixture-owner-attempt', 'case': 'sha256:2b30b9403ba124514e3ab8777b187a225099e2f7683fdd41d0ad48ef3d9649a7', 'client': 'example-portal', ...}}) _
self = <test_owner_configuration_contract.OwnerFlowTests testMethod=test_inverse_scope_mismatches_refused_without_mutation>
def test_inverse_scope_mismatches_refused_without_mutation(self):
self.setup_flow(); transaction, _ = self.flow()
scope = {'operation': 'configuration-inverse',
'binding': deepcopy(self.binding), 'transaction': transaction}
before = deepcopy(self.owner.current)
cases = [{'operation': 'hand-over'}, {'transaction': 'other-transaction'}]
cases += [{'binding': {**self.binding, field: 'other'}} for field in self.binding]
for changed in cases:
with self.subTest(changed=changed):
self.owner.inverse_authority_override = {**scope, **changed}
> with self.assertRaisesRegex(Refused, 'scope/authority'):
E AssertionError: Refused not raised
nk48-apps-f3-r2-negative/test_owner_configuration_contract.py:388: AssertionError
_ OwnerFlowTests.test_inverse_scope_mismatches_refused_without_mutation (changed={'binding': {'acceptance': 'sha256:3e5028cf109dc045bc677e34aa63c6e809f0ce98a7c53888d78a5c7bdb865a73', 'attempt': 'fixt...', 'case': 'sha256:2b30b9403ba124514e3ab8777b187a225099e2f7683fdd41d0ad48ef3d9649a7', 'client': 'example-portal', ...}}) _
self = <test_owner_configuration_contract.OwnerFlowTests testMethod=test_inverse_scope_mismatches_refused_without_mutation>
def test_inverse_scope_mismatches_refused_without_mutation(self):
self.setup_flow(); transaction, _ = self.flow()
scope = {'operation': 'configuration-inverse',
'binding': deepcopy(self.binding), 'transaction': transaction}
before = deepcopy(self.owner.current)
cases = [{'operation': 'hand-over'}, {'transaction': 'other-transaction'}]
cases += [{'binding': {**self.binding, field: 'other'}} for field in self.binding]
for changed in cases:
with self.subTest(changed=changed):
self.owner.inverse_authority_override = {**scope, **changed}
> with self.assertRaisesRegex(Refused, 'scope/authority'):
E AssertionError: Refused not raised
nk48-apps-f3-r2-negative/test_owner_configuration_contract.py:388: AssertionError
_ OwnerFlowTests.test_inverse_scope_mismatches_refused_without_mutation (changed={'binding': {'acceptance': 'sha256:3e5028cf109dc045bc677e34aa63c6e809f0ce98a7c53888d78a5c7bdb865a73', 'attempt': 'fixt...', 'case': 'sha256:2b30b9403ba124514e3ab8777b187a225099e2f7683fdd41d0ad48ef3d9649a7', 'client': 'example-portal', ...}}) _
self = <test_owner_configuration_contract.OwnerFlowTests testMethod=test_inverse_scope_mismatches_refused_without_mutation>
def test_inverse_scope_mismatches_refused_without_mutation(self):
self.setup_flow(); transaction, _ = self.flow()
scope = {'operation': 'configuration-inverse',
'binding': deepcopy(self.binding), 'transaction': transaction}
before = deepcopy(self.owner.current)
cases = [{'operation': 'hand-over'}, {'transaction': 'other-transaction'}]
cases += [{'binding': {**self.binding, field: 'other'}} for field in self.binding]
for changed in cases:
with self.subTest(changed=changed):
self.owner.inverse_authority_override = {**scope, **changed}
> with self.assertRaisesRegex(Refused, 'scope/authority'):
E AssertionError: Refused not raised
nk48-apps-f3-r2-negative/test_owner_configuration_contract.py:388: AssertionError
_ OwnerFlowTests.test_inverse_scope_mismatches_refused_without_mutation (changed={'binding': {'acceptance': 'sha256:3e5028cf109dc045bc677e34aa63c6e809f0ce98a7c53888d78a5c7bdb865a73', 'attempt': 'fixt...', 'case': 'sha256:2b30b9403ba124514e3ab8777b187a225099e2f7683fdd41d0ad48ef3d9649a7', 'client': 'example-portal', ...}}) _
self = <test_owner_configuration_contract.OwnerFlowTests testMethod=test_inverse_scope_mismatches_refused_without_mutation>
def test_inverse_scope_mismatches_refused_without_mutation(self):
self.setup_flow(); transaction, _ = self.flow()
scope = {'operation': 'configuration-inverse',
'binding': deepcopy(self.binding), 'transaction': transaction}
before = deepcopy(self.owner.current)
cases = [{'operation': 'hand-over'}, {'transaction': 'other-transaction'}]
cases += [{'binding': {**self.binding, field: 'other'}} for field in self.binding]
for changed in cases:
with self.subTest(changed=changed):
self.owner.inverse_authority_override = {**scope, **changed}
> with self.assertRaisesRegex(Refused, 'scope/authority'):
E AssertionError: Refused not raised
nk48-apps-f3-r2-negative/test_owner_configuration_contract.py:388: AssertionError
_ OwnerFlowTests.test_inverse_scope_mismatches_refused_without_mutation (changed={'binding': {'acceptance': 'sha256:3e5028cf109dc045bc677e34aa63c6e809f0ce98a7c53888d78a5c7bdb865a73', 'attempt': 'fixt...', 'case': 'sha256:2b30b9403ba124514e3ab8777b187a225099e2f7683fdd41d0ad48ef3d9649a7', 'client': 'example-portal', ...}}) _
self = <test_owner_configuration_contract.OwnerFlowTests testMethod=test_inverse_scope_mismatches_refused_without_mutation>
def test_inverse_scope_mismatches_refused_without_mutation(self):
self.setup_flow(); transaction, _ = self.flow()
scope = {'operation': 'configuration-inverse',
'binding': deepcopy(self.binding), 'transaction': transaction}
before = deepcopy(self.owner.current)
cases = [{'operation': 'hand-over'}, {'transaction': 'other-transaction'}]
cases += [{'binding': {**self.binding, field: 'other'}} for field in self.binding]
for changed in cases:
with self.subTest(changed=changed):
self.owner.inverse_authority_override = {**scope, **changed}
> with self.assertRaisesRegex(Refused, 'scope/authority'):
E AssertionError: Refused not raised
nk48-apps-f3-r2-negative/test_owner_configuration_contract.py:388: AssertionError
_ OwnerFlowTests.test_inverse_scope_mismatches_refused_without_mutation (changed={'binding': {'acceptance': 'sha256:3e5028cf109dc045bc677e34aa63c6e809f0ce98a7c53888d78a5c7bdb865a73', 'attempt': 'fixt...', 'case': 'sha256:2b30b9403ba124514e3ab8777b187a225099e2f7683fdd41d0ad48ef3d9649a7', 'client': 'example-portal', ...}}) _
self = <test_owner_configuration_contract.OwnerFlowTests testMethod=test_inverse_scope_mismatches_refused_without_mutation>
def test_inverse_scope_mismatches_refused_without_mutation(self):
self.setup_flow(); transaction, _ = self.flow()
scope = {'operation': 'configuration-inverse',
'binding': deepcopy(self.binding), 'transaction': transaction}
before = deepcopy(self.owner.current)
cases = [{'operation': 'hand-over'}, {'transaction': 'other-transaction'}]
cases += [{'binding': {**self.binding, field: 'other'}} for field in self.binding]
for changed in cases:
with self.subTest(changed=changed):
self.owner.inverse_authority_override = {**scope, **changed}
> with self.assertRaisesRegex(Refused, 'scope/authority'):
E AssertionError: Refused not raised
nk48-apps-f3-r2-negative/test_owner_configuration_contract.py:388: AssertionError
_ OwnerFlowTests.test_inverse_scope_mismatches_refused_without_mutation (changed={'binding': {'acceptance': 'sha256:3e5028cf109dc045bc677e34aa63c6e809f0ce98a7c53888d78a5c7bdb865a73', 'attempt': 'fixt...', 'case': 'sha256:2b30b9403ba124514e3ab8777b187a225099e2f7683fdd41d0ad48ef3d9649a7', 'client': 'example-portal', ...}}) _
self = <test_owner_configuration_contract.OwnerFlowTests testMethod=test_inverse_scope_mismatches_refused_without_mutation>
def test_inverse_scope_mismatches_refused_without_mutation(self):
self.setup_flow(); transaction, _ = self.flow()
scope = {'operation': 'configuration-inverse',
'binding': deepcopy(self.binding), 'transaction': transaction}
before = deepcopy(self.owner.current)
cases = [{'operation': 'hand-over'}, {'transaction': 'other-transaction'}]
cases += [{'binding': {**self.binding, field: 'other'}} for field in self.binding]
for changed in cases:
with self.subTest(changed=changed):
self.owner.inverse_authority_override = {**scope, **changed}
> with self.assertRaisesRegex(Refused, 'scope/authority'):
E AssertionError: Refused not raised
nk48-apps-f3-r2-negative/test_owner_configuration_contract.py:388: AssertionError
_ OwnerFlowTests.test_inverse_scope_mismatches_refused_without_mutation (changed={'binding': {'acceptance': 'sha256:3e5028cf109dc045bc677e34aa63c6e809f0ce98a7c53888d78a5c7bdb865a73', 'attempt': 'fixt...r-attempt', 'case': 'sha256:2b30b9403ba124514e3ab8777b187a225099e2f7683fdd41d0ad48ef3d9649a7', 'client': 'other', ...}}) _
self = <test_owner_configuration_contract.OwnerFlowTests testMethod=test_inverse_scope_mismatches_refused_without_mutation>
def test_inverse_scope_mismatches_refused_without_mutation(self):
self.setup_flow(); transaction, _ = self.flow()
scope = {'operation': 'configuration-inverse',
'binding': deepcopy(self.binding), 'transaction': transaction}
before = deepcopy(self.owner.current)
cases = [{'operation': 'hand-over'}, {'transaction': 'other-transaction'}]
cases += [{'binding': {**self.binding, field: 'other'}} for field in self.binding]
for changed in cases:
with self.subTest(changed=changed):
self.owner.inverse_authority_override = {**scope, **changed}
> with self.assertRaisesRegex(Refused, 'scope/authority'):
E AssertionError: Refused not raised
nk48-apps-f3-r2-negative/test_owner_configuration_contract.py:388: AssertionError
_ OwnerFlowTests.test_inverse_scope_mismatches_refused_without_mutation (changed={'binding': {'acceptance': 'sha256:3e5028cf109dc045bc677e34aa63c6e809f0ce98a7c53888d78a5c7bdb865a73', 'attempt': 'fixt...', 'case': 'sha256:2b30b9403ba124514e3ab8777b187a225099e2f7683fdd41d0ad48ef3d9649a7', 'client': 'example-portal', ...}}) _
self = <test_owner_configuration_contract.OwnerFlowTests testMethod=test_inverse_scope_mismatches_refused_without_mutation>
def test_inverse_scope_mismatches_refused_without_mutation(self):
self.setup_flow(); transaction, _ = self.flow()
scope = {'operation': 'configuration-inverse',
'binding': deepcopy(self.binding), 'transaction': transaction}
before = deepcopy(self.owner.current)
cases = [{'operation': 'hand-over'}, {'transaction': 'other-transaction'}]
cases += [{'binding': {**self.binding, field: 'other'}} for field in self.binding]
for changed in cases:
with self.subTest(changed=changed):
self.owner.inverse_authority_override = {**scope, **changed}
> with self.assertRaisesRegex(Refused, 'scope/authority'):
E AssertionError: Refused not raised
nk48-apps-f3-r2-negative/test_owner_configuration_contract.py:388: AssertionError
_ OwnerFlowTests.test_inverse_scope_mismatches_refused_without_mutation (changed={'binding': {'acceptance': 'sha256:3e5028cf109dc045bc677e34aa63c6e809f0ce98a7c53888d78a5c7bdb865a73', 'attempt': 'other', 'case': 'sha256:2b30b9403ba124514e3ab8777b187a225099e2f7683fdd41d0ad48ef3d9649a7', 'client': 'example-portal', ...}}) _
self = <test_owner_configuration_contract.OwnerFlowTests testMethod=test_inverse_scope_mismatches_refused_without_mutation>
def test_inverse_scope_mismatches_refused_without_mutation(self):
self.setup_flow(); transaction, _ = self.flow()
scope = {'operation': 'configuration-inverse',
'binding': deepcopy(self.binding), 'transaction': transaction}
before = deepcopy(self.owner.current)
cases = [{'operation': 'hand-over'}, {'transaction': 'other-transaction'}]
cases += [{'binding': {**self.binding, field: 'other'}} for field in self.binding]
for changed in cases:
with self.subTest(changed=changed):
self.owner.inverse_authority_override = {**scope, **changed}
> with self.assertRaisesRegex(Refused, 'scope/authority'):
E AssertionError: Refused not raised
nk48-apps-f3-r2-negative/test_owner_configuration_contract.py:388: AssertionError
_ OwnerFlowTests.test_missing_fresh_authority_failure_unwind_retains_original_error _
warden.case_receipt_state.Refused: consumer verification contract differs
During handling of the above exception, another exception occurred:
self = <test_owner_configuration_contract.OwnerFlowTests testMethod=test_missing_fresh_authority_failure_unwind_retains_original_error>
def test_missing_fresh_authority_failure_unwind_retains_original_error(self):
self.setup_flow(); self.fail_verify = True
self.owner.inverse_authority_available = False
> with self.assertRaisesRegex(Refused, 'fresh attended') as caught:
E AssertionError: "fresh attended" does not match "consumer verification contract differs"
nk48-apps-f3-r2-negative/test_owner_configuration_contract.py:369: AssertionError
____ OwnerFlowTests.test_revoked_handover_session_refused_for_later_inverse ____
self = <test_owner_configuration_contract.OwnerFlowTests testMethod=test_revoked_handover_session_refused_for_later_inverse>
def test_revoked_handover_session_refused_for_later_inverse(self):
self.setup_flow(); transaction, _ = self.flow()
self.owner.inverse_session_id_override = self.inv.attempt_id
before = deepcopy(self.owner.current)
> with self.assertRaisesRegex(Refused, 'revoked session'):
E AssertionError: Refused not raised
nk48-apps-f3-r2-negative/test_owner_configuration_contract.py:360: AssertionError
=========================== short test summary info ============================
FAILED nk48-apps-f3-r2-negative/test_owner_configuration_contract.py::OwnerFlowTests::test_fresh_inverse_session_exact_scope_open_and_revoked
SUBFAILED(failure='drift') nk48-apps-f3-r2-negative/test_owner_configuration_contract.py::OwnerFlowTests::test_fresh_inverse_session_revoked_on_guard_and_persistence_failure
SUBFAILED(failure='persistence') nk48-apps-f3-r2-negative/test_owner_configuration_contract.py::OwnerFlowTests::test_fresh_inverse_session_revoked_on_guard_and_persistence_failure
FAILED nk48-apps-f3-r2-negative/test_owner_configuration_contract.py::OwnerFlowTests::test_fresh_inverse_session_revoked_on_inverse_failure
SUBFAILED(changed={'operation': 'hand-over'}) nk48-apps-f3-r2-negative/test_owner_configuration_contract.py::OwnerFlowTests::test_inverse_scope_mismatches_refused_without_mutation
SUBFAILED(changed={'transaction': 'other-transaction'}) nk48-apps-f3-r2-negative/test_owner_configuration_contract.py::OwnerFlowTests::test_inverse_scope_mismatches_refused_without_mutation
SUBFAILED(changed={'binding': {'acceptance': 'sha256:3e5028cf109dc045bc677e34aa63c6e809f0ce98a7c53888d78a5c7bdb865a73', 'attempt': 'fixture-owner-attempt', 'case': 'other', 'client': 'example-portal', ...}}) nk48-apps-f3-r2-negative/test_owner_configuration_contract.py::OwnerFlowTests::test_inverse_scope_mismatches_refused_without_mutation
SUBFAILED(changed={'binding': {'acceptance': 'other', 'attempt': 'fixture-owner-attempt', 'case': 'sha256:2b30b9403ba124514e3ab8777b187a225099e2f7683fdd41d0ad48ef3d9649a7', 'client': 'example-portal', ...}}) nk48-apps-f3-r2-negative/test_owner_configuration_contract.py::OwnerFlowTests::test_inverse_scope_mismatches_refused_without_mutation
SUBFAILED(changed={'binding': {'acceptance': 'sha256:3e5028cf109dc045bc677e34aa63c6e809f0ce98a7c53888d78a5c7bdb865a73', 'attempt': 'fixt...', 'case': 'sha256:2b30b9403ba124514e3ab8777b187a225099e2f7683fdd41d0ad48ef3d9649a7', 'client': 'example-portal', ...}}) nk48-apps-f3-r2-negative/test_owner_configuration_contract.py::OwnerFlowTests::test_inverse_scope_mismatches_refused_without_mutation
SUBFAILED(changed={'binding': {'acceptance': 'sha256:3e5028cf109dc045bc677e34aa63c6e809f0ce98a7c53888d78a5c7bdb865a73', 'attempt': 'fixt...', 'case': 'sha256:2b30b9403ba124514e3ab8777b187a225099e2f7683fdd41d0ad48ef3d9649a7', 'client': 'example-portal', ...}}) nk48-apps-f3-r2-negative/test_owner_configuration_contract.py::OwnerFlowTests::test_inverse_scope_mismatches_refused_without_mutation
SUBFAILED(changed={'binding': {'acceptance': 'sha256:3e5028cf109dc045bc677e34aa63c6e809f0ce98a7c53888d78a5c7bdb865a73', 'attempt': 'fixt...', 'case': 'sha256:2b30b9403ba124514e3ab8777b187a225099e2f7683fdd41d0ad48ef3d9649a7', 'client': 'example-portal', ...}}) nk48-apps-f3-r2-negative/test_owner_configuration_contract.py::OwnerFlowTests::test_inverse_scope_mismatches_refused_without_mutation
SUBFAILED(changed={'binding': {'acceptance': 'sha256:3e5028cf109dc045bc677e34aa63c6e809f0ce98a7c53888d78a5c7bdb865a73', 'attempt': 'fixt...', 'case': 'sha256:2b30b9403ba124514e3ab8777b187a225099e2f7683fdd41d0ad48ef3d9649a7', 'client': 'example-portal', ...}}) nk48-apps-f3-r2-negative/test_owner_configuration_contract.py::OwnerFlowTests::test_inverse_scope_mismatches_refused_without_mutation
SUBFAILED(changed={'binding': {'acceptance': 'sha256:3e5028cf109dc045bc677e34aa63c6e809f0ce98a7c53888d78a5c7bdb865a73', 'attempt': 'fixt...', 'case': 'sha256:2b30b9403ba124514e3ab8777b187a225099e2f7683fdd41d0ad48ef3d9649a7', 'client': 'example-portal', ...}}) nk48-apps-f3-r2-negative/test_owner_configuration_contract.py::OwnerFlowTests::test_inverse_scope_mismatches_refused_without_mutation
SUBFAILED(changed={'binding': {'acceptance': 'sha256:3e5028cf109dc045bc677e34aa63c6e809f0ce98a7c53888d78a5c7bdb865a73', 'attempt': 'fixt...', 'case': 'sha256:2b30b9403ba124514e3ab8777b187a225099e2f7683fdd41d0ad48ef3d9649a7', 'client': 'example-portal', ...}}) nk48-apps-f3-r2-negative/test_owner_configuration_contract.py::OwnerFlowTests::test_inverse_scope_mismatches_refused_without_mutation
SUBFAILED(changed={'binding': {'acceptance': 'sha256:3e5028cf109dc045bc677e34aa63c6e809f0ce98a7c53888d78a5c7bdb865a73', 'attempt': 'fixt...r-attempt', 'case': 'sha256:2b30b9403ba124514e3ab8777b187a225099e2f7683fdd41d0ad48ef3d9649a7', 'client': 'other', ...}}) nk48-apps-f3-r2-negative/test_owner_configuration_contract.py::OwnerFlowTests::test_inverse_scope_mismatches_refused_without_mutation
SUBFAILED(changed={'binding': {'acceptance': 'sha256:3e5028cf109dc045bc677e34aa63c6e809f0ce98a7c53888d78a5c7bdb865a73', 'attempt': 'fixt...', 'case': 'sha256:2b30b9403ba124514e3ab8777b187a225099e2f7683fdd41d0ad48ef3d9649a7', 'client': 'example-portal', ...}}) nk48-apps-f3-r2-negative/test_owner_configuration_contract.py::OwnerFlowTests::test_inverse_scope_mismatches_refused_without_mutation
SUBFAILED(changed={'binding': {'acceptance': 'sha256:3e5028cf109dc045bc677e34aa63c6e809f0ce98a7c53888d78a5c7bdb865a73', 'attempt': 'other', 'case': 'sha256:2b30b9403ba124514e3ab8777b187a225099e2f7683fdd41d0ad48ef3d9649a7', 'client': 'example-portal', ...}}) nk48-apps-f3-r2-negative/test_owner_configuration_contract.py::OwnerFlowTests::test_inverse_scope_mismatches_refused_without_mutation
FAILED nk48-apps-f3-r2-negative/test_owner_configuration_contract.py::OwnerFlowTests::test_missing_fresh_authority_failure_unwind_retains_original_error
FAILED nk48-apps-f3-r2-negative/test_owner_configuration_contract.py::OwnerFlowTests::test_revoked_handover_session_refused_for_later_inverse
19 failed, 2 passed, 13 deselected in 0.50s

View file

@ -0,0 +1,146 @@
{
"schema": "custodian.nk48-r5-f3-candidate.v1",
"status": "corrected-source-candidate-synthetic-tested-owner-backend-absent",
"finding": "NK48-R5-F3",
"canonical_edits": false,
"native_actions": false,
"owner_assent": false,
"source_pins": [
{
"repo": "net-kingdom",
"head": "3236d5000e52e0643f33906a26a7825aa0b67465",
"path": "AGENTS.md",
"sha256": "95158b1d5c15e6cf8be980f5c0e3b48aeef252654846273887f40516b3fa6a78",
"working_bytes_equal_commit": true
},
{
"repo": "net-kingdom",
"head": "3236d5000e52e0643f33906a26a7825aa0b67465",
"path": "INTENT.md",
"sha256": "fd2ba8dff9dfd5583ec6d8b0348f273bfe00c59923b5d5d47cd22b1fb4658c54",
"working_bytes_equal_commit": true
},
{
"repo": "net-kingdom",
"head": "3236d5000e52e0643f33906a26a7825aa0b67465",
"path": "SECURITY-COMPANION.md",
"sha256": "06374445ca888549f1bf79527d1345d94918d7a7ec363160d8427d0e7a31d643",
"working_bytes_equal_commit": true
},
{
"repo": "net-kingdom",
"head": "3236d5000e52e0643f33906a26a7825aa0b67465",
"path": "sso-mfa/k8s/keycape/handover-adapter.py",
"sha256": "bad7c1132258f2140b0d83548031ac86eecea4f4019f34adedc1e9e3774a3832",
"working_bytes_equal_commit": true
},
{
"repo": "net-kingdom",
"head": "3236d5000e52e0643f33906a26a7825aa0b67465",
"path": "sso-mfa/k8s/keycape/deliver-consumer-secret-adapter.py",
"sha256": "65db5386cdf08a1aa932886c046abed20330a36c96e63d4267adb8341b23da42",
"working_bytes_equal_commit": true
},
{
"repo": "net-kingdom",
"head": "3236d5000e52e0643f33906a26a7825aa0b67465",
"path": "security-case-type-revisions/1.3/oidc-browser-relying-party/type.yaml",
"sha256": "e75fe94110ef9fb7fb18072fe2856941b33507c99cb406d36576046481485d0a",
"working_bytes_equal_commit": true
},
{
"repo": "net-kingdom",
"head": "3236d5000e52e0643f33906a26a7825aa0b67465",
"path": "security-case-type-revisions/1.3/oidc-browser-relying-party/examples/example-public-portal.yaml",
"sha256": "2b30b9403ba124514e3ab8777b187a225099e2f7683fdd41d0ad48ef3d9649a7",
"working_bytes_equal_commit": true
},
{
"repo": "net-kingdom",
"head": "3236d5000e52e0643f33906a26a7825aa0b67465",
"path": "security-case-type-revisions/1.3/oidc-browser-relying-party/examples/forgejo.yaml",
"sha256": "546aad5cf26842c484fc6ba99940d56697b85531afea11872556761008923c94",
"working_bytes_equal_commit": true
},
{
"repo": "railiance-apps",
"head": "944b30ad63ab054cc24617e374e177d34cd572da",
"path": "AGENTS.md",
"sha256": "2a366a5fbb69ba235df97ee117153cbfa1af11ab579372751b4fb5dbb30c31a7",
"working_bytes_equal_commit": true
},
{
"repo": "railiance-apps",
"head": "944b30ad63ab054cc24617e374e177d34cd572da",
"path": "INTENT.md",
"sha256": "31060dc1738aa4540f78735019359a276ece39dad8afbf1be740713bebbe65e5",
"working_bytes_equal_commit": true
},
{
"repo": "railiance-apps",
"head": "944b30ad63ab054cc24617e374e177d34cd572da",
"path": "Makefile",
"sha256": "a319e264e6f04378f6b3d2c7781860fc4bac1abdb248cffa8c26065e08f2f290",
"working_bytes_equal_commit": true
},
{
"repo": "railiance-apps",
"head": "944b30ad63ab054cc24617e374e177d34cd572da",
"path": "workplans/RAPPS-WP-0016-forgejo-netkingdom-login.md",
"sha256": "1077e9de358433bf1316e3db1459dd271f82875b9590bf3d7b5b22c0df42bbda",
"working_bytes_equal_commit": true
},
{
"repo": "railiance-apps",
"head": "944b30ad63ab054cc24617e374e177d34cd572da",
"path": "docs/evidence/nk48-t16-owner-review/owner-review-railiance-apps.json",
"sha256": "67e9ded8d37462ba04a0a224bac03a840d0c94326235474e1d3371c8ff61865d",
"working_bytes_equal_commit": true
},
{
"repo": "railiance-apps",
"head": "944b30ad63ab054cc24617e374e177d34cd572da",
"path": "docs/proposals/2026-10-11-forgejo-consumer-eso/owner-design.md",
"sha256": "c523949454e4c6eb58e8caee382380a561f561704136de62bf5783c592f962f2",
"working_bytes_equal_commit": true
},
{
"repo": "the-custodian",
"path": "docs/changes/netkingdom-target-2026-10-17/nk48-adapters-1.2-review/coordinator-review-r5.json",
"sha256": "e3016352c58178894ccbfc600b960d3f9e2b5d534a05b8df2c5b79737cb0127a",
"historical_receipt_untouched": true
},
{
"repo": "the-custodian",
"path": "docs/changes/netkingdom-target-2026-10-17/nk48-1.3-owner-review/owner-review-railiance-apps.json",
"sha256": "aedca85f3a742f6510c45aa17ade7375b93c46069cf4e5a7119bec9765efbd7d",
"historical_receipt_untouched": true
}
],
"kernel": "117fec9241e7b985dfb187b992e173d0a5dc3700",
"candidate_type_before_sha256": "e75fe94110ef9fb7fb18072fe2856941b33507c99cb406d36576046481485d0a",
"candidate_type_after_sha256": "6e744be190c4f047e609bef7670ff987fe1606b4d7dac3f740f164bebc205a13",
"candidate_wrapper_sha256": "2cd04432cf3681afc126736f7daacfab46630441aeb223b3e222d6951d24ce2e",
"tests": {
"passed": 19,
"subtests_passed": 33,
"negative_controls_detected": [
"mandatory fresh inverse-session guard omitted"
]
},
"blocking_owner_work": "T05 real owner backend absent: exact authenticated current inverse operation authority/session lifecycle, durable journal and recovery, all-writer release exclusion and accepted configuration projection/source binding must be selected and implemented by Apps, reusing current attended owner deployment path; no source-only operational completion.",
"proposed_type_structural_scope": [
"follow_through[hand-over].entry_point",
"follow_through[hand-over].auth",
"rollback[hand-over].action",
"rollback[deliver-consumer-secret].action"
],
"knowledge_orientation": {
"package": "0.2.6",
"digest": "8d1d6a9d4d07fec7b619618de7dfe2254c1790f85f4198c7fe19db9751c02",
"start_and_identity": "withheld-source-changed; owner AGENTS/INTENT and direct seam/owner sources reviewed"
},
"predecessor": "nk48-r5-f3-implementation-r1",
"role": "implementation continuation after independent r1 review; no claim of independence for this correction",
"independent_review": "nk48-r5-f3-review-r1"
}

View file

@ -0,0 +1,69 @@
{
"schema": "custodian.source-packet-manifest.v1",
"files": [
{
"path": "README.md",
"sha256": "168b80f6cc0d50650a484d24696022cac8a4cf8279c2fb4da0c112cfbf10fd08"
},
{
"path": "candidate-validation.json",
"sha256": "200132ab91899f6cfb2782b19ff59c72056f05197690cecdda708ccbdd343edc"
},
{
"path": "focused-tests.json",
"sha256": "2f48369c3d00f9a43989486882519ee5c09d4eb1f5be599a92b06ca0d7cc8581"
},
{
"path": "focused-tests.txt",
"sha256": "436459ced69db381f44a0f14d2ebcf00a4067f596a9881cfdcca7146c237d3a1"
},
{
"path": "fresh-inverse-session.candidate.patch",
"sha256": "0b4a466c23cdeeb34789ab79c3d7cdc4a186495a910e23bc6491a29b5ff9cff8"
},
{
"path": "fresh-session-guard-omitted.txt",
"sha256": "f47357c3303b026dcd9edcce564725a1c7d0d77969dd2857a6f1c008f169c1e1"
},
{
"path": "implementation.json",
"sha256": "8517713a130f23cb38c597d333f65ec27700f347c16dca617e988f1cbc948abf"
},
{
"path": "negative-controls.json",
"sha256": "8f96396f3d342f505d1dc6ac235a7f37a7c2481d51d100125f05b26619d3d314"
},
{
"path": "owner-backend-next.md",
"sha256": "6243d7a70868224cb89fd30818c23ac379f37b6576e4947e444ef5057d06aea8"
},
{
"path": "owner-contract-candidate.md",
"sha256": "4f9b91a2389acbc52558347a794da02faa6de4fb189373a11feeac10ae7e7c4f"
},
{
"path": "owner-source-inventory.json",
"sha256": "4510ce7b2f9eb9f4695ef4c57bba888c3839d3adcc397f4b4024be0fa71767bb"
},
{
"path": "owner_configuration_contract.py",
"sha256": "2cd04432cf3681afc126736f7daacfab46630441aeb223b3e222d6951d24ce2e"
},
{
"path": "test_owner_configuration_contract.py",
"sha256": "0f4246fea95bd3139c604da8712e6f6ac08976dd34d73e56ba83663ecba42761"
},
{
"path": "type-owner-configuration-inverse.candidate.patch",
"sha256": "949ef6d3e0acc574e1d69c251d6d09e3da636bc73d043dec1c5677f30de29947"
},
{
"path": "type.before.yaml",
"sha256": "e75fe94110ef9fb7fb18072fe2856941b33507c99cb406d36576046481485d0a"
},
{
"path": "type.candidate.yaml",
"sha256": "6e744be190c4f047e609bef7670ff987fe1606b4d7dac3f740f164bebc205a13"
}
]
}

View file

@ -0,0 +1,18 @@
{
"command": [
"/home/worsch/ops-warden/.venv/bin/python",
"-m",
"pytest",
"-q",
"-p",
"no:cacheprovider",
"/tmp/nk48-apps-f3-r2-negative/test_owner_configuration_contract.py",
"-k",
"fresh_inverse_session or revoked_handover_session or missing_fresh_authority or inverse_scope_mismatches"
],
"exit_code": 1,
"mutation": "omit mandatory inverse_session context only",
"expected": "fresh session lifecycle and scope/refusal regressions detect mutation",
"detected": true,
"native_actions": false
}

View file

@ -0,0 +1,28 @@
# Actual Apps source and minimum T05 continuation
Apps HEAD remains `944b30ad63ab054cc24617e374e177d34cd572da`. Reviewed source is recorded by path/hash in `owner-source-inventory.json`; only source text was read. No referenced script was executed, no environment credential or home configuration was inspected and no native/network call was made.
## Existing surfaces
`Makefile:476` owns the attended Forgejo release path. It runs mailer/template prerequisite deployment targets, checks a Secret by name, then invokes normal Helm upgrade with chart/version, ordinary values, registry values and the existing value-bearing helper. It does not bind an accepted case, classify case-only configuration ownership, persist intent/post/inverse records, enforce all-release-writer exclusion or authenticate a separate inverse session. Running this entire target inside the new candidate would not prove that all its effects belong to the case. Its prerequisite applies also fall outside an OAuth-only inverse.
`tools/forgejo-verify.sh` is an older workload/TLS/database/mail/SSH/operator smoke script for another workplan's T05. It uses ambient operator contexts and optional credential sources and is not the four NK-WP-0048 login/group observation backend. It was read as source only and was not run. `tools/forgejo-smoke.sh` checks web/registry reachability, not member/non-member/group transitions. Neither is a hand-over receipt store or configuration inverse.
The actual owner review names the intended hand-over store as a committed `docs/evidence/<date>-forgejo-netkingdom-handover.json` identified by path@commit, with removal as a revert confirmed on main. This is a useful existing owner design to reuse where appropriate, not executable journal/authentication code. T05 still says implement the real owner_apply/owner_remove, verifier and receipt store; it does not provide their implementation. The current repo's executable source contains no implementation of the queried owner methods, transaction/inverse session or lanes. There is no supported existing backend to bind in this packet.
## Exact missing owner choices
1. Adopt/publish the corrected external wrapper plus independent inverse placement, replacing the earlier C3 placement inside owner_remove. Name the accepted configuration and helper source pins and the precise non-secret projection that proves this case's OAuth/group-reference change while preserving local sign-in and unrelated release configuration. The actual any-of access_groups behavior remains the owner T02/T03 verification duty; ordinary Helm revision advancement is not proof of case-only ownership.
2. Name the existing owner-authenticated durable evidence mechanism and its write/readback semantics for pre-mutation intent, confirmed post state, inverse attempt, outcome and recovery. The existing committed-evidence design can be considered; this packet does not silently promote it into an implemented transactional configuration journal. Pending or publication ambiguity must survive process loss, and operation history must distinguish unrelated changes without storing Helm values, rendered Secrets or credentials. Loss after rollback but before inverse confirmation requires authenticated owner reconciliation, never unguarded replay.
3. Identify the actual all-release-writer exclusion mechanism, or enforce the existing attended owner release-freeze/reconciliation path across current-revision/configuration check and rollback. It must cover every manual, automation and reconciliation writer. A local wrapper lock or pre-command revision equality is insufficient because Helm supplies no CAS. Source presently establishes no such enforcement.
4. Bind `inverse_session` to the real attended owner runtime's current operation authorization and revocation evidence, using the exact original principal/owner/case/acceptance/source/cluster/namespace/release/client/descriptor/transaction scope and distinct inverse operation. Name how that exact scope/disposition is authenticated, retained and revoked on success/refusal/failure. The same operator can perform the fresh inverse; no standing agent credential is proposed. Existing valid scoped session authorization need not be requested again, but the revoked hand-over parent is unusable and a stored configuration receipt is not execution permission.
These are missing implementations/owner bindings inside existing T05, not new workflow/permission gates or a new service, store or authority created by this packet.
## Smallest actual source implementation next
Keep the current Apps attended deployment and owner-reviewed evidence location. Add one owner source module in its existing tools surface and a bounded Make entry which receives the exact accepted case/source/operation binding, invokes the selected durable/session/exclusion callbacks, performs only the accepted release change through the existing Helm deployment path, and invokes the unchanged admitted hand-over seam. Publish a separate inverse entry using that same journal and exact operation scope. An implementation must factor or explicitly exclude current unrelated deployment prerequisites; it cannot claim the whole target is an OAuth-only transaction.
Use the candidate protocol as the control-flow seam. Bind its callbacks only after the four choices above identify real owner facilities; do not ship an always-true authority oracle, local dictionary journal or advisory mutex as production enforcement. Then meaningful fake-command tests should cover argument/source pinning, metadata-only release readback, absent/matched/conflicting state, release-writer interference, pre/post/inverse persistence failures and revoked/mismatched session refusal before any command. Without those real facility choices, a command shim would falsely suggest operational completion, so none was implemented here.
Existing owner_apply/owner_remove must use completed configuration-inverse evidence and prove no remaining reference before deleting only authenticated case-created objects. The real four-probe observer and committed hand-over receipt/revert backend still belong to T05. T01 break-glass and attended T02/T03 acceptance remain the existing first-case work, not claims made by this source packet.

View file

@ -0,0 +1,31 @@
# Consumer configuration transaction candidate for Apps T05
This is a coordinator-authored proposal for independent Apps review under RAPPS-WP-0016-T05, not Apps assent or an implemented owner capability. T02 already owns the Helm change and rollback to its noted previous revision. Keep that deployment mechanism and the seven kernel descriptors. Do not move Helm side effects into hand-over's verification, receipt callbacks, or Adapter.rollback. Do not add an eighth kernel step, a new service, a standing credential, or a new catalog grant.
The existing seven kernel adapters alone do not express this inverse: hand-over can authenticate only a newly created receipt, and confidential delivery can authenticate only its case-created objects. Public delivery is skipped; matched delivery returns NO_CHANGE. No such creation can grant authority over a Helm configuration. The reproducible direct-seam counterexample in the fixtures removes the public client's receipt and leaves the consumer configuration present. Merely adding an unused rollback helper would not fix it.
## Proposed reachable owner entry and inverse
The existing external owner deployment before hand-over becomes **one reviewed owner-run entry path**: begin configuration transaction, perform/confirm the T02 Helm change, then invoke the admitted hand-over step in the same attended owner session. `owner_configuration_contract.run` is an executable control-flow candidate for that path, exercised by the fixtures through the unchanged real hand-over adapter. It is not installed or callable by production. The owner must actually implement and publish its backend and bind its entry path under T05 before this text can establish readiness.
Before any mutation, bind an owner-authenticated durable intent to the exact case bytes/digest, case acceptance, owner, human principal, cluster identity, namespace/release, client, configuration source and reviewed wrapper/helper source digests, hand-over descriptor, and distinct owner operation/attempt ID. The wrapper also compares the case/acceptance/principal/owner/client/descriptor against the exact admitted hand-over Invocation. Bind the selected configuration to the case (issuer/client/groups and the confidential reference where applicable), not just to an arbitrary revision. Never store Helm values, rendered Secrets, login cookies or credential material in this record. Full values/chart material may be sensitive: retain only the owner-reviewed non-secret projection/digests and provenance references needed for the inverse. The real owner journal must authenticate the entire record; the fixture's dictionary and literal source pin provide no durable/native proof.
Under the owner's exclusive release-writer boundary, observe the before revision and its configuration identity, persist intent, perform the source-reviewed deployment, and persist authenticated post revision/configuration identity and a changed/no-change classification **before hand-over verification or receipt publication**. Confirm the operation provenance and that the allowed change was exactly this case's new OIDC configuration; do not infer ownership merely because a revision incremented. A deployment affecting unrelated chart/configuration changes is outside this inverse. Match an identical preexisting configuration as NO_CHANGE and give it no rollback authority. Refuse a conflicting preexisting source rather than overwrite it. Preserve local sign-in, unrelated configuration, data and preexisting resources.
`owner_configuration_contract.inverse` is an owner-run unwind entry on the same durable transaction, independent of secret-delivery outcomes and hand-over receipt creation. It is reached (a) by the wrapper on verification, receipt-recording or publication failure after confirmed configuration and (b) by the owner case-unwind path after a successful hand-over, before removing any delivery object. It applies to public and confidential cases. A failed hand-over is not an excuse to wait for a successful kernel receipt before invoking the owner inverse. A configuration NO_CHANGE is preserved; no Helm action is authorized. An authenticated, already completed inverse is idempotent evidence, not permission to run Helm again.
Every configuration inverse requires a distinct fresh authorized attended owner operation. `owner.inverse_session(binding, transaction)` must authenticate operation `configuration-inverse`, the exact original principal, owner, case/acceptance, configuration/helper source, cluster/namespace/release, client, descriptor and transaction scope against current disposition before entering the release-writer boundary. Its context must durably retain the inverse attempt authority/outcome and revoke its own session on every exit, including refused readback, rollback and persistence failures. The hand-over adapter revokes its own parent session on both success and failure before this inverse can run: never reuse that revoked session. Historical transaction provenance does not supply fresh execution authority. A same-principal fresh owner operation can use already established explicit scoped session authorization where valid; no new standing grant or additional workflow is proposed. If no current authorized attended inverse operation can be established, retain the confirmed/pending transaction and original hand-over error for owner reconciliation rather than mutate. The fixture's synthetic context does not authenticate any real principal or grant native authority.
For a changed configuration, authenticate the transaction and exact scope, require the current release still equals the recorded case-created post revision/configuration, and hold the owner's release-writer exclusion continuously across that check and the normal Helm rollback to the noted before revision. Confirm a new post-rollback revision restoring the exact approved before configuration; retain inverse evidence and transaction history. Current revision or configuration drift stops the inverse without touching other work. Helm's rollback command is not a revision CAS: the owner must identify a real exclusion mechanism covering *all* writers to this release, including manual and reconciliation paths, or retain an attended per-release freeze/owner-reconciliation gate. A local mutex that other writers ignore is insufficient. No such real exclusion is supplied by this packet or the existing forgejo-deploy Make target.
An intent/configuration/post-record persistence gap yields an ambiguous configuration outcome: keep the durable pending intent and stop for owner reconciliation using the exact operation/history/source binding. Do not replay, assert NO_CHANGE, or infer mutation ownership from an untrusted local record. Process loss does not confer an automatic inverse; T05 must supply authenticated durable readback/reconciliation before resuming. The fixture demonstrates this refusal; it does not implement the real journal/recovery backend. Failed inverse remains pending for owner reconciliation and retains the original hand-over failure as context.
The adapter's rollback remains exactly receipt-only and created-only. The independent configuration inverse must not fabricate or extend its `allowed_created`. If publication failed after receipt creation, configuration can be restored by its separately authenticated transaction while that receipt remains uncertain for Apps reconciliation; never report whole-case rollback complete from the configuration inverse alone.
For delivery cleanup, `owner_remove` must first verify the authenticated configuration inverse/no remaining consumer reference; it may reuse the completed inverse evidence, not run a second unguarded Helm rollback. It then removes only its authenticated case-created UIDs, in the existing reverse order, and reports `consumer_reference_reverted=true` only when actually proven. Preserve all preexisting-matched objects. If configuration itself was preexisting and still references a delivery object, NO_CHANGE is not proof of deactivation: refuse removing the referenced object until the owner resolves that dependency. Role/store deletion alone remains no token-revocation proof.
## Exact owner choice still required
Apps must adopt this external wrapper/transaction entry and inverse in T05, name the real authenticated journal, accepted configuration/source binding, release identity and writer-exclusion mechanism, and re-review the type's revised C3 wording. Its previous C3 expressly placed the Helm inverse inside owner_remove; this proposal changes that placement and cannot be treated as prior assent. The existing Makefile invokes normal Helm upgrade with no transaction journal or all-writer revision guard. T02's operational rollback intention is an existing legitimate mechanism, not an implemented proof boundary.
If Apps does not adopt an owner-run external transaction around the existing pre-hand-over deployment, there is no reachable complete inverse in the current seven-step contract. Then the owners must choose a structural execution/receipt contract change before accepting this type; do not silently expand either Adapter's object scope. The seven descriptors and their pins remain untouched in this proposal. Owner backend implementation, independent review, exact source publication, kernel/catalog acceptance and separately authorized native proof all remain outstanding.

View file

@ -0,0 +1,62 @@
{
"apps_head": "944b30ad63ab054cc24617e374e177d34cd572da",
"inspection": "source-only; no native/backend/credential call",
"files": [
{
"path": "AGENTS.md",
"sha256": "2a366a5fbb69ba235df97ee117153cbfa1af11ab579372751b4fb5dbb30c31a7"
},
{
"path": "INTENT.md",
"sha256": "31060dc1738aa4540f78735019359a276ece39dad8afbf1be740713bebbe65e5"
},
{
"path": "Makefile",
"sha256": "a319e264e6f04378f6b3d2c7781860fc4bac1abdb248cffa8c26065e08f2f290"
},
{
"path": "tools/forgejo-verify.sh",
"sha256": "799571da79db58be2e6eee533ec20765aa9961bb74ea3865d69f48273a86f242"
},
{
"path": "tools/forgejo-smoke.sh",
"sha256": "f6146412cbe29f4abba9d81b3bc7a0652cf281b30a56fd7054e72f1574553869"
},
{
"path": "workplans/RAPPS-WP-0016-forgejo-netkingdom-login.md",
"sha256": "1077e9de358433bf1316e3db1459dd271f82875b9590bf3d7b5b22c0df42bbda"
},
{
"path": "docs/proposals/2026-10-11-forgejo-consumer-eso/owner-design.md",
"sha256": "c523949454e4c6eb58e8caee382380a561f561704136de62bf5783c592f962f2"
},
{
"path": "docs/evidence/nk48-t16-owner-review/owner-review-railiance-apps.json",
"sha256": "67e9ded8d37462ba04a0a224bac03a840d0c94326235474e1d3371c8ff61865d"
}
],
"executable_surface_search": {
"command": [
"rg",
"-n",
"configuration.transaction|inverse_session|owner_configuration|consumer-secret-delivery\\.v1|def owner_apply|def owner_remove|handover-receipt|apps-consumer-handover|consumer-owner-deployment",
"--glob",
"!docs/**",
"--glob",
"!workplans/**",
"--glob",
"!wiki/**",
"--glob",
"!*.lock",
"."
],
"cwd": "/home/worsch/railiance-apps",
"exit_code": 1,
"stdout": "",
"stderr": ""
},
"supported_config_inverse_backend_found": false,
"real_binding_implemented": false,
"missing_choices": "owner-backend-next.md",
"existing_task": "RAPPS-WP-0016-T05"
}

View file

@ -0,0 +1,72 @@
"""Unadopted Apps T05 control-flow candidate; no CLI, Helm or native capability.
The owner backend is mandatory, including durable authenticated records and an
exclusive release-writer boundary. Tests supply a deliberately synthetic backend.
This wraps the existing owner deployment + hand-over entry path, not an eighth
kernel step or a replacement Adapter. No execution authority is provided here.
"""
from warden.case_run import Invocation
from warden.case_receipt_state import Refused
def inverse(binding, transaction, owner):
"""Owner unwind entry, reachable without any delivery/hand-over creation."""
# This is a distinct operation: hand-over has already revoked its session.
# Backend must authenticate a fresh attended inverse attempt, exact operation,
# principal/owner/case/source/release/transaction scope and current disposition;
# durably retain authority + outcome and revoke this attempt on every exit.
# Durable configuration provenance alone is never execution authority.
with owner.inverse_session(binding, transaction), owner.exclusive(binding):
record = owner.authenticated_record(transaction)
if record['binding'] != binding:
raise Refused('configuration transaction binding differs')
if record['state'] == 'restored':
return 'already-restored'
if record['state'] != 'confirmed':
raise Refused('configuration outcome requires owner reconciliation')
if not record['changed']:
return 'preexisting-configuration-preserved'
# The owner must serialize ALL release writers across this read + inverse.
# Checking revision before an unguarded Helm command would leave a race.
if owner.observe(binding) != record['after']:
raise Refused('current release differs from case-created post revision')
restored = owner.rollback_to(binding, record['before']['revision'])
if (restored['configuration'] != record['before']['configuration']
or restored['revision'] <= record['after']['revision']):
raise Refused('owner Helm inverse readback differs')
owner.confirm_inverse(transaction, restored)
return 'case-configuration-restored'
def run(binding, owner, admitted, handover):
"""One owner entry: record/configure/confirm, then invoke real hand-over.
The owner must persist begin BEFORE configure and confirm BEFORE hand-over.
Configuration/publication ambiguity stays pending for owner reconciliation.
A failure after confirmed configuration reaches the same owner inverse even
when verification fails before a receipt exists. A successful call returns a
durable transaction identifier for a subsequent owner-run inverse request.
"""
if (type(admitted) is not Invocation or admitted.operation != 'admission'
or admitted.attempt_id or admitted.step != 'hand-over'
or admitted.actor != 'human' or admitted.lane != 'apps-consumer-handover'):
raise Refused('owner wrapper requires exact hand-over admission')
expected = {'case': admitted.pins.case, 'acceptance': admitted.pins.acceptance,
'principal': admitted.principal, 'owner': admitted.owner,
'client': admitted.target, 'handover_descriptor': admitted.pins.adapter}
if any(binding.get(key) != value for key, value in expected.items()):
raise Refused('owner wrapper differs from hand-over admission')
with owner.exclusive(binding):
before = owner.observe(binding)
transaction = owner.begin(binding, before)
# begin/configure/confirm exceptions deliberately do not infer rollback
# authority: a mutation may have occurred before its proof was committed.
after, changed = owner.configure(binding, before)
owner.confirm(transaction, after, changed)
try:
result = handover(admitted)
except BaseException:
# Failure to unwind propagates with the original error as its context.
inverse(binding, transaction, owner)
raise
return transaction, result

View file

@ -0,0 +1,421 @@
"""Synthetic owner flow through the unchanged frozen hand-over/delivery seams.
No owner approval, trusted journal, real Helm, kernel admission or native proof.
"""
from contextlib import contextmanager
from copy import deepcopy
from dataclasses import asdict, replace
import importlib.util
import json
import os
from pathlib import Path
import unittest
import yaml
from warden.case_run import Invocation, digest
from warden.case_receipt_state import Outcome, Pins, Refused
from owner_configuration_contract import run, inverse
ROOT = Path(os.environ['NK_FROZEN_ROOT'])
ADAPTERS = ROOT / 'sso-mfa/k8s/keycape'
def load(name, filename):
spec = importlib.util.spec_from_file_location(name, ADAPTERS / filename)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
h = load('f3_frozen_handover', 'handover-adapter.py')
d = load('f3_frozen_delivery', 'deliver-consumer-secret-adapter.py')
class SyntheticOwner:
"""In-memory stand-in for the *required*, currently absent Apps T05 backend."""
def __init__(self, binding, desired):
self.binding, self.desired = deepcopy(binding), desired
self.current = {'revision': 7, 'configuration': {'local_signin': True, 'oidc': None}}
self.history = {7: deepcopy(self.current['configuration'])}
self.records, self.calls = {}, []
self.exclusive_available = True
self.locked = False
self.fail_confirm = self.fail_inverse = False
# Explicitly synthetic authority oracle. No real principal or grant.
self.inverse_authority_available = True
self.inverse_authority_override = None
self.inverse_session_id_override = None
self.inverse_session_events = []
self.revoked_sessions = set()
self.inverse_session_open = False
self.inverse_attempt_count = 0
@contextmanager
def inverse_session(self, binding, transaction):
requested = {'operation': 'configuration-inverse',
'binding': deepcopy(binding), 'transaction': transaction}
authorized = (deepcopy(self.inverse_authority_override)
if self.inverse_authority_override is not None else requested)
if (not self.inverse_authority_available or authorized != requested
or binding != self.binding or transaction not in self.records):
raise Refused('fresh attended inverse scope/authority absent')
self.inverse_attempt_count += 1
session = (self.inverse_session_id_override
or 'fixture-inverse:' + str(self.inverse_attempt_count))
if session in self.revoked_sessions:
raise Refused('inverse cannot reuse a revoked session')
assert not self.inverse_session_open
self.inverse_session_open = True
self.inverse_session_events.append(('open', session, deepcopy(requested)))
try:
yield session
finally:
self.inverse_session_open = False
self.revoked_sessions.add(session)
self.inverse_session_events.append(('revoke', session))
@contextmanager
def exclusive(self, binding):
if not self.exclusive_available or binding != self.binding:
raise Refused('owner release-writer boundary or source/principal binding absent')
assert not self.locked
self.locked = True
try:
yield
finally:
self.locked = False
def observe(self, binding):
assert self.locked and binding == self.binding
return deepcopy(self.current)
def begin(self, binding, before):
assert self.locked
ident = 'fixture-transaction:' + str(len(self.records))
self.records[ident] = {'binding': deepcopy(binding), 'before': deepcopy(before), 'state': 'pending'}
self.calls.append('durable-intent')
return ident
def configure(self, binding, before):
assert self.locked and binding == self.binding and before == self.current
if self.current['configuration']['oidc'] == self.desired:
self.calls.append('matched-configuration')
return deepcopy(self.current), False
if self.current['configuration']['oidc'] is not None:
raise Refused('preexisting configuration conflicts')
self.current['revision'] += 1
self.current['configuration']['oidc'] = self.desired
self.history[self.current['revision']] = deepcopy(self.current['configuration'])
self.calls.append('helm-configure')
return deepcopy(self.current), True
def confirm(self, ident, after, changed):
assert self.locked
if self.fail_confirm:
raise Refused('configuration receipt persistence failed')
self.records[ident].update(after=deepcopy(after), changed=changed, state='confirmed')
self.calls.append('durable-post-revision')
def authenticated_record(self, ident):
assert self.locked
if ident not in self.records:
raise Refused('owner transaction not authenticated')
return deepcopy(self.records[ident])
def rollback_to(self, binding, revision):
assert self.locked and binding == self.binding
self.calls.append(('helm-rollback', revision))
if self.fail_inverse:
raise Refused('owner rollback failed')
self.current = {'revision': self.current['revision'] + 1,
'configuration': deepcopy(self.history[revision])}
return deepcopy(self.current)
def confirm_inverse(self, ident, restored):
assert self.locked
self.records[ident].update(state='restored', inverse=deepcopy(restored))
self.calls.append('durable-inverse')
class OwnerFlowTests(unittest.TestCase):
def setup_flow(self, confidential=False):
name = 'forgejo.yaml' if confidential else 'example-public-portal.yaml'
source = (ROOT / 'security-case-type-revisions/1.3/oidc-browser-relying-party/examples' / name).read_bytes()
self.case = source
params = yaml.safe_load(source)['spec']['parameters']
self.params = params
self.owner_repo, self.client = params['consumer_owner_repo'], params['client_id']
desc = h.descriptor(self.owner_repo)
encoded = json.dumps(asdict(desc), sort_keys=True, separators=(',', ':')).encode()
pins = Pins(digest(source), digest(b'fixture-type'), digest(encoded), digest(b'fixture-catalog'),
digest(b'fixture-acceptance'), digest(b'fixture-run-sheet'))
self.admitted = Invocation('hand-over', self.owner_repo, self.client, 'human', 'fixture-founder',
'apps-consumer-handover', pins)
self.inv = replace(self.admitted, attempt_id='fixture-run', operation='run')
self.rb = replace(self.admitted, attempt_id='fixture-rollback', operation='rollback')
self.binding = {'case': pins.case, 'acceptance': pins.acceptance, 'principal': self.admitted.principal,
'owner': self.owner_repo, 'source': digest(b'fixture-approved-owner-config-source'),
'cluster': 'fixture-cluster-uid', 'namespace': params['consumer_namespace'],
'release': params['consuming_service'], 'client': self.client,
'handover_descriptor': pins.adapter, 'attempt': 'fixture-owner-attempt'}
desired = {'issuer': params.get('issuer', 'https://fixture-issuer.invalid'), 'client': self.client,
'groups': params['access_groups']}
self.owner = SyntheticOwner(self.binding, desired)
self.receipts, self.evidence, self.revoked = set(), {}, []
self.fail_verify = self.fail_record = self.fail_publish = False
def verify(client, groups):
self.owner.calls.append('handover-verification')
return {'client_id': client, 'groups': list(groups), 'enforces_groups': True,
'login_verified': not self.fail_verify,
'observations': [{'probe': p, 'evidence': 'fixture-probe:' + p} for p in sorted(h.PROBES)]}
def record(client, observed):
self.owner.calls.append('handover-receipt')
if self.fail_record:
raise Refused('fixture receipt failure')
ref = 'fixture-handover:' + client
created = ref not in self.receipts
self.receipts.add(ref)
return ref, created
def publish(payload):
if self.fail_publish:
raise Refused('fixture publication failed')
ident = 'fixture-evidence:' + str(len(self.evidence))
self.evidence[ident] = payload
return ident
def remove(ref):
self.receipts.remove(ref)
return True
def revoke(inv):
self.revoked.append(inv)
self.owner.revoked_sessions.add(inv.attempt_id)
return 'fixture-revoked:' + inv.attempt_id
self.adapter = h.build(admitted=self.admitted, case_source=source, case_acceptance='fixture-acceptance',
verify_consumer=verify, record_handover=record, remove_handover=remove, publish=publish,
authenticate=lambda ref, payload: self.evidence.get(ref) == payload,
revoke_session=revoke, version_root='1.3')
def flow(self):
# Reachable candidate owner entry: configuration encloses the REAL seam.
return run(self.binding, self.owner, self.admitted,
lambda admitted: self.adapter.invoke(replace(admitted, attempt_id=self.inv.attempt_id, operation="run")))
def delivery_no_change(self):
encoded = json.dumps(asdict(d.descriptor(self.owner_repo)), sort_keys=True, separators=(',', ':')).encode()
admitted = replace(self.admitted, step='deliver-consumer-secret', lane='consumer-owner-deployment',
pins=replace(self.admitted.pins, adapter=digest(encoded)))
self.delivery_removals = []
evidence = {}
def apply(inv, want):
return {'schema': 'netkingdom.consumer-secret-delivery.v1', 'operation': 'apply',
'values_emitted': False, 'consumer_reference_reverted': False,
'objects': {name: {'uid': 'preexisting-' + name, 'created': False, 'metadata': metadata}
for name, metadata in want.items()}}
def remove(inv, created):
self.delivery_removals.append(created)
raise AssertionError('matched delivery cannot authorize removal')
def publish(payload):
ident = 'fixture-delivery:' + str(len(evidence)); evidence[ident] = payload; return ident
adapter = d.build(admitted=admitted, case_source=self.case, owner_apply=apply, owner_remove=remove,
publish=publish, authenticate=lambda ref, payload: evidence.get(ref) == payload,
revoke_session=lambda inv: 'fixture-revoked:' + inv.attempt_id)
result = adapter.invoke(replace(admitted, attempt_id='fixture-delivery', operation='run'))
self.assertEqual((result.outcome, result.created), (Outcome.NO_CHANGE, ()))
return adapter, replace(admitted, attempt_id='fixture-delivery-inverse', operation='rollback')
def test_existing_public_flow_counterexample_receipt_rollback_leaves_helm_change(self):
self.setup_flow()
self.assertEqual(self.params['client_type'], 'public')
# Reproduce existing text: owner's own configuration, then receipt seam.
self.owner.current['configuration']['oidc'] = self.owner.desired
result = self.adapter.invoke(self.inv)
self.adapter.rollback(self.rb, result.created)
self.assertEqual(self.receipts, set())
self.assertEqual(self.owner.current['configuration']['oidc'], self.owner.desired)
def test_candidate_public_flow_reaches_independent_inverse(self):
self.setup_flow()
transaction, result = self.flow()
self.assertTrue(self.adapter.verify(self.inv, result))
self.assertLess(self.owner.calls.index('durable-post-revision'), self.owner.calls.index('handover-verification'))
self.adapter.rollback(self.rb, result.created) # unchanged receipt-only path
self.assertEqual(self.owner.current['configuration']['oidc'], self.owner.desired)
self.assertEqual(inverse(self.binding, transaction, self.owner), 'case-configuration-restored')
self.assertEqual(self.owner.current['configuration'], {'local_signin': True, 'oidc': None})
self.assertEqual(inverse(self.binding, transaction, self.owner), 'already-restored')
def test_confidential_no_change_delivery_cannot_block_configuration_inverse(self):
self.setup_flow(confidential=True)
adapter, rb = self.delivery_no_change()
transaction, result = self.flow()
inverse(self.binding, transaction, self.owner)
with self.assertRaises(Refused): # contained owner path sanitizes refusal text
adapter.rollback(rb, ('consumer-externalsecret',))
self.adapter.rollback(self.rb, result.created)
self.assertEqual(self.delivery_removals, [])
self.assertIsNone(self.owner.current['configuration']['oidc'])
def test_preexisting_configuration_and_receipt_preserved(self):
self.setup_flow()
self.owner.current['configuration']['oidc'] = self.owner.desired
before = deepcopy(self.owner.current)
self.receipts.add('fixture-handover:' + self.client)
transaction, result = self.flow()
self.assertEqual(result.outcome, Outcome.NO_CHANGE)
self.assertEqual(inverse(self.binding, transaction, self.owner), 'preexisting-configuration-preserved')
with self.assertRaises(Refused):
self.adapter.rollback(self.rb, (h.RECEIPT,))
self.assertEqual(self.owner.current, before)
self.assertEqual(len(self.receipts), 1)
self.assertFalse(any(isinstance(c, tuple) for c in self.owner.calls))
def test_changed_revision_refuses_inverse_and_preserves_other_work(self):
self.setup_flow()
transaction, _ = self.flow()
self.owner.current['revision'] += 1
self.owner.current['configuration']['other_work'] = True
before = deepcopy(self.owner.current)
with self.assertRaisesRegex(Refused, 'current release differs'):
inverse(self.binding, transaction, self.owner)
self.assertEqual(self.owner.current, before)
self.assertEqual(self.owner.records[transaction]['state'], 'confirmed')
def test_failure_before_receipt_still_reaches_inverse(self):
for failure in ('fail_verify', 'fail_record'):
with self.subTest(failure=failure):
self.setup_flow(); setattr(self, failure, True)
with self.assertRaises(Refused): self.flow()
self.assertEqual(self.receipts, set())
self.assertIsNone(self.owner.current['configuration']['oidc'])
self.assertEqual(next(iter(self.owner.records.values()))['state'], 'restored')
self.assertEqual(self.revoked, [self.inv])
def test_receipt_publication_failure_restores_config_without_claiming_receipt_removal(self):
self.setup_flow(); self.fail_publish = True
with self.assertRaises(Refused): self.flow()
self.assertIsNone(self.owner.current['configuration']['oidc'])
self.assertEqual(len(self.receipts), 1) # uncertain receipt must be reconciled
with self.assertRaises(Refused): self.adapter.rollback(self.rb, (h.RECEIPT,))
def test_configuration_confirmation_failure_requires_owner_reconciliation(self):
self.setup_flow(); self.owner.fail_confirm = True
with self.assertRaises(Refused): self.flow()
self.assertEqual(next(iter(self.owner.records.values()))['state'], 'pending')
self.assertEqual(self.owner.current['configuration']['oidc'], self.owner.desired)
self.assertNotIn('handover-verification', self.owner.calls)
with self.assertRaisesRegex(Refused, 'requires owner reconciliation'):
inverse(self.binding, 'fixture-transaction:0', self.owner)
def test_exact_binding_mismatches_refused(self):
self.setup_flow(); transaction, _ = self.flow()
before = deepcopy(self.owner.current)
for field in self.binding:
with self.subTest(field=field), self.assertRaises(Refused):
inverse({**self.binding, field: 'different'}, transaction, self.owner)
self.assertEqual(self.owner.current, before)
def test_wrapper_admission_binding_refused_before_owner_mutation(self):
self.setup_flow()
for field, value in [('principal', 'other'), ('target', 'other'), ('owner', 'other'),
('actor', 'agent'), ('lane', 'other')]:
with self.subTest(field=field), self.assertRaises(Refused):
run(self.binding, self.owner, replace(self.admitted, **{field: value}),
lambda admitted: self.fail('must not reach hand-over'))
self.assertEqual(self.owner.records, {})
def test_missing_release_writer_guard_refuses_before_configuration(self):
self.setup_flow(); self.owner.exclusive_available = False
with self.assertRaises(Refused): self.flow()
self.assertEqual(self.owner.records, {})
self.assertIsNone(self.owner.current['configuration']['oidc'])
def test_preexisting_conflicting_configuration_not_overwritten(self):
self.setup_flow(); self.owner.current['configuration']['oidc'] = {'client': 'unrelated'}
before = deepcopy(self.owner.current)
with self.assertRaisesRegex(Refused, 'configuration conflicts'): self.flow()
self.assertEqual(self.owner.current, before)
self.assertNotIn('handover-verification', self.owner.calls)
def test_inverse_failure_retains_original_failure_and_pending_unwind(self):
self.setup_flow(); self.fail_verify = True; self.owner.fail_inverse = True
with self.assertRaisesRegex(Refused, 'owner rollback failed') as caught:
self.flow()
self.assertIsInstance(caught.exception.__context__, Refused)
self.assertEqual(next(iter(self.owner.records.values()))['state'], 'confirmed')
self.assertEqual(self.owner.current['configuration']['oidc'], self.owner.desired)
def test_fresh_inverse_session_exact_scope_open_and_revoked(self):
self.setup_flow()
transaction, _ = self.flow()
self.assertEqual(self.revoked, [self.inv])
inverse(self.binding, transaction, self.owner)
opened, closed = self.owner.inverse_session_events
self.assertEqual(opened[0], 'open')
self.assertNotEqual(opened[1], self.inv.attempt_id)
self.assertEqual(opened[2], {'operation': 'configuration-inverse',
'binding': self.binding, 'transaction': transaction})
self.assertEqual(closed, ('revoke', opened[1]))
self.assertIn(opened[1], self.owner.revoked_sessions)
self.assertFalse(self.owner.inverse_session_open)
def test_revoked_handover_session_refused_for_later_inverse(self):
self.setup_flow(); transaction, _ = self.flow()
self.owner.inverse_session_id_override = self.inv.attempt_id
before = deepcopy(self.owner.current)
with self.assertRaisesRegex(Refused, 'revoked session'):
inverse(self.binding, transaction, self.owner)
self.assertEqual(self.owner.current, before)
self.assertEqual(self.owner.inverse_session_events, [])
self.assertEqual(self.owner.records[transaction]['state'], 'confirmed')
def test_missing_fresh_authority_failure_unwind_retains_original_error(self):
self.setup_flow(); self.fail_verify = True
self.owner.inverse_authority_available = False
with self.assertRaisesRegex(Refused, 'fresh attended') as caught:
self.flow()
self.assertEqual(self.revoked, [self.inv])
self.assertIsInstance(caught.exception.__context__, Refused)
self.assertIn('consumer verification', str(caught.exception.__context__))
self.assertEqual(self.owner.current['configuration']['oidc'], self.owner.desired)
self.assertEqual(next(iter(self.owner.records.values()))['state'], 'confirmed')
self.assertEqual(self.owner.inverse_session_events, [])
def test_inverse_scope_mismatches_refused_without_mutation(self):
self.setup_flow(); transaction, _ = self.flow()
scope = {'operation': 'configuration-inverse',
'binding': deepcopy(self.binding), 'transaction': transaction}
before = deepcopy(self.owner.current)
cases = [{'operation': 'hand-over'}, {'transaction': 'other-transaction'}]
cases += [{'binding': {**self.binding, field: 'other'}} for field in self.binding]
for changed in cases:
with self.subTest(changed=changed):
self.owner.inverse_authority_override = {**scope, **changed}
with self.assertRaisesRegex(Refused, 'scope/authority'):
inverse(self.binding, transaction, self.owner)
self.assertEqual(self.owner.current, before)
self.assertEqual(self.owner.inverse_session_events, [])
def test_fresh_inverse_session_revoked_on_inverse_failure(self):
self.setup_flow(); transaction, _ = self.flow()
self.owner.fail_inverse = True
with self.assertRaisesRegex(Refused, 'owner rollback failed'):
inverse(self.binding, transaction, self.owner)
opened, closed = self.owner.inverse_session_events
self.assertEqual(closed, ('revoke', opened[1]))
self.assertFalse(self.owner.inverse_session_open)
self.assertEqual(self.owner.records[transaction]['state'], 'confirmed')
def test_fresh_inverse_session_revoked_on_guard_and_persistence_failure(self):
for failure in ('drift', 'persistence'):
with self.subTest(failure=failure):
self.setup_flow(); transaction, _ = self.flow()
if failure == 'drift':
self.owner.current['revision'] += 1
else:
def refuse(ident, restored):
raise Refused('inverse persistence failed')
self.owner.confirm_inverse = refuse
with self.assertRaises(Refused):
inverse(self.binding, transaction, self.owner)
opened, closed = self.owner.inverse_session_events
self.assertEqual(closed, ('revoke', opened[1]))
self.assertFalse(self.owner.inverse_session_open)
self.assertEqual(self.owner.records[transaction]['state'], 'confirmed')
if __name__ == '__main__':
unittest.main()

View file

@ -0,0 +1,69 @@
--- a/security-case-type-revisions/1.3/oidc-browser-relying-party/type.yaml
+++ b/security-case-type-revisions/1.3/oidc-browser-relying-party/type.yaml
@@ -355,16 +355,30 @@
Secret values.
- id: hand-over
owner: '{consumer_owner_repo}'
- entry_point: The consumer owner first makes its own Helm change configuring {consuming_service}
- with issuer {issuer}, client {client_id} and custody bao:platform/workloads/keycape/{client_id},
- requiring one of {access_groups} in the groups claim before sign-in completes.
- Then reviewed adapter net-kingdom/keycape-hand-over, dispatched by Warden's case
- kernel inside the attended consumer-owner session, verifies the consumer with
- four value-free probes (member-login, non-member-refused, membership-add-grants,
- membership-remove-revokes) and records the hand-over receipt.
+ entry_point: The consumer owner's reviewed deployment entry wraps its own Helm
+ configuration and the admitted hand-over in one owner configuration transaction,
+ for public clients as well as confidential clients even when delivery is NO_CHANGE.
+ Before changing {consuming_service}, durably bind exact case, acceptance, human
+ principal, owner configuration source, cluster/release and before revision;
+ authenticate the case-created post revision under the owner's release-writer
+ exclusion before verification. Configure issuer {issuer}, client {client_id}
+ and, only for confidential clients, custody bao:platform/workloads/keycape/{client_id},
+ requiring one of {access_groups} before sign-in completes. Preserve identical
+ preexisting configuration as NO_CHANGE and refuse conflicts. Then the reviewed
+ adapter net-kingdom/keycape-hand-over, dispatched by Warden's case kernel inside
+ the attended consumer-owner session, verifies the four value-free probes
+ (member-login, non-member-refused, membership-add-grants, membership-remove-revokes)
+ and records the receipt. The owner wrapper's separate configuration inverse is
+ reachable on failure before a receipt exists and on later owner case unwind;
+ it does not depend on delivery-created objects or receipt creation. Missing or
+ ambiguous configuration provenance requires owner reconciliation, never replay.
auth: the attended consumer-owner session on lane apps-consumer-handover (actor human,
parent-session revoked per attempt); the consumer configuration itself is the
- owner's own deployment path
+ owner's own deployment path. Every configuration inverse is a distinct fresh
+ authorized attended owner operation, bound to operation, exact principal, case,
+ source, release and transaction, with its own per-attempt revocation on every
+ exit. Never reuse the already revoked hand-over parent session. Missing current
+ inverse authority retains pending owner reconciliation evidence
lane: apps-consumer-handover
warden_executes: false
attended: true
@@ -430,13 +444,22 @@
reads_secret_value: false
rollback:
- reverses: hand-over
- action: the adapter removes only its hand-over receipt. Removing the OIDC source
- for {client_id} from {consuming_service} is the owner's Helm rollback (owner
- {consumer_owner_repo}), done inside deliver-consumer-secret's owner_remove
- before any consumer object is removed
+ action: the adapter removes only its authenticated case-created hand-over receipt.
+ Independently, the consumer owner's configuration transaction inverse restores
+ only this case's configuration change through the noted before Helm revision,
+ after authenticating exact case/principal/source/release binding and checking
+ the unchanged post revision under exclusion of all release writers. It runs
+ on owner unwind for public and confidential cases, including skipped or NO_CHANGE
+ delivery and failures before receipt creation. Preserve preexisting configuration;
+ drift or ambiguous provenance requires owner reconciliation. This owner inverse
+ is outside the receipt adapter and precedes any delivery-object removal;
+ receipt removal alone never proves configuration rollback.
- reverses: deliver-consumer-secret
when: client_type == confidential
- action: After reverting consumer secret reference, delete only authenticated created
+ action: First verify the owner configuration transaction inverse and absence of
+ remaining consumer secret references; NO_CHANGE configuration alone is not proof of
+ deactivation. Do not repeat an unguarded Helm rollback inside owner_remove.
+ Then delete only authenticated created
ExternalSecret/owned target by UID and ownerReference, then SecretStore and
dedicated SA; preserve preexisting-matched resources and never delete a shared
target. Retain audit and ownership receipts; removing SecretStore/SA alone is

View file

@ -0,0 +1,495 @@
apiVersion: netkingdom.io/security-case-type/v0.1
kind: SecurityCaseType
spec:
id: oidc-browser-relying-party
version: '1.3'
status: proposed
title: Browser OIDC relying party for an internal service
purpose: 'Let an internal web service sign its human users in through NetKingdom
(KeyCape): an exact-redirect OIDC client, optional MFA pin, the LLDAP groups the
service maps to its own roles, and, for confidential clients, a client secret
minted straight into OpenBao custody. Replaces the per-client rollout scripts
in sso-mfa/k8s/keycape. First case: Forgejo (KEY-WP-0038).'
parameters:
- name: client_id
type: string
case_settable: true
constraints:
pattern: ^[a-z0-9][a-z0-9-]{1,62}$
sensitivity: operational
description: OAuth2 client_id, unique in KeyCape.
- name: display_name
type: string
case_settable: true
constraints:
pattern: ^[^\n\r]{1,80}$
sensitivity: public
description: Name shown on the sign-in page.
- name: redirect_uris
type: string
case_settable: true
constraints:
pattern: ^https://[^*?#\s,]+(,https://[^*?#\s,]+)*$
sensitivity: security_sensitive
description: 'Comma-separated exact https redirect URIs. No wildcards, no plain
http, no query and no fragment: KeyCape refuses ''*'' and ''?'' at startup,
and OAuth 2.0 forbids fragments (key-cape type review R1).'
- name: allowed_scopes
type: string
case_settable: true
constraints:
enum:
- openid,profile,email,groups
- openid,email,groups
- openid,profile,groups
- openid,groups
sensitivity: security_sensitive
description: Scopes the client may request, openid first. Always includes groups,
because access_groups is required and KeyCape's UserInfo returns groups only
for that scope (key-cape type review R2).
- name: client_type
type: string
case_settable: true
constraints:
enum:
- confidential
- public
sensitivity: security_sensitive
description: confidential for server-side services that can hold a secret; public
for SPAs and native apps (PKCE only).
- name: mfa_required
type: string
case_settable: true
constraints:
enum:
- 'true'
- 'false'
sensitivity: security_sensitive
description: '"true" pins MFA on this client; required when the service grants
administrative or owner rights. "false" is not "no MFA": the client then carries
no mfaRequired field and KeyCape applies the per-user privacyIDEA policy. A
renderer must never emit an explicit mfaRequired false, which would skip that
policy (key-cape type review R3).'
- name: access_groups
type: string
case_settable: true
constraints:
pattern: ^[a-z][a-z0-9-]{1,62}(,[a-z][a-z0-9-]{1,62})*$
sensitivity: operational
description: 'Comma-separated LLDAP groups that the service maps to its roles.
Created if absent. KeyCape has no per-client group gate: it issues tokens to
any authenticated user and only carries the groups claim, so the consuming service
enforces the gate itself (for Forgejo, its required-claim setting; key-cape
type review R2).'
- name: initial_members
type: string
required: false
case_settable: true
constraints:
pattern: ^[a-z][a-z0-9-]*=[a-z][a-z0-9._-]*(\+[a-z][a-z0-9._-]*)*(;[a-z][a-z0-9-]*=[a-z][a-z0-9._-]*(\+[a-z][a-z0-9._-]*)*)*$
sensitivity: operational
description: Initial memberships as group=user+user;group=user. Usernames are
directory usernames, never secrets.
- name: consuming_service
type: string
case_settable: true
constraints:
pattern: ^[a-z0-9][a-z0-9-]{0,62}$
sensitivity: operational
description: The service that signs users in with this client.
- name: consumer_owner_repo
type: string
case_settable: true
constraints:
pattern: ^[a-z0-9][a-z0-9-]*$
sensitivity: operational
description: The repository that owns the consuming service's configuration.
- name: consumer_namespace
type: string
case_settable: true
constraints:
pattern: ^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$
sensitivity: security_sensitive
description: Consumer namespace for namespaced ESO TokenRequest reader; actual
workload owner must confirm; never infer from service name.
- name: issuer
type: string
case_settable: false
default: https://kc.coulomb.social
sensitivity: security_sensitive
description: The NetKingdom issuer; fixed by the type.
composition:
authority: netkingdom
initial_trust:
- runtime_secret_trust
- runtime_identity_trust
capabilities:
- c1.lightweight-sso
providers:
c1.lightweight-sso: net-kingdom.keycape-client-registration
parameter_bindings:
net-kingdom.keycape-client-registration.client_id: client_id
net-kingdom.keycape-client-registration.display_name: display_name
net-kingdom.keycape-client-registration.redirect_uris: redirect_uris
net-kingdom.keycape-client-registration.allowed_scopes: allowed_scopes
net-kingdom.keycape-client-registration.client_type: client_type
net-kingdom.keycape-client-registration.mfa_required: mfa_required
materialization:
- id: client-secret-path
kind: kv_path
when: client_type == confidential
template: platform/workloads/keycape/{client_id}
body: field CLIENT_SECRET; minted by the credential broker, never written by hand
- id: keycape-reader-policy
kind: openbao_policy
when: client_type == confidential
template: workload-kv-read-keycape-{client_id}-eso
body: "path \"platform/data/workloads/keycape/{client_id}\" {{\n capabilities\
\ = [\"read\"]\n}}\n"
description: KeyCape reads its client secret only through ESO. No metadata, list,
parent or sibling access.
- id: consumer-reader-policy
kind: openbao_policy
when: client_type == confidential
template: workload-kv-read-keycape-{client_id}-{consuming_service}
body: "path \"platform/data/workloads/keycape/{client_id}\" {{\n capabilities\
\ = [\"read\"]\n}}\n"
description: The consuming service reads the same secret. No metadata, list, parent
or sibling access.
- id: eso-role
kind: openbao_role
when: client_type == confidential
template: external-secrets-keycape-{client_id}
body: 'auth: kubernetes
bound_service_account_names: [external-secrets]
bound_service_account_namespaces: [external-secrets]
token_policies: [workload-kv-read-keycape-{client_id}-eso]
token_no_default_policy: true
token_ttl: 15m
token_max_ttl: 15m
'
description: The Kubernetes-auth role deliver-secret uses; bound to the ESO service
account only.
- id: consumer-eso-role
kind: openbao_role
when: client_type == confidential
template: external-secrets-keycape-{client_id}-{consuming_service}
body: 'auth: kubernetes
bound_service_account_names: [{consuming_service}-keycape-{client_id}-eso]
bound_service_account_namespaces: [{consumer_namespace}]
audience: openbao
token_policies: [workload-kv-read-keycape-{client_id}-{consuming_service}]
token_no_default_policy: true
token_ttl: 15m
token_max_ttl: 15m
'
description: Consumer ESO requests a token for this separate reference identity.
Common ESO controller remains trusted and can request tokens; no cryptographic
controller isolation is claimed.
- id: catalog-entry
kind: catalog_entry
template: '{consuming_service}-keycape-client'
body: 'id: {consuming_service}-keycape-client
owner_repo: key-cape
warden_executes: false
risk: high
status: active once verification passes
workload_ref: applicable, name {consuming_service}, declaration_ref of the consumer''s
published declaration or the explicit unknown form, never inferred
path_template: platform/workloads/keycape/{client_id}
field: CLIENT_SECRET
delegation: native, intended_owner key-cape
rotation: mint-secret then verification; automatable false until a run has been
proven
'
description: One entry per case (ops-mason item 5, ops-warden R4'). It carries
the consumer's workload reference, which ADR-0009 needs for zone selection.
follow_through:
- id: ensure-groups
owner: net-kingdom
entry_point: reviewed adapter net-kingdom/keycape-ensure-groups, dispatched by Warden's
case kernel inside the attended session; it calls identity-provisioner
ensure_service_groups in the provisioner pod through the session's contained
cluster access (groups {access_groups}; members exactly {initial_members} as
recorded in the accepted case, nothing else)
auth: the attended human session on lane keycape-attended-case-change (actor human,
parent-session revoked per attempt); the in-pod call uses the provisioner's own
directory binding, and no provisioner or user-engine token is read or sent
lane: keycape-attended-case-change
warden_executes: false
attended: true
value_handling: none
attended_justification: The provisioner's only HTTP credential is user-engine's
runtime token, and its NetworkPolicy admits only user-engine pods. A proxy lane
would act as user-engine (ops-warden review R8; canon section 3; ops-warden
ADR-0002). Running it in the session register-client already needs adds no attended
session. It can become unattended once the provisioner has an operator credential
of its own and an admitted path. Warden's case kernel dispatches the reviewed adapter only inside the one attended human session (descriptor actor human, lane keycape-attended-case-change, parent-session revoked per attempt); the human session is the authority and the kernel is only the dispatcher.
- id: mint-secret
owner: railiance-platform
when: client_type == confidential
entry_point: 'railiance-platform credential broker: generate a client secret inside
custody at bao:platform/workloads/keycape/{client_id}, through reviewed adapter
railiance-platform/keycape-mint-secret'
auth: a human caller (net-kingdom-admins, aal2) starts it; Warden runs it unattended
through the broker lane under the caller's session, with the caller's broker grant
for the case's KV path; agents and root are refused, and the value is generated
in custody and never returned
lane: keycape-client-secret-generate
warden_executes: true
attended: false
value_handling: generated_in_custody
- id: deliver-secret
owner: railiance-platform
when: client_type == confidential
entry_point: reviewed adapter railiance-platform/keycape-deliver-secret, dispatched
by Warden's case kernel; Platform's deliver capability applies ExternalSecret
sso/keycape-{client_id}-client (key client-secret) through the ESO store for
role external-secrets-keycape-{client_id}, from bao:platform/workloads/keycape/{client_id}
auth: Platform's own delivery path on lane platform-secret-delivery, inside the human
aal2 session; ESO reads custody through role external-secrets-keycape-{client_id};
Warden holds no Platform credential, and no value leaves custody
lane: platform-secret-delivery
warden_executes: false
attended: false
value_handling: none
ready_check: the delivery for sso/keycape-{client_id}-client reports Ready=True
(status only, no value read)
- id: bind-env
owner: net-kingdom
when: client_type == confidential
entry_point: 'reviewed adapter net-kingdom/keycape-bind-env, dispatched by Warden''s
case kernel inside the attended session: patch Deployment sso/keycape env
KEYCAPE_{client_id:env}_CLIENT_SECRET from sso/keycape-{client_id}-client, by
reference only'
auth: the attended human session on lane keycape-attended-case-change, with operator
kube credentials for namespace sso (actor human, parent-session revoked per attempt)
lane: keycape-attended-case-change
warden_executes: false
attended: true
value_handling: none
attended_justification: A change to the live identity provider's Deployment. It
runs in the same attended operator session as register-client, before it, because
KeyCape resolves a confidential client's secret at start and fails closed if
the binding is missing. Warden's case kernel dispatches the reviewed adapter only inside the one attended human session (descriptor actor human, lane keycape-attended-case-change, parent-session revoked per attempt); the human session is the authority and the kernel is only the dispatcher.
- id: register-client
owner: net-kingdom
entry_point: reviewed adapter net-kingdom/keycape-register-client, dispatched by Warden's
case kernel inside the attended session; it runs sso-mfa/k8s/keycape/keycape-client.py
apply --client-file <client-from-case> --case-acceptance <record> --check-config
<keycape binary> (net-kingdom.keycape-client-registration)
auth: the attended human session on lane keycape-attended-case-change, with operator
write access to the live KeyCape client registry in namespace sso (actor human,
parent-session revoked per attempt)
lane: keycape-attended-case-change
warden_executes: false
attended: true
value_handling: none
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
session applies the rendered registration. ops-warden groups all attended steps
of a run into that one session. KeyCape loads clients at startup and keeps login,
authorization and handoff state in memory, so the restart drops in-flight logins
and sessions for every client; runs go in a quiet window (key-cape type review
R4). The rendered registry must pass KeyCape''s own startup checks (keycape
check-config, the pinned checker artifact sha256:be37738fe1c08259fb4f9ca0daf0f9301bfffc237e5bb38435525dfbf11ccff5
built from key-cape 3c71226, which keycape-client.py runs before it patches)
before the restart: a refused entry stops the single replica and every client
loses sign-in (R1). Warden''s case kernel dispatches the reviewed adapter only inside the one attended human session (descriptor actor human, lane keycape-attended-case-change, parent-session revoked per attempt); the human session is the authority and the kernel is only the dispatcher.'
- id: deliver-consumer-secret
owner: '{consumer_owner_repo}'
when: client_type == confidential
entry_point: Reviewed adapter net-kingdom/keycape-deliver-consumer-secret, dispatched
by Warden's case kernel inside the attended consumer-owner session; the owner's
apply capability creates dedicated ServiceAccount {consumer_namespace}/{consuming_service}-keycape-{client_id}-eso
(automount=false, no legacy tokens), namespace SecretStore openbao-keycape-{client_id}-consumer
with serviceAccountRef and audiences [openbao], and ExternalSecret {consuming_service}-netkingdom-oidc-client
with Owner target, Retain remote deletion and only CLIENT_SECRET from workloads/keycape/{client_id}.
Admission validates actual installed ESO TokenRequest support and common controller
RBAC before any owner-approved apply.
auth: the attended consumer-owner session on lane consumer-owner-deployment (actor
human, the consumer owner's operator deployment authority, on the founder's go
for the change); ESO authenticates to OpenBao role external-secrets-keycape-{client_id}-{consuming_service}
with a TokenRequest for the dedicated namespaced serviceAccountRef; controller
remains trusted
lane: consumer-owner-deployment
warden_executes: false
attended: true
value_handling: none
attended_justification: The consumer owner's only deployment authority in the consumer
namespace is its operator credential, and production applies need the founder's
go per change. No contained agent deployer identity exists, and the owner does
not want a standing credential an agent could trigger. It runs in one attended
consumer-owner session together with hand-over. Warden's case kernel dispatches
the reviewed adapter only inside that session (actor human, parent-session
revoked per attempt); the human session is the authority and the kernel is only
the dispatcher.
ready_check: Namespace SecretStore and ExternalSecret both report Ready=True;
dedicated SA has no legacy token refs; status and fixed metadata only, never
Secret values.
- id: hand-over
owner: '{consumer_owner_repo}'
entry_point: The consumer owner first makes its own Helm change configuring {consuming_service}
with issuer {issuer}, client {client_id} and custody bao:platform/workloads/keycape/{client_id},
requiring one of {access_groups} in the groups claim before sign-in completes.
Then reviewed adapter net-kingdom/keycape-hand-over, dispatched by Warden's case
kernel inside the attended consumer-owner session, verifies the consumer with
four value-free probes (member-login, non-member-refused, membership-add-grants,
membership-remove-revokes) and records the hand-over receipt.
auth: the attended consumer-owner session on lane apps-consumer-handover (actor human,
parent-session revoked per attempt); the consumer configuration itself is the
owner's own deployment path
lane: apps-consumer-handover
warden_executes: false
attended: true
value_handling: none
attended_justification: The verification needs a real member login and a refused
non-member login, which only an attended session can perform, and it follows
the owner's own Helm change in the same consumer-owner session as
deliver-consumer-secret. Warden's case kernel dispatches the reviewed adapter
only inside that session (actor human, parent-session revoked per attempt); the
human session is the authority and the kernel is only the dispatcher.
verification:
- id: authorize-accepts-redirects
check: GET {issuer}/authorize with client {client_id} and each of {redirect_uris}
returns the login page
expect: present
reads_secret_value: false
- id: authorize-refuses-other-redirect
check: GET {issuer}/authorize with client {client_id} and an unregistered redirect
returns an OAuth error
expect: deny
reads_secret_value: false
- id: custody-denied-to-default
when: client_type == confidential
check: a default-only OpenBao identity has no read capability on bao:platform/workloads/keycape/{client_id}
expect: deny
reads_secret_value: false
- id: custody-allowed-to-consumer
when: client_type == confidential
check: policy workload-kv-read-keycape-{client_id}-{consuming_service} grants
read on bao:platform/workloads/keycape/{client_id} (capabilities check only)
expect: allow
reads_secret_value: false
- id: groups-present
check: LLDAP groups {access_groups} exist, with exactly the memberships of the
accepted case added
expect: present
reads_secret_value: false
- id: consumer-enforces-groups
check: '{consuming_service}''s OIDC source for {client_id} (owner {consumer_owner_repo})
requires one of {access_groups} in the groups claim (configuration read, no
secret value)'
expect: present
reads_secret_value: false
- id: secret-delivered
when: client_type == confidential
check: the delivery for sso/keycape-{client_id}-client reports Ready=True (status
only)
expect: present
reads_secret_value: false
- id: mfa-pinned
when: mfa_required == true
check: keycape-client.py inspect for {client_id} shows the MFA pin on the registered
client (non-secret client view)
expect: present
reads_secret_value: false
- id: consumer-secret-delivered
when: client_type == confidential
check: '{consumer_namespace} SecretStore openbao-keycape-{client_id}-consumer
and ExternalSecret {consuming_service}-netkingdom-oidc-client Ready=True, exact
dedicated SA/audience role binding, fixed CLIENT_SECRET mapping; no Secret value
read'
expect: present
reads_secret_value: false
rollback:
- reverses: hand-over
action: the adapter removes only its hand-over receipt. Removing the OIDC source
for {client_id} from {consuming_service} is the owner's Helm rollback (owner
{consumer_owner_repo}), done inside deliver-consumer-secret's owner_remove
before any consumer object is removed
- reverses: deliver-consumer-secret
when: client_type == confidential
action: After reverting consumer secret reference, delete only authenticated created
ExternalSecret/owned target by UID and ownerReference, then SecretStore and
dedicated SA; preserve preexisting-matched resources and never delete a shared
target. Retain audit and ownership receipts; removing SecretStore/SA alone is
not immediate OpenBao token revocation.
- reverses: register-client
action: keycape-client.py apply --operation remove for {client_id}
- reverses: bind-env
when: client_type == confidential
action: remove env KEYCAPE_{client_id:env}_CLIENT_SECRET from Deployment sso/keycape,
after register-client is reversed
- reverses: deliver-secret
when: client_type == confidential
action: delete ExternalSecret sso/keycape-{client_id}-client and its target
- reverses: mint-secret
when: client_type == confidential
action: 'covered by client-secret-path: one metadata delete, after deliver-secret
is reversed'
- reverses: ensure-groups
action: identity-provisioner revoke_service_groups inside the provisioner pod
(attended, as ensure-groups) with the case's memberships; delete_if_empty only
if no other case uses {access_groups}
- reverses: client-secret-path
when: client_type == confidential
action: delete metadata and all versions at platform/workloads/keycape/{client_id},
after the ExternalSecret is gone (this also reverses mint-secret)
- reverses: eso-role
when: client_type == confidential
action: delete Kubernetes-auth role external-secrets-keycape-{client_id}
- reverses: keycape-reader-policy
when: client_type == confidential
action: delete policy workload-kv-read-keycape-{client_id}-eso, after eso-role
- reverses: consumer-eso-role
when: client_type == confidential
action: Delete only this case-created role external-secrets-keycape-{client_id}-{consuming_service},
before deleting consumer-reader-policy; revoke case-issued OpenBao tokens or
prove expiration before declaring deactivation.
- reverses: consumer-reader-policy
when: client_type == confidential
action: delete policy workload-kv-read-keycape-{client_id}-{consuming_service}
- reverses: catalog-entry
action: remove catalog entry {consuming_service}-keycape-client
acceptance:
accepted_by:
- owner
- founder
risk_class: medium
# Reviewed machine descriptors (NK-WP-0048-T16 successor candidate). Digests are
# generated by sso-mfa/k8s/keycape/compose-1.2-descriptors.py --version 1.3.
execution_descriptors:
bind-env: sha256:d5fcb20cf15bf7454890d2529180c582218e3df12584a99162e4b664b899caa0
deliver-consumer-secret: sha256:36281df8139fb1ec64b226d94f80eb592de5addab7063ff54c691c641e275fc7
deliver-secret: sha256:75aaa7346aebdea2a0eb0ba993995515b5248ffefc34caf8e713525e9b58da85
ensure-groups: sha256:ebc9d5b9167d18e6015f4bbeb9699e8d396d64fbfaf195142781f84f4f47a70a
hand-over: sha256:1bce07a3229b7d0c7c730f35ce5b40fe471009a82de8dd53788ed970a9be1224
mint-secret: sha256:c92957227235db04953809d92b260d32c8b947472a8e8fd17a258ade3ae8da70
register-client: sha256:4ecc769f75433b66c7161f13c0fe7e5599633cf466d46cb6d8031f0456f426e7

View file

@ -0,0 +1,518 @@
apiVersion: netkingdom.io/security-case-type/v0.1
kind: SecurityCaseType
spec:
id: oidc-browser-relying-party
version: '1.3'
status: proposed
title: Browser OIDC relying party for an internal service
purpose: 'Let an internal web service sign its human users in through NetKingdom
(KeyCape): an exact-redirect OIDC client, optional MFA pin, the LLDAP groups the
service maps to its own roles, and, for confidential clients, a client secret
minted straight into OpenBao custody. Replaces the per-client rollout scripts
in sso-mfa/k8s/keycape. First case: Forgejo (KEY-WP-0038).'
parameters:
- name: client_id
type: string
case_settable: true
constraints:
pattern: ^[a-z0-9][a-z0-9-]{1,62}$
sensitivity: operational
description: OAuth2 client_id, unique in KeyCape.
- name: display_name
type: string
case_settable: true
constraints:
pattern: ^[^\n\r]{1,80}$
sensitivity: public
description: Name shown on the sign-in page.
- name: redirect_uris
type: string
case_settable: true
constraints:
pattern: ^https://[^*?#\s,]+(,https://[^*?#\s,]+)*$
sensitivity: security_sensitive
description: 'Comma-separated exact https redirect URIs. No wildcards, no plain
http, no query and no fragment: KeyCape refuses ''*'' and ''?'' at startup,
and OAuth 2.0 forbids fragments (key-cape type review R1).'
- name: allowed_scopes
type: string
case_settable: true
constraints:
enum:
- openid,profile,email,groups
- openid,email,groups
- openid,profile,groups
- openid,groups
sensitivity: security_sensitive
description: Scopes the client may request, openid first. Always includes groups,
because access_groups is required and KeyCape's UserInfo returns groups only
for that scope (key-cape type review R2).
- name: client_type
type: string
case_settable: true
constraints:
enum:
- confidential
- public
sensitivity: security_sensitive
description: confidential for server-side services that can hold a secret; public
for SPAs and native apps (PKCE only).
- name: mfa_required
type: string
case_settable: true
constraints:
enum:
- 'true'
- 'false'
sensitivity: security_sensitive
description: '"true" pins MFA on this client; required when the service grants
administrative or owner rights. "false" is not "no MFA": the client then carries
no mfaRequired field and KeyCape applies the per-user privacyIDEA policy. A
renderer must never emit an explicit mfaRequired false, which would skip that
policy (key-cape type review R3).'
- name: access_groups
type: string
case_settable: true
constraints:
pattern: ^[a-z][a-z0-9-]{1,62}(,[a-z][a-z0-9-]{1,62})*$
sensitivity: operational
description: 'Comma-separated LLDAP groups that the service maps to its roles.
Created if absent. KeyCape has no per-client group gate: it issues tokens to
any authenticated user and only carries the groups claim, so the consuming service
enforces the gate itself (for Forgejo, its required-claim setting; key-cape
type review R2).'
- name: initial_members
type: string
required: false
case_settable: true
constraints:
pattern: ^[a-z][a-z0-9-]*=[a-z][a-z0-9._-]*(\+[a-z][a-z0-9._-]*)*(;[a-z][a-z0-9-]*=[a-z][a-z0-9._-]*(\+[a-z][a-z0-9._-]*)*)*$
sensitivity: operational
description: Initial memberships as group=user+user;group=user. Usernames are
directory usernames, never secrets.
- name: consuming_service
type: string
case_settable: true
constraints:
pattern: ^[a-z0-9][a-z0-9-]{0,62}$
sensitivity: operational
description: The service that signs users in with this client.
- name: consumer_owner_repo
type: string
case_settable: true
constraints:
pattern: ^[a-z0-9][a-z0-9-]*$
sensitivity: operational
description: The repository that owns the consuming service's configuration.
- name: consumer_namespace
type: string
case_settable: true
constraints:
pattern: ^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$
sensitivity: security_sensitive
description: Consumer namespace for namespaced ESO TokenRequest reader; actual
workload owner must confirm; never infer from service name.
- name: issuer
type: string
case_settable: false
default: https://kc.coulomb.social
sensitivity: security_sensitive
description: The NetKingdom issuer; fixed by the type.
composition:
authority: netkingdom
initial_trust:
- runtime_secret_trust
- runtime_identity_trust
capabilities:
- c1.lightweight-sso
providers:
c1.lightweight-sso: net-kingdom.keycape-client-registration
parameter_bindings:
net-kingdom.keycape-client-registration.client_id: client_id
net-kingdom.keycape-client-registration.display_name: display_name
net-kingdom.keycape-client-registration.redirect_uris: redirect_uris
net-kingdom.keycape-client-registration.allowed_scopes: allowed_scopes
net-kingdom.keycape-client-registration.client_type: client_type
net-kingdom.keycape-client-registration.mfa_required: mfa_required
materialization:
- id: client-secret-path
kind: kv_path
when: client_type == confidential
template: platform/workloads/keycape/{client_id}
body: field CLIENT_SECRET; minted by the credential broker, never written by hand
- id: keycape-reader-policy
kind: openbao_policy
when: client_type == confidential
template: workload-kv-read-keycape-{client_id}-eso
body: "path \"platform/data/workloads/keycape/{client_id}\" {{\n capabilities\
\ = [\"read\"]\n}}\n"
description: KeyCape reads its client secret only through ESO. No metadata, list,
parent or sibling access.
- id: consumer-reader-policy
kind: openbao_policy
when: client_type == confidential
template: workload-kv-read-keycape-{client_id}-{consuming_service}
body: "path \"platform/data/workloads/keycape/{client_id}\" {{\n capabilities\
\ = [\"read\"]\n}}\n"
description: The consuming service reads the same secret. No metadata, list, parent
or sibling access.
- id: eso-role
kind: openbao_role
when: client_type == confidential
template: external-secrets-keycape-{client_id}
body: 'auth: kubernetes
bound_service_account_names: [external-secrets]
bound_service_account_namespaces: [external-secrets]
token_policies: [workload-kv-read-keycape-{client_id}-eso]
token_no_default_policy: true
token_ttl: 15m
token_max_ttl: 15m
'
description: The Kubernetes-auth role deliver-secret uses; bound to the ESO service
account only.
- id: consumer-eso-role
kind: openbao_role
when: client_type == confidential
template: external-secrets-keycape-{client_id}-{consuming_service}
body: 'auth: kubernetes
bound_service_account_names: [{consuming_service}-keycape-{client_id}-eso]
bound_service_account_namespaces: [{consumer_namespace}]
audience: openbao
token_policies: [workload-kv-read-keycape-{client_id}-{consuming_service}]
token_no_default_policy: true
token_ttl: 15m
token_max_ttl: 15m
'
description: Consumer ESO requests a token for this separate reference identity.
Common ESO controller remains trusted and can request tokens; no cryptographic
controller isolation is claimed.
- id: catalog-entry
kind: catalog_entry
template: '{consuming_service}-keycape-client'
body: 'id: {consuming_service}-keycape-client
owner_repo: key-cape
warden_executes: false
risk: high
status: active once verification passes
workload_ref: applicable, name {consuming_service}, declaration_ref of the consumer''s
published declaration or the explicit unknown form, never inferred
path_template: platform/workloads/keycape/{client_id}
field: CLIENT_SECRET
delegation: native, intended_owner key-cape
rotation: mint-secret then verification; automatable false until a run has been
proven
'
description: One entry per case (ops-mason item 5, ops-warden R4'). It carries
the consumer's workload reference, which ADR-0009 needs for zone selection.
follow_through:
- id: ensure-groups
owner: net-kingdom
entry_point: reviewed adapter net-kingdom/keycape-ensure-groups, dispatched by Warden's
case kernel inside the attended session; it calls identity-provisioner
ensure_service_groups in the provisioner pod through the session's contained
cluster access (groups {access_groups}; members exactly {initial_members} as
recorded in the accepted case, nothing else)
auth: the attended human session on lane keycape-attended-case-change (actor human,
parent-session revoked per attempt); the in-pod call uses the provisioner's own
directory binding, and no provisioner or user-engine token is read or sent
lane: keycape-attended-case-change
warden_executes: false
attended: true
value_handling: none
attended_justification: The provisioner's only HTTP credential is user-engine's
runtime token, and its NetworkPolicy admits only user-engine pods. A proxy lane
would act as user-engine (ops-warden review R8; canon section 3; ops-warden
ADR-0002). Running it in the session register-client already needs adds no attended
session. It can become unattended once the provisioner has an operator credential
of its own and an admitted path. Warden's case kernel dispatches the reviewed adapter only inside the one attended human session (descriptor actor human, lane keycape-attended-case-change, parent-session revoked per attempt); the human session is the authority and the kernel is only the dispatcher.
- id: mint-secret
owner: railiance-platform
when: client_type == confidential
entry_point: 'railiance-platform credential broker: generate a client secret inside
custody at bao:platform/workloads/keycape/{client_id}, through reviewed adapter
railiance-platform/keycape-mint-secret'
auth: a human caller (net-kingdom-admins, aal2) starts it; Warden runs it unattended
through the broker lane under the caller's session, with the caller's broker grant
for the case's KV path; agents and root are refused, and the value is generated
in custody and never returned
lane: keycape-client-secret-generate
warden_executes: true
attended: false
value_handling: generated_in_custody
- id: deliver-secret
owner: railiance-platform
when: client_type == confidential
entry_point: reviewed adapter railiance-platform/keycape-deliver-secret, dispatched
by Warden's case kernel; Platform's deliver capability applies ExternalSecret
sso/keycape-{client_id}-client (key client-secret) through the ESO store for
role external-secrets-keycape-{client_id}, from bao:platform/workloads/keycape/{client_id}
auth: Platform's own delivery path on lane platform-secret-delivery, inside the human
aal2 session; ESO reads custody through role external-secrets-keycape-{client_id};
Warden holds no Platform credential, and no value leaves custody
lane: platform-secret-delivery
warden_executes: false
attended: false
value_handling: none
ready_check: the delivery for sso/keycape-{client_id}-client reports Ready=True
(status only, no value read)
- id: bind-env
owner: net-kingdom
when: client_type == confidential
entry_point: 'reviewed adapter net-kingdom/keycape-bind-env, dispatched by Warden''s
case kernel inside the attended session: patch Deployment sso/keycape env
KEYCAPE_{client_id:env}_CLIENT_SECRET from sso/keycape-{client_id}-client, by
reference only'
auth: the attended human session on lane keycape-attended-case-change, with operator
kube credentials for namespace sso (actor human, parent-session revoked per attempt)
lane: keycape-attended-case-change
warden_executes: false
attended: true
value_handling: none
attended_justification: A change to the live identity provider's Deployment. It
runs in the same attended operator session as register-client, before it, because
KeyCape resolves a confidential client's secret at start and fails closed if
the binding is missing. Warden's case kernel dispatches the reviewed adapter only inside the one attended human session (descriptor actor human, lane keycape-attended-case-change, parent-session revoked per attempt); the human session is the authority and the kernel is only the dispatcher.
- id: register-client
owner: net-kingdom
entry_point: reviewed adapter net-kingdom/keycape-register-client, dispatched by Warden's
case kernel inside the attended session; it runs sso-mfa/k8s/keycape/keycape-client.py
apply --client-file <client-from-case> --case-acceptance <record> --check-config
<keycape binary> (net-kingdom.keycape-client-registration)
auth: the attended human session on lane keycape-attended-case-change, with operator
write access to the live KeyCape client registry in namespace sso (actor human,
parent-session revoked per attempt)
lane: keycape-attended-case-change
warden_executes: false
attended: true
value_handling: none
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
session applies the rendered registration. ops-warden groups all attended steps
of a run into that one session. KeyCape loads clients at startup and keeps login,
authorization and handoff state in memory, so the restart drops in-flight logins
and sessions for every client; runs go in a quiet window (key-cape type review
R4). The rendered registry must pass KeyCape''s own startup checks (keycape
check-config, the pinned checker artifact sha256:be37738fe1c08259fb4f9ca0daf0f9301bfffc237e5bb38435525dfbf11ccff5
built from key-cape 3c71226, which keycape-client.py runs before it patches)
before the restart: a refused entry stops the single replica and every client
loses sign-in (R1). Warden''s case kernel dispatches the reviewed adapter only inside the one attended human session (descriptor actor human, lane keycape-attended-case-change, parent-session revoked per attempt); the human session is the authority and the kernel is only the dispatcher.'
- id: deliver-consumer-secret
owner: '{consumer_owner_repo}'
when: client_type == confidential
entry_point: Reviewed adapter net-kingdom/keycape-deliver-consumer-secret, dispatched
by Warden's case kernel inside the attended consumer-owner session; the owner's
apply capability creates dedicated ServiceAccount {consumer_namespace}/{consuming_service}-keycape-{client_id}-eso
(automount=false, no legacy tokens), namespace SecretStore openbao-keycape-{client_id}-consumer
with serviceAccountRef and audiences [openbao], and ExternalSecret {consuming_service}-netkingdom-oidc-client
with Owner target, Retain remote deletion and only CLIENT_SECRET from workloads/keycape/{client_id}.
Admission validates actual installed ESO TokenRequest support and common controller
RBAC before any owner-approved apply.
auth: the attended consumer-owner session on lane consumer-owner-deployment (actor
human, the consumer owner's operator deployment authority, on the founder's go
for the change); ESO authenticates to OpenBao role external-secrets-keycape-{client_id}-{consuming_service}
with a TokenRequest for the dedicated namespaced serviceAccountRef; controller
remains trusted
lane: consumer-owner-deployment
warden_executes: false
attended: true
value_handling: none
attended_justification: The consumer owner's only deployment authority in the consumer
namespace is its operator credential, and production applies need the founder's
go per change. No contained agent deployer identity exists, and the owner does
not want a standing credential an agent could trigger. It runs in one attended
consumer-owner session together with hand-over. Warden's case kernel dispatches
the reviewed adapter only inside that session (actor human, parent-session
revoked per attempt); the human session is the authority and the kernel is only
the dispatcher.
ready_check: Namespace SecretStore and ExternalSecret both report Ready=True;
dedicated SA has no legacy token refs; status and fixed metadata only, never
Secret values.
- id: hand-over
owner: '{consumer_owner_repo}'
entry_point: The consumer owner's reviewed deployment entry wraps its own Helm
configuration and the admitted hand-over in one owner configuration transaction,
for public clients as well as confidential clients even when delivery is NO_CHANGE.
Before changing {consuming_service}, durably bind exact case, acceptance, human
principal, owner configuration source, cluster/release and before revision;
authenticate the case-created post revision under the owner's release-writer
exclusion before verification. Configure issuer {issuer}, client {client_id}
and, only for confidential clients, custody bao:platform/workloads/keycape/{client_id},
requiring one of {access_groups} before sign-in completes. Preserve identical
preexisting configuration as NO_CHANGE and refuse conflicts. Then the reviewed
adapter net-kingdom/keycape-hand-over, dispatched by Warden's case kernel inside
the attended consumer-owner session, verifies the four value-free probes
(member-login, non-member-refused, membership-add-grants, membership-remove-revokes)
and records the receipt. The owner wrapper's separate configuration inverse is
reachable on failure before a receipt exists and on later owner case unwind;
it does not depend on delivery-created objects or receipt creation. Missing or
ambiguous configuration provenance requires owner reconciliation, never replay.
auth: the attended consumer-owner session on lane apps-consumer-handover (actor human,
parent-session revoked per attempt); the consumer configuration itself is the
owner's own deployment path. Every configuration inverse is a distinct fresh
authorized attended owner operation, bound to operation, exact principal, case,
source, release and transaction, with its own per-attempt revocation on every
exit. Never reuse the already revoked hand-over parent session. Missing current
inverse authority retains pending owner reconciliation evidence
lane: apps-consumer-handover
warden_executes: false
attended: true
value_handling: none
attended_justification: The verification needs a real member login and a refused
non-member login, which only an attended session can perform, and it follows
the owner's own Helm change in the same consumer-owner session as
deliver-consumer-secret. Warden's case kernel dispatches the reviewed adapter
only inside that session (actor human, parent-session revoked per attempt); the
human session is the authority and the kernel is only the dispatcher.
verification:
- id: authorize-accepts-redirects
check: GET {issuer}/authorize with client {client_id} and each of {redirect_uris}
returns the login page
expect: present
reads_secret_value: false
- id: authorize-refuses-other-redirect
check: GET {issuer}/authorize with client {client_id} and an unregistered redirect
returns an OAuth error
expect: deny
reads_secret_value: false
- id: custody-denied-to-default
when: client_type == confidential
check: a default-only OpenBao identity has no read capability on bao:platform/workloads/keycape/{client_id}
expect: deny
reads_secret_value: false
- id: custody-allowed-to-consumer
when: client_type == confidential
check: policy workload-kv-read-keycape-{client_id}-{consuming_service} grants
read on bao:platform/workloads/keycape/{client_id} (capabilities check only)
expect: allow
reads_secret_value: false
- id: groups-present
check: LLDAP groups {access_groups} exist, with exactly the memberships of the
accepted case added
expect: present
reads_secret_value: false
- id: consumer-enforces-groups
check: '{consuming_service}''s OIDC source for {client_id} (owner {consumer_owner_repo})
requires one of {access_groups} in the groups claim (configuration read, no
secret value)'
expect: present
reads_secret_value: false
- id: secret-delivered
when: client_type == confidential
check: the delivery for sso/keycape-{client_id}-client reports Ready=True (status
only)
expect: present
reads_secret_value: false
- id: mfa-pinned
when: mfa_required == true
check: keycape-client.py inspect for {client_id} shows the MFA pin on the registered
client (non-secret client view)
expect: present
reads_secret_value: false
- id: consumer-secret-delivered
when: client_type == confidential
check: '{consumer_namespace} SecretStore openbao-keycape-{client_id}-consumer
and ExternalSecret {consuming_service}-netkingdom-oidc-client Ready=True, exact
dedicated SA/audience role binding, fixed CLIENT_SECRET mapping; no Secret value
read'
expect: present
reads_secret_value: false
rollback:
- reverses: hand-over
action: the adapter removes only its authenticated case-created hand-over receipt.
Independently, the consumer owner's configuration transaction inverse restores
only this case's configuration change through the noted before Helm revision,
after authenticating exact case/principal/source/release binding and checking
the unchanged post revision under exclusion of all release writers. It runs
on owner unwind for public and confidential cases, including skipped or NO_CHANGE
delivery and failures before receipt creation. Preserve preexisting configuration;
drift or ambiguous provenance requires owner reconciliation. This owner inverse
is outside the receipt adapter and precedes any delivery-object removal;
receipt removal alone never proves configuration rollback.
- reverses: deliver-consumer-secret
when: client_type == confidential
action: First verify the owner configuration transaction inverse and absence of
remaining consumer secret references; NO_CHANGE configuration alone is not proof of
deactivation. Do not repeat an unguarded Helm rollback inside owner_remove.
Then delete only authenticated created
ExternalSecret/owned target by UID and ownerReference, then SecretStore and
dedicated SA; preserve preexisting-matched resources and never delete a shared
target. Retain audit and ownership receipts; removing SecretStore/SA alone is
not immediate OpenBao token revocation.
- reverses: register-client
action: keycape-client.py apply --operation remove for {client_id}
- reverses: bind-env
when: client_type == confidential
action: remove env KEYCAPE_{client_id:env}_CLIENT_SECRET from Deployment sso/keycape,
after register-client is reversed
- reverses: deliver-secret
when: client_type == confidential
action: delete ExternalSecret sso/keycape-{client_id}-client and its target
- reverses: mint-secret
when: client_type == confidential
action: 'covered by client-secret-path: one metadata delete, after deliver-secret
is reversed'
- reverses: ensure-groups
action: identity-provisioner revoke_service_groups inside the provisioner pod
(attended, as ensure-groups) with the case's memberships; delete_if_empty only
if no other case uses {access_groups}
- reverses: client-secret-path
when: client_type == confidential
action: delete metadata and all versions at platform/workloads/keycape/{client_id},
after the ExternalSecret is gone (this also reverses mint-secret)
- reverses: eso-role
when: client_type == confidential
action: delete Kubernetes-auth role external-secrets-keycape-{client_id}
- reverses: keycape-reader-policy
when: client_type == confidential
action: delete policy workload-kv-read-keycape-{client_id}-eso, after eso-role
- reverses: consumer-eso-role
when: client_type == confidential
action: Delete only this case-created role external-secrets-keycape-{client_id}-{consuming_service},
before deleting consumer-reader-policy; revoke case-issued OpenBao tokens or
prove expiration before declaring deactivation.
- reverses: consumer-reader-policy
when: client_type == confidential
action: delete policy workload-kv-read-keycape-{client_id}-{consuming_service}
- reverses: catalog-entry
action: remove catalog entry {consuming_service}-keycape-client
acceptance:
accepted_by:
- owner
- founder
risk_class: medium
# Reviewed machine descriptors (NK-WP-0048-T16 successor candidate). Digests are
# generated by sso-mfa/k8s/keycape/compose-1.2-descriptors.py --version 1.3.
execution_descriptors:
bind-env: sha256:d5fcb20cf15bf7454890d2529180c582218e3df12584a99162e4b664b899caa0
deliver-consumer-secret: sha256:36281df8139fb1ec64b226d94f80eb592de5addab7063ff54c691c641e275fc7
deliver-secret: sha256:75aaa7346aebdea2a0eb0ba993995515b5248ffefc34caf8e713525e9b58da85
ensure-groups: sha256:ebc9d5b9167d18e6015f4bbeb9699e8d396d64fbfaf195142781f84f4f47a70a
hand-over: sha256:1bce07a3229b7d0c7c730f35ce5b40fe471009a82de8dd53788ed970a9be1224
mint-secret: sha256:c92957227235db04953809d92b260d32c8b947472a8e8fd17a258ade3ae8da70
register-client: sha256:4ecc769f75433b66c7161f13c0fe7e5599633cf466d46cb6d8031f0456f426e7

View file

@ -0,0 +1,29 @@
# Independent Apps-scoped F3 source review
Verdict: **needs-change (RAPPS-F3-SESSION-1)**. The external owner configuration transaction is a coherent source design for public clients and confidential delivery NO_CHANGE. The proposed executable inverse omits a distinct fresh attended inverse-operation authorization/session boundary. This review is coordinator-delegated source review, not an actual railiance-apps owner acceptance, native grant or published implementation.
Reviewed Apps `944b30ad63ab054cc24617e374e177d34cd572da` and NetKingdom `3236d5000e52e0643f33906a26a7825aa0b67465`. `review.json` records exact candidate/source SHA-256 values and independently checked all 17 retained input pins against their current bytes. Sources include Apps AGENTS/INTENT, owner-design, actual T16/1.3 review records, Makefile and RAPPS-WP-0016. Original candidate wrapper SHA-256: `62752f518b78fa7713bdb4d47e68decbf3aa89ecbc7950549ffdc3d08e01c554`; candidate type SHA-256: `739c927ab494f4e0f6618e6e55394dd8dc4b720db428cd4973f7a81524e1ecd9`.
## Finding and supplied correction
The real frozen hand-over adapter's `execute` invokes `revoke_session(inv)` in `finally` on success and failure. Candidate `run` calls `inverse` only after hand-over exits. Its `inverse` then takes only the historical binding and transaction plus `owner.exclusive`; it never asks a backend to authenticate current inverse authority. In fixtures the original session is already revoked and Helm rollback nevertheless succeeds. A durable authenticated transaction proves provenance, not current permission. Retaining the same human principal is compatible with a fresh operation session; the revoked original session cannot authorize that new operation.
Three independent regressions fail against the original candidate and pass against the separate scratch correction: deny inverse after successful hand-over revocation; deny automatic failure unwind after revocation while retaining confirmed pending work; require fresh scoped session entry and revocation around a permitted inverse. The original 13 tests independently pass unchanged. Runs use explicit clean environments, frozen local source imports, no network and synthetic callbacks. Failed and corrected outputs are retained as `original-authority-tests.txt` and `patched-authority-tests.txt`.
`fresh-inverse-session.candidate.patch` adds a mandatory `owner.inverse_session(binding, transaction)` context before the release lock. This is a proposed backend interface, not an implemented credential boundary. T05 must supply authenticated operation-specific evidence, exact scope and current disposition, durable attempt/outcome records and final revocation on every exit. A missing fresh authorized attended session must leave the inverse pending with original error context; it cannot be replaced with a no-op context or reuse of the hand-over session. Existing historical authorization can cover that inverse scope where explicitly established; a new user permission ceremony is not implied. The new context models the required runtime authority, not a demand for another workflow.
Candidate type auth and owner-contract text must explicitly say: “Every configuration inverse runs as a distinct fresh authorized attended owner operation, with its own exact transaction/case/source/release binding and per-attempt revocation. The already revoked hand-over parent session is never reused. Missing current authority retains pending inverse/reconciliation evidence.” The separate patch intentionally does not edit canonical type/owner files or invent owner assent.
## Placement, exclusion and recovery
The proposed wrapper/inverse placement solves the reachability defect at source-design level: it covers public clients with delivery skipped and confidential delivery NO_CHANGE, and verification/receipt failures before receipt creation. Keeping receipt rollback receipt-only and delivery cleanup created-only preserves the existing authority boundaries. Preexisting configuration remains NO_CHANGE; reference deactivation is separately required before created delivery cleanup. Actual 1.3 C3 placed the inverse in owner_remove; this proposal needs explicit owner adoption of its revised placement and cannot inherit C3 assent.
The current Forgejo Make target performs a regular Helm upgrade, has prerequisite production applies, and mixes values inputs including a secret-producing helper. It supplies no durable transaction, authenticated configuration projection, operation provenance or all-writer exclusion. T05 must publish a narrowly reviewed source/configuration projection preserving local login and unrelated changes; it cannot classify an entire ordinary release upgrade as this case's exclusive configuration mutation. No values, rendered manifests or helper outputs were executed/read during this review.
The candidate correctly requires all release writers excluded across read-current and rollback. Helm has no CAS here. A local lock that only this wrapper honors is insufficient: manual Helm, automation and any reconciliation writer must share an enforced release boundary or an attended owner-controlled release freeze covering the critical section. Source review accepts that requirement; no real boundary is implemented or verified by this packet. A raw revision check followed by rollback remains rejected.
The pending-intent/confirmed/restored model safely refuses unproven mutation ownership and drift. Loss after configuration but before confirmation, or rollback but before durable inverse confirmation, still requires owner reconciliation against authenticated history and operation/source binding. T05 must retain inverse-attempt intent/outcome, classify ambiguous outcomes and read back the approved before configuration; it must not replay a rollback, infer NO_CHANGE or claim whole-case success while publication/receipt uncertainty remains. Recovery is plausible within the existing T05 implementation, but its durable backend is absent.
## Concrete existing T05 work remaining
Implement and publish the wrapper entry plus independently reachable inverse, the mandatory fresh inverse-operation session lifecycle, authenticated durable journal/recovery, exact accepted source/configuration binding and all-writer release exclusion. Reconcile the revised C3 type prose with actual Apps owner acceptance. Complete real owner_apply/owner_remove with authenticated created UIDs and reference-deactivation proof, all four observed Forgejo probe records, and the committed evidence store/revert behavior. Preserve T01 break-glass, T02/T03 login/group verification and the existing attended production authority. Exact source publication, kernel/catalog acceptance and separately authorized native evidence remain unfinished; no eighth step, standing credential, new grant or additional workplan is proposed.

View file

@ -0,0 +1,16 @@
--- a/owner_configuration_contract.py
+++ b/owner_configuration_contract.py
@@ -11,7 +11,12 @@
def inverse(binding, transaction, owner):
"""Owner unwind entry, reachable without any delivery/hand-over creation."""
- with owner.exclusive(binding):
+ # This is a distinct operation: hand-over has already revoked its session.
+ # Backend must authenticate a fresh attended inverse attempt, exact operation,
+ # principal/owner/case/source/release/transaction scope and current disposition;
+ # durably retain authority + outcome and revoke this attempt on every exit.
+ # Durable configuration provenance alone is never execution authority.
+ with owner.inverse_session(binding, transaction), owner.exclusive(binding):
record = owner.authenticated_record(transaction)
if record['binding'] != binding:
raise Refused('configuration transaction binding differs')

View file

@ -0,0 +1,43 @@
test_revoked_handover_session_cannot_authorize_later_inverse (__main__.InverseAuthorityTests.test_revoked_handover_session_cannot_authorize_later_inverse) ... FAIL
test_failure_unwind_must_not_reuse_revoked_handover_session (__main__.InverseAuthorityTests.test_failure_unwind_must_not_reuse_revoked_handover_session) ... FAIL
test_fresh_scoped_inverse_session_closed_after_restore (__main__.InverseAuthorityTests.test_fresh_scoped_inverse_session_closed_after_restore) ... FAIL
======================================================================
FAIL: test_revoked_handover_session_cannot_authorize_later_inverse (__main__.InverseAuthorityTests.test_revoked_handover_session_cannot_authorize_later_inverse)
----------------------------------------------------------------------
Traceback (most recent call last):
File "/tmp/nk48-apps-f3-review/test_inverse_authority.py", line 21, in test_revoked_handover_session_cannot_authorize_later_inverse
with self.assertRaisesRegex(Refused, 'fresh attended'):
AssertionError: Refused not raised
======================================================================
FAIL: test_failure_unwind_must_not_reuse_revoked_handover_session (__main__.InverseAuthorityTests.test_failure_unwind_must_not_reuse_revoked_handover_session)
----------------------------------------------------------------------
warden.case_receipt_state.Refused: consumer verification contract differs
During handling of the above exception, another exception occurred:
Traceback (most recent call last):
File "/tmp/nk48-apps-f3-review/test_inverse_authority.py", line 34, in test_failure_unwind_must_not_reuse_revoked_handover_session
with self.assertRaisesRegex(Refused, 'fresh attended'):
AssertionError: "fresh attended" does not match "consumer verification contract differs"
======================================================================
FAIL: test_fresh_scoped_inverse_session_closed_after_restore (__main__.InverseAuthorityTests.test_fresh_scoped_inverse_session_closed_after_restore)
----------------------------------------------------------------------
Traceback (most recent call last):
File "/tmp/nk48-apps-f3-review/test_inverse_authority.py", line 54, in test_fresh_scoped_inverse_session_closed_after_restore
self.assertEqual(events, ['fresh-scoped-authority', 'inverse-session-revoked'])
AssertionError: Lists differ: [] != ['fresh-scoped-authority', 'inverse-session-revoked']
Second list contains 2 additional elements.
First extra element 0:
'fresh-scoped-authority'
- []
+ ['fresh-scoped-authority', 'inverse-session-revoked']
----------------------------------------------------------------------
Ran 3 tests in 0.013s
FAILED (failures=3)

View file

@ -0,0 +1,8 @@
test_revoked_handover_session_cannot_authorize_later_inverse (__main__.InverseAuthorityTests.test_revoked_handover_session_cannot_authorize_later_inverse) ... ok
test_failure_unwind_must_not_reuse_revoked_handover_session (__main__.InverseAuthorityTests.test_failure_unwind_must_not_reuse_revoked_handover_session) ... ok
test_fresh_scoped_inverse_session_closed_after_restore (__main__.InverseAuthorityTests.test_fresh_scoped_inverse_session_closed_after_restore) ... ok
----------------------------------------------------------------------
Ran 3 tests in 0.015s
OK