From aef56bcba1f4a4ca696f4e8e86629d0eac8d1537 Mon Sep 17 00:00:00 2001 From: tegwick Date: Sun, 11 Oct 2026 02:21:03 +0200 Subject: [PATCH] Bind JWT negative probes to exact claim and cleanup evidence Assistant: codex Assistant-Model: gpt-6.1-sol Assistant-Session: 01a126e1-76f8-7643-8cf3-e93336539ade --- ...openbao-negative-probe-session-proposal.md | 117 ++++++++++++++++++ scripts/openbao_negative_probe.py | 111 ++++++++++++++--- scripts/test_openbao_negative_probe.py | 98 ++++++++++++++- ...Y-WP-0039-openbao-negative-probe-tokens.md | 18 ++- 4 files changed, 322 insertions(+), 22 deletions(-) create mode 100644 docs/openbao-negative-probe-session-proposal.md diff --git a/docs/openbao-negative-probe-session-proposal.md b/docs/openbao-negative-probe-session-proposal.md new file mode 100644 index 0000000..e7e3229 --- /dev/null +++ b/docs/openbao-negative-probe-session-proposal.md @@ -0,0 +1,117 @@ +# Coordinated OpenBao JWT refusal session proposal + +Status: prepared, not an execution grant. Proposal ID: KEY-WP-0039-T02/coordinated-negatives-v2. + +The current positive service-JWT login and no-fallback results are retained. This +session adds native refusals for CCR-2026-0038 and must not replay registration, +custody minting or accepted provisioning. It leaves the secrets-engine-openbao +registration, custody and OpenBao role/policy unchanged. + +## Exact owner re-agreement and execution authority + +Railiance-platform must re-agree the packet's two documented departures: all +non-audience probes pin audience secrets-engine-openbao, and the sixth expiry +client has every production claim and therefore is capable of logging in until +removed. The script now exercises that capability as a positive control before +expiry and immediately revokes the resulting self-only OpenBao session. This +change belongs in the exact owner review, rather than being hidden in a test. +NetKingdom owns live registry/env changes and both KeyCape restarts. KeyCape owns +claim isolation and issuance checks. Platform owns refusal interpretation, +effective-policy capability checks and the final CCR receipt. Founder approval +must name the exact revised script and registration packet digests, bounded +session duration, temporary Secret and registration names, and rollback below. +Previous approval of CCR-2026-0038 alone is not approval to add six clients. + +No existing coding-agent secret is requested through chat or a repository. If +codex-railiance-platform is not registered and independently available through +its admitted route, that case is inconclusive and requires owner disposition; +it is never silently skipped or replaced by an altered production client. + +## Preflight before any mutation + +Record only value-free metadata: current KeyCape Deployment image/revision, +registry content digest with secretRefs only, its resourceVersion, admission of +six unique client IDs, a spare restart window, and baseline workload health. +Confirm live JWT issuer, RS256, exact role bindings and current +expiration_leeway. Omitted/zero OpenBao expiration_leeway means the provider +default 150 seconds; a disabled leeway must be normalized to effective zero +before passing --leeway. Record effective leeway in the receipt. Abort if role +bindings differ from CCR-2026-0038, if any temporary client already exists, or +if the registry has an unreviewed revision. No value or Secret object read is +part of this preflight. + +The execution packet must provide a private admitted path to the existing +coding-agent token and an attended human login. Issuer discovery must remain +https://kc.coulomb.social and the token endpoint on that origin. The revised +script rejects redirects and mismatched discovery origins. + +## Bounded session and proof + +Generate six independent secrets in the owner-controlled session. Materialize +one uniquely named transient Kubernetes Secret and precisely six env references; +no OpenBao custody, retained consumer file, or additional persistent copy. +Merge six registrations from docs/openbao-negative-probe-clients.yaml, preserving +all existing clients byte for byte. Restart once and verify Ready and all six +client exchanges. Run the revised script with private stdin and an attended +human token; never print the stdin, claims, bearer tokens, errors or Secret. + +The script inspects decoded claim shape only to establish probe isolation; this +is not signature verification. OpenBao remains the verifier. Audience, subject, +tenant, roles and scope probes must differ on exactly their designated claim; +the expiry probe must match all six bound claims and have its one-minute TTL. +The expiry token must first be accepted and successfully self-revoked, then its +JWT is presented after exp + effective leeway + 10 seconds. An unrelated claim +refusal, not-yet-valid error, generic permission denial, transport failure, +malformed response, unexpected acceptance or unconfirmed revocation fails the +proof. Existing human/coding-agent tokens may differ in several claims and prove +only their class's refusal, not isolated principal_type enforcement. + +After any unexpected acceptance, successful revoke-self is required; if it +cannot be confirmed, the runner stops further presentations and the owner must abort and escalate that session to the platform owner. +The runner produces a v2 value-free receipt. all_refused means all eight refusal +checks have semantically appropriate classes and the expiry control succeeded; +there is no broad success from HTTP 400/403 alone. + +Platform additionally performs sys/capabilities-self with the actual admitted +service session for both guarded KV data/metadata paths plus +`sys/policies/acl/*`, `auth/*/role/*`, and `auth/token/create`, confirms deny for +every CCR-required path, and revokes the session. No data read is needed. The existing no-fallback receipt +is retained unless the provider or consumer revision changed. These checks are +outside the negative runner and remain required by RPF-WP-0035-T02. + +## Cleanup and rollback + +In a finally path remove all six registrations and exactly six env references, +restore the unchanged baseline registry/config, restart and wait Ready, then +verify client_credentials for each removed client is invalid_client using the +still-private temporary values. Only then remove the transient Secret and any +private human-token file owned by this session. Confirm all six client IDs and +Secret/env refs are absent, service positive behavior and baseline deployment +health restored. Never restore a complete Deployment over concurrent changes; +compare baseline/revision and stop for owner reconciliation if they changed. +Do not destroy or rotate the production secrets-engine credential. + +If interrupted after registration, the session remains incomplete until the +same cleanup is verified. A failed login's unconfirmed revocation remains a +failure in the receipt; later successful cleanup does not rewrite that history. +The exposure is at most the attended window plus the final issued JWT's TTL and +any OpenBao 5-minute session lifetime; unexpected issued sessions require +verified revocation. Cleanup evidence is mandatory before KEY-WP-0039 can finish. + +## Distinct remaining acceptance + +RPF-WP-0035-T02 can finish only after the eight native refusals, effective-policy +deny checks, unchanged no-fallback evidence and cleanup are retained. This does +not finish MASON-WP-0009-T05: its owner-return requirements remain live operator +and ESO token capability checks, a nonmember refusal, and ops-warden activation +of the already proposed pointer after evidence. No mint, operator policy widening +or routing activation is authorized by this proposal. + +## Validation + +17 runner tests pass, including malformed/ambiguous semantics, exact claim +isolation, valid expiry control, failed revoke, redirect refusal and no retained +credential values. Two semantic tests fail against the original source: it +reported success when the expiry positive control was refused for a subject +mismatch, and it classified not-yet-valid as expired. Native execution has not +been attempted by this preparation. diff --git a/scripts/openbao_negative_probe.py b/scripts/openbao_negative_probe.py index 134bb7a..cb864ae 100644 --- a/scripts/openbao_negative_probe.py +++ b/scripts/openbao_negative_probe.py @@ -21,7 +21,7 @@ import urllib.error import urllib.parse import urllib.request -SCHEMA = 'keycape.openbao-negative-probe.v1' +SCHEMA = 'keycape.openbao-negative-probe.v2' CLAIM_PROBES = [ ('aud', 'openbao-probe-aud', 'openbao:login'), ('sub', 'openbao-probe-sub', 'openbao:login'), @@ -34,6 +34,11 @@ CODING_AGENT = ('coding_agent', 'codex-railiance-platform', 'openbao:login') DEFAULT_LEEWAY = 150 # OpenBao jwt auth expiration_leeway default, seconds +class NoRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, req, fp, code, msg, headers, newurl): + return None + + class Inconclusive(Exception): """The probe could not tell refusal from failure; never counted as a refusal.""" @@ -48,7 +53,7 @@ def http_post(url, form=None, body=None, headers=None, timeout=15): headers['Content-Type'] = 'application/json' req = urllib.request.Request(url, data=data, headers=headers, method='POST') try: - with urllib.request.urlopen(req, timeout=timeout) as resp: + with urllib.request.build_opener(NoRedirect()).open(req, timeout=timeout) as resp: return resp.status, resp.read() except urllib.error.HTTPError as err: return err.code, err.read() @@ -61,7 +66,7 @@ def classify(errors_text): text = errors_text.lower() if 'principal_type' in text: return 'not_service_principal' - if re.search(r'expired|\bexp\b|not valid yet|nbf', text): + if re.search(r'expired|\bexp\b', text): return 'expired' if re.search(r'claim|audience|\baud\b|subject|\bsub\b|bound', text): return 'claim_mismatch' @@ -76,11 +81,51 @@ def exchange(post, token_endpoint, client_id, secret, scope): if status != 200: raise Inconclusive('token_exchange_failed') try: - return json.loads(raw)['access_token'] + token = json.loads(raw)['access_token'] + if not isinstance(token, str) or not token: + raise ValueError + return token except (ValueError, KeyError, TypeError): raise Inconclusive('token_response_invalid') +REFERENCE = {'aud': 'secrets-engine-openbao', 'sub': 'service:secrets-engine', + 'tenant': 'tenant:platform', 'roles': ['secrets-engine'], + 'scope': 'openbao:login', 'principal_type': 'service'} + + +def check_claims(token, case, now): + """Inspect claim shape for probe isolation, without asserting signature validity.""" + try: + if not isinstance(token, str): + raise ValueError + payload = token.split('.')[1] + claims = json.loads(base64.urlsafe_b64decode(payload + '=' * (-len(payload) % 4))) + exp = claims['exp'] + if not isinstance(exp, int) or isinstance(exp, bool) or exp <= now(): + raise ValueError + if claims.get('iss') != 'https://kc.coulomb.social': + raise ValueError + if 'nbf' in claims and claims['nbf'] > now(): + raise ValueError + mismatches = {key for key, expected in REFERENCE.items() if claims.get(key) != expected} + expected = set() if case == 'expired' else {case} + if case == 'principal_type': + valid = claims.get('principal_type') == 'human' + elif case == 'coding_agent': + valid = claims.get('principal_type') == 'service' and 'coding-agent' in claims.get('roles', []) + else: + valid = mismatches == expected + if case == 'expired': + issued = claims.get('iat') + valid = valid and isinstance(issued, int) and not isinstance(issued, bool) and 0 < exp - issued <= 60 + if not valid: + raise ValueError + return exp + except (IndexError, ValueError, KeyError, TypeError): + raise Inconclusive('probe_claims_invalid') + + def jwt_exp(token): try: payload = token.split('.')[1] @@ -95,6 +140,8 @@ def present(post, bao_addr, mount, role, token): if status == 200: try: bao_token = json.loads(raw)['auth']['client_token'] + if not isinstance(bao_token, str) or not bao_token: + raise ValueError except (ValueError, KeyError, TypeError): return False, 'accepted', False rstatus, _ = post(f'{bao_addr}/v1/auth/token/revoke-self', body={}, @@ -102,7 +149,10 @@ def present(post, bao_addr, mount, role, token): return False, 'accepted', rstatus == 204 if status in (400, 403): try: - errors = ' '.join(str(e) for e in json.loads(raw).get('errors', [])) + items = json.loads(raw).get('errors', []) + if not isinstance(items, list) or not all(isinstance(e, str) for e in items): + raise ValueError + errors = ' '.join(items) except (ValueError, AttributeError): errors = '' if errors: @@ -112,23 +162,35 @@ def present(post, bao_addr, mount, role, token): def run(post, secrets, token_endpoint, bao_addr, mount, role, human_token=None, leeway=DEFAULT_LEEWAY, sleep=time.sleep, now=time.time): + if isinstance(leeway, bool) or not isinstance(leeway, int) or leeway < 0: + raise Inconclusive('leeway_invalid') cases = [] + halted = False def record(case, fn): + nonlocal halted try: + if halted: + raise Inconclusive('session_aborted') refused, cls, revoked = fn() - entry = {'case': case, 'refused': refused, 'class': cls} + expected = {'expired': {'expired'}, 'principal_type': {'not_service_principal', 'claim_mismatch'}, + 'coding_agent': {'not_service_principal', 'claim_mismatch'}}.get(case, {'claim_mismatch'}) + semantic = refused and cls in expected + entry = {'case': case, 'refused': refused, 'class': cls, 'verified': semantic} if revoked is not None: entry['revoked'] = revoked + if revoked is False: + halted = True except Inconclusive as err: - entry = {'case': case, 'refused': False, 'class': 'inconclusive', 'reason': str(err)} + entry = {'case': case, 'refused': False, 'class': 'inconclusive', 'verified': False, 'reason': str(err)} cases.append(entry) - def via_client(client_id, scope): + def via_client(case, client_id, scope): if client_id not in secrets: raise Inconclusive('secret_not_supplied') - return present(post, bao_addr, mount, role, - exchange(post, token_endpoint, client_id, secrets[client_id], scope)) + token = exchange(post, token_endpoint, client_id, secrets[client_id], scope) + check_claims(token, case, now) + return present(post, bao_addr, mount, role, token) # Mint the expiry token first so its wait overlaps the other cases. expiry_token, expiry_error = None, None @@ -136,18 +198,25 @@ def run(post, secrets, token_endpoint, bao_addr, mount, role, human_token=None, if EXPIRY_PROBE[1] not in secrets: raise Inconclusive('secret_not_supplied') expiry_token = exchange(post, token_endpoint, EXPIRY_PROBE[1], secrets[EXPIRY_PROBE[1]], EXPIRY_PROBE[2]) - expires_at = jwt_exp(expiry_token) + expires_at = check_claims(expiry_token, 'expired', now) + refused, cls, revoked = present(post, bao_addr, mount, role, expiry_token) + if refused or cls != 'accepted' or revoked is not True: + raise Inconclusive('expiry_positive_control_failed') except Inconclusive as err: expiry_error = err + halted = True for case, client_id, scope in CLAIM_PROBES: - record(case, lambda c=client_id, s=scope: via_client(c, s)) - record(CODING_AGENT[0], lambda: via_client(CODING_AGENT[1], CODING_AGENT[2])) + record(case, lambda c=client_id, s=scope: via_client(case, c, s)) + record(CODING_AGENT[0], lambda: via_client(CODING_AGENT[0], CODING_AGENT[1], CODING_AGENT[2])) if human_token: - record('principal_type', lambda: present(post, bao_addr, mount, role, human_token)) + def principal(): + check_claims(human_token, 'principal_type', now) + return present(post, bao_addr, mount, role, human_token) + record('principal_type', principal) else: cases.append({'case': 'principal_type', 'refused': False, 'class': 'inconclusive', - 'reason': 'human_token_not_supplied'}) + 'verified': False, 'reason': 'human_token_not_supplied'}) def expired(): if expiry_error: @@ -164,7 +233,9 @@ def run(post, secrets, token_endpoint, bao_addr, mount, role, human_token=None, 'mount': mount, 'role': role, 'cases': cases, - 'all_refused': all(c['refused'] for c in cases), + 'all_refused': all(c['verified'] for c in cases), + 'expiry_positive_control_verified': expiry_error is None, + 'expiration_leeway_seconds': leeway, 'values_retained': False, } @@ -179,7 +250,7 @@ def main(argv=None): ap.add_argument('--leeway', type=int, default=DEFAULT_LEEWAY, help='the role mount expiration_leeway in seconds') args = ap.parse_args(argv) - if not args.issuer.startswith('https://') or not args.bao_addr.startswith('https://'): + if args.issuer.rstrip('/') != 'https://kc.coulomb.social' or not args.bao_addr.startswith('https://') or args.leeway < 0: print(json.dumps({'schema': SCHEMA, 'error': 'https_required'})) return 2 try: @@ -198,8 +269,12 @@ def main(argv=None): print(json.dumps({'schema': SCHEMA, 'error': 'human_token_file_invalid'})) return 2 try: - with urllib.request.urlopen(args.issuer.rstrip('/') + '/.well-known/openid-configuration', timeout=15) as r: + with urllib.request.build_opener(NoRedirect()).open(args.issuer.rstrip('/') + '/.well-known/openid-configuration', timeout=15) as r: token_endpoint = json.loads(r.read())['token_endpoint'] + issuer_url = urllib.parse.urlsplit(args.issuer) + endpoint_url = urllib.parse.urlsplit(token_endpoint) + if (endpoint_url.scheme, endpoint_url.netloc) != (issuer_url.scheme, issuer_url.netloc) or endpoint_url.username or endpoint_url.password: + raise ValueError except (OSError, ValueError, KeyError): print(json.dumps({'schema': SCHEMA, 'error': 'discovery_failed'})) return 2 diff --git a/scripts/test_openbao_negative_probe.py b/scripts/test_openbao_negative_probe.py index 8a24d0b..242d450 100644 --- a/scripts/test_openbao_negative_probe.py +++ b/scripts/test_openbao_negative_probe.py @@ -14,7 +14,15 @@ SECRETS = {cid: SECRET for _, cid, _ in probe.CLAIM_PROBES + [probe.EXPIRY_PROBE def fake_jwt(client_id, exp=1000): - payload = base64.urlsafe_b64encode(json.dumps({'exp': exp, 'cid': client_id}).encode()).decode().rstrip('=') + claims = dict({'aud': 'secrets-engine-openbao', 'sub': 'service:secrets-engine', 'tenant': 'tenant:platform', 'roles': ['secrets-engine'], 'scope': 'openbao:login', 'principal_type': 'service'}, exp=exp, iat=exp-60, cid=client_id, iss='https://kc.coulomb.social') + field = client_id.removeprefix('openbao-probe-') + if field in {'aud', 'sub', 'tenant', 'roles', 'scope'}: + claims[field] = ['probe'] if field == 'roles' else 'probe' + if client_id == 'human': + claims['principal_type'] = 'human' + if client_id == 'codex-railiance-platform': + claims['roles'] = ['coding-agent'] + payload = base64.urlsafe_b64encode(json.dumps(claims).encode()).decode().rstrip('=') return f'h.{payload}.s' @@ -25,6 +33,7 @@ class FakeBao: self.accept = set(accept) self.login_status = login_status self.revoked = 0 + self.expiry_presentations = 0 def __call__(self, url, form=None, body=None, headers=None, timeout=15): if url == TOKEN_URL: @@ -36,7 +45,9 @@ class FakeBao: cid = json.loads(base64.urlsafe_b64decode(body['jwt'].split('.')[1] + '==')).get('cid', 'human') if self.login_status: return self.login_status, b'' - if cid in self.accept: + if cid == 'openbao-probe-expiry': + self.expiry_presentations += 1 + if cid in self.accept or (cid == 'openbao-probe-expiry' and self.expiry_presentations == 1): return 200, json.dumps({'auth': {'client_token': 'bao-token'}}).encode() reason = {'openbao-probe-expiry': 'token is expired', 'human': 'claim "principal_type" does not match'}.get(cid, 'claim "sub" does not match any bound value') @@ -65,7 +76,7 @@ class ProbeTest(unittest.TestCase): self.assertFalse(receipt['all_refused']) tenant = next(c for c in receipt['cases'] if c['case'] == 'tenant') self.assertEqual((tenant['refused'], tenant['class'], tenant['revoked']), (False, 'accepted', True)) - self.assertEqual(fake.revoked, 1) + self.assertEqual(fake.revoked, 2) def test_transport_failure_is_never_a_refusal(self): receipt = run(FakeBao(login_status=503)) @@ -89,6 +100,87 @@ class ProbeTest(unittest.TestCase): leeway=150, sleep=waits.append, now=lambda: 900) self.assertEqual(waits, [1000 + 150 + 10 - 900]) + def test_wrong_expiry_reason_is_not_evidence(self): + fake = FakeBao() + def wrong(url, **kw): + result = fake(url, **kw) + if url.endswith('/login') and fake.expiry_presentations > 1: + return 400, json.dumps({'errors': ['claim sub does not match']}).encode() + return result + receipt = run(wrong) + self.assertFalse(receipt['all_refused']) + expired = receipt['cases'][-1] + self.assertTrue(expired['refused']) + self.assertFalse(expired['verified']) + + def test_generic_denial_is_not_evidence(self): + fake = FakeBao() + def deny(url, **kw): + result = fake(url, **kw) + if result[0] == 400: + return 403, b'{"errors":["permission denied"]}' + return result + self.assertFalse(run(deny)['all_refused']) + + def test_expiry_requires_valid_control(self): + fake = FakeBao() + def reject_control(url, **kw): + result = fake(url, **kw) + if url.endswith('/login') and fake.expiry_presentations == 1: + return 400, b'{"errors":["claim sub mismatch"]}' + return result + receipt = run(reject_control) + self.assertFalse(receipt['all_refused']) + self.assertFalse(receipt['expiry_positive_control_verified']) + + def test_unconfirmed_control_revoke_fails(self): + fake = FakeBao() + def bad_revoke(url, **kw): + if url.endswith('/revoke-self'): + return 500, b'' + return fake(url, **kw) + self.assertFalse(run(bad_revoke)['all_refused']) + + def test_each_probe_has_exactly_one_claim_defect(self): + for case, cid, _ in probe.CLAIM_PROBES: + self.assertEqual(probe.check_claims(fake_jwt(cid), case, lambda: 0), 1000) + with self.assertRaises(probe.Inconclusive): + probe.check_claims(fake_jwt(cid), 'expired', lambda: 0) + self.assertEqual(probe.check_claims(fake_jwt('openbao-probe-expiry'), 'expired', lambda: 0), 1000) + + def test_audience_alias_breaks_probe_isolation(self): + token = fake_jwt('openbao-probe-sub') + claims = json.loads(base64.urlsafe_b64decode(token.split('.')[1] + '==')) + claims['aud'] = 'openbao-probe-sub' + payload = base64.urlsafe_b64encode(json.dumps(claims).encode()).decode().rstrip('=') + with self.assertRaises(probe.Inconclusive): + probe.check_claims(f'h.{payload}.s', 'sub', lambda: 0) + + def test_redirects_are_not_followed(self): + self.assertIsNone(probe.NoRedirect().redirect_request(None, None, 302, '', {}, 'https://other.example')) + + def test_not_yet_valid_does_not_count_as_expired(self): + self.assertNotEqual(probe.classify('token not valid yet: nbf'), 'expired') + + def test_malformed_refusal_is_inconclusive(self): + fake = FakeBao() + def malformed(url, **kw): + result = fake(url, **kw) + if result[0] == 400: + return 400, b'{"errors":null}' + return result + receipt = run(malformed) + self.assertFalse(receipt['all_refused']) + self.assertTrue(all(c['class'] == 'inconclusive' for c in receipt['cases'] if c['case'] != 'expired')) + + def test_expiry_client_ttl_is_bounded(self): + token = fake_jwt('openbao-probe-expiry') + claims = json.loads(base64.urlsafe_b64decode(token.split('.')[1] + '==')) + claims['iat'] = claims['exp'] - 900 + payload = base64.urlsafe_b64encode(json.dumps(claims).encode()).decode().rstrip('=') + with self.assertRaises(probe.Inconclusive): + probe.check_claims(f'h.{payload}.s', 'expired', lambda: 0) + def test_classify(self): self.assertEqual(probe.classify('error validating token: token is expired'), 'expired') self.assertEqual(probe.classify('aud claim does not match any bound audience'), 'claim_mismatch') diff --git a/workplans/KEY-WP-0039-openbao-negative-probe-tokens.md b/workplans/KEY-WP-0039-openbao-negative-probe-tokens.md index 2f4b5e3..26d244a 100644 --- a/workplans/KEY-WP-0039-openbao-negative-probe-tokens.md +++ b/workplans/KEY-WP-0039-openbao-negative-probe-tokens.md @@ -9,7 +9,7 @@ flavor: implementation owner: claude-code topic_slug: netkingdom created: "2026-10-10" -updated: "2026-10-10" +updated: "2026-10-11" depends_on: [] related_repos: - railiance-platform @@ -121,3 +121,19 @@ a value-free receipt in `docs/evidence/`, and deregister the clients afterwards. Acceptance: every case is refused, the receipt is committed, and the probe clients are gone. + + +2026-10-11 source preparation: T01 remains progress because the exact expiry +client and audience-pin amendments require platform-owner re-agreement; the +prior agreement covered the earlier case list, not this revised packet. The +prepared runner revision adds a valid expiry positive control and immediate +revoke, exact decoded claim-isolation preflight (not signature evidence), +semantic refusal classes, bounded expiry TTL and redirect refusal. Seventeen +local tests pass; original-source semantic regressions demonstrate two false +successes. See `docs/openbao-negative-probe-session-proposal.md` for exact owner +returns, live authority, effective leeway, custody boundaries and cleanup. No +live token, registry or OpenBao action occurred. T02 continues to wait for owner +re-agreement plus separate attended live authorization. MASON-WP-0009-T05 +operator/ESO capability and nonmember checks remain independent acceptance. + +2026-10-11: Platform independently re-agreed the final amended design (session proposal SHA25623f13d2a5a4204f749f35c5d44ef39d213838cd2fce1c36fb4775254ef1c7f09). Original conditional review and narrow confirmation are retained in railiance-platform/docs/reviews/2026-10-11-keycape-openbao-negative-session-owner-review*. Source acceptance T01 is complete; actual registry/leeway/readiness, founder live go, negative/cleanup and separate capability/no-fallback obligations remain T02 and owner plans.