docs: preserve reviewed NetKingdom session artifacts
Some checks are pending
CI Smoke / host-smoke (push) Waiting to run
CI Smoke / container-smoke (push) Waiting to run
Reject generated-only commits / generated-commit-guard (push) Waiting to run

This commit is contained in:
codex 2026-11-01 23:14:27 +01:00
parent d9a1fdabc3
commit a192d24c9a
45 changed files with 11795 additions and 0 deletions

View file

@ -0,0 +1,14 @@
{
"workplan_id": "dfadb19b-445e-59c6-bcd6-48e87b912c52",
"id": "c1524ae9-ae86-440c-8c42-d09fd1f30439",
"topic_id": null,
"task_id": "6aca534c-5c7a-556f-b6b6-406433670c29",
"decision_id": null,
"event_type": "note",
"summary": "User-requested pause checkpoint published as 3607d3e08ffdbc473d19e9baf7225843c4075ae0; normal scanner passed for reduced 26-file checkpoint and consistency sync completed 89/89, zero failures/automation errors. Original staged brief restored and independently verified. Browser source 916ba4b and owner receipt cbabb6d published; KeyCape owner review 834b0d published. Independent checker reconstruction passed 39 affected checks and 11 actual-checker scenarios; recovery R5 passed 67 synthetic checks but first real disposable Docker positive trial failed before native verification. Cleanup independently confirmed; no trial or subagent remains running. Six raw evidence files were scanner-rejected; original evidence and complete frozen R5 source packet retained locally, withheld paths and transfer limitation documented in pause-publication-scope.json. All sixteen TSA gates remain open. Rough overall progress estimate 40%, range 30\u201350%, not audited completion. Resume points in docs/netkingdom-target-implementation.md; pause does not complete the workplan.",
"detail": null,
"author": "codex",
"session_id": null,
"created_at": "2026-10-16T20:39:21.428030Z",
"workstream_id": "dfadb19b-445e-59c6-bcd6-48e87b912c52"
}

View file

@ -0,0 +1,14 @@
{
"workplan_id": "dfadb19b-445e-59c6-bcd6-48e87b912c52",
"id": "17e6e0a4-890c-4ef7-b7e7-43fba98569a7",
"topic_id": null,
"task_id": "6aca534c-5c7a-556f-b6b6-406433670c29",
"decision_id": null,
"event_type": "note",
"summary": "CUST-WP-0080-T06: published Access dc042022f7c30671cf0a7605cf43ef6d96d04242 (normal Helm zero/one outbox writers) and Platform 84c529f239c81e6d3ca337015700b03e3a862f53 (distinct runtime/storage versus GitOps owner scopes), exact committed hashes and origin ancestry verified. Access sync38/38; Platform exact primary/railiance01 applied, no refused records. Root86 Helm cases plus existing suite,45 native/preparation tests; independent cluster54 overlapping custody tests. R4 scanner refusal retained; typed public OCI references pass unchanged scanner in R5. Cluster own RCLUSTER-WP-0017 published523669fb and bounded formal R5 source role review recorded; ADR007 confirms canonical prefix, C26 historical-majority inference warning is not authority to rename. RolloutR2/custodyP3 being frozen; no native action or gate closure.33 source slices published; all16 TSA gates remain open. Prior rootfa0fc15 sync completed89/89 zero failures.",
"detail": null,
"author": "codex",
"session_id": null,
"created_at": "2026-10-16T18:29:37.495335Z",
"workstream_id": "dfadb19b-445e-59c6-bcd6-48e87b912c52"
}

View file

@ -0,0 +1,23 @@
{
"request": {
"summary": "CUST-WP-0080-T06 concrete source progress: published Platform d870596 exact metadata baseline/readback and b9eaa20 authenticated process-loss cleanup recovery; Access919ce25 native preparation and4f6946b real authenticated service HTTP join; Secrets5a2099f exact native CLI preparation; Approval3485415 producer-owned Go fact adapter. Independent32Platform readback tests,53recovery/journal tests,9Accessreceipt+2runtime-twin tests,3B1Go packages,14114Python/Go canonical differential cases and2producer tests passed. Unintegrated trusted successor fullAccessGo suite and176Secretsfocused tests passed. Whole-stack work exposed incompatible15s/30s+reserve timing and retainedregistry normalization/digest mismatch; owner corrections and proof underway, no workaround or nativeacceptance. All16TSA gates remainopen. Normal owner publication/reconciliation succeeded after retainedAccessquerytimeouts; Approval dirtyworkplan/generatedwriteback caveat preserved. No deployment/credential/trust/grant changes.",
"event_type": "note",
"author": "codex",
"workplan_id": "dfadb19b-445e-59c6-bcd6-48e87b912c52",
"task_id": "6aca534c-5c7a-556f-b6b6-406433670c29"
},
"receipt": {
"workplan_id": "dfadb19b-445e-59c6-bcd6-48e87b912c52",
"id": "7a6a964b-0fa4-4ff1-ba9c-d903b8cb861e",
"topic_id": null,
"task_id": "6aca534c-5c7a-556f-b6b6-406433670c29",
"decision_id": null,
"event_type": "note",
"summary": "CUST-WP-0080-T06 concrete source progress: published Platform d870596 exact metadata baseline/readback and b9eaa20 authenticated process-loss cleanup recovery; Access919ce25 native preparation and4f6946b real authenticated service HTTP join; Secrets5a2099f exact native CLI preparation; Approval3485415 producer-owned Go fact adapter. Independent32Platform readback tests,53recovery/journal tests,9Accessreceipt+2runtime-twin tests,3B1Go packages,14114Python/Go canonical differential cases and2producer tests passed. Unintegrated trusted successor fullAccessGo suite and176Secretsfocused tests passed. Whole-stack work exposed incompatible15s/30s+reserve timing and retainedregistry normalization/digest mismatch; owner corrections and proof underway, no workaround or nativeacceptance. All16TSA gates remainopen. Normal owner publication/reconciliation succeeded after retainedAccessquerytimeouts; Approval dirtyworkplan/generatedwriteback caveat preserved. No deployment/credential/trust/grant changes.",
"detail": null,
"author": "codex",
"session_id": null,
"created_at": "2026-10-16T14:45:06.591043Z",
"workstream_id": "dfadb19b-445e-59c6-bcd6-48e87b912c52"
}
}

View file

@ -0,0 +1,33 @@
# Protected cleanup recovery source successor
`implementation-r1.patch` adds operation-bound private fixture custody and a
separately admitted cleanup-only executor. Actual forked executor death can be
reconciled through fixed accessor revocation/absence and exact grant-policy
retirement. The runner never restores business or consume admission, mints a
session, or retries an effect. Its durable native intent and receipt records
preserve unknown outcomes; a fresh owner permit is required after failure.
Custody is a separate owner-only directory and persists accessors, not bearer
credentials. This proves process-independent fixture recovery. Same-UID hostile
code isolation, production storage/encryption/retention, exact recovery-role scope
and native actor/audit admission remain unselected/unadmitted. No estate backend,
credential read, new live grant or generic broker is introduced. Lost token-create
response before accessor commit remains explicit unresolved token cleanup debt.
Exact source hashes and preimages are retained in `manifest-r1.json` and
`preimages/`. Parent owns integration, work-record sync and progress log.
The separate Platform signer-custody proposal remains frozen.
Validation: 48 regression tests passed; final frozen recovery/journal suite24
tests passed. Counts overlap. Exact run chronology and retained initial failures
are in `validation-r1.json`. `owner-source-review-r1.json` approves source only.
Canonical apply-check and source/preimage hashes passed.
R2 supersedes the R1 source proposal after root refusal review. R1 files remain
unchanged. Use `implementation-r2.patch` (full) or `implementation-r1-to-r2.patch`
(delta over R1). Exact hashes are in `manifest-r2.json`; owner source review and
validation are `owner-source-review-r2.json` / `validation-r2.json`. R2 fixes
arbitrary404 absence, broad rstrip equivalence and unauthenticated/corrupt capsule
acceptance.53successor tests and29final frozen cases passed; counts overlap.
`r1-refusal-counterexamples.json` retains reproduced original failures.

View file

@ -0,0 +1,28 @@
{
"files": {
"scripts/task_execution_recovery.py": {
"before_sha256": null,
"after_sha256": "910f38919a8b6f57591366a2ef4da40d1ed7dfd7556c845a4b89496eca39ce1c"
},
"scripts/task_execution_native_fixture.py": {
"before_sha256": "4575d048f79b125b0f87c70b9a76558d8cce6e61cb66b5b0583934d1a62f2ef6",
"after_sha256": "1191bfe2cfd0dd967f51405a1fd453e2f24c929d558554110137c2cc99f71941"
},
"scripts/task_execution_journal.py": {
"before_sha256": "507b41a790e678dfd16af747bd851f5abc2fed5a8fb70bca05449d9e5ac674e8",
"after_sha256": "604cf285286a8a1be23d9bc0d8a547bd1195b7f80286778e1d4f30c711fd6a7e"
},
"tests/test_task_execution_recovery.py": {
"before_sha256": null,
"after_sha256": "1322469a116305a3a44bca7191eb52b76614d911b9eb7ce68864c52ce13c36e1"
},
"docs/task-execution-native-fixture.md": {
"before_sha256": "4501a512bc03a8d912b11a199a1385d75cfa077ba849dfcfe3eca53f31d1c8ea",
"after_sha256": "e299ff92fbd5282ca73066e41d5ef3753fad2e46ded2d8eda9935d20effaae46"
},
"workplans/RPF-WP-0035-credential-lane-implementation.md": {
"before_sha256": "e90d963f8699360a62907ccc32d2346a33db62945dfb10473217c6dce7b2d383",
"after_sha256": "312a3b4b9228897a1a256736eb0d4995b38274a47c6a52870d106aba16b532fd"
}
}
}

View file

@ -0,0 +1,394 @@
diff --git a/docs/task-execution-native-fixture.md b/docs/task-execution-native-fixture.md
index 4fb60fe..f4ff070 100644
--- a/docs/task-execution-native-fixture.md
+++ b/docs/task-execution-native-fixture.md
@@ -174,3 +174,34 @@ revision drift, namespace policy-name reuse and private-file/binding refusals.
This is source fixture proof only. Native custody, isolation, exact recovery-role
permissions, retention/encryption, clock admission, audit delivery and human scope
approval remain missing. No generic broker, production reader, grant or CLI is added.
+
+
+### Recovery R2 exact absence and authenticated capsule refinement
+
+R1 overclaimed policy absence for arbitrary 404 bodies. R2 requires exactly
+`{"errors":[]}` for both initial GET and post-DELETE GET. Other 404 bodies,
+permission denials, extra data, malformed/duplicate-key/nonfinite JSON and failed
+readback cannot confirm policy cleanup. A successful DELETE status alone cannot
+confirm absence. Recovery native policy comparison admits precisely one optional
+terminal LF, matching the retained OpenBao HCL source/readback convention; spaces,
+tabs, CR, extra terminal LF and any other byte change are refused. This is a
+narrow declared normalization, not full whitespace or semantic equivalence.
+
+Private fixture capsules now carry HMAC-SHA256 authentication under an exactly
+32-byte integrity key supplied independently by the recovery owner. The key is
+never stored in the capsule or public journal, and is not a bearer credential.
+The fixture owner retains this key independently across executor death; a fresh
+owner custody object must supply the same key. Wrong-key or damaged accessor/HCL
+bytes refuse before admission. This is integrity protection, not encryption or
+same-UID isolation; production key custody, rotation/retention and recovery actor
+selection remain explicitly unadmitted.
+
+After authentication the reader enforces exact top-level and nested binding/
+cleanup key sets and types, validated Binding/Cleanup formats and the ordered
+source-derived cleanup_specs operation digests. Duplicate JSON keys, nonfinite
+numbers, malformed phase/revision combinations and type-coercible values refuse.
+The reader bounds bytes, rejects indirect/nonprivate files and detects stat/size
+changes during its single read. A valid older prepared capsule still cannot
+prove a task token absent when durable journal state/calls reached issuance:
+that remains unknown cleanup debt. Authentication alone is not anti-rollback.
+No business or consume admission is restored and no production scope is granted.
diff --git a/scripts/task_execution_recovery.py b/scripts/task_execution_recovery.py
index b4ce2b9..ab8b8cb 100644
--- a/scripts/task_execution_recovery.py
+++ b/scripts/task_execution_recovery.py
@@ -8,6 +8,7 @@ public request/journal data. No business adapter or resumed admission is built.
from __future__ import annotations
from dataclasses import asdict, dataclass, field
import hashlib
+import hmac
import http.client
import json
import os
@@ -17,17 +18,45 @@ import subprocess
import time
import uuid
-from task_execution_journal import Binding, Journal, Refused
+from task_execution_journal import Binding, Cleanup, Journal, Refused
+from task_execution_plan import cleanup_specs
def _encoded(value):
- return json.dumps(value, sort_keys=True, separators=(',', ':')).encode()
+ return json.dumps(value, sort_keys=True, separators=(',', ':'), allow_nan=False).encode()
def _digest(value):
return hashlib.sha256(_encoded(value)).hexdigest()
+def _json_unique(pairs):
+ result={}
+ for key,value in pairs:
+ if key in result: raise Refused('duplicate private custody field')
+ result[key]=value
+ return result
+
+
+def _invalid_constant(value):
+ raise Refused('nonfinite private custody value')
+
+
+def _policy_absent(status, response):
+ # Exactly the native OpenBao empty-errors 404. Denial/malformed/error-shaped
+ # bodies never prove absence, including after a successful delete response.
+ return (status==404 and type(response) is dict and set(response)=={'errors'}
+ and type(response['errors']) is list and not response['errors'])
+
+
+def _same_policy(actual, expected):
+ # OpenBao may omit one terminal LF from HCL source. Only that equivalence
+ # is admitted: spaces, tabs, CR and additional blank lines remain meaningful.
+ if type(actual) is not str or type(expected) is not str: return False
+ strip_one=lambda value:value[:-1] if value.endswith('\n') else value
+ return strip_one(actual)==strip_one(expected)
+
+
class FixtureRecoveryCustody:
"""Independent owner-private persistent fixture custody; no public API.
@@ -36,13 +65,17 @@ class FixtureRecoveryCustody:
enforced owner storage/executor authority; this implementation refuses estate
backends and is never evidence of a production grant.
"""
- def __init__(self, directory: Path):
+ def __init__(self, directory: Path, *, integrity_key: bytes):
+ if type(integrity_key) is not bytes or len(integrity_key)!=32:
+ raise Refused('independent owner capsule integrity key required')
+ self._integrity_key=integrity_key
self.directory = directory
if directory.is_symlink() or directory.absolute() != directory.resolve():
raise Refused('indirect recovery custody directory')
Journal._private(directory.stat(), directory=True)
def _path(self, binding):
+ if type(binding) is not Binding: raise Refused('exact recovery binding required')
binding.validate()
return self.directory / (binding.operation_id + '.private.json')
@@ -52,30 +85,59 @@ class FixtureRecoveryCustody:
finally: os.close(fd)
def read(self, binding):
- fd = os.open(self._path(binding), os.O_RDONLY | os.O_NOFOLLOW)
+ try: fd = os.open(self._path(binding), os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
+ except OSError: raise Refused('private custody unavailable') from None
try:
- Journal._private(os.fstat(fd))
+ before=os.fstat(fd)
+ Journal._private(before)
with os.fdopen(fd, 'rb', closefd=False) as stream: raw = stream.read(65537)
+ after=os.fstat(fd)
+ identity=lambda value:(value.st_dev,value.st_ino,value.st_size,value.st_mtime_ns,value.st_ctime_ns,value.st_mode,value.st_nlink)
+ if identity(before)!=identity(after) or len(raw)!=before.st_size:
+ raise Refused('private custody changed during read')
finally: os.close(fd)
if len(raw) > 65536: raise Refused('oversize private custody record')
- try: value = json.loads(raw)
+ try: capsule = json.loads(raw,object_pairs_hook=_json_unique,parse_constant=_invalid_constant)
except Exception: raise Refused('invalid private custody record') from None
- if type(value) is not dict or value.get('binding') != asdict(binding):
- raise Refused('private custody binding mismatch')
+ if (type(capsule) is not dict or set(capsule)!={'payload','mac'} or type(capsule['mac']) is not str
+ or len(capsule['mac'])!=64 or any(x not in '0123456789abcdef' for x in capsule['mac'])):
+ raise Refused('invalid authenticated private capsule')
+ expected_mac=hmac.new(self._integrity_key,_encoded(capsule['payload']),hashlib.sha256).hexdigest()
+ if not hmac.compare_digest(capsule['mac'],expected_mac):
+ raise Refused('private capsule integrity mismatch')
+ value=capsule['payload']
expected={'binding','cleanup','port','policy_hcl','identity_accessor','task_accessor','phase','revision'}
- if (set(value)!=expected or type(value['port']) is not int or not 1<=value['port']<=65535
+ if type(value) is not dict or set(value)!=expected:
+ raise Refused('invalid private custody schema')
+ stored=value['binding']
+ if (type(stored) is not dict or set(stored)!=set(asdict(binding))
+ or any(type(v) is not str for v in stored.values())):
+ raise Refused('invalid private custody binding schema')
+ restored=Binding(**stored)
+ restored.validate()
+ if asdict(restored)!=asdict(binding): raise Refused('private custody binding mismatch')
+ if (type(value['port']) is not int or not 1<=value['port']<=65535
or type(value['policy_hcl']) is not str or not value['policy_hcl']
or type(value['identity_accessor']) is not str or not value['identity_accessor']
- or value['phase'] not in {'prepared','mint-intent','issued'}
+ or type(value['phase']) is not str or value['phase'] not in {'prepared','mint-intent','issued'}
or type(value['revision']) is not int or value['revision']!={'prepared':0,'mint-intent':1,'issued':2}[value['phase']]
or (value['phase']=='issued' and (type(value['task_accessor']) is not str or not value['task_accessor']))
or (value['phase']!='issued' and value['task_accessor'] is not None)
or type(value['cleanup']) is not list or len(value['cleanup'])!=3):
raise Refused('invalid private custody schema')
+ for item in value['cleanup']:
+ if type(item) is not dict or set(item)!={'kind','call_digest'}:
+ raise Refused('invalid private cleanup schema')
+ Cleanup(**item).validate()
+ expected_cleanup=[{'kind':item['kind'],'call_digest':item['call_digest']}
+ for item in cleanup_specs(binding.operation_id)]
+ if value['cleanup']!=expected_cleanup:
+ raise Refused('private cleanup differs from deterministic owner commitment')
return value
def _write(self, binding, value, initial=False):
- raw = _encoded(value)
+ capsule={'payload':value,'mac':hmac.new(self._integrity_key,_encoded(value),hashlib.sha256).hexdigest()}
+ raw = _encoded(capsule)
if len(raw) > 65536: raise Refused('oversize private custody record')
path = self._path(binding)
if initial:
@@ -159,6 +221,11 @@ class _Recorder:
if all(x['outcome'] == 'confirmed' for x in current):
raise Refused('cleanup already confirmed')
self.current = current
+ # An authenticated older prepared capsule must not erase later issuance
+ # uncertainty. Without an issued accessor, any native call or state at
+ # the grant boundary makes absence unprovable. Recovery is conservative.
+ self.no_mint_proved=(journal.observe(b)['state'] in {'reserved','consume-confirmed','authenticating'}
+ and journal.db.execute('SELECT COUNT(*) FROM calls WHERE operation=?',(b.operation_id,)).fetchone()[0]==0)
db = journal.db
try: db.execute('INSERT INTO recovery_attempts VALUES (?,?,?)', (permit.attempt_id,b.operation_id,json.dumps(current,sort_keys=True)))
except Exception: raise Refused('recovery admission already used') from None
@@ -233,7 +300,7 @@ def recover_fixture_cleanup(journal: Journal, permit: FixtureRecoveryPermit):
headers={'X-Vault-Token':permit.owner_token,'Content-Type':'application/json'})
response=conn.getresponse();raw=response.read(1048577)
if len(raw)>1048576: raise Refused('oversize recovery response')
- data=json.loads(raw) if raw else {}
+ data=json.loads(raw,object_pairs_hook=_json_unique,parse_constant=_invalid_constant) if raw else {}
if type(data) is not dict: raise Refused('invalid recovery response')
status=response.status
except Exception: raise Refused('recovery transport or response unconfirmed') from None
@@ -245,7 +312,7 @@ def recover_fixture_cleanup(journal: Journal, permit: FixtureRecoveryPermit):
if item['kind'] in {'task-token-revoke','identity-token-revoke'}:
accessor=value['task_accessor' if ordinal==0 else 'identity_accessor']
if accessor is None:
- confirmed=ordinal==0 and value['phase']=='prepared'
+ confirmed=ordinal==0 and value['phase']=='prepared' and record.no_mint_proved
else:
status,data=call('POST','auth/token/revoke-accessor',{'accessor':accessor})
# Revoke is idempotent for an already absent accessor;
@@ -256,11 +323,12 @@ def recover_fixture_cleanup(journal: Journal, permit: FixtureRecoveryPermit):
elif item['kind']=='grant-policy-delete':
path='sys/policies/acl/task-exec-'+permit.binding.operation_id
status,data=call('GET',path)
- if status==404:confirmed=True
- elif status==200 and data.get('data',{}).get('policy','').rstrip()==value['policy_hcl'].rstrip():
+ if _policy_absent(status,data):confirmed=True
+ elif (status==200 and not data.get('errors') and type(data.get('data')) is dict
+ and _same_policy(data['data'].get('policy'),value['policy_hcl'])):
status,data=call('DELETE',path)
if status in {200,204}:
- status,data=call('GET',path);confirmed=status==404
+ status,data=call('GET',path);confirmed=_policy_absent(status,data)
except Exception:
# No backend errors/response/handle hashes enter public evidence.
confirmed=False
diff --git a/tests/test_task_execution_recovery.py b/tests/test_task_execution_recovery.py
index e29d716..52b8b61 100644
--- a/tests/test_task_execution_recovery.py
+++ b/tests/test_task_execution_recovery.py
@@ -3,6 +3,7 @@ from dataclasses import replace
import hashlib
import json
import os
+import secrets
from pathlib import Path
import sys
import time
@@ -20,7 +21,7 @@ from task_execution_recovery import FixtureRecoveryCustody, recover_fixture_clea
def protected(owner,tmp_path):
native,journal,binding,call,compiler,descriptor=owner
directory=tmp_path/'separate-owner-custody';directory.mkdir(mode=0o700)
- custody=FixtureRecoveryCustody(directory)
+ custody=FixtureRecoveryCustody(directory,integrity_key=secrets.token_bytes(32))
native=NativeFixtureAdapter(compiler,native.plan,journal,binding,native.admission,recovery_custody=custody)
return native,journal,binding,call,custody
@@ -178,7 +179,7 @@ def test_private_custody_permissions_and_identity_binding_fail_closed(owner,tmp_
path=custody._path(binding);path.chmod(0o644)
with pytest.raises(Refused):custody.read(binding)
path.chmod(0o600)
- data=json.loads(path.read_bytes());data['binding']['operation_id']='other'
+ data=json.loads(path.read_bytes());data['payload']['binding']['operation_id']='other'
path.write_text(json.dumps(data))
with pytest.raises(Refused):custody.read(binding)
@@ -205,3 +206,115 @@ def test_lost_recovery_response_stays_unknown_until_new_independent_attempt(owne
# Reconciliation does not erase the historic uncertain invocation.
assert journal.observe(binding)['external_outcome_unknown']
assert journal.db.execute("SELECT COUNT(*) FROM recovery_calls WHERE outcome='pending'").fetchone()[0]==1
+
+
+@pytest.fixture
+def capsule(tmp_path):
+ from task_execution_journal import Binding,Cleanup
+ from task_execution_plan import cleanup_specs
+ binding=Binding('SYNTHETIC','sha256:'+'a'*64,'12345678-1234-1234-1234-123456789abc','b'*64)
+ directory=tmp_path/'private-capsule';directory.mkdir(mode=0o700)
+ custody=FixtureRecoveryCustody(directory,integrity_key=secrets.token_bytes(32))
+ cleanup=tuple(Cleanup(x['kind'],x['call_digest']) for x in cleanup_specs(binding.operation_id))
+ custody.prepare(binding,cleanup,12345,'path "fixture" {}\n','synthetic-private-accessor')
+ return custody,binding
+
+
+@pytest.mark.parametrize('mutate',[
+ lambda v:v.update(extra=True),lambda v:v.pop('phase'),lambda v:v.update(binding=True),
+ lambda v:v['binding'].update(approval_id=True),lambda v:v['binding'].update(extra='x'),
+ lambda v:v['binding'].update(request_digest='0'*64),lambda v:v.update(port=True),
+ lambda v:v.update(port=12345.0),lambda v:v.update(port=0),lambda v:v.update(phase=[]),
+ lambda v:v.update(phase='issued'),lambda v:v.update(revision=False),
+ lambda v:v.update(revision=0.0),lambda v:v.update(identity_accessor=False),
+ lambda v:v.update(task_accessor='unexpected-before-issue'),lambda v:v.update(policy_hcl=[]),
+ lambda v:v.update(cleanup={}),lambda v:v['cleanup'].pop(),
+ lambda v:v['cleanup'].__setitem__(0,False),lambda v:v['cleanup'][0].update(extra=True),
+ lambda v:v['cleanup'][0].update(kind=True),lambda v:v['cleanup'][0].update(call_digest=0),
+ lambda v:v['cleanup'][0].update(call_digest='f'*64),lambda v:v['cleanup'].reverse(),
+])
+def test_authenticated_but_damaged_capsule_schema_refuses(capsule,mutate):
+ custody,binding=capsule
+ value=custody.read(binding);mutate(value)
+ # Trusted test owner re-signs malformed payload to exercise schema defenses
+ # independently of MAC validation; untrusted corruption cannot do this.
+ custody._write(binding,value)
+ with pytest.raises(Refused):custody.read(binding)
+
+
+def test_capsule_corruption_wrong_key_duplicate_and_nonfinite_refuse(capsule):
+ custody,binding=capsule;path=custody._path(binding);original=path.read_bytes()
+ value=json.loads(original);value['payload']['identity_accessor']='different-synthetic-accessor'
+ path.write_text(json.dumps(value))
+ with pytest.raises(Refused):custody.read(binding)
+ path.write_bytes(original)
+ other=FixtureRecoveryCustody(custody.directory,integrity_key=b'X'*32)
+ with pytest.raises(Refused):other.read(binding)
+ for raw in [b'{',original[:-1]+b',"mac":"duplicate"}',original.replace(b'12345',b'NaN'),b'[]',b'{}',b'{"payload":{},"mac":"'+b'\xc3\xa9'*64+b'"}']:
+ path.write_bytes(raw)
+ with pytest.raises(Refused):custody.read(binding)
+ path.write_bytes(original)
+ assert custody.read(binding)['identity_accessor']=='synthetic-private-accessor'
+
+
+def test_exact_absence_and_single_terminal_lf_contract():
+ from task_execution_recovery import _policy_absent,_same_policy
+ assert _policy_absent(404,{'errors':[]})
+ for status,body in [(403,{'errors':[]}),(404,{}),(404,{'errors':['denied']}),
+ (404,{'errors':None}),(404,{'errors':[]} | {'data':{}}),
+ (404,{'errors':False}),(404,{'errors':0}),(404,{'errors':{}})]:
+ assert not _policy_absent(status,body)
+ assert _same_policy('fixture','fixture\n')
+ assert _same_policy('fixture\n','fixture\n')
+ for text in ['fixture \n','fixture\t','fixture\r\n','fixture\n\n',False,None]:
+ assert not _same_policy(text,'fixture\n')
+
+
+@pytest.mark.parametrize('after_delete',[False,True])
+def test_recovery_policy_absence_rejects_misleading_native_http_bodies(owner,tmp_path,monkeypatch,after_delete):
+ native,journal,binding,call,custody=protected(owner,tmp_path);crash(native,journal,binding)
+ import task_execution_recovery as module
+ malformed=[{}, {'errors':['permission denied']}, {'errors':None}, {'errors':False},
+ {'errors':0}, {'errors':[],'data':{}}, {'errors':['not really absent']},
+ b'{"errors":["permission denied"],"errors":[]}',b'{"errors":NaN}',b'not JSON']
+ path='sys/policies/acl/'+native.policy_name
+ for body in malformed:
+ sent=[];gets=[0]
+ class Response:
+ def __init__(self,status,data):self.status=status;self.data=data
+ def read(self,limit):return self.data if type(self.data) is bytes else json.dumps(self.data).encode()
+ class Connection:
+ def __init__(self,*args,**kwargs):pass
+ def request(self,method,url,**kwargs):sent.append((method,url))
+ def getresponse(self):
+ method,url=sent[-1]
+ if url=='/v1/'+path:
+ if method=='DELETE':return Response(204,{})
+ gets[0]+=1
+ if after_delete and gets[0]==1:
+ return Response(200,{'data':{'policy':native.hcl[:-1]}})
+ return Response(404,body)
+ return Response(403,{'errors':['synthetic refusal']})
+ def close(self):pass
+ with monkeypatch.context() as patch:
+ patch.setattr(module.http.client,'HTTPConnection',Connection)
+ result=recover_fixture_cleanup(journal,permit(native,custody,binding))
+ assert not result['confirmed'] and result['obligations'][2]['outcome']=='owed'
+ assert ('DELETE','/v1/'+path) in sent if after_delete else ('DELETE','/v1/'+path) not in sent
+ # The denied/malformed probes above emitted no real native calls.
+ assert call('GET',path)[0]==200
+ assert recover_fixture_cleanup(journal,permit(native,custody,binding))['confirmed']
+
+
+def test_authenticated_prepared_capsule_rollback_cannot_hide_later_mint(owner,tmp_path):
+ native,journal,binding,call,custody=protected(owner,tmp_path)
+ old=custody._path(binding).read_bytes()
+ crash(native,journal,binding)
+ current=custody.read(binding)
+ custody._path(binding).write_bytes(old)
+ # Still authentic under the owner key, but the durable journal proves the
+ # operation reached issuance. The stale prepared record cannot prove absence.
+ result=recover_fixture_cleanup(journal,permit(native,custody,binding))
+ assert not result['confirmed'] and result['obligations'][0]['outcome']=='owed'
+ assert call('POST','auth/token/lookup-accessor',{'accessor':current['task_accessor']})[0]==200
+ assert call('POST','auth/token/revoke-accessor',{'accessor':current['task_accessor']})[0] in {200,204}
diff --git a/workplans/RPF-WP-0035-credential-lane-implementation.md b/workplans/RPF-WP-0035-credential-lane-implementation.md
index 1b3a079..f58a6b9 100644
--- a/workplans/RPF-WP-0035-credential-lane-implementation.md
+++ b/workplans/RPF-WP-0035-credential-lane-implementation.md
@@ -1299,3 +1299,11 @@ without restoring business/consume admission. Separate durable attempt evidence
retains original unknown outcomes. Lost token-create response remains unresolved
when no accessor was committed. Source-only process-loss fixtures are not native
production custody or recovery-role admission; T12 remains progress.
+
+2026-10-16 T12 recovery R2 source refinement retains R1's overbroad 404 failure:
+policy cleanup now requires exact native empty-errors absence before/after delete;
+HCL uses only documented single optional terminal LF normalization. Private
+capsules are owner-key HMAC authenticated, strict nested schemas/ordered cleanup
+commitments reject damage, and journal history prevents stale prepared capsules
+from hiding issuance. Fixture process-loss source proof remains separate from
+unadmitted production custody, integrity-key storage and recovery-role scope.

View file

@ -0,0 +1,641 @@
diff --git a/docs/task-execution-native-fixture.md b/docs/task-execution-native-fixture.md
index 1dd6b91..4fb60fe 100644
--- a/docs/task-execution-native-fixture.md
+++ b/docs/task-execution-native-fixture.md
@@ -121,3 +121,56 @@ retry cleanup. Native effects can survive process loss. Cleanup handles remain
memory-only: no independently authorized recovery mechanism is implemented here.
This closes disposable sequential apply acceptance only, not TSA03 or native
production recovery/admission.
+
+
+## Independent cleanup recovery source successor
+
+`task_execution_recovery.py` adds separately installed fixture-only owner custody
+and fresh recovery permits. Optional `recovery_custody` on NativeFixtureAdapter
+persists the original binding/cleanup commitments, exact operation policy, backend
+port and identity accessor before any grant effect; it commits mint intent before
+token creation and task accessor immediately after a valid issued response.
+Bearer credentials are never persisted. The custody directory is separate from
+the public journal, owner-only 0700/0600, bounded at 64KiB, no-follow, with atomic
+replacement and fsync. It is real process-independent persistence for synthetic
+fixtures. It does not isolate same-UID hostile execution, encrypt at rest, select
+production storage or establish a production secret policy. Those are explicit
+native custody/deployment gates, not supplied by Python types or file modes.
+
+The independent owner reads its private custody revision and installs a fresh
+bounded permit, current owner credential and retained live disposable Popen.
+Permit revocation is durably owner-recorded. The recovery runner has no mint,
+consume, policy-apply or business method. It dispatches only fixed exact-accessor
+revoke/readback and exact `task-exec-<operation>` policy read/delete/readback.
+A differently recreated policy is never deleted. Backend admission remains the
+same loopback-only owner-spawned dev process; no estate backend can be selected.
+Accessor-based recovery is an independently admitted alternate cleanup projection,
+not a reinterpretation of the original revoke-self call digest. Production needs
+exact owner-reviewed recovery scope; original business grant does not imply it.
+
+Fresh recovery holds the journal process lock but never sets business admission
+or consumed-admission fields. Separate recovery attempt/call/result tables retain
+original cleanup statuses before reconciliation, write native intent before calls,
+and preserve failed/pending outcomes. Proven cleanup alone updates the exact
+original cleanup obligation to confirmed. Business state and original pending
+call uncertainty remain unchanged. Reusing the original approval, continuing
+metadata calls and reusing a recovery permit refuse. Failure needs another
+independent owner permit; there is no automatic retry. Expired/revoked permits
+or changed private custody revision refuse before calls.
+
+A token-create response lost before accessor custody remains unresolved token
+cleanup debt: policy deletion/identity revocation can be independently confirmed,
+but cannot imply task-token revocation. Neither expiry guessed from wall clock
+nor policy absence fabricates token absence. A production successor must admit
+an independent exact ambiguous-issuance reconciliation mechanism or prove bounded
+expiry from owner backend evidence. No such new grant is implemented here.
+
+`test_task_execution_recovery.py` forks a real executor and terminates it without
+finally cleanup after issue, business effect/lost response and revoke/lost response.
+The independent parent, holding the backend and separately installed custody,
+recovers specific obligations and verifies actual token/policy absence. Additional
+cases cover lost-mint response, revoked credential, permit revocation/expiry,
+revision drift, namespace policy-name reuse and private-file/binding refusals.
+This is source fixture proof only. Native custody, isolation, exact recovery-role
+permissions, retention/encryption, clock admission, audit delivery and human scope
+approval remain missing. No generic broker, production reader, grant or CLI is added.
diff --git a/scripts/task_execution_journal.py b/scripts/task_execution_journal.py
index 8f1b9a2..1eaa583 100644
--- a/scripts/task_execution_journal.py
+++ b/scripts/task_execution_journal.py
@@ -104,6 +104,9 @@ class Journal:
self.db.execute("CREATE TABLE IF NOT EXISTS calls (operation TEXT NOT NULL, ordinal INTEGER NOT NULL, call_digest TEXT NOT NULL, outcome TEXT NOT NULL, evidence TEXT, PRIMARY KEY(operation,ordinal))")
self.db.execute("CREATE TABLE IF NOT EXISTS cleanup (operation TEXT NOT NULL, ordinal INTEGER NOT NULL, kind TEXT NOT NULL, call_digest TEXT NOT NULL, outcome TEXT NOT NULL, evidence TEXT, PRIMARY KEY(operation,ordinal), UNIQUE(operation,kind))")
self.db.execute("CREATE TABLE IF NOT EXISTS cleanup_calls (operation TEXT NOT NULL, obligation INTEGER NOT NULL, step INTEGER NOT NULL, call_digest TEXT NOT NULL, outcome TEXT NOT NULL, evidence TEXT, PRIMARY KEY(operation,obligation,step))")
+ self.db.execute("CREATE TABLE IF NOT EXISTS recovery_attempts (attempt TEXT PRIMARY KEY, operation TEXT NOT NULL, original TEXT NOT NULL)")
+ self.db.execute("CREATE TABLE IF NOT EXISTS recovery_calls (attempt TEXT NOT NULL, obligation INTEGER NOT NULL, step INTEGER NOT NULL, digest TEXT NOT NULL, outcome TEXT NOT NULL, evidence TEXT, PRIMARY KEY(attempt,obligation,step))")
+ self.db.execute("CREATE TABLE IF NOT EXISTS recovery_results (attempt TEXT NOT NULL, obligation INTEGER NOT NULL, confirmed INTEGER NOT NULL, evidence TEXT NOT NULL, PRIMARY KEY(attempt,obligation))")
self._held = False
self._admission = None
self._consumed_admission = None
@@ -228,6 +231,7 @@ class Journal:
pending = self.db.execute("SELECT COUNT(*) FROM calls WHERE operation=? AND outcome='pending'", (binding.operation_id,)).fetchone()[0]
pending += self.db.execute("SELECT COUNT(*) FROM cleanup WHERE operation=? AND outcome='pending'", (binding.operation_id,)).fetchone()[0]
pending += self.db.execute("SELECT COUNT(*) FROM cleanup_calls WHERE operation=? AND outcome='pending'", (binding.operation_id,)).fetchone()[0]
+ pending += self.db.execute("SELECT COUNT(*) FROM recovery_calls AS c JOIN recovery_attempts AS a ON c.attempt=a.attempt WHERE a.operation=? AND c.outcome='pending'", (binding.operation_id,)).fetchone()[0]
return {"state": row[3], "evidence_digest": row[4], "dispatch_allowed": False,
"external_outcome_unknown": bool(pending)}
diff --git a/scripts/task_execution_native_fixture.py b/scripts/task_execution_native_fixture.py
index caa7da1..31a822b 100644
--- a/scripts/task_execution_native_fixture.py
+++ b/scripts/task_execution_native_fixture.py
@@ -3,7 +3,8 @@
This adapter deliberately admits only a disposable loopback fixture. It supplies
real native primitive coverage while production grant schema, identity/consume and
origin/source admission are unfinished. It cannot open an estate backend. Owner
-credentials/handles stay in memory; no subprocess, redirects or ambient proxy.
+bearer credentials stay in memory; optional owner-private fixture custody persists
+accessors for independently admitted cleanup recovery; no redirects or ambient proxy.
Python privacy is not credential isolation; production needs the owner runtime.
"""
from __future__ import annotations
@@ -41,7 +42,7 @@ class FixtureAdmission:
class NativeFixtureAdapter:
def __init__(self, compiler: Compiler, plan: Plan, journal: Journal,
- binding: Binding, admission: FixtureAdmission):
+ binding: Binding, admission: FixtureAdmission, *, recovery_custody=None):
if type(admission) is not FixtureAdmission or type(admission.descriptor) is not bytes:
raise Refused('independent fixture admission required')
self.compiler,self.plan,self.journal,self.binding=compiler,plan,journal,binding
@@ -100,6 +101,12 @@ class NativeFixtureAdapter:
if self.meta['cleanup'] != cleanup_specs(binding.operation_id): raise Refused('cleanup plan mismatch')
self.task=None; self._mint_attempted=False; self._policy_attempted=False; self._ordinal=0; self._next_metadata=0
self._cleanup_steps={item.kind:0 for item in self.cleanup}
+ self.recovery_custody=recovery_custody
+ if recovery_custody is not None:
+ from task_execution_recovery import FixtureRecoveryCustody
+ if type(recovery_custody) is not FixtureRecoveryCustody or journal.observe(binding) is not None:
+ raise Refused('fresh independent fixture recovery custody required')
+ recovery_custody.prepare(binding,self.cleanup,self.port,self.hcl,admission.identity_session.accessor)
def _live(self):
if not self.journal._held or self.journal._admission!=self.binding or self.journal._consumed_admission!=self.binding:
@@ -177,6 +184,8 @@ class NativeFixtureAdapter:
'type':'service','display_name':'task-exec-'+self.binding.operation_id,'no_parent':False}
if body!=self.meta['execution_grant']['openbao']['token_create_body']:
raise Refused('native token body differs from bound owner projection')
+ if self.recovery_custody is not None:
+ self.recovery_custody.checkpoint(self.binding,'mint-intent')
self._mint_attempted=True
status,data=self._business('POST','auth/token/create',root,encoded(body));self._ok(status,data,{200})
auth=data.get('auth',{})
@@ -185,6 +194,8 @@ class NativeFixtureAdapter:
# Retain handles before validation so finally cleanup can revoke even a
# malformed overbroad issue response. Never return token-bearing metadata.
self.task=PrivateSession(auth['client_token'],auth['accessor'])
+ if self.recovery_custody is not None:
+ self.recovery_custody.checkpoint(self.binding,'issued',self.task.accessor)
status,data=self._business('POST','auth/token/lookup',root,encoded({'token':self.task.token}));self._ok(status,data,{200})
info=data.get('data',{})
if (info.get('policies')!=[self.policy_name] or info.get('identity_policies',[]) or info.get('renewable') is not False
diff --git a/scripts/task_execution_recovery.py b/scripts/task_execution_recovery.py
new file mode 100644
index 0000000..b4ce2b9
--- /dev/null
+++ b/scripts/task_execution_recovery.py
@@ -0,0 +1,268 @@
+"""Owner-only cleanup recovery for the disposable native fixture.
+
+Private, separately installed custody persists accessors, never bearer tokens.
+This is a fixture implementation, not production custody/isolation admission.
+The recovery owner supplies a fresh capability and credential independently of
+public request/journal data. No business adapter or resumed admission is built.
+"""
+from __future__ import annotations
+from dataclasses import asdict, dataclass, field
+import hashlib
+import http.client
+import json
+import os
+from pathlib import Path
+import secrets
+import subprocess
+import time
+import uuid
+
+from task_execution_journal import Binding, Journal, Refused
+
+
+def _encoded(value):
+ return json.dumps(value, sort_keys=True, separators=(',', ':')).encode()
+
+
+def _digest(value):
+ return hashlib.sha256(_encoded(value)).hexdigest()
+
+
+class FixtureRecoveryCustody:
+ """Independent owner-private persistent fixture custody; no public API.
+
+ Mode/ownership and no-follow checks protect against accidental exposure.
+ Same-UID hostile code is NOT isolated. Production must supply independently
+ enforced owner storage/executor authority; this implementation refuses estate
+ backends and is never evidence of a production grant.
+ """
+ def __init__(self, directory: Path):
+ self.directory = directory
+ if directory.is_symlink() or directory.absolute() != directory.resolve():
+ raise Refused('indirect recovery custody directory')
+ Journal._private(directory.stat(), directory=True)
+
+ def _path(self, binding):
+ binding.validate()
+ return self.directory / (binding.operation_id + '.private.json')
+
+ def _sync(self):
+ fd = os.open(self.directory, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
+ try: os.fsync(fd)
+ finally: os.close(fd)
+
+ def read(self, binding):
+ fd = os.open(self._path(binding), os.O_RDONLY | os.O_NOFOLLOW)
+ try:
+ Journal._private(os.fstat(fd))
+ with os.fdopen(fd, 'rb', closefd=False) as stream: raw = stream.read(65537)
+ finally: os.close(fd)
+ if len(raw) > 65536: raise Refused('oversize private custody record')
+ try: value = json.loads(raw)
+ except Exception: raise Refused('invalid private custody record') from None
+ if type(value) is not dict or value.get('binding') != asdict(binding):
+ raise Refused('private custody binding mismatch')
+ expected={'binding','cleanup','port','policy_hcl','identity_accessor','task_accessor','phase','revision'}
+ if (set(value)!=expected or type(value['port']) is not int or not 1<=value['port']<=65535
+ or type(value['policy_hcl']) is not str or not value['policy_hcl']
+ or type(value['identity_accessor']) is not str or not value['identity_accessor']
+ or value['phase'] not in {'prepared','mint-intent','issued'}
+ or type(value['revision']) is not int or value['revision']!={'prepared':0,'mint-intent':1,'issued':2}[value['phase']]
+ or (value['phase']=='issued' and (type(value['task_accessor']) is not str or not value['task_accessor']))
+ or (value['phase']!='issued' and value['task_accessor'] is not None)
+ or type(value['cleanup']) is not list or len(value['cleanup'])!=3):
+ raise Refused('invalid private custody schema')
+ return value
+
+ def _write(self, binding, value, initial=False):
+ raw = _encoded(value)
+ if len(raw) > 65536: raise Refused('oversize private custody record')
+ path = self._path(binding)
+ if initial:
+ fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600)
+ else:
+ # Existing owner-private record checked before replacement. Unique
+ # temp + fsync + replace + directory fsync never truncates old state.
+ self.read(binding)
+ tmp = self.directory / ('.pending-' + secrets.token_hex(16))
+ fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600)
+ try:
+ with os.fdopen(fd, 'wb', closefd=False) as stream:
+ stream.write(raw); stream.flush(); os.fsync(fd)
+ finally: os.close(fd)
+ if not initial: os.replace(tmp, path)
+ self._sync()
+
+ def prepare(self, binding, cleanup, port, policy_hcl, identity_accessor):
+ value = {'binding': asdict(binding), 'cleanup': [asdict(x) for x in cleanup],
+ 'port': port, 'policy_hcl': policy_hcl,
+ 'identity_accessor': identity_accessor, 'task_accessor': None,
+ 'phase': 'prepared', 'revision': 0}
+ self._write(binding, value, initial=True)
+
+ def checkpoint(self, binding, phase, task_accessor=None):
+ value = self.read(binding)
+ if (value['phase'], phase) not in {('prepared', 'mint-intent'), ('mint-intent', 'issued')}:
+ raise Refused('invalid protected recovery checkpoint')
+ if phase == 'issued' and (type(task_accessor) is not str or not task_accessor):
+ raise Refused('protected issued accessor required')
+ value.update(phase=phase, task_accessor=task_accessor, revision=value['revision'] + 1)
+ self._write(binding, value)
+
+ def revoke(self, permit):
+ if type(permit) is not FixtureRecoveryPermit or permit.custody is not self:
+ raise Refused('exact owner recovery permit required')
+ uuid.UUID(permit.attempt_id)
+ path=self.directory / (permit.attempt_id+'.revoked')
+ fd=os.open(path,os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW,0o600)
+ try:os.fsync(fd)
+ finally:os.close(fd)
+ self._sync()
+
+ def admit(self, binding, *, owner_token, server_process, valid_seconds=30):
+ """Trusted recovery-owner action, not deserialization of caller flags.
+
+ Fresh admission seals the current private custody revision. The owner
+ supplies its credential and retained backend process separately. Neither
+ token nor private capsule digest is emitted to the public journal.
+ """
+ value = self.read(binding)
+ if type(valid_seconds) is not int or not 1 <= valid_seconds <= 60:
+ raise Refused('bounded recovery admission required')
+ if type(owner_token) is not str or not owner_token:
+ raise Refused('independent recovery owner credential required')
+ return FixtureRecoveryPermit(str(uuid.uuid4()), binding, self, _digest(value),
+ owner_token, server_process, time.monotonic()+valid_seconds)
+
+
+@dataclass(frozen=True, repr=False)
+class FixtureRecoveryPermit:
+ attempt_id: str
+ binding: Binding
+ custody: FixtureRecoveryCustody
+ private_revision_digest: str = field(repr=False)
+ owner_token: str = field(repr=False)
+ server_process: subprocess.Popen = field(repr=False)
+ deadline: float
+
+
+class _Recorder:
+ def __init__(self, journal, permit, value):
+ self.journal, self.permit = journal, permit
+ b = permit.binding
+ if journal._admission is not None or journal._consumed_admission is not None:
+ raise Refused('recovery cannot share a business admission')
+ if journal.observe(b) is None: raise Refused('original operation absent')
+ current = journal.cleanup_status(b)['obligations']
+ if [(x['kind'], x['call_digest']) for x in current] != [(x['kind'], x['call_digest']) for x in value['cleanup']]:
+ raise Refused('original cleanup commitment mismatch')
+ if all(x['outcome'] == 'confirmed' for x in current):
+ raise Refused('cleanup already confirmed')
+ self.current = current
+ db = journal.db
+ try: db.execute('INSERT INTO recovery_attempts VALUES (?,?,?)', (permit.attempt_id,b.operation_id,json.dumps(current,sort_keys=True)))
+ except Exception: raise Refused('recovery admission already used') from None
+ journal._sync_directory()
+
+ def before(self, ordinal, step, method, path):
+ digest = _digest({'kind': self.current[ordinal]['kind'], 'commitment':self.current[ordinal]['call_digest'], 'method':method,'path':path,'step':step})
+ self.journal.db.execute("INSERT INTO recovery_calls VALUES (?,?,?,?,'pending',NULL)", (self.permit.attempt_id,ordinal,step,digest))
+ self.journal._sync_directory()
+ return digest
+
+ def after(self, ordinal, step, status):
+ self.journal.db.execute("UPDATE recovery_calls SET outcome='complete', evidence=? WHERE attempt=? AND obligation=? AND step=? AND outcome='pending'", (_digest({'http_status':status}),self.permit.attempt_id,ordinal,step))
+ self.journal._sync_directory()
+
+ def finish(self, ordinal, confirmed):
+ evidence = _digest({'attempt':self.permit.attempt_id,'obligation':ordinal,'confirmed':confirmed})
+ db = self.journal.db
+ try:
+ db.execute('BEGIN IMMEDIATE')
+ pending = db.execute("SELECT COUNT(*) FROM recovery_calls WHERE attempt=? AND obligation=? AND outcome='pending'",(self.permit.attempt_id,ordinal)).fetchone()[0]
+ if confirmed and pending: raise Refused('pending recovery cannot confirm')
+ db.execute('INSERT INTO recovery_results VALUES (?,?,?,?)',(self.permit.attempt_id,ordinal,int(confirmed),evidence))
+ if confirmed:
+ item=self.current[ordinal]
+ db.execute("UPDATE cleanup SET outcome='confirmed', evidence=? WHERE operation=? AND ordinal=? AND kind=? AND call_digest=?",(evidence,self.permit.binding.operation_id,ordinal,item['kind'],item['call_digest']))
+ db.execute('COMMIT');self.journal._sync_directory()
+ except BaseException:
+ if db.in_transaction: db.execute('ROLLBACK')
+ raise
+
+
+def recover_fixture_cleanup(journal: Journal, permit: FixtureRecoveryPermit):
+ """Fresh independent owner admission: fixed cleanup only, no business retry.
+
+ Each attempt preserves original pending/unconfirmed rows in recovery_attempts
+ and native pending call rows unchanged. A fresh owner permit is required for
+ another attempt after failure; never autonomously retry uncertain mutations.
+ """
+ if type(permit) is not FixtureRecoveryPermit or type(permit.custody) is not FixtureRecoveryCustody:
+ raise Refused('independent typed recovery admission required')
+ if str(uuid.UUID(permit.attempt_id)) != permit.attempt_id:
+ raise Refused('invalid recovery attempt identity')
+ value = permit.custody.read(permit.binding)
+ if _digest(value) != permit.private_revision_digest:
+ raise Refused('private custody revision changed')
+ proc=permit.server_process
+ if (type(proc) is not subprocess.Popen or proc.poll() is not None or type(proc.args) is not list
+ or '-dev' not in proc.args or '-dev-no-store-token' not in proc.args
+ or '-dev-listen-address=127.0.0.1:'+str(value['port']) not in proc.args):
+ raise Refused('same live disposable backend required')
+ def fresh():
+ if (permit.custody.directory / (permit.attempt_id+'.revoked')).exists():
+ raise Refused('recovery permit revoked')
+ if time.monotonic() >= permit.deadline: raise Refused('recovery admission expired')
+ if proc.poll() is not None: raise Refused('disposable backend lost')
+ if _digest(permit.custody.read(permit.binding)) != permit.private_revision_digest:
+ raise Refused('private custody changed during recovery')
+ fresh()
+ with journal.locked():
+ record = _Recorder(journal,permit,value)
+ for ordinal,item in enumerate(record.current):
+ if item['outcome']=='confirmed': continue
+ step=0
+ def call(method,path,body=None):
+ nonlocal step
+ fresh(); index=step; step+=1
+ record.before(ordinal,index,method,path)
+ conn=http.client.HTTPConnection('127.0.0.1',value['port'],timeout=2)
+ try:
+ conn.request(method,'/v1/'+path,body=_encoded(body) if body is not None else None,
+ headers={'X-Vault-Token':permit.owner_token,'Content-Type':'application/json'})
+ response=conn.getresponse();raw=response.read(1048577)
+ if len(raw)>1048576: raise Refused('oversize recovery response')
+ data=json.loads(raw) if raw else {}
+ if type(data) is not dict: raise Refused('invalid recovery response')
+ status=response.status
+ except Exception: raise Refused('recovery transport or response unconfirmed') from None
+ finally:conn.close()
+ record.after(ordinal,index,status)
+ return status,data
+ confirmed=False
+ try:
+ if item['kind'] in {'task-token-revoke','identity-token-revoke'}:
+ accessor=value['task_accessor' if ordinal==0 else 'identity_accessor']
+ if accessor is None:
+ confirmed=ordinal==0 and value['phase']=='prepared'
+ else:
+ status,data=call('POST','auth/token/revoke-accessor',{'accessor':accessor})
+ # Revoke is idempotent for an already absent accessor;
+ # actual absence must still be independently read back.
+ if status in {200,204,400}:
+ status,data=call('POST','auth/token/lookup-accessor',{'accessor':accessor})
+ confirmed=status==400 and data.get('errors') in (['invalid accessor'],['1 error occurred:\n\t* invalid accessor\n\n'])
+ elif item['kind']=='grant-policy-delete':
+ path='sys/policies/acl/task-exec-'+permit.binding.operation_id
+ status,data=call('GET',path)
+ if status==404:confirmed=True
+ elif status==200 and data.get('data',{}).get('policy','').rstrip()==value['policy_hcl'].rstrip():
+ status,data=call('DELETE',path)
+ if status in {200,204}:
+ status,data=call('GET',path);confirmed=status==404
+ except Exception:
+ # No backend errors/response/handle hashes enter public evidence.
+ confirmed=False
+ record.finish(ordinal,confirmed)
+ return journal.cleanup_status(permit.binding)
diff --git a/tests/test_task_execution_recovery.py b/tests/test_task_execution_recovery.py
new file mode 100644
index 0000000..e29d716
--- /dev/null
+++ b/tests/test_task_execution_recovery.py
@@ -0,0 +1,207 @@
+"""Actual executor process loss against independent disposable OpenBao."""
+from dataclasses import replace
+import hashlib
+import json
+import os
+from pathlib import Path
+import sys
+import time
+
+import pytest
+
+sys.path.insert(0,str(Path(__file__).resolve().parents[1]/'scripts'))
+from test_task_execution_plan import compiler
+from test_task_execution_native_fixture import bao_dev, owner, admit
+from task_execution_journal import Journal, Refused
+from task_execution_native_fixture import NativeFixtureAdapter
+from task_execution_recovery import FixtureRecoveryCustody, recover_fixture_cleanup
+
+
+def protected(owner,tmp_path):
+ native,journal,binding,call,compiler,descriptor=owner
+ directory=tmp_path/'separate-owner-custody';directory.mkdir(mode=0o700)
+ custody=FixtureRecoveryCustody(directory)
+ native=NativeFixtureAdapter(compiler,native.plan,journal,binding,native.admission,recovery_custody=custody)
+ return native,journal,binding,call,custody
+
+
+def crash(native,journal,binding,stage='issued'):
+ pid=os.fork()
+ if pid==0:
+ try:
+ # The backend belongs to the independent parent, so this child cannot
+ # waitpid it. Parent retains the actual Popen and checks it on recovery.
+ native.admission.server_process.poll=lambda:None
+ native.journal=Journal(journal.directory)
+ with native.journal.locked():
+ admit(native,native.journal,binding)
+ if stage=='mint-response-lost':
+ original=native._http
+ def lost(method,path,token,body=b''):
+ result=original(method,path,token,body)
+ if path=='auth/token/create':os._exit(73)
+ return result
+ native._http=lost
+ native.materialize_grant()
+ native.journal.transition(binding,'grant-issued','applying')
+ if stage=='business-response-lost':
+ native.metadata_call(0)
+ original=native._http
+ def lost(method,path,token,body=b''):
+ result=original(method,path,token,body)
+ if method=='PUT':os._exit(73)
+ return result
+ native._http=lost
+ native.metadata_call(1)
+ if stage=='cleanup-response-lost':
+ from task_execution_cleanup import run_cleanup
+ original=native._http
+ def lost(method,path,token,body=b''):
+ result=original(method,path,token,body)
+ if path=='auth/token/revoke-self':os._exit(73)
+ return result
+ native._http=lost
+ run_cleanup(native.journal,binding,native)
+ os._exit(73)
+ except BaseException:os._exit(74)
+ _,status=os.waitpid(pid,0)
+ assert os.waitstatus_to_exitcode(status)==73
+ # Original admission never appears in the independent parent or reopened DB.
+ assert native.task is None
+ assert journal._admission is None and journal._consumed_admission is None
+
+
+def permit(native,custody,binding,**kwargs):
+ return custody.admit(binding,owner_token=kwargs.pop('owner_token',native.admission.owner_token),
+ server_process=native.admission.server_process,**kwargs)
+
+
+@pytest.mark.parametrize('stage',['issued','business-response-lost','cleanup-response-lost'])
+def test_process_loss_then_independent_cleanup_never_resumes_business(owner,tmp_path,stage,monkeypatch,request):
+ native,journal,binding,call,custody=protected(owner,tmp_path)
+ crash(native,journal,binding,stage)
+ journal=Journal(journal.directory)
+ request.addfinalizer(journal.close)
+ native.journal=journal
+ private=custody.read(binding)
+ assert private['phase']=='issued' and private['task_accessor']
+ target=native.compiler.before_call(native.plan,1).path
+ before=call('GET',target)
+ with journal.locked():
+ with pytest.raises(Refused):journal.reserve(binding,native.cleanup)
+ with pytest.raises(Refused):native.materialize_grant()
+ with pytest.raises(Refused):native.metadata_call(0)
+ with pytest.raises(Refused):native.revoke_task_token(native.cleanup[0].call_digest)
+ admitted=permit(native,custody,binding)
+ import task_execution_recovery as recovery_module
+ sent=[]
+ original_request=recovery_module.http.client.HTTPConnection.request
+ def tracked(connection,method,url,*args,**kwargs):
+ sent.append((method,url)) # metadata only; never capture body/headers
+ return original_request(connection,method,url,*args,**kwargs)
+ with monkeypatch.context() as patch:
+ patch.setattr(recovery_module.http.client.HTTPConnection,'request',tracked)
+ result=recover_fixture_cleanup(journal,admitted)
+ assert all((method,path) in {
+ ('POST','/v1/auth/token/revoke-accessor'),('POST','/v1/auth/token/lookup-accessor'),
+ ('GET','/v1/sys/policies/acl/'+native.policy_name),
+ ('DELETE','/v1/sys/policies/acl/'+native.policy_name)} for method,path in sent)
+ assert result['confirmed']
+ for accessor in [private['task_accessor'],private['identity_accessor']]:
+ assert call('POST','auth/token/lookup-accessor',{'accessor':accessor})[0]==400
+ assert call('GET','sys/policies/acl/'+native.policy_name)[0]==404
+ after=call('GET',target)
+ assert after[0]==before[0] and after[1].get('data')==before[1].get('data') # request IDs vary
+ assert journal.observe(binding)['dispatch_allowed'] is False
+ if stage!='issued':assert journal.observe(binding)['external_outcome_unknown']
+ if stage=='cleanup-response-lost':
+ original=json.loads(journal.db.execute('SELECT original FROM recovery_attempts').fetchone()[0])
+ assert original[0]['outcome']=='pending'
+ assert journal.db.execute("SELECT COUNT(*) FROM cleanup_calls WHERE outcome='pending'").fetchone()[0]==1
+ with pytest.raises(Refused):recover_fixture_cleanup(journal,admitted)
+ public=(journal.directory/'journal.sqlite').read_bytes()
+ for value in [private['task_accessor'],private['identity_accessor'],native.admission.owner_token]:
+ assert value.encode() not in public
+ assert 'client_token' not in json.dumps(private)
+ assert 'token' not in repr(admitted) and private['task_accessor'] not in repr(admitted)
+
+
+def test_process_loss_before_handle_commit_preserves_unknown_mint_debt(owner,tmp_path):
+ native,journal,binding,call,custody=protected(owner,tmp_path)
+ crash(native,journal,binding,'mint-response-lost')
+ assert custody.read(binding)['phase']=='mint-intent'
+ result=recover_fixture_cleanup(journal,permit(native,custody,binding))
+ assert not result['confirmed']
+ assert [x['outcome'] for x in result['obligations']]==['owed','confirmed','confirmed']
+ assert journal.observe(binding)['external_outcome_unknown']
+ assert journal.db.execute('SELECT confirmed FROM recovery_results ORDER BY obligation').fetchall()==[(0,),(1,),(1,)]
+
+
+def test_revoked_expired_wrong_revision_and_public_flag_refuse_before_calls(owner,tmp_path):
+ native,journal,binding,call,custody=protected(owner,tmp_path);crash(native,journal,binding)
+ p=permit(native,custody,binding)
+ custody.revoke(p)
+ for bad in [p,replace(permit(native,custody,binding),deadline=time.monotonic()-1),
+ replace(permit(native,custody,binding),private_revision_digest='0'*64),{'verified':True}]:
+ with pytest.raises(Refused):recover_fixture_cleanup(journal,bad)
+ assert call('GET','sys/policies/acl/'+native.policy_name)[0]==200
+ assert journal.cleanup_status(binding)['obligations'][0]['outcome']=='owed'
+
+
+def test_revoked_recovery_credential_cannot_fake_cleanup_success(owner,tmp_path):
+ native,journal,binding,call,custody=protected(owner,tmp_path);crash(native,journal,binding)
+ status,data=call('POST','auth/token/create',{'policies':['root'],'ttl':'30s'})
+ assert status==200
+ token=data['auth']['client_token']
+ assert call('POST','auth/token/revoke',{'token':token})[0] in {200,204}
+ result=recover_fixture_cleanup(journal,permit(native,custody,binding,owner_token=token))
+ assert not result['confirmed'] and all(x['outcome']=='owed' for x in result['obligations'])
+ assert call('GET','sys/policies/acl/'+native.policy_name)[0]==200
+ # A separately admitted owner attempt can recover after the failed attempt;
+ # neither the failed attempt nor its receipt grants an automatic retry.
+ assert recover_fixture_cleanup(journal,permit(native,custody,binding))['confirmed']
+ assert journal.db.execute('SELECT COUNT(*) FROM recovery_attempts').fetchone()[0]==2
+
+
+def test_policy_name_reuse_never_deletes_different_policy(owner,tmp_path):
+ native,journal,binding,call,custody=protected(owner,tmp_path);crash(native,journal,binding)
+ replacement='path "unrelated" { capabilities = ["read"] }'
+ assert call('PUT','sys/policies/acl/'+native.policy_name,{'policy':replacement})[0] in {200,204}
+ result=recover_fixture_cleanup(journal,permit(native,custody,binding))
+ assert not result['confirmed']
+ assert [x['outcome'] for x in result['obligations']]==['confirmed','confirmed','owed']
+ assert call('GET','sys/policies/acl/'+native.policy_name)[1]['data']['policy']==replacement
+
+
+def test_private_custody_permissions_and_identity_binding_fail_closed(owner,tmp_path):
+ native,journal,binding,call,custody=protected(owner,tmp_path)
+ path=custody._path(binding);path.chmod(0o644)
+ with pytest.raises(Refused):custody.read(binding)
+ path.chmod(0o600)
+ data=json.loads(path.read_bytes());data['binding']['operation_id']='other'
+ path.write_text(json.dumps(data))
+ with pytest.raises(Refused):custody.read(binding)
+
+
+def test_lost_recovery_response_stays_unknown_until_new_independent_attempt(owner,tmp_path,monkeypatch):
+ native,journal,binding,call,custody=protected(owner,tmp_path);crash(native,journal,binding)
+ import task_execution_recovery as recovery_module
+ original=recovery_module.http.client.HTTPConnection.getresponse
+ once=[True]
+ def lost(connection):
+ response=original(connection)
+ if once[0]:
+ once[0]=False
+ response.close()
+ raise Refused('synthetic lost recovery response after real revoke')
+ return response
+ first=permit(native,custody,binding)
+ with monkeypatch.context() as patch:
+ patch.setattr(recovery_module.http.client.HTTPConnection,'getresponse',lost)
+ assert not recover_fixture_cleanup(journal,first)['confirmed']
+ assert journal.observe(binding)['external_outcome_unknown']
+ with pytest.raises(Refused):recover_fixture_cleanup(journal,first)
+ assert recover_fixture_cleanup(journal,permit(native,custody,binding))['confirmed']
+ # Reconciliation does not erase the historic uncertain invocation.
+ assert journal.observe(binding)['external_outcome_unknown']
+ assert journal.db.execute("SELECT COUNT(*) FROM recovery_calls WHERE outcome='pending'").fetchone()[0]==1
diff --git a/workplans/RPF-WP-0035-credential-lane-implementation.md b/workplans/RPF-WP-0035-credential-lane-implementation.md
index 8b09305..1b3a079 100644
--- a/workplans/RPF-WP-0035-credential-lane-implementation.md
+++ b/workplans/RPF-WP-0035-credential-lane-implementation.md
@@ -1290,3 +1290,12 @@ CAS records consumed/unknown with zero native effects. Exact dependency/source
hashes, tests and remaining gates are retained with the source package and in
docs/task-execution-mediator-seam.md. No real grant/custody, policy default, owner
sync or native production acceptance is performed; T12 stays progress.
+
+2026-10-16 T12 recovery successor source preparation: optional independently
+installed fixture custody durably retains operation-bound private accessors and
+issuance checkpoints. Fresh cleanup-only owner permits can reconcile after actual
+executor process death using fixed revoke/readback and policy delete/readback,
+without restoring business/consume admission. Separate durable attempt evidence
+retains original unknown outcomes. Lost token-create response remains unresolved
+when no accessor was committed. Source-only process-loss fixtures are not native
+production custody or recovery-role admission; T12 remains progress.

View file

@ -0,0 +1,861 @@
diff --git a/docs/task-execution-native-fixture.md b/docs/task-execution-native-fixture.md
index 1dd6b91..f4ff070 100644
--- a/docs/task-execution-native-fixture.md
+++ b/docs/task-execution-native-fixture.md
@@ -121,3 +121,87 @@ retry cleanup. Native effects can survive process loss. Cleanup handles remain
memory-only: no independently authorized recovery mechanism is implemented here.
This closes disposable sequential apply acceptance only, not TSA03 or native
production recovery/admission.
+
+
+## Independent cleanup recovery source successor
+
+`task_execution_recovery.py` adds separately installed fixture-only owner custody
+and fresh recovery permits. Optional `recovery_custody` on NativeFixtureAdapter
+persists the original binding/cleanup commitments, exact operation policy, backend
+port and identity accessor before any grant effect; it commits mint intent before
+token creation and task accessor immediately after a valid issued response.
+Bearer credentials are never persisted. The custody directory is separate from
+the public journal, owner-only 0700/0600, bounded at 64KiB, no-follow, with atomic
+replacement and fsync. It is real process-independent persistence for synthetic
+fixtures. It does not isolate same-UID hostile execution, encrypt at rest, select
+production storage or establish a production secret policy. Those are explicit
+native custody/deployment gates, not supplied by Python types or file modes.
+
+The independent owner reads its private custody revision and installs a fresh
+bounded permit, current owner credential and retained live disposable Popen.
+Permit revocation is durably owner-recorded. The recovery runner has no mint,
+consume, policy-apply or business method. It dispatches only fixed exact-accessor
+revoke/readback and exact `task-exec-<operation>` policy read/delete/readback.
+A differently recreated policy is never deleted. Backend admission remains the
+same loopback-only owner-spawned dev process; no estate backend can be selected.
+Accessor-based recovery is an independently admitted alternate cleanup projection,
+not a reinterpretation of the original revoke-self call digest. Production needs
+exact owner-reviewed recovery scope; original business grant does not imply it.
+
+Fresh recovery holds the journal process lock but never sets business admission
+or consumed-admission fields. Separate recovery attempt/call/result tables retain
+original cleanup statuses before reconciliation, write native intent before calls,
+and preserve failed/pending outcomes. Proven cleanup alone updates the exact
+original cleanup obligation to confirmed. Business state and original pending
+call uncertainty remain unchanged. Reusing the original approval, continuing
+metadata calls and reusing a recovery permit refuse. Failure needs another
+independent owner permit; there is no automatic retry. Expired/revoked permits
+or changed private custody revision refuse before calls.
+
+A token-create response lost before accessor custody remains unresolved token
+cleanup debt: policy deletion/identity revocation can be independently confirmed,
+but cannot imply task-token revocation. Neither expiry guessed from wall clock
+nor policy absence fabricates token absence. A production successor must admit
+an independent exact ambiguous-issuance reconciliation mechanism or prove bounded
+expiry from owner backend evidence. No such new grant is implemented here.
+
+`test_task_execution_recovery.py` forks a real executor and terminates it without
+finally cleanup after issue, business effect/lost response and revoke/lost response.
+The independent parent, holding the backend and separately installed custody,
+recovers specific obligations and verifies actual token/policy absence. Additional
+cases cover lost-mint response, revoked credential, permit revocation/expiry,
+revision drift, namespace policy-name reuse and private-file/binding refusals.
+This is source fixture proof only. Native custody, isolation, exact recovery-role
+permissions, retention/encryption, clock admission, audit delivery and human scope
+approval remain missing. No generic broker, production reader, grant or CLI is added.
+
+
+### Recovery R2 exact absence and authenticated capsule refinement
+
+R1 overclaimed policy absence for arbitrary 404 bodies. R2 requires exactly
+`{"errors":[]}` for both initial GET and post-DELETE GET. Other 404 bodies,
+permission denials, extra data, malformed/duplicate-key/nonfinite JSON and failed
+readback cannot confirm policy cleanup. A successful DELETE status alone cannot
+confirm absence. Recovery native policy comparison admits precisely one optional
+terminal LF, matching the retained OpenBao HCL source/readback convention; spaces,
+tabs, CR, extra terminal LF and any other byte change are refused. This is a
+narrow declared normalization, not full whitespace or semantic equivalence.
+
+Private fixture capsules now carry HMAC-SHA256 authentication under an exactly
+32-byte integrity key supplied independently by the recovery owner. The key is
+never stored in the capsule or public journal, and is not a bearer credential.
+The fixture owner retains this key independently across executor death; a fresh
+owner custody object must supply the same key. Wrong-key or damaged accessor/HCL
+bytes refuse before admission. This is integrity protection, not encryption or
+same-UID isolation; production key custody, rotation/retention and recovery actor
+selection remain explicitly unadmitted.
+
+After authentication the reader enforces exact top-level and nested binding/
+cleanup key sets and types, validated Binding/Cleanup formats and the ordered
+source-derived cleanup_specs operation digests. Duplicate JSON keys, nonfinite
+numbers, malformed phase/revision combinations and type-coercible values refuse.
+The reader bounds bytes, rejects indirect/nonprivate files and detects stat/size
+changes during its single read. A valid older prepared capsule still cannot
+prove a task token absent when durable journal state/calls reached issuance:
+that remains unknown cleanup debt. Authentication alone is not anti-rollback.
+No business or consume admission is restored and no production scope is granted.
diff --git a/scripts/task_execution_journal.py b/scripts/task_execution_journal.py
index 8f1b9a2..1eaa583 100644
--- a/scripts/task_execution_journal.py
+++ b/scripts/task_execution_journal.py
@@ -104,6 +104,9 @@ class Journal:
self.db.execute("CREATE TABLE IF NOT EXISTS calls (operation TEXT NOT NULL, ordinal INTEGER NOT NULL, call_digest TEXT NOT NULL, outcome TEXT NOT NULL, evidence TEXT, PRIMARY KEY(operation,ordinal))")
self.db.execute("CREATE TABLE IF NOT EXISTS cleanup (operation TEXT NOT NULL, ordinal INTEGER NOT NULL, kind TEXT NOT NULL, call_digest TEXT NOT NULL, outcome TEXT NOT NULL, evidence TEXT, PRIMARY KEY(operation,ordinal), UNIQUE(operation,kind))")
self.db.execute("CREATE TABLE IF NOT EXISTS cleanup_calls (operation TEXT NOT NULL, obligation INTEGER NOT NULL, step INTEGER NOT NULL, call_digest TEXT NOT NULL, outcome TEXT NOT NULL, evidence TEXT, PRIMARY KEY(operation,obligation,step))")
+ self.db.execute("CREATE TABLE IF NOT EXISTS recovery_attempts (attempt TEXT PRIMARY KEY, operation TEXT NOT NULL, original TEXT NOT NULL)")
+ self.db.execute("CREATE TABLE IF NOT EXISTS recovery_calls (attempt TEXT NOT NULL, obligation INTEGER NOT NULL, step INTEGER NOT NULL, digest TEXT NOT NULL, outcome TEXT NOT NULL, evidence TEXT, PRIMARY KEY(attempt,obligation,step))")
+ self.db.execute("CREATE TABLE IF NOT EXISTS recovery_results (attempt TEXT NOT NULL, obligation INTEGER NOT NULL, confirmed INTEGER NOT NULL, evidence TEXT NOT NULL, PRIMARY KEY(attempt,obligation))")
self._held = False
self._admission = None
self._consumed_admission = None
@@ -228,6 +231,7 @@ class Journal:
pending = self.db.execute("SELECT COUNT(*) FROM calls WHERE operation=? AND outcome='pending'", (binding.operation_id,)).fetchone()[0]
pending += self.db.execute("SELECT COUNT(*) FROM cleanup WHERE operation=? AND outcome='pending'", (binding.operation_id,)).fetchone()[0]
pending += self.db.execute("SELECT COUNT(*) FROM cleanup_calls WHERE operation=? AND outcome='pending'", (binding.operation_id,)).fetchone()[0]
+ pending += self.db.execute("SELECT COUNT(*) FROM recovery_calls AS c JOIN recovery_attempts AS a ON c.attempt=a.attempt WHERE a.operation=? AND c.outcome='pending'", (binding.operation_id,)).fetchone()[0]
return {"state": row[3], "evidence_digest": row[4], "dispatch_allowed": False,
"external_outcome_unknown": bool(pending)}
diff --git a/scripts/task_execution_native_fixture.py b/scripts/task_execution_native_fixture.py
index caa7da1..31a822b 100644
--- a/scripts/task_execution_native_fixture.py
+++ b/scripts/task_execution_native_fixture.py
@@ -3,7 +3,8 @@
This adapter deliberately admits only a disposable loopback fixture. It supplies
real native primitive coverage while production grant schema, identity/consume and
origin/source admission are unfinished. It cannot open an estate backend. Owner
-credentials/handles stay in memory; no subprocess, redirects or ambient proxy.
+bearer credentials stay in memory; optional owner-private fixture custody persists
+accessors for independently admitted cleanup recovery; no redirects or ambient proxy.
Python privacy is not credential isolation; production needs the owner runtime.
"""
from __future__ import annotations
@@ -41,7 +42,7 @@ class FixtureAdmission:
class NativeFixtureAdapter:
def __init__(self, compiler: Compiler, plan: Plan, journal: Journal,
- binding: Binding, admission: FixtureAdmission):
+ binding: Binding, admission: FixtureAdmission, *, recovery_custody=None):
if type(admission) is not FixtureAdmission or type(admission.descriptor) is not bytes:
raise Refused('independent fixture admission required')
self.compiler,self.plan,self.journal,self.binding=compiler,plan,journal,binding
@@ -100,6 +101,12 @@ class NativeFixtureAdapter:
if self.meta['cleanup'] != cleanup_specs(binding.operation_id): raise Refused('cleanup plan mismatch')
self.task=None; self._mint_attempted=False; self._policy_attempted=False; self._ordinal=0; self._next_metadata=0
self._cleanup_steps={item.kind:0 for item in self.cleanup}
+ self.recovery_custody=recovery_custody
+ if recovery_custody is not None:
+ from task_execution_recovery import FixtureRecoveryCustody
+ if type(recovery_custody) is not FixtureRecoveryCustody or journal.observe(binding) is not None:
+ raise Refused('fresh independent fixture recovery custody required')
+ recovery_custody.prepare(binding,self.cleanup,self.port,self.hcl,admission.identity_session.accessor)
def _live(self):
if not self.journal._held or self.journal._admission!=self.binding or self.journal._consumed_admission!=self.binding:
@@ -177,6 +184,8 @@ class NativeFixtureAdapter:
'type':'service','display_name':'task-exec-'+self.binding.operation_id,'no_parent':False}
if body!=self.meta['execution_grant']['openbao']['token_create_body']:
raise Refused('native token body differs from bound owner projection')
+ if self.recovery_custody is not None:
+ self.recovery_custody.checkpoint(self.binding,'mint-intent')
self._mint_attempted=True
status,data=self._business('POST','auth/token/create',root,encoded(body));self._ok(status,data,{200})
auth=data.get('auth',{})
@@ -185,6 +194,8 @@ class NativeFixtureAdapter:
# Retain handles before validation so finally cleanup can revoke even a
# malformed overbroad issue response. Never return token-bearing metadata.
self.task=PrivateSession(auth['client_token'],auth['accessor'])
+ if self.recovery_custody is not None:
+ self.recovery_custody.checkpoint(self.binding,'issued',self.task.accessor)
status,data=self._business('POST','auth/token/lookup',root,encoded({'token':self.task.token}));self._ok(status,data,{200})
info=data.get('data',{})
if (info.get('policies')!=[self.policy_name] or info.get('identity_policies',[]) or info.get('renewable') is not False
diff --git a/scripts/task_execution_recovery.py b/scripts/task_execution_recovery.py
new file mode 100644
index 0000000..ab8b8cb
--- /dev/null
+++ b/scripts/task_execution_recovery.py
@@ -0,0 +1,336 @@
+"""Owner-only cleanup recovery for the disposable native fixture.
+
+Private, separately installed custody persists accessors, never bearer tokens.
+This is a fixture implementation, not production custody/isolation admission.
+The recovery owner supplies a fresh capability and credential independently of
+public request/journal data. No business adapter or resumed admission is built.
+"""
+from __future__ import annotations
+from dataclasses import asdict, dataclass, field
+import hashlib
+import hmac
+import http.client
+import json
+import os
+from pathlib import Path
+import secrets
+import subprocess
+import time
+import uuid
+
+from task_execution_journal import Binding, Cleanup, Journal, Refused
+from task_execution_plan import cleanup_specs
+
+
+def _encoded(value):
+ return json.dumps(value, sort_keys=True, separators=(',', ':'), allow_nan=False).encode()
+
+
+def _digest(value):
+ return hashlib.sha256(_encoded(value)).hexdigest()
+
+
+def _json_unique(pairs):
+ result={}
+ for key,value in pairs:
+ if key in result: raise Refused('duplicate private custody field')
+ result[key]=value
+ return result
+
+
+def _invalid_constant(value):
+ raise Refused('nonfinite private custody value')
+
+
+def _policy_absent(status, response):
+ # Exactly the native OpenBao empty-errors 404. Denial/malformed/error-shaped
+ # bodies never prove absence, including after a successful delete response.
+ return (status==404 and type(response) is dict and set(response)=={'errors'}
+ and type(response['errors']) is list and not response['errors'])
+
+
+def _same_policy(actual, expected):
+ # OpenBao may omit one terminal LF from HCL source. Only that equivalence
+ # is admitted: spaces, tabs, CR and additional blank lines remain meaningful.
+ if type(actual) is not str or type(expected) is not str: return False
+ strip_one=lambda value:value[:-1] if value.endswith('\n') else value
+ return strip_one(actual)==strip_one(expected)
+
+
+class FixtureRecoveryCustody:
+ """Independent owner-private persistent fixture custody; no public API.
+
+ Mode/ownership and no-follow checks protect against accidental exposure.
+ Same-UID hostile code is NOT isolated. Production must supply independently
+ enforced owner storage/executor authority; this implementation refuses estate
+ backends and is never evidence of a production grant.
+ """
+ def __init__(self, directory: Path, *, integrity_key: bytes):
+ if type(integrity_key) is not bytes or len(integrity_key)!=32:
+ raise Refused('independent owner capsule integrity key required')
+ self._integrity_key=integrity_key
+ self.directory = directory
+ if directory.is_symlink() or directory.absolute() != directory.resolve():
+ raise Refused('indirect recovery custody directory')
+ Journal._private(directory.stat(), directory=True)
+
+ def _path(self, binding):
+ if type(binding) is not Binding: raise Refused('exact recovery binding required')
+ binding.validate()
+ return self.directory / (binding.operation_id + '.private.json')
+
+ def _sync(self):
+ fd = os.open(self.directory, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
+ try: os.fsync(fd)
+ finally: os.close(fd)
+
+ def read(self, binding):
+ try: fd = os.open(self._path(binding), os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
+ except OSError: raise Refused('private custody unavailable') from None
+ try:
+ before=os.fstat(fd)
+ Journal._private(before)
+ with os.fdopen(fd, 'rb', closefd=False) as stream: raw = stream.read(65537)
+ after=os.fstat(fd)
+ identity=lambda value:(value.st_dev,value.st_ino,value.st_size,value.st_mtime_ns,value.st_ctime_ns,value.st_mode,value.st_nlink)
+ if identity(before)!=identity(after) or len(raw)!=before.st_size:
+ raise Refused('private custody changed during read')
+ finally: os.close(fd)
+ if len(raw) > 65536: raise Refused('oversize private custody record')
+ try: capsule = json.loads(raw,object_pairs_hook=_json_unique,parse_constant=_invalid_constant)
+ except Exception: raise Refused('invalid private custody record') from None
+ if (type(capsule) is not dict or set(capsule)!={'payload','mac'} or type(capsule['mac']) is not str
+ or len(capsule['mac'])!=64 or any(x not in '0123456789abcdef' for x in capsule['mac'])):
+ raise Refused('invalid authenticated private capsule')
+ expected_mac=hmac.new(self._integrity_key,_encoded(capsule['payload']),hashlib.sha256).hexdigest()
+ if not hmac.compare_digest(capsule['mac'],expected_mac):
+ raise Refused('private capsule integrity mismatch')
+ value=capsule['payload']
+ expected={'binding','cleanup','port','policy_hcl','identity_accessor','task_accessor','phase','revision'}
+ if type(value) is not dict or set(value)!=expected:
+ raise Refused('invalid private custody schema')
+ stored=value['binding']
+ if (type(stored) is not dict or set(stored)!=set(asdict(binding))
+ or any(type(v) is not str for v in stored.values())):
+ raise Refused('invalid private custody binding schema')
+ restored=Binding(**stored)
+ restored.validate()
+ if asdict(restored)!=asdict(binding): raise Refused('private custody binding mismatch')
+ if (type(value['port']) is not int or not 1<=value['port']<=65535
+ or type(value['policy_hcl']) is not str or not value['policy_hcl']
+ or type(value['identity_accessor']) is not str or not value['identity_accessor']
+ or type(value['phase']) is not str or value['phase'] not in {'prepared','mint-intent','issued'}
+ or type(value['revision']) is not int or value['revision']!={'prepared':0,'mint-intent':1,'issued':2}[value['phase']]
+ or (value['phase']=='issued' and (type(value['task_accessor']) is not str or not value['task_accessor']))
+ or (value['phase']!='issued' and value['task_accessor'] is not None)
+ or type(value['cleanup']) is not list or len(value['cleanup'])!=3):
+ raise Refused('invalid private custody schema')
+ for item in value['cleanup']:
+ if type(item) is not dict or set(item)!={'kind','call_digest'}:
+ raise Refused('invalid private cleanup schema')
+ Cleanup(**item).validate()
+ expected_cleanup=[{'kind':item['kind'],'call_digest':item['call_digest']}
+ for item in cleanup_specs(binding.operation_id)]
+ if value['cleanup']!=expected_cleanup:
+ raise Refused('private cleanup differs from deterministic owner commitment')
+ return value
+
+ def _write(self, binding, value, initial=False):
+ capsule={'payload':value,'mac':hmac.new(self._integrity_key,_encoded(value),hashlib.sha256).hexdigest()}
+ raw = _encoded(capsule)
+ if len(raw) > 65536: raise Refused('oversize private custody record')
+ path = self._path(binding)
+ if initial:
+ fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600)
+ else:
+ # Existing owner-private record checked before replacement. Unique
+ # temp + fsync + replace + directory fsync never truncates old state.
+ self.read(binding)
+ tmp = self.directory / ('.pending-' + secrets.token_hex(16))
+ fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600)
+ try:
+ with os.fdopen(fd, 'wb', closefd=False) as stream:
+ stream.write(raw); stream.flush(); os.fsync(fd)
+ finally: os.close(fd)
+ if not initial: os.replace(tmp, path)
+ self._sync()
+
+ def prepare(self, binding, cleanup, port, policy_hcl, identity_accessor):
+ value = {'binding': asdict(binding), 'cleanup': [asdict(x) for x in cleanup],
+ 'port': port, 'policy_hcl': policy_hcl,
+ 'identity_accessor': identity_accessor, 'task_accessor': None,
+ 'phase': 'prepared', 'revision': 0}
+ self._write(binding, value, initial=True)
+
+ def checkpoint(self, binding, phase, task_accessor=None):
+ value = self.read(binding)
+ if (value['phase'], phase) not in {('prepared', 'mint-intent'), ('mint-intent', 'issued')}:
+ raise Refused('invalid protected recovery checkpoint')
+ if phase == 'issued' and (type(task_accessor) is not str or not task_accessor):
+ raise Refused('protected issued accessor required')
+ value.update(phase=phase, task_accessor=task_accessor, revision=value['revision'] + 1)
+ self._write(binding, value)
+
+ def revoke(self, permit):
+ if type(permit) is not FixtureRecoveryPermit or permit.custody is not self:
+ raise Refused('exact owner recovery permit required')
+ uuid.UUID(permit.attempt_id)
+ path=self.directory / (permit.attempt_id+'.revoked')
+ fd=os.open(path,os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW,0o600)
+ try:os.fsync(fd)
+ finally:os.close(fd)
+ self._sync()
+
+ def admit(self, binding, *, owner_token, server_process, valid_seconds=30):
+ """Trusted recovery-owner action, not deserialization of caller flags.
+
+ Fresh admission seals the current private custody revision. The owner
+ supplies its credential and retained backend process separately. Neither
+ token nor private capsule digest is emitted to the public journal.
+ """
+ value = self.read(binding)
+ if type(valid_seconds) is not int or not 1 <= valid_seconds <= 60:
+ raise Refused('bounded recovery admission required')
+ if type(owner_token) is not str or not owner_token:
+ raise Refused('independent recovery owner credential required')
+ return FixtureRecoveryPermit(str(uuid.uuid4()), binding, self, _digest(value),
+ owner_token, server_process, time.monotonic()+valid_seconds)
+
+
+@dataclass(frozen=True, repr=False)
+class FixtureRecoveryPermit:
+ attempt_id: str
+ binding: Binding
+ custody: FixtureRecoveryCustody
+ private_revision_digest: str = field(repr=False)
+ owner_token: str = field(repr=False)
+ server_process: subprocess.Popen = field(repr=False)
+ deadline: float
+
+
+class _Recorder:
+ def __init__(self, journal, permit, value):
+ self.journal, self.permit = journal, permit
+ b = permit.binding
+ if journal._admission is not None or journal._consumed_admission is not None:
+ raise Refused('recovery cannot share a business admission')
+ if journal.observe(b) is None: raise Refused('original operation absent')
+ current = journal.cleanup_status(b)['obligations']
+ if [(x['kind'], x['call_digest']) for x in current] != [(x['kind'], x['call_digest']) for x in value['cleanup']]:
+ raise Refused('original cleanup commitment mismatch')
+ if all(x['outcome'] == 'confirmed' for x in current):
+ raise Refused('cleanup already confirmed')
+ self.current = current
+ # An authenticated older prepared capsule must not erase later issuance
+ # uncertainty. Without an issued accessor, any native call or state at
+ # the grant boundary makes absence unprovable. Recovery is conservative.
+ self.no_mint_proved=(journal.observe(b)['state'] in {'reserved','consume-confirmed','authenticating'}
+ and journal.db.execute('SELECT COUNT(*) FROM calls WHERE operation=?',(b.operation_id,)).fetchone()[0]==0)
+ db = journal.db
+ try: db.execute('INSERT INTO recovery_attempts VALUES (?,?,?)', (permit.attempt_id,b.operation_id,json.dumps(current,sort_keys=True)))
+ except Exception: raise Refused('recovery admission already used') from None
+ journal._sync_directory()
+
+ def before(self, ordinal, step, method, path):
+ digest = _digest({'kind': self.current[ordinal]['kind'], 'commitment':self.current[ordinal]['call_digest'], 'method':method,'path':path,'step':step})
+ self.journal.db.execute("INSERT INTO recovery_calls VALUES (?,?,?,?,'pending',NULL)", (self.permit.attempt_id,ordinal,step,digest))
+ self.journal._sync_directory()
+ return digest
+
+ def after(self, ordinal, step, status):
+ self.journal.db.execute("UPDATE recovery_calls SET outcome='complete', evidence=? WHERE attempt=? AND obligation=? AND step=? AND outcome='pending'", (_digest({'http_status':status}),self.permit.attempt_id,ordinal,step))
+ self.journal._sync_directory()
+
+ def finish(self, ordinal, confirmed):
+ evidence = _digest({'attempt':self.permit.attempt_id,'obligation':ordinal,'confirmed':confirmed})
+ db = self.journal.db
+ try:
+ db.execute('BEGIN IMMEDIATE')
+ pending = db.execute("SELECT COUNT(*) FROM recovery_calls WHERE attempt=? AND obligation=? AND outcome='pending'",(self.permit.attempt_id,ordinal)).fetchone()[0]
+ if confirmed and pending: raise Refused('pending recovery cannot confirm')
+ db.execute('INSERT INTO recovery_results VALUES (?,?,?,?)',(self.permit.attempt_id,ordinal,int(confirmed),evidence))
+ if confirmed:
+ item=self.current[ordinal]
+ db.execute("UPDATE cleanup SET outcome='confirmed', evidence=? WHERE operation=? AND ordinal=? AND kind=? AND call_digest=?",(evidence,self.permit.binding.operation_id,ordinal,item['kind'],item['call_digest']))
+ db.execute('COMMIT');self.journal._sync_directory()
+ except BaseException:
+ if db.in_transaction: db.execute('ROLLBACK')
+ raise
+
+
+def recover_fixture_cleanup(journal: Journal, permit: FixtureRecoveryPermit):
+ """Fresh independent owner admission: fixed cleanup only, no business retry.
+
+ Each attempt preserves original pending/unconfirmed rows in recovery_attempts
+ and native pending call rows unchanged. A fresh owner permit is required for
+ another attempt after failure; never autonomously retry uncertain mutations.
+ """
+ if type(permit) is not FixtureRecoveryPermit or type(permit.custody) is not FixtureRecoveryCustody:
+ raise Refused('independent typed recovery admission required')
+ if str(uuid.UUID(permit.attempt_id)) != permit.attempt_id:
+ raise Refused('invalid recovery attempt identity')
+ value = permit.custody.read(permit.binding)
+ if _digest(value) != permit.private_revision_digest:
+ raise Refused('private custody revision changed')
+ proc=permit.server_process
+ if (type(proc) is not subprocess.Popen or proc.poll() is not None or type(proc.args) is not list
+ or '-dev' not in proc.args or '-dev-no-store-token' not in proc.args
+ or '-dev-listen-address=127.0.0.1:'+str(value['port']) not in proc.args):
+ raise Refused('same live disposable backend required')
+ def fresh():
+ if (permit.custody.directory / (permit.attempt_id+'.revoked')).exists():
+ raise Refused('recovery permit revoked')
+ if time.monotonic() >= permit.deadline: raise Refused('recovery admission expired')
+ if proc.poll() is not None: raise Refused('disposable backend lost')
+ if _digest(permit.custody.read(permit.binding)) != permit.private_revision_digest:
+ raise Refused('private custody changed during recovery')
+ fresh()
+ with journal.locked():
+ record = _Recorder(journal,permit,value)
+ for ordinal,item in enumerate(record.current):
+ if item['outcome']=='confirmed': continue
+ step=0
+ def call(method,path,body=None):
+ nonlocal step
+ fresh(); index=step; step+=1
+ record.before(ordinal,index,method,path)
+ conn=http.client.HTTPConnection('127.0.0.1',value['port'],timeout=2)
+ try:
+ conn.request(method,'/v1/'+path,body=_encoded(body) if body is not None else None,
+ headers={'X-Vault-Token':permit.owner_token,'Content-Type':'application/json'})
+ response=conn.getresponse();raw=response.read(1048577)
+ if len(raw)>1048576: raise Refused('oversize recovery response')
+ data=json.loads(raw,object_pairs_hook=_json_unique,parse_constant=_invalid_constant) if raw else {}
+ if type(data) is not dict: raise Refused('invalid recovery response')
+ status=response.status
+ except Exception: raise Refused('recovery transport or response unconfirmed') from None
+ finally:conn.close()
+ record.after(ordinal,index,status)
+ return status,data
+ confirmed=False
+ try:
+ if item['kind'] in {'task-token-revoke','identity-token-revoke'}:
+ accessor=value['task_accessor' if ordinal==0 else 'identity_accessor']
+ if accessor is None:
+ confirmed=ordinal==0 and value['phase']=='prepared' and record.no_mint_proved
+ else:
+ status,data=call('POST','auth/token/revoke-accessor',{'accessor':accessor})
+ # Revoke is idempotent for an already absent accessor;
+ # actual absence must still be independently read back.
+ if status in {200,204,400}:
+ status,data=call('POST','auth/token/lookup-accessor',{'accessor':accessor})
+ confirmed=status==400 and data.get('errors') in (['invalid accessor'],['1 error occurred:\n\t* invalid accessor\n\n'])
+ elif item['kind']=='grant-policy-delete':
+ path='sys/policies/acl/task-exec-'+permit.binding.operation_id
+ status,data=call('GET',path)
+ if _policy_absent(status,data):confirmed=True
+ elif (status==200 and not data.get('errors') and type(data.get('data')) is dict
+ and _same_policy(data['data'].get('policy'),value['policy_hcl'])):
+ status,data=call('DELETE',path)
+ if status in {200,204}:
+ status,data=call('GET',path);confirmed=_policy_absent(status,data)
+ except Exception:
+ # No backend errors/response/handle hashes enter public evidence.
+ confirmed=False
+ record.finish(ordinal,confirmed)
+ return journal.cleanup_status(permit.binding)
diff --git a/tests/test_task_execution_recovery.py b/tests/test_task_execution_recovery.py
new file mode 100644
index 0000000..52b8b61
--- /dev/null
+++ b/tests/test_task_execution_recovery.py
@@ -0,0 +1,320 @@
+"""Actual executor process loss against independent disposable OpenBao."""
+from dataclasses import replace
+import hashlib
+import json
+import os
+import secrets
+from pathlib import Path
+import sys
+import time
+
+import pytest
+
+sys.path.insert(0,str(Path(__file__).resolve().parents[1]/'scripts'))
+from test_task_execution_plan import compiler
+from test_task_execution_native_fixture import bao_dev, owner, admit
+from task_execution_journal import Journal, Refused
+from task_execution_native_fixture import NativeFixtureAdapter
+from task_execution_recovery import FixtureRecoveryCustody, recover_fixture_cleanup
+
+
+def protected(owner,tmp_path):
+ native,journal,binding,call,compiler,descriptor=owner
+ directory=tmp_path/'separate-owner-custody';directory.mkdir(mode=0o700)
+ custody=FixtureRecoveryCustody(directory,integrity_key=secrets.token_bytes(32))
+ native=NativeFixtureAdapter(compiler,native.plan,journal,binding,native.admission,recovery_custody=custody)
+ return native,journal,binding,call,custody
+
+
+def crash(native,journal,binding,stage='issued'):
+ pid=os.fork()
+ if pid==0:
+ try:
+ # The backend belongs to the independent parent, so this child cannot
+ # waitpid it. Parent retains the actual Popen and checks it on recovery.
+ native.admission.server_process.poll=lambda:None
+ native.journal=Journal(journal.directory)
+ with native.journal.locked():
+ admit(native,native.journal,binding)
+ if stage=='mint-response-lost':
+ original=native._http
+ def lost(method,path,token,body=b''):
+ result=original(method,path,token,body)
+ if path=='auth/token/create':os._exit(73)
+ return result
+ native._http=lost
+ native.materialize_grant()
+ native.journal.transition(binding,'grant-issued','applying')
+ if stage=='business-response-lost':
+ native.metadata_call(0)
+ original=native._http
+ def lost(method,path,token,body=b''):
+ result=original(method,path,token,body)
+ if method=='PUT':os._exit(73)
+ return result
+ native._http=lost
+ native.metadata_call(1)
+ if stage=='cleanup-response-lost':
+ from task_execution_cleanup import run_cleanup
+ original=native._http
+ def lost(method,path,token,body=b''):
+ result=original(method,path,token,body)
+ if path=='auth/token/revoke-self':os._exit(73)
+ return result
+ native._http=lost
+ run_cleanup(native.journal,binding,native)
+ os._exit(73)
+ except BaseException:os._exit(74)
+ _,status=os.waitpid(pid,0)
+ assert os.waitstatus_to_exitcode(status)==73
+ # Original admission never appears in the independent parent or reopened DB.
+ assert native.task is None
+ assert journal._admission is None and journal._consumed_admission is None
+
+
+def permit(native,custody,binding,**kwargs):
+ return custody.admit(binding,owner_token=kwargs.pop('owner_token',native.admission.owner_token),
+ server_process=native.admission.server_process,**kwargs)
+
+
+@pytest.mark.parametrize('stage',['issued','business-response-lost','cleanup-response-lost'])
+def test_process_loss_then_independent_cleanup_never_resumes_business(owner,tmp_path,stage,monkeypatch,request):
+ native,journal,binding,call,custody=protected(owner,tmp_path)
+ crash(native,journal,binding,stage)
+ journal=Journal(journal.directory)
+ request.addfinalizer(journal.close)
+ native.journal=journal
+ private=custody.read(binding)
+ assert private['phase']=='issued' and private['task_accessor']
+ target=native.compiler.before_call(native.plan,1).path
+ before=call('GET',target)
+ with journal.locked():
+ with pytest.raises(Refused):journal.reserve(binding,native.cleanup)
+ with pytest.raises(Refused):native.materialize_grant()
+ with pytest.raises(Refused):native.metadata_call(0)
+ with pytest.raises(Refused):native.revoke_task_token(native.cleanup[0].call_digest)
+ admitted=permit(native,custody,binding)
+ import task_execution_recovery as recovery_module
+ sent=[]
+ original_request=recovery_module.http.client.HTTPConnection.request
+ def tracked(connection,method,url,*args,**kwargs):
+ sent.append((method,url)) # metadata only; never capture body/headers
+ return original_request(connection,method,url,*args,**kwargs)
+ with monkeypatch.context() as patch:
+ patch.setattr(recovery_module.http.client.HTTPConnection,'request',tracked)
+ result=recover_fixture_cleanup(journal,admitted)
+ assert all((method,path) in {
+ ('POST','/v1/auth/token/revoke-accessor'),('POST','/v1/auth/token/lookup-accessor'),
+ ('GET','/v1/sys/policies/acl/'+native.policy_name),
+ ('DELETE','/v1/sys/policies/acl/'+native.policy_name)} for method,path in sent)
+ assert result['confirmed']
+ for accessor in [private['task_accessor'],private['identity_accessor']]:
+ assert call('POST','auth/token/lookup-accessor',{'accessor':accessor})[0]==400
+ assert call('GET','sys/policies/acl/'+native.policy_name)[0]==404
+ after=call('GET',target)
+ assert after[0]==before[0] and after[1].get('data')==before[1].get('data') # request IDs vary
+ assert journal.observe(binding)['dispatch_allowed'] is False
+ if stage!='issued':assert journal.observe(binding)['external_outcome_unknown']
+ if stage=='cleanup-response-lost':
+ original=json.loads(journal.db.execute('SELECT original FROM recovery_attempts').fetchone()[0])
+ assert original[0]['outcome']=='pending'
+ assert journal.db.execute("SELECT COUNT(*) FROM cleanup_calls WHERE outcome='pending'").fetchone()[0]==1
+ with pytest.raises(Refused):recover_fixture_cleanup(journal,admitted)
+ public=(journal.directory/'journal.sqlite').read_bytes()
+ for value in [private['task_accessor'],private['identity_accessor'],native.admission.owner_token]:
+ assert value.encode() not in public
+ assert 'client_token' not in json.dumps(private)
+ assert 'token' not in repr(admitted) and private['task_accessor'] not in repr(admitted)
+
+
+def test_process_loss_before_handle_commit_preserves_unknown_mint_debt(owner,tmp_path):
+ native,journal,binding,call,custody=protected(owner,tmp_path)
+ crash(native,journal,binding,'mint-response-lost')
+ assert custody.read(binding)['phase']=='mint-intent'
+ result=recover_fixture_cleanup(journal,permit(native,custody,binding))
+ assert not result['confirmed']
+ assert [x['outcome'] for x in result['obligations']]==['owed','confirmed','confirmed']
+ assert journal.observe(binding)['external_outcome_unknown']
+ assert journal.db.execute('SELECT confirmed FROM recovery_results ORDER BY obligation').fetchall()==[(0,),(1,),(1,)]
+
+
+def test_revoked_expired_wrong_revision_and_public_flag_refuse_before_calls(owner,tmp_path):
+ native,journal,binding,call,custody=protected(owner,tmp_path);crash(native,journal,binding)
+ p=permit(native,custody,binding)
+ custody.revoke(p)
+ for bad in [p,replace(permit(native,custody,binding),deadline=time.monotonic()-1),
+ replace(permit(native,custody,binding),private_revision_digest='0'*64),{'verified':True}]:
+ with pytest.raises(Refused):recover_fixture_cleanup(journal,bad)
+ assert call('GET','sys/policies/acl/'+native.policy_name)[0]==200
+ assert journal.cleanup_status(binding)['obligations'][0]['outcome']=='owed'
+
+
+def test_revoked_recovery_credential_cannot_fake_cleanup_success(owner,tmp_path):
+ native,journal,binding,call,custody=protected(owner,tmp_path);crash(native,journal,binding)
+ status,data=call('POST','auth/token/create',{'policies':['root'],'ttl':'30s'})
+ assert status==200
+ token=data['auth']['client_token']
+ assert call('POST','auth/token/revoke',{'token':token})[0] in {200,204}
+ result=recover_fixture_cleanup(journal,permit(native,custody,binding,owner_token=token))
+ assert not result['confirmed'] and all(x['outcome']=='owed' for x in result['obligations'])
+ assert call('GET','sys/policies/acl/'+native.policy_name)[0]==200
+ # A separately admitted owner attempt can recover after the failed attempt;
+ # neither the failed attempt nor its receipt grants an automatic retry.
+ assert recover_fixture_cleanup(journal,permit(native,custody,binding))['confirmed']
+ assert journal.db.execute('SELECT COUNT(*) FROM recovery_attempts').fetchone()[0]==2
+
+
+def test_policy_name_reuse_never_deletes_different_policy(owner,tmp_path):
+ native,journal,binding,call,custody=protected(owner,tmp_path);crash(native,journal,binding)
+ replacement='path "unrelated" { capabilities = ["read"] }'
+ assert call('PUT','sys/policies/acl/'+native.policy_name,{'policy':replacement})[0] in {200,204}
+ result=recover_fixture_cleanup(journal,permit(native,custody,binding))
+ assert not result['confirmed']
+ assert [x['outcome'] for x in result['obligations']]==['confirmed','confirmed','owed']
+ assert call('GET','sys/policies/acl/'+native.policy_name)[1]['data']['policy']==replacement
+
+
+def test_private_custody_permissions_and_identity_binding_fail_closed(owner,tmp_path):
+ native,journal,binding,call,custody=protected(owner,tmp_path)
+ path=custody._path(binding);path.chmod(0o644)
+ with pytest.raises(Refused):custody.read(binding)
+ path.chmod(0o600)
+ data=json.loads(path.read_bytes());data['payload']['binding']['operation_id']='other'
+ path.write_text(json.dumps(data))
+ with pytest.raises(Refused):custody.read(binding)
+
+
+def test_lost_recovery_response_stays_unknown_until_new_independent_attempt(owner,tmp_path,monkeypatch):
+ native,journal,binding,call,custody=protected(owner,tmp_path);crash(native,journal,binding)
+ import task_execution_recovery as recovery_module
+ original=recovery_module.http.client.HTTPConnection.getresponse
+ once=[True]
+ def lost(connection):
+ response=original(connection)
+ if once[0]:
+ once[0]=False
+ response.close()
+ raise Refused('synthetic lost recovery response after real revoke')
+ return response
+ first=permit(native,custody,binding)
+ with monkeypatch.context() as patch:
+ patch.setattr(recovery_module.http.client.HTTPConnection,'getresponse',lost)
+ assert not recover_fixture_cleanup(journal,first)['confirmed']
+ assert journal.observe(binding)['external_outcome_unknown']
+ with pytest.raises(Refused):recover_fixture_cleanup(journal,first)
+ assert recover_fixture_cleanup(journal,permit(native,custody,binding))['confirmed']
+ # Reconciliation does not erase the historic uncertain invocation.
+ assert journal.observe(binding)['external_outcome_unknown']
+ assert journal.db.execute("SELECT COUNT(*) FROM recovery_calls WHERE outcome='pending'").fetchone()[0]==1
+
+
+@pytest.fixture
+def capsule(tmp_path):
+ from task_execution_journal import Binding,Cleanup
+ from task_execution_plan import cleanup_specs
+ binding=Binding('SYNTHETIC','sha256:'+'a'*64,'12345678-1234-1234-1234-123456789abc','b'*64)
+ directory=tmp_path/'private-capsule';directory.mkdir(mode=0o700)
+ custody=FixtureRecoveryCustody(directory,integrity_key=secrets.token_bytes(32))
+ cleanup=tuple(Cleanup(x['kind'],x['call_digest']) for x in cleanup_specs(binding.operation_id))
+ custody.prepare(binding,cleanup,12345,'path "fixture" {}\n','synthetic-private-accessor')
+ return custody,binding
+
+
+@pytest.mark.parametrize('mutate',[
+ lambda v:v.update(extra=True),lambda v:v.pop('phase'),lambda v:v.update(binding=True),
+ lambda v:v['binding'].update(approval_id=True),lambda v:v['binding'].update(extra='x'),
+ lambda v:v['binding'].update(request_digest='0'*64),lambda v:v.update(port=True),
+ lambda v:v.update(port=12345.0),lambda v:v.update(port=0),lambda v:v.update(phase=[]),
+ lambda v:v.update(phase='issued'),lambda v:v.update(revision=False),
+ lambda v:v.update(revision=0.0),lambda v:v.update(identity_accessor=False),
+ lambda v:v.update(task_accessor='unexpected-before-issue'),lambda v:v.update(policy_hcl=[]),
+ lambda v:v.update(cleanup={}),lambda v:v['cleanup'].pop(),
+ lambda v:v['cleanup'].__setitem__(0,False),lambda v:v['cleanup'][0].update(extra=True),
+ lambda v:v['cleanup'][0].update(kind=True),lambda v:v['cleanup'][0].update(call_digest=0),
+ lambda v:v['cleanup'][0].update(call_digest='f'*64),lambda v:v['cleanup'].reverse(),
+])
+def test_authenticated_but_damaged_capsule_schema_refuses(capsule,mutate):
+ custody,binding=capsule
+ value=custody.read(binding);mutate(value)
+ # Trusted test owner re-signs malformed payload to exercise schema defenses
+ # independently of MAC validation; untrusted corruption cannot do this.
+ custody._write(binding,value)
+ with pytest.raises(Refused):custody.read(binding)
+
+
+def test_capsule_corruption_wrong_key_duplicate_and_nonfinite_refuse(capsule):
+ custody,binding=capsule;path=custody._path(binding);original=path.read_bytes()
+ value=json.loads(original);value['payload']['identity_accessor']='different-synthetic-accessor'
+ path.write_text(json.dumps(value))
+ with pytest.raises(Refused):custody.read(binding)
+ path.write_bytes(original)
+ other=FixtureRecoveryCustody(custody.directory,integrity_key=b'X'*32)
+ with pytest.raises(Refused):other.read(binding)
+ for raw in [b'{',original[:-1]+b',"mac":"duplicate"}',original.replace(b'12345',b'NaN'),b'[]',b'{}',b'{"payload":{},"mac":"'+b'\xc3\xa9'*64+b'"}']:
+ path.write_bytes(raw)
+ with pytest.raises(Refused):custody.read(binding)
+ path.write_bytes(original)
+ assert custody.read(binding)['identity_accessor']=='synthetic-private-accessor'
+
+
+def test_exact_absence_and_single_terminal_lf_contract():
+ from task_execution_recovery import _policy_absent,_same_policy
+ assert _policy_absent(404,{'errors':[]})
+ for status,body in [(403,{'errors':[]}),(404,{}),(404,{'errors':['denied']}),
+ (404,{'errors':None}),(404,{'errors':[]} | {'data':{}}),
+ (404,{'errors':False}),(404,{'errors':0}),(404,{'errors':{}})]:
+ assert not _policy_absent(status,body)
+ assert _same_policy('fixture','fixture\n')
+ assert _same_policy('fixture\n','fixture\n')
+ for text in ['fixture \n','fixture\t','fixture\r\n','fixture\n\n',False,None]:
+ assert not _same_policy(text,'fixture\n')
+
+
+@pytest.mark.parametrize('after_delete',[False,True])
+def test_recovery_policy_absence_rejects_misleading_native_http_bodies(owner,tmp_path,monkeypatch,after_delete):
+ native,journal,binding,call,custody=protected(owner,tmp_path);crash(native,journal,binding)
+ import task_execution_recovery as module
+ malformed=[{}, {'errors':['permission denied']}, {'errors':None}, {'errors':False},
+ {'errors':0}, {'errors':[],'data':{}}, {'errors':['not really absent']},
+ b'{"errors":["permission denied"],"errors":[]}',b'{"errors":NaN}',b'not JSON']
+ path='sys/policies/acl/'+native.policy_name
+ for body in malformed:
+ sent=[];gets=[0]
+ class Response:
+ def __init__(self,status,data):self.status=status;self.data=data
+ def read(self,limit):return self.data if type(self.data) is bytes else json.dumps(self.data).encode()
+ class Connection:
+ def __init__(self,*args,**kwargs):pass
+ def request(self,method,url,**kwargs):sent.append((method,url))
+ def getresponse(self):
+ method,url=sent[-1]
+ if url=='/v1/'+path:
+ if method=='DELETE':return Response(204,{})
+ gets[0]+=1
+ if after_delete and gets[0]==1:
+ return Response(200,{'data':{'policy':native.hcl[:-1]}})
+ return Response(404,body)
+ return Response(403,{'errors':['synthetic refusal']})
+ def close(self):pass
+ with monkeypatch.context() as patch:
+ patch.setattr(module.http.client,'HTTPConnection',Connection)
+ result=recover_fixture_cleanup(journal,permit(native,custody,binding))
+ assert not result['confirmed'] and result['obligations'][2]['outcome']=='owed'
+ assert ('DELETE','/v1/'+path) in sent if after_delete else ('DELETE','/v1/'+path) not in sent
+ # The denied/malformed probes above emitted no real native calls.
+ assert call('GET',path)[0]==200
+ assert recover_fixture_cleanup(journal,permit(native,custody,binding))['confirmed']
+
+
+def test_authenticated_prepared_capsule_rollback_cannot_hide_later_mint(owner,tmp_path):
+ native,journal,binding,call,custody=protected(owner,tmp_path)
+ old=custody._path(binding).read_bytes()
+ crash(native,journal,binding)
+ current=custody.read(binding)
+ custody._path(binding).write_bytes(old)
+ # Still authentic under the owner key, but the durable journal proves the
+ # operation reached issuance. The stale prepared record cannot prove absence.
+ result=recover_fixture_cleanup(journal,permit(native,custody,binding))
+ assert not result['confirmed'] and result['obligations'][0]['outcome']=='owed'
+ assert call('POST','auth/token/lookup-accessor',{'accessor':current['task_accessor']})[0]==200
+ assert call('POST','auth/token/revoke-accessor',{'accessor':current['task_accessor']})[0] in {200,204}
diff --git a/workplans/RPF-WP-0035-credential-lane-implementation.md b/workplans/RPF-WP-0035-credential-lane-implementation.md
index 8b09305..f58a6b9 100644
--- a/workplans/RPF-WP-0035-credential-lane-implementation.md
+++ b/workplans/RPF-WP-0035-credential-lane-implementation.md
@@ -1290,3 +1290,20 @@ CAS records consumed/unknown with zero native effects. Exact dependency/source
hashes, tests and remaining gates are retained with the source package and in
docs/task-execution-mediator-seam.md. No real grant/custody, policy default, owner
sync or native production acceptance is performed; T12 stays progress.
+
+2026-10-16 T12 recovery successor source preparation: optional independently
+installed fixture custody durably retains operation-bound private accessors and
+issuance checkpoints. Fresh cleanup-only owner permits can reconcile after actual
+executor process death using fixed revoke/readback and policy delete/readback,
+without restoring business/consume admission. Separate durable attempt evidence
+retains original unknown outcomes. Lost token-create response remains unresolved
+when no accessor was committed. Source-only process-loss fixtures are not native
+production custody or recovery-role admission; T12 remains progress.
+
+2026-10-16 T12 recovery R2 source refinement retains R1's overbroad 404 failure:
+policy cleanup now requires exact native empty-errors absence before/after delete;
+HCL uses only documented single optional terminal LF normalization. Private
+capsules are owner-key HMAC authenticated, strict nested schemas/ordered cleanup
+commitments reject damage, and journal history prevents stale prepared capsules
+from hiding issuance. Fixture process-loss source proof remains separate from
+unadmitted production custody, integrity-key storage and recovery-role scope.

View file

@ -0,0 +1,60 @@
{
"schema": "railiance-platform.protected-recovery-source-manifest.v1",
"proposal_id": "RPF35-PROTECTED-RECOVERY-20261016-R1",
"canonical_base_revision": "d870596cc72f74a5762cce2400802e2b351e903e",
"isolated_preimage_revision": "45419a49955b4a05f3f3d468c8216f6e9d0da2ec",
"isolated_source": "/tmp/netkingdom-platform-recovery-20261016",
"patch_sha256": "47812816af915534d6aec9431259f4290fb9b109a4fb47a54ed7dbd3b145c615",
"files": {
"scripts/task_execution_recovery.py": {
"before_sha256": null,
"after_sha256": "419517ea07e602eee975aa5d64c0b150ed5546dfda319beab44246c15bfa7b11"
},
"scripts/task_execution_native_fixture.py": {
"before_sha256": "4575d048f79b125b0f87c70b9a76558d8cce6e61cb66b5b0583934d1a62f2ef6",
"after_sha256": "1191bfe2cfd0dd967f51405a1fd453e2f24c929d558554110137c2cc99f71941"
},
"scripts/task_execution_journal.py": {
"before_sha256": "507b41a790e678dfd16af747bd851f5abc2fed5a8fb70bca05449d9e5ac674e8",
"after_sha256": "604cf285286a8a1be23d9bc0d8a547bd1195b7f80286778e1d4f30c711fd6a7e"
},
"tests/test_task_execution_recovery.py": {
"before_sha256": null,
"after_sha256": "3a1722df7c63fa2a8a9f529cd9611c10a9664ac553dca04c1a940bd43ab75485"
},
"docs/task-execution-native-fixture.md": {
"before_sha256": "4501a512bc03a8d912b11a199a1385d75cfa077ba849dfcfe3eca53f31d1c8ea",
"after_sha256": "605a9effe69d57abedf734eda66dbd9a7c70c3da07cc3006012de9343c435f91"
},
"workplans/RPF-WP-0035-credential-lane-implementation.md": {
"before_sha256": "e90d963f8699360a62907ccc32d2346a33db62945dfb10473217c6dce7b2d383",
"after_sha256": "53ec6b02ef9dfb698937607449ac662178e237d7fa33cc402d4796852e2f347a"
}
},
"canonical_preimages_match": {
"scripts/task_execution_native_fixture.py": true,
"scripts/task_execution_journal.py": true,
"docs/task-execution-native-fixture.md": true,
"workplans/RPF-WP-0035-credential-lane-implementation.md": true
},
"preimages_directory": "preimages",
"authority": "Source-only independently admitted disposable fixture cleanup. No production custody/grant/credential read.",
"unchanged": [
"Frozen signer-custody proposal",
"CCR-2026-0048",
"CCR-2026-0049",
"WORK-RECORDS.md"
],
"validation_pending": false,
"source_input_sha256": {
"/tmp/netkingdom-platform-recovery-20261016/tests/test_task_execution_plan.py": "679bfe0450e7975b8ad6747cd210afce1de33da54acdf64bc9f00b3603772b95",
"/tmp/netkingdom-platform-recovery-20261016/tests/test_task_execution_native_fixture.py": "801dfeb601ac0f2f4ace8847e25333cb191218b86482a22eb6e90780ca9906e8",
"/tmp/netkingdom-platform-recovery-20261016/tests/test_task_execution_journal.py": "b51a4f5b8122221c69ad2486d6e1751298bd4665ac37c94f363dd5a27c5a014c",
"/tmp/netkingdom-platform-recovery-20261016/tests/test_task_execution_readback.py": "3c3ea5b06e8779d396a9d9301cc0e79477f8bb382e15a5e66366dde941f80b33",
"/tmp/netkingdom-platform-recovery-20261016/scripts/task_execution_plan.py": "cd984055ca8a1d51ef563bb7534b30c2fc9e326ed89a459362419b407397b9dc",
"/tmp/netkingdom-platform-recovery-20261016/scripts/task_execution_grant.py": "b76d7dce419c0548f33be3efaec2589e50eec48b9e277c90ab0e4615b9c0f3e7",
"/home/worsch/secrets-engine/src/secrets_engine/execution_contract.py": "75323911c229cb8b49d5116706a329c3f9ec2efdbeab6bfac8dbf5b92729f14a",
"/home/worsch/secrets-engine/src/secrets_engine/task_execution_handoff.py": "b82695b88c201fdcbd8854a0d3c37ee2d2d3feee2c0663d784180fa070ac6677",
"/home/worsch/secrets-engine/src/secrets_engine/catalog.py": "9af3a10cca7f45a201ef0567fb885d086390bc6fd2091e982ef0cd6191619514"
}
}

View file

@ -0,0 +1,57 @@
{
"schema": "railiance-platform.protected-recovery-source-manifest.v2",
"proposal_id": "RPF35-PROTECTED-RECOVERY-20261016-R2",
"same_trial_as": "RPF35-PROTECTED-RECOVERY-20261016-R1",
"original_base": "d870596cc72f74a5762cce2400802e2b351e903e",
"isolated_source": "/tmp/netkingdom-platform-recovery-r2-20261016",
"r1_isolated_commit": "1f80e617a4119dcea297944dbf328a1a3b2764ed",
"full_patch_sha256": "61f516c876fd612b83300e9913f0e80aad836a50014310797913c7c4505e3114",
"delta_patch_sha256": "6f97bbaa957673a3b28070d1550ffe72e709a573be183062de21e7f7145e8380",
"r1_patch_sha256": "47812816af915534d6aec9431259f4290fb9b109a4fb47a54ed7dbd3b145c615",
"files": {
"scripts/task_execution_recovery.py": {
"original_before_sha256": null,
"r1_before_sha256": "419517ea07e602eee975aa5d64c0b150ed5546dfda319beab44246c15bfa7b11",
"r2_after_sha256": "910f38919a8b6f57591366a2ef4da40d1ed7dfd7556c845a4b89496eca39ce1c"
},
"scripts/task_execution_native_fixture.py": {
"original_before_sha256": "4575d048f79b125b0f87c70b9a76558d8cce6e61cb66b5b0583934d1a62f2ef6",
"r1_before_sha256": "1191bfe2cfd0dd967f51405a1fd453e2f24c929d558554110137c2cc99f71941",
"r2_after_sha256": "1191bfe2cfd0dd967f51405a1fd453e2f24c929d558554110137c2cc99f71941"
},
"scripts/task_execution_journal.py": {
"original_before_sha256": "507b41a790e678dfd16af747bd851f5abc2fed5a8fb70bca05449d9e5ac674e8",
"r1_before_sha256": "604cf285286a8a1be23d9bc0d8a547bd1195b7f80286778e1d4f30c711fd6a7e",
"r2_after_sha256": "604cf285286a8a1be23d9bc0d8a547bd1195b7f80286778e1d4f30c711fd6a7e"
},
"tests/test_task_execution_recovery.py": {
"original_before_sha256": null,
"r1_before_sha256": "3a1722df7c63fa2a8a9f529cd9611c10a9664ac553dca04c1a940bd43ab75485",
"r2_after_sha256": "1322469a116305a3a44bca7191eb52b76614d911b9eb7ce68864c52ce13c36e1"
},
"docs/task-execution-native-fixture.md": {
"original_before_sha256": "4501a512bc03a8d912b11a199a1385d75cfa077ba849dfcfe3eca53f31d1c8ea",
"r1_before_sha256": "605a9effe69d57abedf734eda66dbd9a7c70c3da07cc3006012de9343c435f91",
"r2_after_sha256": "e299ff92fbd5282ca73066e41d5ef3753fad2e46ded2d8eda9935d20effaae46"
},
"workplans/RPF-WP-0035-credential-lane-implementation.md": {
"original_before_sha256": "e90d963f8699360a62907ccc32d2346a33db62945dfb10473217c6dce7b2d383",
"r1_before_sha256": "53ec6b02ef9dfb698937607449ac662178e237d7fa33cc402d4796852e2f347a",
"r2_after_sha256": "312a3b4b9228897a1a256736eb0d4995b38274a47c6a52870d106aba16b532fd"
}
},
"validation_pending": false,
"findings": [
"R1 accepted arbitrary404 bodies as policy absence, including postdelete readback.",
"R1 broad rstrip comparison did not match claimed exact policy equality.",
"R1 private file shape checks did not authenticate corruption; a damaged still-string accessor could be freshly admitted.",
"R1 nested schema comparison ignored extra cleanup keys and could throw type errors for malformed phase."
],
"fixes": [
"Exact native empty-errors404; strict response JSON rejects duplicate and nonfinite values.",
"Explicit single optional terminal LF only, no whitespace folding.",
"Independent owner-key HMAC authenticates private capsule payload before exact nested schema validation; owner key never persisted in capsule/journal/log.",
"Strict Binding/Cleanup formats and ordered deterministic cleanup_specs; journal history prevents authenticated prepared-capsule rollback from hiding issuance."
],
"authority": "Source-only disposable native fixtures. Production custody/isolation/key persistence/recovery scope remain unadmitted."
}

View file

@ -0,0 +1,17 @@
{
"reviewer": "railiance-platform-owner (agent railiance-platform, codex)",
"decision": "approved",
"scope": "Source-only disposable fixture recovery successor, not production custody/grant/native recovery admission.",
"comment": "Platform INTENT principles 3 Durable by Default, 4 Secure Custody and 5 Operable and Observable require real process-loss cleanup recovery with independent owner authority. Reviewed patch sha256:47812816af915534d6aec9431259f4290fb9b109a4fb47a54ed7dbd3b145c615 against canonical base d870596cc72f74a5762cce2400802e2b351e903e and exact preserved preimages, under RPF-WP-0035-T12. Reviewed in isolated owner checkout with owner instructions/INTENT loaded. Recovery never restores business admission, consumes approval, creates credentials or reapplies effects; original uncertainty and failed recovery attempts remain durable. Private accessors stay out of public journal and receipts. Only Platform owner role reviewed.",
"verified_source_evidence": [
"24 final recovery/journal tests passed, including actual executor process death, reopened journal, native revoked accessors and policy absence, independent recovery permit revocation/expiry, stale custody revision, revoked backend credential, policy replacement protection and lost recovery response unknown preservation.",
"Three separately rerun reopened-journal process-loss cases passed.",
"Canonical git apply --check and isolated diff --check passed."
],
"retained_limits": [
"Native production custody/isolation/storage/encryption/retention and exact recovery-role permissions remain unselected and require independent owner/human scope admission.",
"Lost token-create response before accessor commit remains unresolved cleanup debt; no inferred expiry/success.",
"Source fixture recovery is not authorization to use estate backend."
],
"authority_granted": false
}

View file

@ -0,0 +1,13 @@
{
"reviewer": "railiance-platform-owner (agent railiance-platform, codex)",
"decision": "approved",
"scope": "R2 source-only disposable fixture recovery; supersedes R1 source review, not native production admission.",
"comment": "Platform INTENT principles3 Durable by Default,4 Secure Custody and5 Operable and Observable require actual absence evidence, authenticated recoverable handles and explicit uncertainty. Under RPF-WP-0035-T12 reviewed full patch sha256:61f516c876fd612b83300e9913f0e80aad836a50014310797913c7c4505e3114 and R1 delta sha256:6f97bbaa957673a3b28070d1550ffe72e709a573be183062de21e7f7145e8380; exact original and R1 preimages/hashes retained. Root R1 counterexamples were accepted and reproduced offline: damaged valid-string accessor and extra cleanup fields accepted; phase list raised TypeError; rstrip accepted tab changes. R2 authenticates capsules under independently supplied owner key before exact schemas and deterministic cleanup commitments, conservatively detects prepared rollback through journal history, and accepts only actual empty-errors404 absence. HCL admits precisely one optional terminalLF, documented and tested; no broad semantic equality. Owner instructions/INTENT and governing standing authorization loaded in isolated owner checkout; only Platform role reviewed.",
"validation": "53 recovery/journal tests passed; final frozen29targeted cases passed, overlapping counts; exact chronology retained in validation-r2.json. Full/delta patch checks and final hashes pass.",
"remaining_native_gates": [
"Owner-enforced integrity-key custody and process isolation, durable key availability/rotation, capsule encryption/retention, production actor/role/exact cleanup permission and audit delivery remain unselected/unadmitted.",
"HMAC authenticates payload but is not general anti-rollback; a stale prepared payload cannot establish no issuance at the durable grant/call boundary.",
"Unknown issuance without an authenticated retained accessor remains unconfirmed cleanup debt."
],
"authority_granted": false
}

View file

@ -0,0 +1,123 @@
# Executable native metadata/grant/cleanup preparation
RPF-WP-0035-T12, 2026-10-16, second source package after the cleanup journal.
The fixed `NativeFixtureAdapter` actually dispatches to disposable loopback OpenBao.
It requires a live owner-spawned OpenBao dev-process handle with the exact loopback
port and dev/no-store-token arguments, and refuses every non-loopback origin.
Production is deliberately not implemented. This is a guard against accidental
misrouting inside the trusted fixture runtime, not a Python security sandbox.
No estate credentials, grants, approvals or policy packages are read or changed.
The tests start in-memory dev servers with synthetic root/session credentials,
synthetic owner admission, grant/state/source digests and a synthetic postconsume
journal transition. This proves native primitives, not authenticated admission.
The owner Compiler now deterministically includes the operation-specific temporary
policy name and cleanup call commitments in each serialized Plan. Commitments
include identity/task credential slots, exact native revoke/verify or delete/verify
method/path pairs, and the narrow no-issue/no-create skip branches. The full
execution descriptor can be bound into that Plan only by byte equality with an
independently installed owner descriptor, with operation/actor/CCR/catalogue/six
operations correspondence. Compiler revalidation preserves both descriptor and
cleanup content. The descriptor's SHA is preparation provenance; no opaque digest
or local file becomes approval or verified canonical request correspondence.
The adapter needs the exact compiler/Plan/binding and separately installed owner
fixture inputs. All handles stay in owner memory with hidden representations. It
requires the same live consumed journal admission and exact grant-issuing state.
It refuses an existing per-operation grant policy, creates only the two compiler
metadata endpoints plus revoke-self, verifies HCL, mints one parented service token
with only this policy, no default/identity policies, nonrenewable TTL <=120 seconds
and twelve uses, then verifies native token bounds through the owner. Six ordered
metadata calls use independently recompiled source/method/path/body. A task use is
reserved for revoke; native token limits also enforce the ceiling. Business calls
check wall and monotonic deadlines before effect, with a five-second cleanup reserve.
No retry/remint, caller HCL/JSON/path, mount/AppRole/KV/SecretID or shell API exists.
Wave 5 below adds exact sequential snapshot acceptance; native baseline CAS remains absent.
Every business/grant request records durable intent and closed status evidence.
Native cleanup has its own durable per-request intents beneath the original
committed obligation; accessor verification cannot erase an uncertain revoke.
The native adapter compares the exact committed cleanup sequence before request.
Self-revoke success must be followed by owner lookup-accessor's specific invalid
accessor response; generic HTTP400/403, timeout and policy deletion do not prove
revocation. The two explicitly accepted error encodings cover OpenBao's plain and
wrapped single-invalid-accessor forms. Native error text is never logged or hashed.
Cleanup bypasses expired business deadlines under the retained explicit obligation,
never source-driven business repair. Independent cleanup continues after failure;
retirement never overwrites the original unknown/failed business outcome.
Current actual native tests cover policy/body and K8s-role application/readback,
owner-parented TTL/use policy bounds, native sibling/KV/token/mount/SecretID capability
denial, both token accessor-invalid proofs, policy absence, deadline-expired cleanup,
source drift, no preconsume materialization, changed descriptor/cleanup commitments,
nonloopback refusal, actual write followed by simulated lost response, and actual
policy retirement with failed task revocation. The lost response is fault injection
after a successful real native write; it does not pretend to be an actual network
partition. The failed-revoke fixture independently confirms the still-existing
native token, then performs explicit synthetic owner recovery. No blind retry occurs.
## Minimal remaining decisions and integration
1. Define/admit `task-execution-grant` in the owner CCR validator. Bind the exact
cleanup commitments, temporary policy/token bodies and source revisions into its
immutable projection, referenced by the existing descriptor grant SHA. The
descriptor currently has no cleanup field; no engine schema extension is needed
if this owner grant binding is made explicit. The immutable projection/excluded
append-only fields are not implemented in the current owner validator. This is
the concrete owner schema/admission decision, not a need for a new service.
2. Have the existing authoritative engines supply authenticated initiating principal,
exact canonical request + served claim/PDP/package and independent signed CAS
consume verification to the owner mediator before its journal transition.
The native fixture's local transition supplies none of that evidence.
3. Install exact origin/cluster/source/grant/baseline/readback inputs under the
attended owner runtime. The current binder compares independently installed
descriptor bytes, but does not authenticate source-bundle paths, grant digest or
compute immutable CCR projection. The six-operation plan still comes from pinned
actual owner source. Production origin authentication and deadline bounds against
served approval/PDP remain separate.
4. Finish expected-state normalization and conflict handling for native K8s/OIDC
defaults; add per-policy/role approved baseline comparison and exact readback
verification. The fixture checks known fields, not total production equality.
OpenBao metadata PUT has no native atomic CAS; the owner must choose exclusive
admitted target custody or an honest conflict/recovery protocol.
5. Wire the fixed adapter to that mediator with bounded runtime isolation,
owner-private uncertain-issuance recovery, external audit outbox and exact
founder-scoped attended trial. No production caller or protected recovery channel
exists here. The existing production readiness gate remains refused.
No umbrella broker, general executor, new credential catalogue or standing service
admin entitlement is introduced. Approval CAS prevents approval replay; it cannot
make uncertain native effects exactly-once. RPF T12 and Secrets WP13 remain open.
### Exact disposable metadata acceptance (2026-10-16, wave 5)
The owner-installed `FixtureAdmission` now carries canonical JSON before/after
snapshot bytes. Their SHA-256 values must equal the existing descriptor's
`expected_before` and `expected_after`, and both snapshots must name exactly the
two compiled metadata paths. Explicit JSON null means absence. Only the actual
OpenBao empty-errors 404 is accepted as absence; denied/malformed reads fail.
These inputs contain metadata, never KV values or private token handles.
Policy comparison retains name, HCL, version and cas_required. Only the
modification timestamp is excluded, after requiring an aware timestamp and the
closed observed policy field set. Role comparison retains every returned field,
including aliases, token defaults and unexpected additions, using canonical JSON
bytes (therefore false cannot equal zero). The disposable Kubernetes fixture pins
the complete OpenBao 2.5.5 response shape. Another plugin/version/default requires
an independently reviewed owner snapshot; nothing infers new accepted defaults.
Each baseline gates its corresponding write. Policy readback occurs before role
baseline/write/readback. A role conflict can therefore leave the policy applied;
that operation remains unknown. This sequence has no native CAS, aggregate
baseline gate, concurrent-writer exclusion, or simultaneous final-state proof.
After all six calls match the committed snapshots, the same-live adapter records
readback and verified with a value-free evidence digest. The mediator reports
native_apply verified; cleanup-confirmed requires all separate cleanup receipts.
Readback success with unconfirmed cleanup cannot reach cleanup-confirmed.
Reopening SQLite cannot dispatch, reconsume, restore token/accessor handles or
retry cleanup. Native effects can survive process loss. Cleanup handles remain
memory-only: no independently authorized recovery mechanism is implemented here.
This closes disposable sequential apply acceptance only, not TSA03 or native
production recovery/admission.

View file

@ -0,0 +1,390 @@
"""Source-only admission primitive; never authorizes or dispatches native work.
Trusted owner mediation must validate claim/PDP/consume before transitions.
One approval admits one operation. A persisted reservation cannot be resumed.
The process lock is held across the whole owner invocation, including cleanup.
"""
from __future__ import annotations
import fcntl
import os
import re
import sqlite3
import stat
import uuid
from contextlib import contextmanager
from dataclasses import dataclass
from pathlib import Path
class Refused(Exception):
pass
@dataclass(frozen=True)
class Binding:
approval_id: str
request_digest: str
operation_id: str
plan_digest: str
def validate(self):
if any(type(v) is not str for v in (self.approval_id, self.request_digest, self.operation_id, self.plan_digest)):
raise Refused("binding fields must be strings")
if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_-]{0,127}", self.approval_id):
raise Refused("invalid approval identifier")
# Preserve the exact served PDP/Approval value; never silently strip it.
if not re.fullmatch(r"sha256:[0-9a-f]{64}", self.request_digest):
raise Refused("invalid canonical request digest")
if not re.fullmatch(r"[0-9a-f]{64}", self.plan_digest):
raise Refused("invalid owner plan content digest")
try:
if str(uuid.UUID(self.operation_id)) != self.operation_id:
raise ValueError()
except (ValueError, AttributeError):
raise Refused("invalid operation identifier") from None
_NEXT = {
"reserved": {"consume-confirmed", "failed", "unknown"},
"consume-confirmed": {"authenticating", "failed", "unknown"},
"authenticating": {"grant-issuing", "failed", "unknown"},
"grant-issuing": {"grant-issued", "failed", "unknown"},
"grant-issued": {"applying", "failed", "unknown"},
"applying": {"readback", "failed", "unknown"},
"readback": {"verified", "failed", "unknown"},
"verified": {"cleanup-confirmed", "unknown"},
}
@dataclass(frozen=True)
class Cleanup:
"""Owner-derived exact call commitment, never a handle or authority token.
The owner mediator must include these commitments in its approved plan before
reservation, then compare the actual native cleanup call immediately before
dispatch. This journal does not authenticate that plan or native adapter.
"""
kind: str
call_digest: str
def validate(self):
if type(self.kind) is not str or self.kind not in {
"task-token-revoke", "identity-token-revoke", "grant-policy-delete"
}:
raise Refused("unsupported cleanup obligation")
if type(self.call_digest) is not str or not re.fullmatch(r"[0-9a-f]{64}", self.call_digest):
raise Refused("invalid cleanup call digest")
class Journal:
def __init__(self, directory: Path):
# Require an existing owner-private directory: deployment creates it.
if directory.is_symlink() or directory.resolve() != directory.absolute():
raise Refused("journal path is indirect")
self.directory = directory
self._private(directory.stat(), directory=True)
self._lock_fd = self._open("execution.lock")
try:
fd = self._open("journal.sqlite")
except BaseException:
os.close(self._lock_fd)
del self._lock_fd
raise
original = os.fstat(fd)
os.close(fd)
self.db = sqlite3.connect(directory / "journal.sqlite", isolation_level=None)
current = (directory / "journal.sqlite").stat()
if (original.st_dev, original.st_ino) != (current.st_dev, current.st_ino):
self.close()
raise Refused("journal changed during open")
self.db.execute("PRAGMA synchronous=FULL")
self.db.execute("PRAGMA journal_mode=DELETE")
self.db.execute("CREATE TABLE IF NOT EXISTS operations (approval TEXT PRIMARY KEY, request TEXT NOT NULL, operation TEXT UNIQUE NOT NULL, plan TEXT NOT NULL, state TEXT NOT NULL, evidence TEXT)")
self.db.execute("CREATE TABLE IF NOT EXISTS calls (operation TEXT NOT NULL, ordinal INTEGER NOT NULL, call_digest TEXT NOT NULL, outcome TEXT NOT NULL, evidence TEXT, PRIMARY KEY(operation,ordinal))")
self.db.execute("CREATE TABLE IF NOT EXISTS cleanup (operation TEXT NOT NULL, ordinal INTEGER NOT NULL, kind TEXT NOT NULL, call_digest TEXT NOT NULL, outcome TEXT NOT NULL, evidence TEXT, PRIMARY KEY(operation,ordinal), UNIQUE(operation,kind))")
self.db.execute("CREATE TABLE IF NOT EXISTS cleanup_calls (operation TEXT NOT NULL, obligation INTEGER NOT NULL, step INTEGER NOT NULL, call_digest TEXT NOT NULL, outcome TEXT NOT NULL, evidence TEXT, PRIMARY KEY(operation,obligation,step))")
self._held = False
self._admission = None
self._consumed_admission = None
self._sync_directory()
@staticmethod
def _private(s, directory=False):
kind = stat.S_ISDIR(s.st_mode) if directory else stat.S_ISREG(s.st_mode)
if not kind or s.st_uid != os.getuid() or s.st_mode & 0o077:
raise Refused("journal is not owner private")
if not directory and s.st_nlink != 1:
raise Refused("journal file has multiple links")
def _open(self, name):
fd = os.open(self.directory / name, os.O_CREAT | os.O_RDWR | os.O_NOFOLLOW, 0o600)
try:
self._private(os.fstat(fd))
except BaseException:
os.close(fd)
raise
return fd
def _sync_directory(self):
fd = os.open(self.directory, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
try:
os.fsync(fd)
finally:
os.close(fd)
@contextmanager
def locked(self):
if self._held:
raise Refused("nested journal admission")
try:
fcntl.flock(self._lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError:
raise Refused("owner invocation already active") from None
self._held = True
try:
yield self
finally:
self._admission = None
self._consumed_admission = None
self._held = False
fcntl.flock(self._lock_fd, fcntl.LOCK_UN)
def reserve(self, binding: Binding, cleanup: tuple[Cleanup, ...] = ()):
binding.validate()
if type(cleanup) is not tuple or len(cleanup) > 3:
raise Refused("closed cleanup plan required")
for item in cleanup:
if type(item) is not Cleanup:
raise Refused("typed cleanup obligation required")
item.validate()
if len({item.kind for item in cleanup}) != len(cleanup):
raise Refused("duplicate cleanup obligation")
if not self._held or self._admission is not None:
raise Refused("reservation requires fresh exclusive invocation")
try:
self.db.execute("BEGIN IMMEDIATE")
self.db.execute("INSERT INTO operations VALUES (?,?,?,?,?,NULL)",
(binding.approval_id, binding.request_digest,
binding.operation_id, binding.plan_digest, "reserved"))
self.db.executemany("INSERT INTO cleanup VALUES (?,?,?,?,'owed',NULL)",
[(binding.operation_id, i, item.kind, item.call_digest)
for i, item in enumerate(cleanup)])
self.db.execute("COMMIT")
self._sync_directory()
except sqlite3.IntegrityError:
self.db.execute("ROLLBACK")
raise Refused("approval or operation already admitted") from None
except BaseException:
if self.db.in_transaction:
self.db.execute("ROLLBACK")
raise
self._admission = binding
def transition(self, binding: Binding, expected: str, target: str, evidence_digest=None):
if not self._held or self._admission != binding:
raise Refused("no live admission")
if target not in _NEXT.get(expected, set()):
raise Refused("invalid state transition")
if target == "cleanup-confirmed" and evidence_digest is None:
raise Refused("cleanup requires evidence")
if evidence_digest is not None and not re.fullmatch(r"[0-9a-f]{64}", evidence_digest):
raise Refused("invalid evidence digest")
try:
self.db.execute("BEGIN IMMEDIATE")
pending=self.db.execute("SELECT COUNT(*) FROM calls WHERE operation=? AND outcome='pending'",(binding.operation_id,)).fetchone()[0]
started = self.db.execute("SELECT COUNT(*) FROM cleanup WHERE operation=? AND outcome!='owed'", (binding.operation_id,)).fetchone()[0]
if started and target not in {'unknown', 'cleanup-confirmed'}:
raise Refused("cleanup has closed business execution")
if target == 'cleanup-confirmed':
total, owed = self.db.execute("SELECT COUNT(*),COALESCE(SUM(outcome!='confirmed'),0) FROM cleanup WHERE operation=?", (binding.operation_id,)).fetchone()
if not total or owed:
raise Refused("explicit cleanup obligations are unconfirmed")
if pending and target != 'unknown':
raise Refused("unconfirmed external call requires unknown")
changed = self.db.execute(
"UPDATE operations SET state=?, evidence=? WHERE approval=? AND request=? AND operation=? AND plan=? AND state=?",
(target, evidence_digest, binding.approval_id, binding.request_digest,
binding.operation_id, binding.plan_digest, expected)).rowcount
if changed != 1:
raise Refused("journal state changed")
self.db.execute("COMMIT")
self._sync_directory()
if target == "consume-confirmed":
self._consumed_admission = binding
except BaseException:
if self.db.in_transaction:
self.db.execute("ROLLBACK")
raise
def observe(self, binding: Binding):
binding.validate()
row = self.db.execute("SELECT request,operation,plan,state,evidence FROM operations WHERE approval=?", (binding.approval_id,)).fetchone()
if row is None:
return None
if row[:3] != (binding.request_digest, binding.operation_id, binding.plan_digest):
raise Refused("journal binding mismatch")
# Readback cannot create a live admission, even for an interrupted state.
pending = self.db.execute("SELECT COUNT(*) FROM calls WHERE operation=? AND outcome='pending'", (binding.operation_id,)).fetchone()[0]
pending += self.db.execute("SELECT COUNT(*) FROM cleanup WHERE operation=? AND outcome='pending'", (binding.operation_id,)).fetchone()[0]
pending += self.db.execute("SELECT COUNT(*) FROM cleanup_calls WHERE operation=? AND outcome='pending'", (binding.operation_id,)).fetchone()[0]
return {"state": row[3], "evidence_digest": row[4], "dispatch_allowed": False,
"external_outcome_unknown": bool(pending)}
def next_call_ordinal(self, binding: Binding):
"""Continue the same admitted invocation after owner auth/consume calls.
This never grants dispatch or resumes a persisted admission. The actual
before_call transaction remains the final serialization/intent guard.
"""
if not self._held or self._admission != binding:
raise Refused("next call requires same live admission")
count,pending=self.db.execute("SELECT COUNT(*),COALESCE(SUM(outcome='pending'),0) FROM calls WHERE operation=?",(binding.operation_id,)).fetchone()
cleanup=self.db.execute("SELECT COUNT(*) FROM cleanup WHERE operation=? AND outcome!='owed'",(binding.operation_id,)).fetchone()[0]
state=self.db.execute("SELECT state FROM operations WHERE operation=?",(binding.operation_id,)).fetchone()[0]
if pending or cleanup or state in {'failed','unknown','cleanup-confirmed'} or count>63:
raise Refused("call sequence is uncertain, closed or exhausted")
return count
def before_call(self, binding: Binding, ordinal: int, call_digest: str):
"""Persist one exact plan-call intent before the mediator issues it.
Caller must derive ordinal/digest from the independently pinned owner plan.
This primitive doesn't validate method/body or decide whether a call is allowed.
"""
if not self._held or self._admission != binding:
raise Refused("no live admission")
if type(ordinal) is not int or ordinal < 0 or ordinal > 63:
raise Refused("invalid fixed-plan ordinal")
if type(call_digest) is not str or not re.fullmatch(r"[0-9a-f]{64}", call_digest):
raise Refused("invalid call digest")
try:
self.db.execute("BEGIN IMMEDIATE")
count,pending = self.db.execute("SELECT COUNT(*), COALESCE(SUM(outcome='pending'),0) FROM calls WHERE operation=?",(binding.operation_id,)).fetchone()
state=self.db.execute("SELECT state FROM operations WHERE operation=?",(binding.operation_id,)).fetchone()[0]
cleanup_started = self.db.execute("SELECT COUNT(*) FROM cleanup WHERE operation=? AND outcome!='owed'", (binding.operation_id,)).fetchone()[0]
if cleanup_started or ordinal != count or pending or state in {'failed','unknown','cleanup-confirmed'}:
raise Refused("call is replayed, uncertain or terminal")
self.db.execute("INSERT INTO calls VALUES (?,?,?,'pending',NULL)",(binding.operation_id,ordinal,call_digest))
self.db.execute("COMMIT")
self._sync_directory()
except BaseException:
if self.db.in_transaction: self.db.execute("ROLLBACK")
raise
def after_call(self, binding: Binding, ordinal: int, call_digest: str, evidence_digest: str):
if not self._held or self._admission != binding:
raise Refused("no live admission")
if type(ordinal) is not int or ordinal < 0 or ordinal > 63:
raise Refused("invalid fixed-plan ordinal")
if type(call_digest) is not str or not re.fullmatch(r"[0-9a-f]{64}", call_digest):
raise Refused("invalid call digest")
if type(evidence_digest) is not str or not re.fullmatch(r"[0-9a-f]{64}", evidence_digest):
raise Refused("invalid evidence digest")
try:
self.db.execute("BEGIN IMMEDIATE")
n=self.db.execute("UPDATE calls SET outcome='complete', evidence=? WHERE operation=? AND ordinal=? AND call_digest=? AND outcome='pending'",(evidence_digest,binding.operation_id,ordinal,call_digest)).rowcount
if n != 1: raise Refused("call confirmation mismatch")
self.db.execute("COMMIT")
self._sync_directory()
except BaseException:
if self.db.in_transaction: self.db.execute("ROLLBACK")
raise
def cleanup_status(self, binding: Binding):
# Observation never restores live execution or cleanup authority.
if self.observe(binding) is None:
raise Refused("operation is not admitted")
rows = self.db.execute("SELECT ordinal,kind,call_digest,outcome,evidence FROM cleanup WHERE operation=? ORDER BY ordinal", (binding.operation_id,)).fetchall()
return {"confirmed": bool(rows) and all(row[3] == "confirmed" for row in rows),
"obligations": [{"ordinal": row[0], "kind": row[1], "call_digest": row[2],
"outcome": row[3], "evidence_digest": row[4]} for row in rows]}
def _cleanup_admission(self, binding, ordinal, obligation):
if not self._held or self._admission != binding or self._consumed_admission != binding:
raise Refused("cleanup requires same live consumed admission")
if type(ordinal) is not int or not 0 <= ordinal < 3 or type(obligation) is not Cleanup:
raise Refused("exact cleanup obligation required")
obligation.validate()
def before_cleanup(self, binding: Binding, ordinal: int, obligation: Cleanup):
"""Commit intent for one pre-admitted cleanup call, even after unknown.
Never resumes an interrupted invocation. Independent owner recovery after
a crash still requires the owner's protected recovery channel/approval.
Cleanup has no business deadline: its authority is the original separate
explicit obligation, and cannot authorize a read, repair or business write.
"""
self._cleanup_admission(binding, ordinal, obligation)
try:
self.db.execute("BEGIN IMMEDIATE")
changed = self.db.execute("UPDATE cleanup SET outcome='pending' WHERE operation=? AND ordinal=? AND kind=? AND call_digest=? AND outcome='owed'",
(binding.operation_id, ordinal, obligation.kind, obligation.call_digest)).rowcount
if changed != 1:
raise Refused("cleanup is unadmitted, replayed or uncertain")
self.db.execute("COMMIT")
self._sync_directory()
except BaseException:
if self.db.in_transaction: self.db.execute("ROLLBACK")
raise
def after_cleanup(self, binding: Binding, ordinal: int, obligation: Cleanup,
*, confirmed: bool, evidence_digest: str):
self._cleanup_admission(binding, ordinal, obligation)
if type(confirmed) is not bool or type(evidence_digest) is not str or not re.fullmatch(r"[0-9a-f]{64}", evidence_digest):
raise Refused("closed cleanup evidence required")
try:
self.db.execute("BEGIN IMMEDIATE")
if confirmed and self.db.execute("SELECT COUNT(*) FROM cleanup_calls WHERE operation=? AND obligation=? AND outcome='pending'", (binding.operation_id,ordinal)).fetchone()[0]:
raise Refused("pending cleanup native call cannot confirm")
changed = self.db.execute("UPDATE cleanup SET outcome=?, evidence=? WHERE operation=? AND ordinal=? AND kind=? AND call_digest=? AND outcome='pending'",
('confirmed' if confirmed else 'unconfirmed', evidence_digest,
binding.operation_id, ordinal, obligation.kind, obligation.call_digest)).rowcount
if changed != 1:
raise Refused("cleanup confirmation mismatch")
self.db.execute("COMMIT")
self._sync_directory()
except BaseException:
if self.db.in_transaction: self.db.execute("ROLLBACK")
raise
def before_cleanup_call(self, binding, ordinal, obligation, step, call_digest):
"""Journal each native revoke/delete AND its independent verification."""
self._cleanup_admission(binding,ordinal,obligation)
if type(step) is not int or not 0<=step<2 or type(call_digest) is not str or not re.fullmatch(r"[0-9a-f]{64}",call_digest):
raise Refused("exact bounded cleanup native call required")
try:
self.db.execute("BEGIN IMMEDIATE")
parent=self.db.execute("SELECT kind,call_digest,outcome FROM cleanup WHERE operation=? AND ordinal=?",(binding.operation_id,ordinal)).fetchone()
count,pending=self.db.execute("SELECT COUNT(*),COALESCE(SUM(outcome='pending'),0) FROM cleanup_calls WHERE operation=? AND obligation=?",(binding.operation_id,ordinal)).fetchone()
if parent!=(obligation.kind,obligation.call_digest,'pending') or step!=count or pending:
raise Refused("cleanup call unadmitted, replayed or uncertain")
self.db.execute("INSERT INTO cleanup_calls VALUES (?,?,?,?,'pending',NULL)",(binding.operation_id,ordinal,step,call_digest))
self.db.execute("COMMIT");self._sync_directory()
except BaseException:
if self.db.in_transaction:self.db.execute("ROLLBACK")
raise
def after_cleanup_call(self,binding,ordinal,obligation,step,call_digest,evidence_digest):
self._cleanup_admission(binding,ordinal,obligation)
if (type(step) is not int or not 0<=step<2 or type(call_digest) is not str or not re.fullmatch(r"[0-9a-f]{64}",call_digest)
or type(evidence_digest) is not str or not re.fullmatch(r"[0-9a-f]{64}",evidence_digest)):
raise Refused("exact cleanup native evidence required")
try:
self.db.execute("BEGIN IMMEDIATE")
parent=self.db.execute("SELECT kind,call_digest,outcome FROM cleanup WHERE operation=? AND ordinal=?",(binding.operation_id,ordinal)).fetchone()
if parent!=(obligation.kind,obligation.call_digest,'pending'):
raise Refused("cleanup parent is no longer pending")
changed=self.db.execute("UPDATE cleanup_calls SET outcome='complete',evidence=? WHERE operation=? AND obligation=? AND step=? AND call_digest=? AND outcome='pending'",
(evidence_digest,binding.operation_id,ordinal,step,call_digest)).rowcount
if changed!=1:raise Refused("cleanup native confirmation mismatch")
self.db.execute("COMMIT");self._sync_directory()
except BaseException:
if self.db.in_transaction:self.db.execute("ROLLBACK")
raise
def close(self):
if hasattr(self, "db"):
self.db.close()
if hasattr(self, "_lock_fd"):
os.close(self._lock_fd)

View file

@ -0,0 +1,300 @@
"""Fixed native metadata/grant/cleanup adapter for disposable loopback OpenBao.
This adapter deliberately admits only a disposable loopback fixture. It supplies
real native primitive coverage while production grant schema, identity/consume and
origin/source admission are unfinished. It cannot open an estate backend. Owner
credentials/handles stay in memory; no subprocess, redirects or ambient proxy.
Python privacy is not credential isolation; production needs the owner runtime.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime, timezone
import hashlib
import http.client
import json
import subprocess
import time
from pathlib import Path
from urllib.parse import urlsplit
from task_execution_cleanup import CleanupResult
from task_execution_journal import Binding, Cleanup, Journal, Refused
from task_execution_plan import Compiler, Plan, cleanup_specs, encoded, content_hash
@dataclass(frozen=True)
class PrivateSession:
token: str = field(repr=False)
accessor: str = field(repr=False)
@dataclass(frozen=True)
class FixtureAdmission:
"""Independently installed owner fixture inputs, not a request schema."""
descriptor: bytes
expected_before: bytes
expected_after: bytes
owner_token: str = field(repr=False)
identity_session: PrivateSession = field(repr=False)
server_process: subprocess.Popen = field(repr=False)
class NativeFixtureAdapter:
def __init__(self, compiler: Compiler, plan: Plan, journal: Journal,
binding: Binding, admission: FixtureAdmission):
if type(admission) is not FixtureAdmission or type(admission.descriptor) is not bytes:
raise Refused('independent fixture admission required')
self.compiler,self.plan,self.journal,self.binding=compiler,plan,journal,binding
self.admission=admission
self.meta=plan.metadata()
if self.meta.get('execution_grant',{}).get('status')!='approved':
raise Refused('independently source-bound synthetic approved grant required')
descriptor=json.loads(admission.descriptor)
fresh=compiler.bind_execution(compiler.compile(self.meta['request']),descriptor,admission.descriptor)
if fresh.serialized != plan.serialized or binding.plan_digest != plan.content_digest:
raise Refused('owner bound plan mismatch')
if binding.operation_id != descriptor['operation_id'] or binding.request_digest != self.meta['request']['request_digest']:
raise Refused('owner operation mismatch')
origin=urlsplit(descriptor['backend_origin'])
if origin.scheme!='http' or origin.hostname!='127.0.0.1' or not origin.port or origin.username or origin.password or origin.path or origin.query or origin.fragment:
raise Refused('disposable loopback backend only')
self.host,self.port=origin.hostname,origin.port
process=admission.server_process
if (type(process) is not subprocess.Popen or process.poll() is not None
or type(process.args) is not list or '-dev' not in process.args
or '-dev-no-store-token' not in process.args
or '-dev-listen-address=127.0.0.1:'+str(self.port) not in process.args):
raise Refused('live owner-spawned disposable dev process required')
budget=descriptor['backend_budget']
if set(budget)!={'ttl_seconds','uses'} or type(budget['ttl_seconds']) is not int or not 30<=budget['ttl_seconds']<=120 or type(budget['uses']) is not int or budget['uses']!=12:
raise Refused('fixed native task budget required')
self.ttl=budget['ttl_seconds']; self.uses=budget['uses']; self.used=0
self.deadline=datetime.fromisoformat(descriptor['operation_deadline'].replace('Z','+00:00'))
if self.deadline.tzinfo is None: raise Refused('aware operation deadline required')
remaining=(self.deadline-datetime.now(timezone.utc)).total_seconds()
self.monotonic_deadline=time.monotonic()+remaining
self.policy_name='task-exec-'+binding.operation_id
calls=[compiler.before_call(plan,i) for i in range(6)]
paths=sorted({call.path for call in calls})
self.hcl=''.join('path '+json.dumps(path)+' {\n capabilities = ["read", "create", "update"]\n}\n' for path in paths)
self.hcl+='path "auth/token/revoke-self" {\n capabilities = ["update"]\n}\n'
if self.meta['execution_grant']['openbao']['policy']!={'name':self.policy_name,'hcl':self.hcl}:
raise Refused('native grant body differs from bound owner projection')
# Installed owner snapshots use the existing descriptor commitments. No
# request-supplied expectation or response may authorize itself.
self.expected={}
for phase in ('before','after'):
raw=getattr(admission,'expected_'+phase)
if type(raw) is not bytes or 'sha256:'+content_hash(raw)!=descriptor['expected_'+phase]:
raise Refused('owner expected-state digest mismatch')
value=json.loads(raw)
if type(value) is not dict or set(value)!=set(paths) or encoded(value)!=raw:
raise Refused('canonical exact target snapshot required')
if any(item is not None and type(item) is not dict for item in value.values()):
raise Refused('metadata snapshot object or explicit absence required')
if phase=='after' and any(item is None for item in value.values()):
raise Refused('present final metadata required')
self.expected[phase]=value
self._observed={'before':{},'after':{}}
self.cleanup=tuple(Cleanup(item['kind'],item['call_digest']) for item in cleanup_specs(binding.operation_id))
if self.meta['cleanup'] != cleanup_specs(binding.operation_id): raise Refused('cleanup plan mismatch')
self.task=None; self._mint_attempted=False; self._policy_attempted=False; self._ordinal=0; self._next_metadata=0
self._cleanup_steps={item.kind:0 for item in self.cleanup}
def _live(self):
if not self.journal._held or self.journal._admission!=self.binding or self.journal._consumed_admission!=self.binding:
raise Refused('same live consumed owner admission required')
def _fresh(self):
self._live()
if min((self.deadline-datetime.now(timezone.utc)).total_seconds(),self.monotonic_deadline-time.monotonic())<=5:
raise Refused('business deadline exhausted')
# The constructor independently rebuilt the complete immutable Plan and
# grant. Recheck every pinned byte before each native effect. Metadata
# dispatch additionally reconstructs its exact ordinal/body below; grant
# creation reconstructs and compares its fixed body before issuance.
for path in self.compiler.pins:self.compiler.read(Path(path))
def _http(self, method, path, token, body=b''):
# Fixed caller-generated paths only; http.client has no proxy or redirect
# following. Never surface backend body/header or transport exception text.
if self.admission.server_process.poll() is not None:
raise Refused('disposable backend exited; outcome unconfirmed')
conn=http.client.HTTPConnection(self.host,self.port,timeout=2)
try:
conn.request(method,'/v1/'+path,body=body or None,
headers={'X-Vault-Token':token,'Content-Type':'application/json'})
response=conn.getresponse(); raw=response.read(1024*1024+1)
if len(raw)>1024*1024: raise Refused('oversize native response')
data=json.loads(raw) if raw else {}
if type(data) is not dict: raise Refused('invalid native response')
return response.status,data
except Exception:
raise Refused('native transport or response unconfirmed') from None
finally: conn.close()
def _business(self, method, path, token, payload=b'', task=False):
self._fresh()
if task:
if self.used>=self.uses-1: raise Refused('task cleanup use reserved')
self.used+=1
evidence_payload = encoded({'token':'owner-retained-task-token'}) if path=='auth/token/lookup' else payload
digest=content_hash(encoded({'method':method,'path':path,'payload_sha256':'sha256:'+content_hash(evidence_payload),'credential_slot':'task' if task else 'owner'}))
ordinal=self.journal.next_call_ordinal(self.binding)
self.journal.before_call(self.binding,ordinal,digest);self._ordinal=ordinal+1
try: status,data=self._http(method,path,token,payload)
except Exception:
state=self.journal.observe(self.binding)['state']
if state!='unknown': self.journal.transition(self.binding,state,'unknown')
raise
# Native responses may contain tokens/accessors; evidence is closed status
# metadata rather than raw bytes or a guessable secret-value fingerprint.
evidence=content_hash(encoded({'call_digest':digest,'http_status':status}))
self.journal.after_call(self.binding,ordinal,digest,evidence)
return status,data
@staticmethod
def _ok(status,data,allowed):
if status not in allowed or data.get('errors'):
raise Refused('native request refused or unverified')
def materialize_grant(self):
self._fresh()
if self.journal.observe(self.binding)['state']!='grant-issuing' or self._policy_attempted:
raise Refused('fresh grant issuing state required')
if min((self.deadline-datetime.now(timezone.utc)).total_seconds(),self.monotonic_deadline-time.monotonic())<self.ttl+5:
raise Refused('insufficient lifetime for exact fixed task TTL')
root=self.admission.owner_token; path='sys/policies/acl/'+self.policy_name
status,data=self._business('GET',path,root)
if status!=404 or data.get('data'):
raise Refused('per-operation grant policy must be absent')
self._policy_attempted=True
status,data=self._business('PUT',path,root,encoded({'policy':self.hcl}));self._ok(status,data,{200,204})
status,data=self._business('GET',path,root);self._ok(status,data,{200})
if data.get('data',{}).get('policy','').rstrip()!=self.hcl.rstrip(): raise Refused('grant policy readback mismatch')
body={'policies':[self.policy_name],'no_default_policy':True,'renewable':False,
'ttl':str(self.ttl)+'s','explicit_max_ttl':str(self.ttl)+'s','num_uses':self.uses,
'type':'service','display_name':'task-exec-'+self.binding.operation_id,'no_parent':False}
if body!=self.meta['execution_grant']['openbao']['token_create_body']:
raise Refused('native token body differs from bound owner projection')
self._mint_attempted=True
status,data=self._business('POST','auth/token/create',root,encoded(body));self._ok(status,data,{200})
auth=data.get('auth',{})
if type(auth.get('client_token')) is not str or not auth['client_token'] or type(auth.get('accessor')) is not str or not auth['accessor']:
raise Refused('native token issue uncertain')
# Retain handles before validation so finally cleanup can revoke even a
# malformed overbroad issue response. Never return token-bearing metadata.
self.task=PrivateSession(auth['client_token'],auth['accessor'])
status,data=self._business('POST','auth/token/lookup',root,encoded({'token':self.task.token}));self._ok(status,data,{200})
info=data.get('data',{})
if (info.get('policies')!=[self.policy_name] or info.get('identity_policies',[]) or info.get('renewable') is not False
or info.get('num_uses')!=self.uses or not 0<info.get('ttl',0)<=self.ttl
or info.get('explicit_max_ttl')!=self.ttl or info.get('orphan') is not False):
raise Refused('native task token bounds mismatch')
self.journal.transition(self.binding,'grant-issuing','grant-issued')
return {'issued':True,'policy_name':self.policy_name,'ttl_ceiling_seconds':self.ttl,'use_ceiling':self.uses}
def metadata_call(self, ordinal):
self._fresh()
if self.task is None or self.journal.observe(self.binding)['state']!='applying' or type(ordinal) is not int or not 0<=ordinal<6 or ordinal!=self._next_metadata:
raise Refused('ordered fixed metadata call required')
call=self.compiler.before_call(self.plan,ordinal)
status,data=self._business(call.method,call.path,self.task.token,call.payload,task=True)
self._next_metadata+=1
try:
self._ok(status,data,{200,204,404} if ordinal in {0,3} else {200,204})
if ordinal in {0,2,3,5}:
phase='before' if ordinal in {0,3} else 'after'
value=self._metadata_snapshot(call.path,status,data)
if encoded(value)!=encoded(self.expected[phase][call.path]):
raise Refused('native exact expected-state mismatch')
self._observed[phase][call.path]=value
except Exception:
state=self.journal.observe(self.binding)['state']
if state!='unknown': self.journal.transition(self.binding,state,'unknown')
raise Refused('native baseline or readback unconfirmed') from None
return status,data
@staticmethod
def _metadata_snapshot(path,status,response):
# HTTP errors/denials are never absence. OpenBao's exact empty 404
# metadata response is the only absent representation admitted here.
if status==404 and response=={'errors':[]}:
return None
if status!=200 or response.get('errors') or type(response.get('data')) is not dict:
raise Refused('metadata read not confirmed')
value=dict(response['data'])
if path.startswith('sys/policies/acl/'):
if set(value)!={'cas_required','modified','name','policy','version'}:
raise Refused('unrecognized policy metadata fields')
modified=value.pop('modified')
if type(modified) is not str or datetime.fromisoformat(modified.replace('Z','+00:00')).tzinfo is None:
raise Refused('invalid policy modification timestamp')
# All role fields, aliases, defaults and unknown additions participate in
# exact canonical JSON comparison. True never equals integer 1 here.
return value
def verify_metadata(self):
self._fresh()
if self._next_metadata!=6 or self.journal.observe(self.binding)['state']!='applying':
raise Refused('complete same-live metadata sequence required')
for phase in ('before','after'):
if encoded(self._observed[phase])!=encoded(self.expected[phase]):
raise Refused('complete expected metadata evidence required')
evidence=content_hash(encoded({'expected_before':'sha256:'+content_hash(self.admission.expected_before),
'expected_after':'sha256:'+content_hash(self.admission.expected_after),
'scope':'per-target-sequential-readback-no-native-cas'}))
self.journal.transition(self.binding,'applying','readback',evidence)
self.journal.transition(self.binding,'readback','verified',evidence)
return evidence
def _cleanup_admitted(self, kind, digest):
self._live()
expected=next(item for item in self.cleanup if item.kind==kind)
status=self.journal.cleanup_status(self.binding)
actual=next(item for item in status['obligations'] if item['kind']==kind)
if digest!=expected.call_digest or actual['call_digest']!=digest or actual['outcome']!='pending':
raise Refused('exact pending cleanup obligation required')
def _cleanup_http(self,kind,method,path,token,body=b''):
ordinal=next(i for i,item in enumerate(self.cleanup) if item.kind==kind)
obligation=self.cleanup[ordinal];step=self._cleanup_steps[kind]
spec=cleanup_specs(self.binding.operation_id)[ordinal]
call_index=step + (1 if kind=='grant-policy-delete' and not self._policy_attempted else 0)
if call_index>=len(spec['calls']) or spec['calls'][call_index]!={'method':method,'path':path}:
raise Refused('cleanup native method/target differs from commitment')
# Commit shape/slot only: private accessor/token bytes never enter hashes
# or durable evidence. Their exact identity is owner-retained custody.
digest=content_hash(encoded({'obligation_digest':obligation.call_digest,'step':step,
'method':method,'path':path}))
self.journal.before_cleanup_call(self.binding,ordinal,obligation,step,digest)
self._cleanup_steps[kind]+=1
status,data=self._http(method,path,token,body)
evidence=content_hash(encoded({'call_digest':digest,'http_status':status}))
self.journal.after_cleanup_call(self.binding,ordinal,obligation,step,digest,evidence)
return status,data
def _revoke(self, kind, digest, session, issue_uncertain=False):
self._cleanup_admitted(kind,digest)
if session is None:
return CleanupResult(not issue_uncertain,content_hash(encoded({'kind':kind,'issue_uncertain':issue_uncertain})))
status,data=self._cleanup_http(kind,'POST','auth/token/revoke-self',session.token)
self._ok(status,data,{200,204})
status,data=self._cleanup_http(kind,'POST','auth/token/lookup-accessor',self.admission.owner_token,encoded({'accessor':session.accessor}))
confirmed=status==400 and data.get('errors') in (['invalid accessor'],['1 error occurred:\n\t* invalid accessor\n\n'])
return CleanupResult(confirmed,content_hash(encoded({'kind':kind,'revocation_confirmed':confirmed})))
def revoke_task_token(self, digest):
return self._revoke('task-token-revoke',digest,self.task,self._mint_attempted and self.task is None)
def revoke_identity_token(self, digest):
return self._revoke('identity-token-revoke',digest,self.admission.identity_session)
def delete_grant_policy(self, digest):
self._cleanup_admitted('grant-policy-delete',digest)
path='sys/policies/acl/'+self.policy_name
if self._policy_attempted:
status,data=self._cleanup_http('grant-policy-delete','DELETE',path,self.admission.owner_token);self._ok(status,data,{200,204})
status,data=self._cleanup_http('grant-policy-delete','GET',path,self.admission.owner_token)
confirmed=status==404 and not data.get('data')
return CleanupResult(confirmed,content_hash(encoded({'kind':'grant-policy-delete','absence_confirmed':confirmed})))

View file

@ -0,0 +1,7 @@
{
"damaged_still_string_accessor_accepted_by_R1": true,
"extra_cleanup_key_accepted_by_R1": true,
"unhashable_phase_result": "TypeError",
"broad_rstrip_accepts_tab_change": true,
"scope": "Synthetic offline R1 reader counterexamples only; no backend or credentials."
}

View file

@ -0,0 +1,57 @@
{
"schema": "railiance-platform.protected-recovery-validation.v1",
"patch_sha256": "47812816af915534d6aec9431259f4290fb9b109a4fb47a54ed7dbd3b145c615",
"scope": "Only synthetic disposable loopback OpenBao, local owner-private fixture custody and local source checks; no estate backend, real credentials, production grant, live rollout or publication.",
"runs": [
{
"name": "initial setup",
"result": "8 errors",
"reason": "Missing SECRETS_ENGINE_SOURCE_ROOT for isolated checkout; no native fixture effects reached. Fixed runner environment, no implementation relaxation."
},
{
"name": "initial process-loss run",
"result": "7 passed, 1 assertion failure",
"reason": "OpenBao per-request UUID differs between reads. Compared stable metadata data/status instead; source recovery behavior unchanged."
},
{
"name": "broader recovery/native/journal/readback regression",
"result": "48 passed",
"seconds": 393.9,
"command": "SECRETS_ENGINE_SOURCE_ROOT=/home/worsch/secrets-engine PYTHONPATH=/home/worsch/secrets-engine/src:/home/worsch/approval-engine pytest -q tests/test_task_execution_recovery.py tests/test_task_execution_native_fixture.py tests/test_task_execution_journal.py tests/test_task_execution_readback.py",
"limit": "Started before the final recovery-pending observation addition and ninth recovery test; final frozen journal/module/test delta is covered by the final24 run below."
},
{
"name": "reopened journal process-loss subset",
"result": "3 passed, 5 deselected",
"seconds": 54.42,
"command": "same source environment; pytest -q tests/test_task_execution_recovery.py -k independent_cleanup_never_resumes_business"
},
{
"name": "final frozen recovery/journal suite",
"result": "24 passed",
"seconds": 159.31,
"command": "SECRETS_ENGINE_SOURCE_ROOT=/home/worsch/secrets-engine PYTHONPATH=/home/worsch/secrets-engine/src:/home/worsch/approval-engine pytest -q tests/test_task_execution_recovery.py tests/test_task_execution_journal.py",
"covers": [
"actual executor os.fork/os._exit at issued, business write/lost response, revoke/lost response",
"reopened public Journal plus separate independently retained private custody",
"native actual accessor invalidation and exact grant-policy absence",
"no mint/consume/business calls in recovery transport trace",
"original approval and recovery permit replay refusals",
"lost mint response without accessor remains unresolved debt",
"revoked/expired/stale/private-drift and public-flag refusals",
"revoked backend credential cannot fake cleanup success; fresh independent owner attempt recovers",
"same-name different policy never deleted",
"private file permissions and binding refusal",
"lost recovery response remains unknown across subsequent independently confirmed cleanup"
]
}
],
"checks": {
"canonical_git_apply_check": "passed",
"isolated_git_diff_check": "passed",
"source_py_compile": "passed",
"frozen_patch_and_source_after_hashes": "passed"
},
"repeat_counts": "Runs overlap; counts are not additive unique tests.",
"authority_granted": false
}

View file

@ -0,0 +1,45 @@
{
"schema": "railiance-platform.protected-recovery-validation.v2",
"same_trial_as": "validation-r1.json",
"full_patch_sha256": "61f516c876fd612b83300e9913f0e80aad836a50014310797913c7c4505e3114",
"delta_patch_sha256": "6f97bbaa957673a3b28070d1550ffe72e709a573be183062de21e7f7145e8380",
"preserved_counterexamples": "r1-refusal-counterexamples.json",
"runs": [
{
"name": "fast nested schema and normalization cases",
"result": "26 passed, 11 deselected",
"seconds": 0.54
},
{
"name": "recovery and journal successor regression",
"result": "53 passed",
"seconds": 168.51,
"command": "SECRETS_ENGINE_SOURCE_ROOT=/home/worsch/secrets-engine PYTHONPATH=/home/worsch/secrets-engine/src:/home/worsch/approval-engine pytest -q tests/test_task_execution_recovery.py tests/test_task_execution_journal.py",
"limit": "Started before final strict native duplicate/nonfinite JSON decode refinement and expanded malformed-body byte fixtures; final frozen29cases below verify that delta including real native cleanup."
},
{
"name": "final frozen capsule/normalization/native HTTP absence/replay cases",
"result": "29 passed, 9 deselected",
"seconds": 58.81,
"command": "same source environment; pytest -q tests/test_task_execution_recovery.py -k \"capsule or terminal or absence\"",
"includes": [
"MAC damage and wrong owner key refusal",
"24 authenticated malformed nested schema and typed-value cases",
"duplicate/nonfinite JSON, malformed MAC and invalid framing refusal",
"single optional terminal LF comparison, all other whitespace refused",
"both initial GET and post-DELETE GET misleading404 bodies, duplicate keys, nonfinite response and malformed bytes refuse",
"real owner-issued native cleanup succeeds after failed synthetic HTTP attempts",
"authenticated prepared capsule rollback cannot hide actual token issuance"
]
}
],
"checks": {
"full_patch_canonical_apply": "passed",
"delta_patch_frozen_R1_apply": "passed",
"diff_check": "passed",
"py_compile": "passed",
"all_frozen_after_hashes": "passed"
},
"counts": "Runs overlap; do not add counts as unique tests.",
"authority": "Synthetic disposable loopback fixtures only. No production custody/key selection, credential retrieval, live scope grant or canonical Platform mutation."
}

View file

@ -0,0 +1,21 @@
# Native budget and exact source slice
`budget-source-slice-r1.patch` is independently reviewable against canonical
Platform `b9eaa20` (frozen recovery R2 integrated). Its five files tighten absolute
native/cleanup budgets, actual credential expiry and actual-v2 descriptor source
bytes.17final frozen tests pass, including a real disposable native positive.
Exact preimages/hashes, source-only owner review and run history are retained.
This slice does **not** establish actual v2 full-stack ALLOW/native acceptance.
`retained-registry-failure-r1.json` preserves the concrete500source correspondence
failure beforeCAS/native; `temporal-contract-r1.json` preserves the original
15s/30s+5s timing contradiction and agreed bounded temporal source successor.
No registry/policy/digest workaround, fixture backdating or TTL extension is used.
`followup-draft/` retains the unfinished actual-provider fixture and tests as
source preparation, excluded from the integration patch. Access/Secrets owners
must supply authoritative normalized preapproval and immediateCAS precision joins;
then the actual builder→source→strictprepare→consume/GET→sixnativecalls/readback/
cleanup positive and refusal cases must be completed under CUST-WP-0080/T12.
The legacy ec215 proof, signer proposal and recovery R2 remain preserved.
No canonical Platform mutation, real credential, live grant or publication.

View file

@ -0,0 +1,12 @@
{
"reviewer": "railiance-platform-owner (agent railiance-platform, codex)",
"decision": "approved",
"scope": "Independent budget/source/credential-expiry source slice only. No actual-v2 whole-stack/native admission approval.",
"comment": "Platform INTENT principles3 Durable by Default,4 Secure Custody and5 Operable and Observable require bounded effects and verifiable owner source. Under RPF-WP-0035-T12 reviewed patch sha256:7c17f607dacc9ed4f24fd8032ddac5f4fc9ae2f24555cf87a0b73c871ec8eb19 against exact canonical b9eaa20 preimages, in isolated owner checkout with owner AGENTS/INTENT loaded. Absolute native method/transport/cleanup bounds and effective actual credential expiry narrow existing authority; source bundle checks bind installed descriptor paths/hash bytes before authorization/effects. No global deadline/TTL is extended, no credential renewed/reminted, no generic service or broker is introduced. Native policy absence/JSON use reviewed R2 strict helpers. Only Platform role reviewed.",
"validation": "17 final frozen tests passed including real local native backend and absolute header/body/close/compile failures; failed actual provider correspondence remains explicitly open.",
"native_gates": [
"Dedicated production owner process/time/runtime enforcement, trusted clock, recovery/custody and exact role/actor admission remain unadmitted.",
"Actual retained registry + source-normalized preapproval + precise immediateCAS full-stack proof is still required; unfinished fixture draft is excluded from this patch."
],
"authority_granted": false
}

View file

@ -0,0 +1,545 @@
diff --git a/docs/task-execution-native-fixture.md b/docs/task-execution-native-fixture.md
index f4ff070..c4292db 100644
--- a/docs/task-execution-native-fixture.md
+++ b/docs/task-execution-native-fixture.md
@@ -205,3 +205,56 @@ changes during its single read. A valid older prepared capsule still cannot
prove a task token absent when durable journal state/calls reached issuance:
that remains unknown cleanup debt. Authentication alone is not anti-rollback.
No business or consume admission is restored and no production scope is granted.
+
+
+## Absolute budgets, effective credential expiry and exact source bytes
+
+This source successor tightens the disposable native owner adapter; it does not
+establish actual v2 whole-stack ALLOW/native acceptance. The retained-registry
+Approval correspondence failure is preserved in the coordinator artifact. The
+new real provider fixture is retained separately as unfinished follow-up, while
+the existing ec215-based fixture evidence remains its original scoped proof.
+
+Every native business/grant method now runs inside the earlier wall/monotonic
+operation deadline minus the five-second reserve. After exact owner token lookup,
+the adapter requires aware expire_time and retains the earlier actual expiry and
+conservative mint-start + declared TTL bound, in wall and monotonic coordinates.
+Every subsequent business call uses the minimum of global and credential bounds.
+The descriptor and signed global deadline are never extended or rewritten.
+Missing/expired credential evidence refuses issuance acceptance. Cleanup continues
+under its original separate obligation after either business bound expires.
+
+The fixture owner main-thread execution profile uses absolute SIGALRM budgets,
+not socket inactivity timeouts alone. Each native transport and cleanup native
+call has a two-second execution deadline, including intent/source checking and
+send/header/body/close work. Larger compiler reconstruction remains within the
+earlier operation/credential window. A 50ms alarm backstop keeps blocked teardown
+interruptible after timeout; it cannot authorize another business effect. Nested
+timers retain the shorter outer deadline rather than refreshing it. Unsupported
+threads, invalid budgets and conflicting ambient timers refuse. This fixed Unix
+fixture profile does not select a production process supervisor or provide a
+shared arbitrary-thread execution service. Native rollout still needs exact owner
+runtime/admission, trusted clock and independent recovery/custody enforcement.
+
+The actual v2 package/version selects additional mediator checks over the exact
+independently installed descriptor source_bundle before authorization and each
+freshness gate, including pre-native grant entry. It reads bounded regular,
+files without group/world write, non-indirect paths, compares exact source hashes and stat stability,
+and applies an absolute read budget. The existing compiler's own pin set does not
+implicitly authenticate other source_bundle roles; these explicit checks fill
+that gap. No requester-selected path, approval flag or source boolean can enable
+this path. Legacy synthetic fixture proof remains separately scoped and is not
+used to satisfy v2 acceptance.
+
+Native policy/grant absence now requires exact empty-errors404 and policy HCL
+comparison admits only the already reviewed single optional terminal LF, reusing
+frozen recovery R2 helpers. Native response JSON rejects duplicate/nonfinite data.
+
+The independent slice tests cover actual header/body drips, stalled close after
+timeout, slow compiler and shorter actual credential expiry before any HTTP
+effect, nested/conflicting/foreign-thread budgets, private source hash drift,
+unsafe modes/indirection/FIFO/oversize refusal, and bounded cleanup after expiry.
+Actual native fixed grant/metadata/readback/cleanup remains a separate positive
+compatibility check. There is no successful actual normalized provider-chain
+claim in this slice; source-managed pre-approval normalization and immediate CAS
+precision must pass the follow-up full-stack proof first.
diff --git a/scripts/task_execution_mediator.py b/scripts/task_execution_mediator.py
index 6614974..ec9d235 100644
--- a/scripts/task_execution_mediator.py
+++ b/scripts/task_execution_mediator.py
@@ -10,13 +10,15 @@ from dataclasses import dataclass
from datetime import datetime,timezone
import importlib
import json
+import os
+import stat
from pathlib import Path
from urllib.parse import urlsplit
from task_execution_cleanup import run_cleanup
from task_execution_journal import Binding,Cleanup,Journal,Refused
from task_execution_plan import Compiler,encoded,content_hash
-from task_execution_native_fixture import FixtureAdmission,NativeFixtureAdapter
+from task_execution_native_fixture import FixtureAdmission,NativeFixtureAdapter,_absolute_budget
@dataclass(frozen=True)
@@ -43,7 +45,36 @@ def _strict_dependency():
raise Refused('strict Secrets task execution handoff dependency unavailable') from None
+def _trusted_v2(cfg):
+ return (getattr(cfg,'authorization_policy_package',None)=='secrets-engine.task-execution.service-pilot'
+ and getattr(cfg,'authorization_policy_version',None)=='v2')
+
+
+def _read_source_bundle(descriptor):
+ # Paths/hash expectations come from independently installed owner_descriptor,
+ # never a requester-selected file lookup. No bytes or raw OS errors escape.
+ for item in descriptor['source_bundle'].values():
+ path=Path(item['path']);fd=None
+ try:
+ if not path.is_absolute() or path.resolve()!=path:raise ValueError()
+ fd=os.open(path,os.O_RDONLY|os.O_NOFOLLOW|os.O_NONBLOCK)
+ before=os.fstat(fd)
+ if not stat.S_ISREG(before.st_mode) or before.st_nlink!=1 or before.st_mode&0o022 or not 0<before.st_size<=1048576:raise ValueError()
+ with os.fdopen(fd,'rb',closefd=False) as stream:raw=stream.read(1048577)
+ after=os.fstat(fd)
+ identity=lambda value:(value.st_dev,value.st_ino,value.st_size,value.st_mtime_ns,value.st_ctime_ns,value.st_mode,value.st_nlink)
+ if identity(before)!=identity(after) or len(raw)!=before.st_size or 'sha256:'+content_hash(raw)!=item['sha256']:raise ValueError()
+ except Exception:raise Refused('exact owner execution source bundle refused') from None
+ finally:
+ if fd is not None:os.close(fd)
+
+
+def _source_bundle(descriptor):
+ with _absolute_budget(2):_read_source_bundle(descriptor)
+
+
def _fresh(cfg,handoff,descriptor):
+ if _trusted_v2(cfg):_source_bundle(descriptor)
from secrets_engine.application_time import read_window,validity_bounds
lower,upper=validity_bounds(window=read_window(cfg))
try:
@@ -79,8 +110,9 @@ def run_native_fixture_task(*,compiler:Compiler,journal:Journal,plan_request:dic
Strict authorization is executed here through the actual Secrets-owned module;
an inbound TaskExecutionAuthorization or arbitrary callable is never accepted.
This function returns closed metadata, not tokens or raw approval/decision data.
- Positive execution requires the module's real signed PDP path; actual unadmitted
- Access policy must still deny. Fixture policy/identity admission is independent
+ Positive execution requires the module's real signed PDP path; the retained v1
+ Access policy stays DENY-only; actual v2 successor source is separately tested
+ through its authenticated caller/fresh-source path without runtime admission. Fixture policy/identity admission is independent
synthetic setup and cannot become production acceptance.
"""
strict=_strict_dependency()
@@ -99,6 +131,7 @@ def run_native_fixture_task(*,compiler:Compiler,journal:Journal,plan_request:dic
raise ValueError()
except Exception:
raise Refused('exact owner descriptor or provider route refused') from None
+ if _trusted_v2(secrets_cfg):_source_bundle(descriptor)
# An independently pinned owner catalogue must match every engine field. Its
# approval IDs are source references, never runtime authority.
proposal=compiler.proposal()
@@ -163,6 +196,7 @@ def run_native_fixture_task(*,compiler:Compiler,journal:Journal,plan_request:dic
# Fixture identity is independently seeded synthetic setup, not CCR38
# provenance proof or a production service login.
journal.transition(binding,'authenticating','grant-issuing')
+ _fresh(secrets_cfg,handoff,descriptor)
native.materialize_grant()
journal.transition(binding,'grant-issued','applying')
for ordinal in range(6):
diff --git a/scripts/task_execution_native_fixture.py b/scripts/task_execution_native_fixture.py
index 31a822b..331a2d5 100644
--- a/scripts/task_execution_native_fixture.py
+++ b/scripts/task_execution_native_fixture.py
@@ -9,7 +9,12 @@ Python privacy is not credential isolation; production needs the owner runtime.
"""
from __future__ import annotations
from dataclasses import dataclass, field
-from datetime import datetime, timezone
+from contextlib import contextmanager
+from functools import wraps
+import signal
+import math
+import threading
+from datetime import datetime, timezone, timedelta
import hashlib
import http.client
import json
@@ -19,10 +24,63 @@ from pathlib import Path
from urllib.parse import urlsplit
from task_execution_cleanup import CleanupResult
+from task_execution_recovery import _same_policy,_policy_absent,_json_unique,_invalid_constant
from task_execution_journal import Binding, Cleanup, Journal, Refused
from task_execution_plan import Compiler, Plan, cleanup_specs, encoded, content_hash
+def _budget_expired(_signum, _frame):
+ # Keep blocked teardown interruptible after the first execution timeout.
+ # This is unwind-only grace, never a refreshed native-effect budget.
+ signal.setitimer(signal.ITIMER_REAL,.05)
+ raise Refused('absolute native call budget exhausted')
+
+
+@contextmanager
+def _absolute_budget(seconds):
+ """Disposable owner-child bound; refuse unsupported/conflicting execution.
+
+ SIGALRM interrupts slow source reads/compilation and HTTP header/body drips.
+ Nested bounds preserve the shorter outer deadline; no timer refresh extends
+ authority. This fixture assumes its owner main-thread process, not a shared
+ arbitrary-thread production runtime.
+ """
+ if (threading.current_thread() is not threading.main_thread() or type(seconds) not in {int,float}
+ or not math.isfinite(seconds) or seconds<=0):
+ raise Refused('bounded owner main-thread execution required')
+ previous=signal.getsignal(signal.SIGALRM)
+ old,interval=signal.getitimer(signal.ITIMER_REAL)
+ if interval or (old>0 and previous is not _budget_expired):
+ raise Refused('conflicting owner execution timer')
+ started=time.monotonic()
+ try:
+ signal.signal(signal.SIGALRM,_budget_expired)
+ signal.setitimer(signal.ITIMER_REAL,min(seconds,old) if old>0 else seconds)
+ yield
+ finally:
+ signal.setitimer(signal.ITIMER_REAL,0)
+ elapsed=time.monotonic()-started
+ signal.signal(signal.SIGALRM,previous)
+ if old>0:
+ if old<=elapsed:raise Refused('outer native authority budget exhausted')
+ signal.setitimer(signal.ITIMER_REAL,old-elapsed)
+
+
+def _business_window(method):
+ @wraps(method)
+ def bounded(self,*args,**kwargs):
+ remaining=self._remaining_business()-5
+ with _absolute_budget(remaining):return method(self,*args,**kwargs)
+ return bounded
+
+
+def _call_window(method):
+ @wraps(method)
+ def bounded(self,*args,**kwargs):
+ with _absolute_budget(2):return method(self,*args,**kwargs)
+ return bounded
+
+
@dataclass(frozen=True)
class PrivateSession:
token: str = field(repr=False)
@@ -101,6 +159,7 @@ class NativeFixtureAdapter:
if self.meta['cleanup'] != cleanup_specs(binding.operation_id): raise Refused('cleanup plan mismatch')
self.task=None; self._mint_attempted=False; self._policy_attempted=False; self._ordinal=0; self._next_metadata=0
self._cleanup_steps={item.kind:0 for item in self.cleanup}
+ self._task_wall_deadline=None;self._task_monotonic_deadline=None
self.recovery_custody=recovery_custody
if recovery_custody is not None:
from task_execution_recovery import FixtureRecoveryCustody
@@ -112,9 +171,17 @@ class NativeFixtureAdapter:
if not self.journal._held or self.journal._admission!=self.binding or self.journal._consumed_admission!=self.binding:
raise Refused('same live consumed owner admission required')
+ def _remaining_business(self):
+ bounds=[(self.deadline-datetime.now(timezone.utc)).total_seconds(),self.monotonic_deadline-time.monotonic()]
+ task_wall=getattr(self,'_task_wall_deadline',None)
+ task_mono=getattr(self,'_task_monotonic_deadline',None)
+ if task_wall is not None:bounds.append((task_wall-datetime.now(timezone.utc)).total_seconds())
+ if task_mono is not None:bounds.append(task_mono-time.monotonic())
+ return min(bounds)
+
def _fresh(self):
self._live()
- if min((self.deadline-datetime.now(timezone.utc)).total_seconds(),self.monotonic_deadline-time.monotonic())<=5:
+ if self._remaining_business()<=5:
raise Refused('business deadline exhausted')
# The constructor independently rebuilt the complete immutable Plan and
# grant. Recheck every pinned byte before each native effect. Metadata
@@ -122,6 +189,7 @@ class NativeFixtureAdapter:
# creation reconstructs and compares its fixed body before issuance.
for path in self.compiler.pins:self.compiler.read(Path(path))
+ @_call_window
def _http(self, method, path, token, body=b''):
# Fixed caller-generated paths only; http.client has no proxy or redirect
# following. Never surface backend body/header or transport exception text.
@@ -133,13 +201,15 @@ class NativeFixtureAdapter:
headers={'X-Vault-Token':token,'Content-Type':'application/json'})
response=conn.getresponse(); raw=response.read(1024*1024+1)
if len(raw)>1024*1024: raise Refused('oversize native response')
- data=json.loads(raw) if raw else {}
+ data=json.loads(raw,object_pairs_hook=_json_unique,parse_constant=_invalid_constant) if raw else {}
if type(data) is not dict: raise Refused('invalid native response')
return response.status,data
except Exception:
raise Refused('native transport or response unconfirmed') from None
finally: conn.close()
+ @_business_window
+ @_call_window
def _business(self, method, path, token, payload=b'', task=False):
self._fresh()
if task:
@@ -165,6 +235,7 @@ class NativeFixtureAdapter:
if status not in allowed or data.get('errors'):
raise Refused('native request refused or unverified')
+ @_business_window
def materialize_grant(self):
self._fresh()
if self.journal.observe(self.binding)['state']!='grant-issuing' or self._policy_attempted:
@@ -173,12 +244,12 @@ class NativeFixtureAdapter:
raise Refused('insufficient lifetime for exact fixed task TTL')
root=self.admission.owner_token; path='sys/policies/acl/'+self.policy_name
status,data=self._business('GET',path,root)
- if status!=404 or data.get('data'):
+ if not _policy_absent(status,data):
raise Refused('per-operation grant policy must be absent')
self._policy_attempted=True
status,data=self._business('PUT',path,root,encoded({'policy':self.hcl}));self._ok(status,data,{200,204})
status,data=self._business('GET',path,root);self._ok(status,data,{200})
- if data.get('data',{}).get('policy','').rstrip()!=self.hcl.rstrip(): raise Refused('grant policy readback mismatch')
+ if not _same_policy(data.get('data',{}).get('policy'),self.hcl): raise Refused('grant policy readback mismatch')
body={'policies':[self.policy_name],'no_default_policy':True,'renewable':False,
'ttl':str(self.ttl)+'s','explicit_max_ttl':str(self.ttl)+'s','num_uses':self.uses,
'type':'service','display_name':'task-exec-'+self.binding.operation_id,'no_parent':False}
@@ -187,6 +258,7 @@ class NativeFixtureAdapter:
if self.recovery_custody is not None:
self.recovery_custody.checkpoint(self.binding,'mint-intent')
self._mint_attempted=True
+ minted_wall=datetime.now(timezone.utc);minted_mono=time.monotonic()
status,data=self._business('POST','auth/token/create',root,encoded(body));self._ok(status,data,{200})
auth=data.get('auth',{})
if type(auth.get('client_token')) is not str or not auth['client_token'] or type(auth.get('accessor')) is not str or not auth['accessor']:
@@ -202,9 +274,19 @@ class NativeFixtureAdapter:
or info.get('num_uses')!=self.uses or not 0<info.get('ttl',0)<=self.ttl
or info.get('explicit_max_ttl')!=self.ttl or info.get('orphan') is not False):
raise Refused('native task token bounds mismatch')
+ try:
+ expiry=datetime.fromisoformat(info['expire_time'].replace('Z','+00:00'))
+ if expiry.tzinfo is None or expiry.utcoffset() is None:raise ValueError()
+ self._task_wall_deadline=min(expiry,minted_wall+timedelta(seconds=self.ttl))
+ self._task_monotonic_deadline=min(minted_mono+self.ttl,
+ time.monotonic()+(self._task_wall_deadline-datetime.now(timezone.utc)).total_seconds())
+ if self._remaining_business()<=5:raise ValueError()
+ except (KeyError,TypeError,ValueError,AttributeError):
+ raise Refused('native task expiry unconfirmed or exhausted') from None
self.journal.transition(self.binding,'grant-issuing','grant-issued')
return {'issued':True,'policy_name':self.policy_name,'ttl_ceiling_seconds':self.ttl,'use_ceiling':self.uses}
+ @_business_window
def metadata_call(self, ordinal):
self._fresh()
if self.task is None or self.journal.observe(self.binding)['state']!='applying' or type(ordinal) is not int or not 0<=ordinal<6 or ordinal!=self._next_metadata:
@@ -245,6 +327,7 @@ class NativeFixtureAdapter:
# exact canonical JSON comparison. True never equals integer 1 here.
return value
+ @_business_window
def verify_metadata(self):
self._fresh()
if self._next_metadata!=6 or self.journal.observe(self.binding)['state']!='applying':
@@ -267,6 +350,7 @@ class NativeFixtureAdapter:
if digest!=expected.call_digest or actual['call_digest']!=digest or actual['outcome']!='pending':
raise Refused('exact pending cleanup obligation required')
+ @_call_window
def _cleanup_http(self,kind,method,path,token,body=b''):
ordinal=next(i for i,item in enumerate(self.cleanup) if item.kind==kind)
obligation=self.cleanup[ordinal];step=self._cleanup_steps[kind]
@@ -307,5 +391,5 @@ class NativeFixtureAdapter:
if self._policy_attempted:
status,data=self._cleanup_http('grant-policy-delete','DELETE',path,self.admission.owner_token);self._ok(status,data,{200,204})
status,data=self._cleanup_http('grant-policy-delete','GET',path,self.admission.owner_token)
- confirmed=status==404 and not data.get('data')
+ confirmed=_policy_absent(status,data)
return CleanupResult(confirmed,content_hash(encoded({'kind':'grant-policy-delete','absence_confirmed':confirmed})))
diff --git a/tests/test_task_execution_native_budget.py b/tests/test_task_execution_native_budget.py
new file mode 100644
index 0000000..75cc126
--- /dev/null
+++ b/tests/test_task_execution_native_budget.py
@@ -0,0 +1,167 @@
+"""Absolute native budgets/source pins; no actual-v2 positive admission claim."""
+from datetime import datetime,timedelta,timezone
+from pathlib import Path
+import signal
+import sys
+import threading
+import time
+from types import SimpleNamespace
+
+import pytest
+sys.path.insert(0,str(Path(__file__).resolve().parents[1]/'scripts'))
+from test_task_execution_native_fixture import owner,bao_dev,compiler
+from task_execution_native_fixture import NativeFixtureAdapter,_absolute_budget
+from task_execution_journal import Refused
+
+
+def test_absolute_budget_interrupts_source_work_and_restores_timer():
+ before=signal.getitimer(signal.ITIMER_REAL)
+ started=time.monotonic()
+ with pytest.raises(Refused):
+ with _absolute_budget(.03):time.sleep(.5)
+ assert time.monotonic()-started<.3
+ assert signal.getitimer(signal.ITIMER_REAL)==before
+
+
+def test_nested_budget_cannot_refresh_outer_deadline():
+ started=time.monotonic()
+ with pytest.raises(Refused):
+ with _absolute_budget(.05):
+ time.sleep(.02)
+ with _absolute_budget(1):time.sleep(.5)
+ assert time.monotonic()-started<.3
+
+
+def test_absolute_budget_rejects_foreign_threads():
+ refused=[]
+ def run():
+ try:
+ with _absolute_budget(1):pass
+ except Refused:refused.append(True)
+ worker=threading.Thread(target=run);worker.start();worker.join(2)
+ assert refused==[True]
+
+
+def test_slow_compiler_is_interrupted_before_native_effect():
+ native=object.__new__(NativeFixtureAdapter)
+ native.deadline=datetime.now(timezone.utc)+timedelta(seconds=5.05)
+ native.monotonic_deadline=time.monotonic()+5.05
+ native.binding='synthetic';native.plan=None;native.task=object();native._next_metadata=0
+ native.journal=SimpleNamespace(_held=True,_admission='synthetic',_consumed_admission='synthetic',observe=lambda binding:{'state':'applying'})
+ native.compiler=SimpleNamespace(pins=[],before_call=lambda *args:time.sleep(.5))
+ sent=[];native._business=lambda *args,**kwargs:sent.append(True)
+ with pytest.raises(Refused):native.metadata_call(0)
+ assert sent==[]
+
+
+@pytest.mark.parametrize('phase',['headers','body','body-and-slow-close'])
+def test_absolute_transport_budget_interrupts_drip(phase,monkeypatch):
+ from http.server import BaseHTTPRequestHandler,ThreadingHTTPServer
+ class Drip(BaseHTTPRequestHandler):
+ def log_message(self,*args):pass
+ def do_GET(self):
+ if phase=='headers':
+ try:
+ for value in b'HTTP/1.1 200 OK\r\nX-Drip: '+b' '*1000:
+ self.connection.sendall(bytes([value]));time.sleep(.05)
+ except (BrokenPipeError,ConnectionResetError):pass
+ return
+ self.send_response(200);self.send_header('Content-Type','application/json');self.send_header('Content-Length','1000');self.end_headers()
+ try:
+ for _ in range(1000):
+ self.wfile.write(b' ');self.wfile.flush();time.sleep(.05)
+ except (BrokenPipeError,ConnectionResetError):pass
+ server=ThreadingHTTPServer(('127.0.0.1',0),Drip)
+ server.daemon_threads=True
+ thread=threading.Thread(target=server.serve_forever,daemon=True);thread.start()
+ native=object.__new__(NativeFixtureAdapter);native.host='127.0.0.1';native.port=server.server_port
+ native.admission=SimpleNamespace(server_process=SimpleNamespace(poll=lambda:None))
+ if phase=='body-and-slow-close':
+ import http.client
+ original_close=http.client.HTTPConnection.close
+ def slow_close(connection):
+ original_close(connection);time.sleep(5)
+ monkeypatch.setattr(http.client.HTTPConnection,'close',slow_close)
+ start=time.monotonic()
+ try:
+ with pytest.raises(Refused):native._http('GET','drip','synthetic')
+ assert time.monotonic()-start<2.6
+ finally:server.shutdown();server.server_close();thread.join(2)
+
+
+def test_cleanup_remains_independent_but_absolute_after_business_expiry(owner,monkeypatch):
+ from test_task_execution_native_fixture import admit
+ from task_execution_cleanup import run_cleanup
+ native,journal,binding,call,*_=owner
+ with journal.locked():
+ admit(native,journal,binding);native.materialize_grant()
+ original=native._http
+ def stalled(method,path,token,body=b''):
+ if path=='auth/token/revoke-self' and token==native.task.token:time.sleep(5)
+ return original(method,path,token,body)
+ monkeypatch.setattr(native,'_http',stalled)
+ native.monotonic_deadline=time.monotonic()-1
+ native._task_monotonic_deadline=time.monotonic()-1
+ started=time.monotonic();result=run_cleanup(journal,binding,native)
+ assert time.monotonic()-started<3
+ assert not result['confirmed']
+ assert [x['outcome'] for x in result['obligations']]==['unconfirmed','confirmed','confirmed']
+ assert journal.observe(binding)['external_outcome_unknown']
+ # Explicit independent synthetic owner recovery, never adapter retry.
+ assert call('POST','auth/token/revoke-accessor',{'accessor':native.task.accessor})[0] in {200,204}
+
+
+def test_absolute_budget_invalid_input_and_conflicting_timer_refuse():
+ original_handler=signal.getsignal(signal.SIGALRM)
+ original_timer=signal.getitimer(signal.ITIMER_REAL)
+ for value in [True,None,0,-1,float('nan'),float('inf')]:
+ with pytest.raises(Refused):
+ with _absolute_budget(value):pass
+ assert signal.getsignal(signal.SIGALRM)==original_handler
+ assert signal.getitimer(signal.ITIMER_REAL)==original_timer
+ try:
+ signal.signal(signal.SIGALRM,lambda *args:None)
+ signal.setitimer(signal.ITIMER_REAL,1)
+ with pytest.raises(Refused):
+ with _absolute_budget(.1):pass
+ assert signal.getitimer(signal.ITIMER_REAL)[0]>0
+ finally:
+ signal.setitimer(signal.ITIMER_REAL,0)
+ signal.signal(signal.SIGALRM,original_handler)
+
+
+def test_actual_credential_bound_interrupts_before_later_global_deadline():
+ native=object.__new__(NativeFixtureAdapter)
+ native.deadline=datetime.now(timezone.utc)+timedelta(minutes=10)
+ native.monotonic_deadline=time.monotonic()+600
+ native._task_wall_deadline=datetime.now(timezone.utc)+timedelta(seconds=5.05)
+ native._task_monotonic_deadline=time.monotonic()+5.05
+ native.binding='synthetic';native.plan=None;native.task=object();native._next_metadata=0
+ native.journal=SimpleNamespace(_held=True,_admission='synthetic',_consumed_admission='synthetic',observe=lambda binding:{'state':'applying'})
+ native.compiler=SimpleNamespace(pins=[],before_call=lambda *args:time.sleep(.5))
+ sent=[];native._business=lambda *args,**kwargs:sent.append(True)
+ started=time.monotonic()
+ with pytest.raises(Refused):native.metadata_call(0)
+ assert time.monotonic()-started<.3 and sent==[]
+
+
+
+@pytest.mark.parametrize('case',['valid','drift','writable','symlink','fifo','oversize'])
+def test_exact_owner_source_bundle_is_bounded_and_fails_closed(tmp_path,case):
+ import hashlib
+ import os
+ from task_execution_mediator import _source_bundle
+ source=tmp_path/'owner-source.py';source.write_bytes(b'# independently installed source\n');source.chmod(0o600)
+ roles=['engine_dispatcher','platform_mediator','typed_ccr','policy_auth']
+ item={'path':str(source),'sha256':'sha256:'+hashlib.sha256(source.read_bytes()).hexdigest()}
+ descriptor={'source_bundle':{role:dict(item) for role in roles}}
+ if case=='valid':_source_bundle(descriptor);return
+ if case=='drift':source.write_bytes(b'# drift\n')
+ elif case=='writable':source.chmod(0o666)
+ elif case=='symlink':
+ target=tmp_path/'target.py';source.rename(target);source.symlink_to(target)
+ elif case=='fifo':source.unlink();os.mkfifo(source,0o600)
+ elif case=='oversize':source.write_bytes(b'X'*1048577)
+ started=time.monotonic()
+ with pytest.raises(Refused):_source_bundle(descriptor)
+ assert time.monotonic()-started<.5
diff --git a/workplans/RPF-WP-0035-credential-lane-implementation.md b/workplans/RPF-WP-0035-credential-lane-implementation.md
index f58a6b9..92a3f3f 100644
--- a/workplans/RPF-WP-0035-credential-lane-implementation.md
+++ b/workplans/RPF-WP-0035-credential-lane-implementation.md
@@ -1307,3 +1307,12 @@ capsules are owner-key HMAC authenticated, strict nested schemas/ordered cleanup
commitments reject damage, and journal history prevents stale prepared capsules
from hiding issuance. Fixture process-loss source proof remains separate from
unadmitted production custody, integrity-key storage and recovery-role scope.
+
+2026-10-16 T12 independently reviewable budget/source slice: bound native HTTP
+header/body/close, compiler work and cleanup through fixed owner main-thread
+absolute budgets, with actual task expiry narrowing global authority. Actual v2
+mediator now verifies each independently installed source_bundle byte hash before
+authorization/effects. No deadline/TTL extension, remint or generic service added.
+Retained registry actual-provider proof still refuses Approval correspondence
+before CAS/native; normalized pre-approval + precise immediate CAS whole-stack
+positive remains live owner follow-up under CUST-WP-0080. T12 stays progress.

View file

@ -0,0 +1,41 @@
{
"schema": "railiance-platform.native-budget-source-validation.v1",
"patch_sha256": "7c17f607dacc9ed4f24fd8032ddac5f4fc9ae2f24555cf87a0b73c871ec8eb19",
"final_frozen_run": {
"result": "17 passed",
"seconds": 32.33,
"command": "SECRETS_ENGINE_SOURCE_ROOT=/tmp/netkingdom-secrets-trusted-successor PYTHONPATH=/tmp/netkingdom-secrets-trusted-successor/src:/home/worsch/approval-engine pytest -q tests/test_task_execution_native_budget.py tests/test_task_execution_native_fixture.py::test_native_fixed_grant_metadata_denials_and_confirmed_cleanup",
"covers": [
"true header/body drip and blocked teardown",
"slow compiler interrupted before effect",
"earlier actual credential bound interrupts before later global deadline",
"nested budget cannot refresh, invalid input/foreign thread/ambient timer refusal",
"separate bounded cleanup after business and credential expiry leaves failure unknown but continues independent cleanup",
"bounded exact source bundle valid bytes and drift/modes/symlink/FIFO/oversize refusal",
"actual fixed native grant, six metadata calls/readback and confirmed cleanup compatibility"
]
},
"earlier_checks": [
{
"result": "9 passed",
"scope": "absolute budget tests including actual credential window and blocked close",
"seconds": 6.96
},
{
"result": "6 passed",
"scope": "native fixed grant, source drift, lost-response and recovery process-loss compatibility",
"seconds": 71.47
},
{
"result": "6 passed",
"scope": "actual-provider refusal cases and frozen source retained registry refusal",
"seconds": 94.58
},
{
"result": "2 failed actual-provider positive attempts",
"scope": "retained registry source correspondence failed beforeCAS/native; closed classification preserved in retained-registry-failure-r1.json"
}
],
"overlap": "Counts overlap, not unique totals. Earlier tests are retained intermediate evidence; final17 checks the frozen slice.",
"whole_positive": "Not established; normalized preapproval receipt and precise immediateCAS source successors require actual full-stack follow-up."
}

View file

@ -0,0 +1,174 @@
"""Actual v2 provider fixture; no generic policy, fabricated claim or live identity."""
from contextlib import contextmanager
from dataclasses import replace
from datetime import datetime,timedelta,timezone
import base64
import hashlib
import json
import os
from pathlib import Path
import socket
import ssl
import subprocess
import threading
import time
from types import SimpleNamespace
from http.server import BaseHTTPRequestHandler,ThreadingHTTPServer
from wsgiref.simple_server import make_server
from cryptography import x509
from cryptography.hazmat.primitives import serialization,hashes
from cryptography.hazmat.primitives.asymmetric import ed25519,rsa
from cryptography.x509.oid import NameOID
from task_execution_source_servers import QuietWSGI,private,free_port,http
from task_execution_journal import Binding
from task_execution_native_fixture import FixtureAdmission,NativeFixtureAdapter
import task_execution_plan as plan_source
import task_execution_grant as grant
ACCESS=Path(os.environ.get('TRUSTED_ACCESS_SOURCE_ROOT','/tmp/access-trusted-b2-b3-r2'))
SECRETS=Path(os.environ.get('SECRETS_ENGINE_SOURCE_ROOT','/tmp/netkingdom-secrets-trusted-successor'))
BINARY=Path(os.environ.get('TRUSTED_ACCESS_BINARY','/tmp/netkingdom-access-trusted-r2'))
VERIFIER=Path(os.environ.get('TRUSTED_PDP_VERIFIER','/tmp/secrets-trusted-successor-verifier'))
POLICY_DIGEST=os.environ.get('TRUSTED_POLICY_DIGEST','sha256:de5683e5a34161b9349b10cd8e4abef704e0136e3b14b97719fcd7a79850fc4d')
PRINCIPAL='system:serviceaccount:secrets-engine:secrets-engine'
def certificate(directory):
key=rsa.generate_private_key(public_exponent=65537,key_size=2048)
name=x509.Name([x509.NameAttribute(NameOID.COMMON_NAME,'localhost')])
now=datetime.now(timezone.utc)
cert=(x509.CertificateBuilder().subject_name(name).issuer_name(name).public_key(key.public_key())
.serial_number(x509.random_serial_number()).not_valid_before(now-timedelta(minutes=1))
.not_valid_after(now+timedelta(hours=1))
.add_extension(x509.SubjectAlternativeName([x509.DNSName('localhost')]),critical=False)
.sign(key,hashes.SHA256()))
ca=directory/'trusted-ca.pem';ca.write_bytes(cert.public_bytes(serialization.Encoding.PEM));ca.chmod(0o600)
private_key=directory/'trusted-tls.key'
private_key.write_bytes(key.private_bytes(serialization.Encoding.PEM,serialization.PrivateFormat.PKCS8,serialization.NoEncryption()));private_key.chmod(0o600)
context=ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER);context.load_cert_chain(ca,private_key)
return ca,context
def serve(server,*,tls=None):
if tls is not None:server.socket=tls.wrap_socket(server.socket,server_side=True)
thread=threading.Thread(target=server.serve_forever,daemon=True);thread.start()
return thread
@contextmanager
def trusted_providers(directory):
from approval_engine.api import App
from approval_engine.auth import Identity,StaticTokenAuthenticator
from approval_engine.store import Engine
ca,tls=certificate(directory)
state=SimpleNamespace(principal=PRINCIPAL,caller_valid=True)
expiry=int(time.time())+600
encoded=base64.urlsafe_b64encode(json.dumps({'exp':expiry}).encode()).decode().rstrip('=')
caller='synthetic.'+encoded+'.synthetic';reviewer='synthetic-trusted-reviewer';calls=[]
class Review(BaseHTTPRequestHandler):
def log_message(self,*args):pass
def do_POST(self):
raw=self.rfile.read(int(self.headers.get('Content-Length','0')));body=json.loads(raw)
valid=(state.caller_valid and self.path=='/apis/authentication.k8s.io/v1/tokenreviews'
and self.headers.get('Authorization')=='Bearer '+reviewer
and body.get('spec',{}).get('token')==caller
and 'flex-auth' in body.get('spec',{}).get('audiences',[]))
calls.append(('review',valid))
response={'status':{'authenticated':valid,'audiences':['flex-auth'],'user':{'username':state.principal}}}
payload=json.dumps(response).encode();self.send_response(200);self.send_header('Content-Type','application/json');self.send_header('Content-Length',str(len(payload)));self.end_headers();self.wfile.write(payload)
review=ThreadingHTTPServer(('127.0.0.1',0),Review);review_thread=serve(review,tls=tls)
def identity(subject,kind,scopes):
return Identity(subject,'synthetic-whole-source',('approval-engine',),kind,'tenant:platform',
frozenset(),frozenset(scopes),{'level':'aal2','methods':['synthetic']},
'fixture:whole-source',tenant_source='directory' if kind=='human' else 'registration')
service='synthetic-whole-service';reader='synthetic-whole-reader';human='synthetic-whole-human'
authenticator=StaticTokenAuthenticator({service:identity('secrets-engine','service',['approval:create','approval:read','approval:consume']),
reader:identity('access-source-reader','service',['approval:read']),
human:identity('whole-human','human',['approval:approve','approval:revoke'])})
# Explicit synthetic owner observation contract, not an API default change.
engine=Engine(str(directory/'whole-approval.sqlite'),freshness_ttl=120)
app=App(engine,authenticator)
def logged(channel):
def handle(environ,start_response):
calls.append((channel,environ['REQUEST_METHOD'],environ['PATH_INFO']))
return app(environ,start_response)
return handle
source=make_server('127.0.0.1',0,logged('source'),handler_class=QuietWSGI)
consumer=make_server('127.0.0.1',0,logged('consumer'),handler_class=QuietWSGI)
source_thread=serve(source,tls=tls);consumer_thread=serve(consumer)
reader_file=private(directory/'approval-reader.token',reader)
review_file=private(directory/'reviewer.token',reviewer)
cfg={'source_id':'synthetic-whole-actual-approval','source_revision':'approval-3485415-actual-fixture',
'origin':'https://localhost:'+str(source.server_port),'store_tenant':'tenant:platform',
'ca_file':str(ca),'token_file':str(reader_file)}
config=directory/'approval-source.json';private(config,json.dumps(cfg))
seed_key=ed25519.Ed25519PrivateKey.generate()
b64=lambda value:base64.urlsafe_b64encode(value).decode().rstrip('=')
seed=private(directory/'whole-signing.seed',b64(seed_key.private_bytes(serialization.Encoding.Raw,serialization.PrivateFormat.Raw,serialization.NoEncryption())))
public=b64(seed_key.public_key().public_bytes(serialization.Encoding.Raw,serialization.PublicFormat.Raw))
registry=directory/'whole-registry.json';registry.write_bytes((ACCESS/'examples/secrets-engine/registry_snapshot.json').read_bytes())
policy=ACCESS/'examples/secrets-engine-service-task-execution-v2/policy_package.md'
record=directory/'whole-access-record.jsonl';port=free_port();origin='http://127.0.0.1:'+str(port)
args=[str(BINARY),'serve','--addr','127.0.0.1:'+str(port),'--registry',str(registry),'--policy',str(policy),
'--log',str(record),'--envelope-signing-key-file',str(seed),'--envelope-signing-kid','synthetic-whole-key',
'--caller-auth-mode','enforce','--caller-audience','flex-auth',
'--caller-kubernetes-url','https://localhost:'+str(review.server_port),
'--caller-reviewer-token-file',str(review_file),'--caller-ca-file',str(ca),
'--caller-binding','secrets-engine='+PRINCIPAL,'--approval-source-file',str(config)]
process=subprocess.Popen(args,stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL,env={})
try:
for _ in range(100):
if process.poll() is not None:raise RuntimeError('actual trusted Access source exited')
try:
with socket.create_connection(('127.0.0.1',port),timeout=.1):break
except OSError:time.sleep(.025)
else:raise RuntimeError('actual trusted Access source startup unconfirmed')
trust={'schema':'secrets-engine.pdp-trust.v1','evaluator':'flex-auth/local','mode':'standalone',
'policy_package':'secrets-engine.task-execution.service-pilot','policy_version':'v2',
'policy_package_digest':POLICY_DIGEST,'keys':{'synthetic-whole-key':public}}
trustfile=private(directory/'whole-pdp-trust.json',json.dumps(trust))
approval_token=private(directory/'whole-consumer-approval.token',service)
pdp_token=private(directory/'whole-consumer-pdp.token',caller)
from task_execution_mediator import ConfirmationConfig
cfg=SimpleNamespace(approval_url='http://127.0.0.1:'+str(consumer.server_port),approval_token_file=approval_token,
approval_client_secret_file=None,pdp_url=origin,pdp_token_file=pdp_token,pdp_trust_file=trustfile,pdp_verifier=VERIFIER,
authorization_subject_id='secrets-engine',authorization_subject_type='service',
authorization_policy_package=trust['policy_package'],authorization_policy_version='v2',clock_trust_file=None)
yield SimpleNamespace(cfg=cfg,engine=engine,calls=calls,state=state,record=record,process=process,
confirmation=ConfirmationConfig(cfg.approval_url,approval_token),human_token=human,
source_config=config,reader_file=reader_file)
finally:
process.terminate();process.wait(timeout=5)
for server in [source,consumer,review]:server.shutdown();server.server_close()
for thread in [source_thread,consumer_thread,review_thread]:thread.join(5)
engine.close()
def install_exact_owner_descriptor(owner,*,window_seconds=60):
"""All server startup precedes this exact proposal time; no remint/reissue."""
native,journal,binding,call,compiler,descriptor=owner
from secrets_engine.execution_contract import ExecutionContract
descriptor=json.loads(json.dumps(descriptor))
descriptor['backend_budget']={'ttl_seconds':30,'uses':12}
descriptor['operation_deadline']=(datetime.now(timezone.utc)+timedelta(seconds=window_seconds)).replace(microsecond=0).isoformat()
ccr=compiler._derive()[0]
sources={'engine_dispatcher':SECRETS/'src/secrets_engine/task_execution_handoff.py',
'platform_mediator':Path(__file__).resolve().parents[1]/'scripts/task_execution_mediator.py',
'typed_ccr':compiler.validator,'policy_auth':compiler.config.platform_repo/ccr['openbao']['policy_file']}
descriptor['source_bundle']={role:{'path':str(path),'sha256':plan_source.tagged(path.read_bytes())} for role,path in sources.items()}
# Exact source descriptor participates in the existing reviewed pure grant
# projection. Synthetic fixture approval is not a production CCR admission.
record=json.loads(json.dumps(__import__('yaml').safe_load(compiler.grant_record.read_text())))
record['execution'],record['openbao']=compiler.grant_effects(compiler.compile(native.meta['request']),descriptor)
compiler.grant_record.write_text(__import__('yaml').safe_dump(record,sort_keys=False))
pins=dict(compiler.config.source_pins);pins[str(compiler.grant_record)]=plan_source.tagged(compiler.grant_record.read_bytes())
compiler=plan_source.Compiler(replace(compiler.config,source_pins=tuple(pins.items())))
descriptor['grant_immutable_sha256']=grant.immutable_digest(record)
descriptor=ExecutionContract.parse(descriptor).as_dict();raw=plan_source.encoded(descriptor)
plan=compiler.bind_execution(compiler.compile(native.meta['request']),descriptor,raw)
binding=Binding(binding.approval_id,binding.request_digest,binding.operation_id,plan.content_digest)
admission=replace(native.admission,descriptor=raw)
native=NativeFixtureAdapter(compiler,plan,journal,binding,admission)
return native,journal,binding,call,compiler,descriptor

View file

@ -0,0 +1,294 @@
"""Exact actual source successor path; legacy fixture evidence remains separate."""
from contextlib import contextmanager
from dataclasses import replace
from datetime import datetime,timedelta,timezone
import json
from pathlib import Path
import signal
import sys
import threading
import time
from types import SimpleNamespace
import uuid
import pytest
sys.path.insert(0,str(Path(__file__).resolve().parents[1]/'scripts'))
from test_task_execution_native_fixture import owner,bao_dev,compiler
from task_execution_trusted_servers import trusted_providers,install_exact_owner_descriptor,http,private
import task_execution_trusted_servers as providers_source
from task_execution_mediator import run_native_fixture_task
from task_execution_native_fixture import NativeFixtureAdapter,_absolute_budget
from task_execution_journal import Refused
from secrets_engine.approval_claim import binding_from_check_request
from secrets_engine.approval_consume import _expected_request
from secrets_engine.authorization import approval_binding_digest
from secrets_engine.execution_contract import ExecutionContract
@contextmanager
def whole(owner,directory,*,window_seconds=60):
# OpenBao, Approval HTTPS, TokenReview TLS and actual Access CLI are all up
# before proposing an immutable descriptor/deadline or issuing approval.
with trusted_providers(directory) as provider:
native,journal,binding,call,compiler,descriptor=install_exact_owner_descriptor(owner,window_seconds=window_seconds)
snapshot={path:compiler.read(Path(path)) for path in compiler.pins}
entry=compiler._catalogue_entry(snapshot)
approval_id=str(uuid.uuid4())
entry=replace(entry,approval={**entry.approval,'authorization_id':approval_id})
request=_expected_request(provider.cfg,entry,'execute-ccr',execution_contract=ExecutionContract.parse(descriptor))
now=datetime.now(timezone.utc)
body={'id':approval_id,'binding':binding_from_check_request(request),'pdp_digest':approval_binding_digest(request),
'pdp_path':True,'human_control':True,'required_count':1,
'validity':{'not_before':(now-timedelta(minutes=1)).isoformat(),'expires_at':(now+timedelta(minutes=3)).isoformat()}}
assert http(provider.cfg.approval_url,'POST','/v1/approvals',body,'synthetic-whole-service')[0]==201
assert http(provider.cfg.approval_url,'POST','/v1/approvals/'+approval_id+'/entries',{},provider.human_token)[0]==200
yield SimpleNamespace(native=native,journal=journal,call=call,compiler=compiler,descriptor=descriptor,
entry=entry,provider=provider,cfg=provider.cfg,approval_id=approval_id,request=request)
def invoke(fixture):
return run_native_fixture_task(compiler=fixture.compiler,journal=fixture.journal,
plan_request=fixture.native.meta['request'],owner_descriptor=fixture.native.admission.descriptor,
secrets_cfg=fixture.cfg,entry=fixture.entry,confirmation=fixture.provider.confirmation,
native_admission=fixture.native.admission)
def untouched(fixture):
assert fixture.call('GET','sys/policies/acl/'+fixture.native.policy_name)==(404,{'errors':[]})
for ordinal in [1,4]:
path=fixture.compiler.before_call(fixture.native.plan,ordinal).path
assert fixture.call('GET',path)==(404,{'errors':[]})
def test_actual_successor_allow_strict_cas_confirm_six_calls_readback_cleanup(owner,tmp_path,monkeypatch):
with whole(owner,tmp_path) as fixture:
trace=[];private_task=[];issued_native=[]
original=NativeFixtureAdapter._http
def observed(native,method,path,token,body=b''):
result=original(native,method,path,token,body)
trace.append((method,path,result[0]))
if method=='POST' and path=='auth/token/create' and result[0]==200:
private_task.append((result[1]['auth']['accessor'],result[1]['auth']['client_token']))
issued_native.append(native)
return result
monkeypatch.setattr(NativeFixtureAdapter,'_http',observed)
try:result=invoke(fixture)
except Exception as error:
from urllib.error import HTTPError
cause=error.__cause__
if isinstance(cause,HTTPError):
raw=cause.read(4097)
known={b'trusted Approval source refused\n':'approval-source-correspondence',
b'service task execution input refused\n':'service-input-correspondence'}
diagnostic={'status':cause.code,'constant_reason':known.get(raw,'other-closed-classification'),
'native_operations':fixture.journal.db.execute('SELECT COUNT(*) FROM operations').fetchone()[0]}
(tmp_path/'closed-source-refusal.json').write_text(json.dumps(diagnostic))
print(json.dumps(diagnostic))
raise
assert result['native_apply']=='verified' and result['native_calls_completed']==6
assert result['cleanup_confirmed'] and result['business_state']=='cleanup-confirmed'
assert fixture.provider.engine.get(fixture.approval_id).status=='consumed'
records=[json.loads(line) for line in fixture.provider.record.read_bytes().splitlines()]
assert len(records)==1 and records[0]['effect']=='allow' and records[0]['signature']['mode']=='signed'
assert records[0]['id']==result['decision_id']
assert records[0]['expires_at']==fixture.descriptor['operation_deadline']
assert records[0]['provenance']['policy_package']=='secrets-engine.task-execution.service-pilot'
assert records[0]['provenance']['policy_version']=='v2'
assert records[0]['provenance']['approval_observation']['source_id']=='synthetic-whole-actual-approval'
assert records[0]['binding']['context']['execution_contract']==fixture.descriptor
native_calls=fixture.journal.db.execute('SELECT ordinal,outcome FROM calls ORDER BY ordinal').fetchall()
assert native_calls[:2]==[(0,'complete'),(1,'complete')] and len(native_calls)==13
assert all(value=='complete' for _,value in native_calls)
assert fixture.provider.calls.count(('consumer','POST','/v1/approvals/'+fixture.approval_id+'/consume'))==1
assert fixture.provider.calls.count(('consumer','GET','/v1/approvals/'+fixture.approval_id))==1
assert any(item==('source','GET','/v1/approvals/'+fixture.approval_id+'/claim') for item in fixture.provider.calls)
expected=fixture.compiler.proposal()['operations']
assert [(method,path) for method,path,_ in trace if path in {item['path'] for item in expected}]==[(x['method'],x['path']) for x in expected]
assert len(private_task)==1
assert issued_native[0]._task_wall_deadline<issued_native[0].deadline
assert issued_native[0]._task_monotonic_deadline<issued_native[0].monotonic_deadline
for accessor in [private_task[0][0],fixture.native.admission.identity_session.accessor]:
assert fixture.call('POST','auth/token/lookup-accessor',{'accessor':accessor})[0]==400
assert fixture.call('GET','sys/policies/acl/'+fixture.native.policy_name)==(404,{'errors':[]})
raw=(fixture.journal.directory/'journal.sqlite').read_bytes()
for secret in [*private_task[0],fixture.native.admission.owner_token,fixture.native.admission.identity_session.token]:assert secret.encode() not in raw
before=list(trace)
with pytest.raises(Exception):invoke(fixture)
assert trace==before
assert fixture.provider.calls.count(('consumer','POST','/v1/approvals/'+fixture.approval_id+'/consume'))==1
@pytest.mark.parametrize('case',['source-drift','foreign-caller','revoked','expired','source-reader-denied'])
def test_actual_successor_refusals_never_consume_or_touch_backend(owner,tmp_path,case):
with whole(owner,tmp_path,window_seconds=-1 if case=='expired' else 60) as fixture:
expected_paths=[fixture.compiler.before_call(fixture.native.plan,x).path for x in [1,4]]
if case=='source-drift':
fixture.compiler.validator.write_bytes(fixture.compiler.validator.read_bytes()+b'\n# changed source\n')
elif case=='foreign-caller':fixture.provider.state.principal='system:serviceaccount:other:other'
elif case=='revoked':
assert http(fixture.cfg.approval_url,'POST','/v1/approvals/'+fixture.approval_id+'/revoke',{},fixture.provider.human_token)[0]==200
elif case=='source-reader-denied':private(fixture.provider.reader_file,'synthetic-unknown-reader')
with pytest.raises(Exception):invoke(fixture)
assert not any(item==('consumer','POST','/v1/approvals/'+fixture.approval_id+'/consume') for item in fixture.provider.calls)
assert fixture.journal.db.execute('SELECT COUNT(*) FROM operations').fetchone()[0]==0
assert not fixture.provider.record.exists() or not fixture.provider.record.read_bytes()
assert fixture.call('GET','sys/policies/acl/'+fixture.native.policy_name)==(404,{'errors':[]})
for path in expected_paths:assert fixture.call('GET',path)==(404,{'errors':[]})
def test_actual_revoke_between_prepare_and_cas_keeps_native_untouched(owner,tmp_path,monkeypatch):
import secrets_engine.task_execution_handoff as strict
with whole(owner,tmp_path) as fixture:
original=strict.consume_task_execution
def revoked(cfg,authorization):
assert http(cfg.approval_url,'POST','/v1/approvals/'+fixture.approval_id+'/revoke',{},fixture.provider.human_token)[0]==200
return original(cfg,authorization)
monkeypatch.setattr(strict,'consume_task_execution',revoked)
with pytest.raises(Refused,match='confirmation unconfirmed'):invoke(fixture)
assert fixture.journal.db.execute('SELECT state FROM operations').fetchone()[0]=='unknown'
untouched(fixture)
assert fixture.provider.engine.get(fixture.approval_id).status=='revoked'
assert json.loads(fixture.provider.record.read_bytes())['effect']=='allow'
def test_frozen_source_retained_registry_refuses_before_native(owner,tmp_path,monkeypatch):
monkeypatch.setattr(providers_source,'ACCESS',Path('/tmp/access-trusted-b2-b3'))
monkeypatch.setattr(providers_source,'BINARY',Path('/tmp/netkingdom-access-trusted-r1'))
monkeypatch.setattr(providers_source,'POLICY_DIGEST','sha256:fc5a8769886b72006ba298bc0799741d0f1ed327dde9e36a6c35d26be4ea0bdc')
with whole(owner,tmp_path) as fixture:
with pytest.raises(Exception):invoke(fixture)
assert not fixture.provider.record.exists() or not fixture.provider.record.read_bytes()
assert fixture.journal.db.execute('SELECT COUNT(*) FROM operations').fetchone()[0]==0
assert not any(item==('consumer','POST','/v1/approvals/'+fixture.approval_id+'/consume') for item in fixture.provider.calls)
untouched(fixture)
def test_absolute_budget_interrupts_source_work_and_restores_timer():
before=signal.getitimer(signal.ITIMER_REAL)
started=time.monotonic()
with pytest.raises(Refused):
with _absolute_budget(.03):time.sleep(.5)
assert time.monotonic()-started<.3
assert signal.getitimer(signal.ITIMER_REAL)==before
def test_nested_budget_cannot_refresh_outer_deadline():
started=time.monotonic()
with pytest.raises(Refused):
with _absolute_budget(.05):
time.sleep(.02)
with _absolute_budget(1):time.sleep(.5)
assert time.monotonic()-started<.3
def test_absolute_budget_rejects_foreign_threads():
refused=[]
def run():
try:
with _absolute_budget(1):pass
except Refused:refused.append(True)
worker=threading.Thread(target=run);worker.start();worker.join(2)
assert refused==[True]
def test_slow_compiler_is_interrupted_before_native_effect():
native=object.__new__(NativeFixtureAdapter)
native.deadline=datetime.now(timezone.utc)+timedelta(seconds=5.05)
native.monotonic_deadline=time.monotonic()+5.05
native.binding='synthetic';native.plan=None;native.task=object();native._next_metadata=0
native.journal=SimpleNamespace(_held=True,_admission='synthetic',_consumed_admission='synthetic',observe=lambda binding:{'state':'applying'})
native.compiler=SimpleNamespace(pins=[],before_call=lambda *args:time.sleep(.5))
sent=[];native._business=lambda *args,**kwargs:sent.append(True)
with pytest.raises(Refused):native.metadata_call(0)
assert sent==[]
@pytest.mark.parametrize('phase',['headers','body','body-and-slow-close'])
def test_absolute_transport_budget_interrupts_drip(phase,monkeypatch):
from http.server import BaseHTTPRequestHandler,ThreadingHTTPServer
class Drip(BaseHTTPRequestHandler):
def log_message(self,*args):pass
def do_GET(self):
if phase=='headers':
try:
for value in b'HTTP/1.1 200 OK\r\nX-Drip: '+b' '*1000:
self.connection.sendall(bytes([value]));time.sleep(.05)
except (BrokenPipeError,ConnectionResetError):pass
return
self.send_response(200);self.send_header('Content-Type','application/json');self.send_header('Content-Length','1000');self.end_headers()
try:
for _ in range(1000):
self.wfile.write(b' ');self.wfile.flush();time.sleep(.05)
except (BrokenPipeError,ConnectionResetError):pass
server=ThreadingHTTPServer(('127.0.0.1',0),Drip)
server.daemon_threads=True
thread=threading.Thread(target=server.serve_forever,daemon=True);thread.start()
native=object.__new__(NativeFixtureAdapter);native.host='127.0.0.1';native.port=server.server_port
native.admission=SimpleNamespace(server_process=SimpleNamespace(poll=lambda:None))
if phase=='body-and-slow-close':
import http.client
original_close=http.client.HTTPConnection.close
def slow_close(connection):
original_close(connection);time.sleep(5)
monkeypatch.setattr(http.client.HTTPConnection,'close',slow_close)
start=time.monotonic()
try:
with pytest.raises(Refused):native._http('GET','drip','synthetic')
assert time.monotonic()-start<2.6
finally:server.shutdown();server.server_close();thread.join(2)
def test_cleanup_remains_independent_but_absolute_after_business_expiry(owner,monkeypatch):
from test_task_execution_native_fixture import admit
from task_execution_cleanup import run_cleanup
native,journal,binding,call,*_=owner
with journal.locked():
admit(native,journal,binding);native.materialize_grant()
original=native._http
def stalled(method,path,token,body=b''):
if path=='auth/token/revoke-self' and token==native.task.token:time.sleep(5)
return original(method,path,token,body)
monkeypatch.setattr(native,'_http',stalled)
native.monotonic_deadline=time.monotonic()-1
native._task_monotonic_deadline=time.monotonic()-1
started=time.monotonic();result=run_cleanup(journal,binding,native)
assert time.monotonic()-started<3
assert not result['confirmed']
assert [x['outcome'] for x in result['obligations']]==['unconfirmed','confirmed','confirmed']
assert journal.observe(binding)['external_outcome_unknown']
# Explicit independent synthetic owner recovery, never adapter retry.
assert call('POST','auth/token/revoke-accessor',{'accessor':native.task.accessor})[0] in {200,204}
def test_absolute_budget_invalid_input_and_conflicting_timer_refuse():
original_handler=signal.getsignal(signal.SIGALRM)
original_timer=signal.getitimer(signal.ITIMER_REAL)
for value in [True,None,0,-1,float('nan'),float('inf')]:
with pytest.raises(Refused):
with _absolute_budget(value):pass
assert signal.getsignal(signal.SIGALRM)==original_handler
assert signal.getitimer(signal.ITIMER_REAL)==original_timer
try:
signal.signal(signal.SIGALRM,lambda *args:None)
signal.setitimer(signal.ITIMER_REAL,1)
with pytest.raises(Refused):
with _absolute_budget(.1):pass
assert signal.getitimer(signal.ITIMER_REAL)[0]>0
finally:
signal.setitimer(signal.ITIMER_REAL,0)
signal.signal(signal.SIGALRM,original_handler)
def test_actual_credential_bound_interrupts_before_later_global_deadline():
native=object.__new__(NativeFixtureAdapter)
native.deadline=datetime.now(timezone.utc)+timedelta(minutes=10)
native.monotonic_deadline=time.monotonic()+600
native._task_wall_deadline=datetime.now(timezone.utc)+timedelta(seconds=5.05)
native._task_monotonic_deadline=time.monotonic()+5.05
native.binding='synthetic';native.plan=None;native.task=object();native._next_metadata=0
native.journal=SimpleNamespace(_held=True,_admission='synthetic',_consumed_admission='synthetic',observe=lambda binding:{'state':'applying'})
native.compiler=SimpleNamespace(pins=[],before_call=lambda *args:time.sleep(.5))
sent=[];native._business=lambda *args,**kwargs:sent.append(True)
started=time.monotonic()
with pytest.raises(Refused):native.metadata_call(0)
assert time.monotonic()-started<.3 and sent==[]

View file

@ -0,0 +1,31 @@
# Actual v2 whole-chain source proof R1
Apply implementation.patch after the frozen budget/source slice SHA7c17f607.
The exact isolated snapshot base, changed-file preimages and hashes, and actual
Access R3 / Secrets R4 / Approval precision dependencies are in manifest.json.
No canonical Platform source was changed by this owner.
The owner fixture starts OpenBao and actual providers before the immutable
proposal. The real Secrets builder requests signed claim-free DENY normalization
facts from retained Access registry source, then actual Approval App issues and
records synthetic human approval. The original builder request remains unchanged.
Real Access v2 supplies signed ALLOW; actual strict Secrets CAS and independently
verified GET precede six owner-native calls and exact readback/cleanup. Immediate
CAS uses Approval precision source; no sleep or backdating occurs in that path.
Startup readiness polling and the negative registry-restart readiness polling are
not authorization clock waivers.
The test uses exact 60-second whole-second descriptor deadline, backend TTL30,
synthetic observation TTL120, existing Access120 cap and five-second cleanup
reserve. Credential expiry further narrows business time; all inherited absolute
HTTP/source/cleanup bounds remain active. The public receipt contains only closed
status/counter/boolean evidence, never bearer material, signing seed or accessors.
Eight tests passed, then the final positive rerun passed after adding public
receipt output and exact precision provenance label. The earlier assertion-only
KeyError is preserved in validation history. Existing retained-registry failure
evidence is preserved in the parent directory.
This is executable source integration proof with synthetic identities and keys,
not production native admission, Kubernetes/OIDC/MFA proof, live signing custody,
CCR admission or a standing grant. Owner review is source-only.

View file

@ -0,0 +1,440 @@
diff --git a/tests/task_execution_trusted_servers.py b/tests/task_execution_trusted_servers.py
new file mode 100644
index 0000000..a9dc9f6
--- /dev/null
+++ b/tests/task_execution_trusted_servers.py
@@ -0,0 +1,189 @@
+"""Actual v2 provider fixture; no generic policy, fabricated claim or live identity."""
+from contextlib import contextmanager
+from dataclasses import replace
+from datetime import datetime,timedelta,timezone
+import base64
+import hashlib
+import json
+import os
+from pathlib import Path
+import socket
+import ssl
+import subprocess
+import threading
+import time
+from types import SimpleNamespace
+from http.server import BaseHTTPRequestHandler,ThreadingHTTPServer
+from wsgiref.simple_server import make_server
+
+from cryptography import x509
+from cryptography.hazmat.primitives import serialization,hashes
+from cryptography.hazmat.primitives.asymmetric import ed25519,rsa
+from cryptography.x509.oid import NameOID
+
+from task_execution_source_servers import QuietWSGI,private,free_port,http
+from task_execution_journal import Binding
+from task_execution_native_fixture import FixtureAdmission,NativeFixtureAdapter
+import task_execution_plan as plan_source
+import task_execution_grant as grant
+
+ACCESS=Path(os.environ.get('TRUSTED_ACCESS_SOURCE_ROOT','/tmp/access-trusted-b2-b3-r3'))
+SECRETS=Path(os.environ.get('SECRETS_ENGINE_SOURCE_ROOT','/tmp/netkingdom-secrets-trusted-successor'))
+BINARY=Path(os.environ.get('TRUSTED_ACCESS_BINARY','/tmp/netkingdom-access-trusted-r3'))
+VERIFIER=Path(os.environ.get('TRUSTED_PDP_VERIFIER','/tmp/secrets-trusted-successor-verifier'))
+POLICY_DIGEST=os.environ.get('TRUSTED_POLICY_DIGEST','sha256:de5683e5a34161b9349b10cd8e4abef704e0136e3b14b97719fcd7a79850fc4d')
+PRINCIPAL='system:serviceaccount:secrets-engine:secrets-engine'
+
+
+def certificate(directory):
+ key=rsa.generate_private_key(public_exponent=65537,key_size=2048)
+ name=x509.Name([x509.NameAttribute(NameOID.COMMON_NAME,'localhost')])
+ now=datetime.now(timezone.utc)
+ cert=(x509.CertificateBuilder().subject_name(name).issuer_name(name).public_key(key.public_key())
+ .serial_number(x509.random_serial_number()).not_valid_before(now-timedelta(minutes=1))
+ .not_valid_after(now+timedelta(hours=1))
+ .add_extension(x509.SubjectAlternativeName([x509.DNSName('localhost')]),critical=False)
+ .sign(key,hashes.SHA256()))
+ ca=directory/'trusted-ca.pem';ca.write_bytes(cert.public_bytes(serialization.Encoding.PEM));ca.chmod(0o600)
+ private_key=directory/'trusted-tls.key'
+ private_key.write_bytes(key.private_bytes(serialization.Encoding.PEM,serialization.PrivateFormat.PKCS8,serialization.NoEncryption()));private_key.chmod(0o600)
+ context=ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER);context.load_cert_chain(ca,private_key)
+ return ca,context
+
+
+def serve(server,*,tls=None):
+ if tls is not None:server.socket=tls.wrap_socket(server.socket,server_side=True)
+ thread=threading.Thread(target=server.serve_forever,daemon=True);thread.start()
+ return thread
+
+
+@contextmanager
+def trusted_providers(directory):
+ from approval_engine.api import App
+ from approval_engine.auth import Identity,StaticTokenAuthenticator
+ from approval_engine.store import Engine
+ ca,tls=certificate(directory)
+ state=SimpleNamespace(principal=PRINCIPAL,caller_valid=True)
+ expiry=int(time.time())+600
+ encoded=base64.urlsafe_b64encode(json.dumps({'exp':expiry}).encode()).decode().rstrip('=')
+ caller='synthetic.'+encoded+'.synthetic';reviewer='synthetic-trusted-reviewer';calls=[]
+ class Review(BaseHTTPRequestHandler):
+ def log_message(self,*args):pass
+ def do_POST(self):
+ raw=self.rfile.read(int(self.headers.get('Content-Length','0')));body=json.loads(raw)
+ valid=(state.caller_valid and self.path=='/apis/authentication.k8s.io/v1/tokenreviews'
+ and self.headers.get('Authorization')=='Bearer '+reviewer
+ and body.get('spec',{}).get('token')==caller
+ and 'flex-auth' in body.get('spec',{}).get('audiences',[]))
+ calls.append(('review',valid))
+ response={'status':{'authenticated':valid,'audiences':['flex-auth'],'user':{'username':state.principal}}}
+ payload=json.dumps(response).encode();self.send_response(200);self.send_header('Content-Type','application/json');self.send_header('Content-Length',str(len(payload)));self.end_headers();self.wfile.write(payload)
+ review=ThreadingHTTPServer(('127.0.0.1',0),Review);review_thread=serve(review,tls=tls)
+ def identity(subject,kind,scopes):
+ return Identity(subject,'synthetic-whole-source',('approval-engine',),kind,'tenant:platform',
+ frozenset(),frozenset(scopes),{'level':'aal2','methods':['synthetic']},
+ 'fixture:whole-source',tenant_source='directory' if kind=='human' else 'registration')
+ service='synthetic-whole-service';reader='synthetic-whole-reader';human='synthetic-whole-human'
+ authenticator=StaticTokenAuthenticator({service:identity('secrets-engine','service',['approval:create','approval:read','approval:consume']),
+ reader:identity('access-source-reader','service',['approval:read']),
+ human:identity('whole-human','human',['approval:approve','approval:revoke'])})
+ # Explicit synthetic owner observation contract, not an API default change.
+ engine=Engine(str(directory/'whole-approval.sqlite'),freshness_ttl=120)
+ app=App(engine,authenticator)
+ def logged(channel):
+ def handle(environ,start_response):
+ calls.append((channel,environ['REQUEST_METHOD'],environ['PATH_INFO']))
+ return app(environ,start_response)
+ return handle
+ source=make_server('127.0.0.1',0,logged('source'),handler_class=QuietWSGI)
+ consumer=make_server('127.0.0.1',0,logged('consumer'),handler_class=QuietWSGI)
+ source_thread=serve(source,tls=tls);consumer_thread=serve(consumer)
+ reader_file=private(directory/'approval-reader.token',reader)
+ review_file=private(directory/'reviewer.token',reviewer)
+ cfg={'source_id':'synthetic-whole-actual-approval','source_revision':'approval-3485415-precision-74eb0ff6-fixture',
+ 'origin':'https://localhost:'+str(source.server_port),'store_tenant':'tenant:platform',
+ 'ca_file':str(ca),'token_file':str(reader_file)}
+ config=directory/'approval-source.json';private(config,json.dumps(cfg))
+ seed_key=ed25519.Ed25519PrivateKey.generate()
+ b64=lambda value:base64.urlsafe_b64encode(value).decode().rstrip('=')
+ seed=private(directory/'whole-signing.seed',b64(seed_key.private_bytes(serialization.Encoding.Raw,serialization.PrivateFormat.Raw,serialization.NoEncryption())))
+ public=b64(seed_key.public_key().public_bytes(serialization.Encoding.Raw,serialization.PublicFormat.Raw))
+ registry=directory/'whole-registry.json';registry.write_bytes((ACCESS/'examples/secrets-engine/registry_snapshot.json').read_bytes())
+ policy=ACCESS/'examples/secrets-engine-service-task-execution-v2/policy_package.md'
+ record=directory/'whole-access-record.jsonl';port=free_port();origin='http://127.0.0.1:'+str(port)
+ args=[str(BINARY),'serve','--addr','127.0.0.1:'+str(port),'--registry',str(registry),'--policy',str(policy),
+ '--log',str(record),'--envelope-signing-key-file',str(seed),'--envelope-signing-kid','synthetic-whole-key',
+ '--caller-auth-mode','enforce','--caller-audience','flex-auth',
+ '--caller-kubernetes-url','https://localhost:'+str(review.server_port),
+ '--caller-reviewer-token-file',str(review_file),'--caller-ca-file',str(ca),
+ '--caller-binding','secrets-engine='+PRINCIPAL,'--approval-source-file',str(config)]
+ process=subprocess.Popen(args,stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL,env={})
+ try:
+ for _ in range(100):
+ if process.poll() is not None:raise RuntimeError('actual trusted Access source exited')
+ try:
+ with socket.create_connection(('127.0.0.1',port),timeout=.1):break
+ except OSError:time.sleep(.025)
+ else:raise RuntimeError('actual trusted Access source startup unconfirmed')
+ trust={'schema':'secrets-engine.pdp-trust.v1','evaluator':'flex-auth/local','mode':'standalone',
+ 'policy_package':'secrets-engine.task-execution.service-pilot','policy_version':'v2',
+ 'policy_package_digest':POLICY_DIGEST,'keys':{'synthetic-whole-key':public}}
+ trustfile=private(directory/'whole-pdp-trust.json',json.dumps(trust))
+ approval_token=private(directory/'whole-consumer-approval.token',service)
+ pdp_token=private(directory/'whole-consumer-pdp.token',caller)
+ from task_execution_mediator import ConfirmationConfig
+ cfg=SimpleNamespace(approval_url='http://127.0.0.1:'+str(consumer.server_port),approval_token_file=approval_token,
+ approval_client_secret_file=None,pdp_url=origin,pdp_token_file=pdp_token,pdp_trust_file=trustfile,pdp_verifier=VERIFIER,
+ authorization_subject_id='secrets-engine',authorization_subject_type='service',
+ authorization_policy_package=trust['policy_package'],authorization_policy_version='v2',clock_trust_file=None)
+ def restart_registry():
+ nonlocal process
+ process.terminate();process.wait(timeout=5)
+ changed=json.loads(registry.read_bytes())
+ for subject in changed['subjects']:
+ if subject['id']=='secrets-engine':subject['metadata']['description']+=' synthetic revision drift'
+ registry.write_text(json.dumps(changed))
+ process=subprocess.Popen(args,stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL,env={})
+ for _ in range(100):
+ if process.poll() is not None:raise RuntimeError('changed-registry source startup refused')
+ try:
+ with socket.create_connection(('127.0.0.1',port),timeout=.1):break
+ except OSError:time.sleep(.025)
+ else:raise RuntimeError('changed-registry source startup unconfirmed')
+ yield SimpleNamespace(cfg=cfg,engine=engine,calls=calls,state=state,record=record,process=process,
+ registry=registry,restart_registry=restart_registry,
+ confirmation=ConfirmationConfig(cfg.approval_url,approval_token),human_token=human,
+ source_config=config,reader_file=reader_file)
+ finally:
+ process.terminate();process.wait(timeout=5)
+ for server in [source,consumer,review]:server.shutdown();server.server_close()
+ for thread in [source_thread,consumer_thread,review_thread]:thread.join(5)
+ engine.close()
+
+
+def install_exact_owner_descriptor(owner,*,window_seconds=60):
+ """All server startup precedes this exact proposal time; no remint/reissue."""
+ native,journal,binding,call,compiler,descriptor=owner
+ from secrets_engine.execution_contract import ExecutionContract
+ descriptor=json.loads(json.dumps(descriptor))
+ descriptor['backend_budget']={'ttl_seconds':30,'uses':12}
+ descriptor['operation_deadline']=(datetime.now(timezone.utc)+timedelta(seconds=window_seconds)).replace(microsecond=0).isoformat().replace('+00:00','Z')
+ ccr=compiler._derive()[0]
+ sources={'engine_dispatcher':SECRETS/'src/secrets_engine/task_execution_handoff.py',
+ 'platform_mediator':Path(__file__).resolve().parents[1]/'scripts/task_execution_mediator.py',
+ 'typed_ccr':compiler.validator,'policy_auth':compiler.config.platform_repo/ccr['openbao']['policy_file']}
+ descriptor['source_bundle']={role:{'path':str(path),'sha256':plan_source.tagged(path.read_bytes())} for role,path in sources.items()}
+ # Exact source descriptor participates in the existing reviewed pure grant
+ # projection. Synthetic fixture approval is not a production CCR admission.
+ record=json.loads(json.dumps(__import__('yaml').safe_load(compiler.grant_record.read_text())))
+ record['execution'],record['openbao']=compiler.grant_effects(compiler.compile(native.meta['request']),descriptor)
+ compiler.grant_record.write_text(__import__('yaml').safe_dump(record,sort_keys=False))
+ pins=dict(compiler.config.source_pins);pins[str(compiler.grant_record)]=plan_source.tagged(compiler.grant_record.read_bytes())
+ compiler=plan_source.Compiler(replace(compiler.config,source_pins=tuple(pins.items())))
+ descriptor['grant_immutable_sha256']=grant.immutable_digest(record)
+ descriptor=ExecutionContract.parse(descriptor).as_dict();raw=plan_source.encoded(descriptor)
+ plan=compiler.bind_execution(compiler.compile(native.meta['request']),descriptor,raw)
+ binding=Binding(binding.approval_id,binding.request_digest,binding.operation_id,plan.content_digest)
+ admission=replace(native.admission,descriptor=raw)
+ native=NativeFixtureAdapter(compiler,plan,journal,binding,admission)
+ return native,journal,binding,call,compiler,descriptor
diff --git a/tests/test_task_execution_trusted_whole.py b/tests/test_task_execution_trusted_whole.py
new file mode 100644
index 0000000..232fce4
--- /dev/null
+++ b/tests/test_task_execution_trusted_whole.py
@@ -0,0 +1,195 @@
+"""Exact actual source successor path; legacy fixture evidence remains separate."""
+from contextlib import contextmanager
+from dataclasses import replace
+from datetime import datetime,timedelta,timezone
+import json
+from pathlib import Path
+import signal
+import sys
+import threading
+import time
+from types import SimpleNamespace
+import uuid
+
+import pytest
+sys.path.insert(0,str(Path(__file__).resolve().parents[1]/'scripts'))
+from test_task_execution_native_fixture import owner,bao_dev,compiler
+from task_execution_trusted_servers import trusted_providers,install_exact_owner_descriptor,http,private
+import task_execution_trusted_servers as providers_source
+from task_execution_mediator import run_native_fixture_task
+from task_execution_native_fixture import NativeFixtureAdapter,_absolute_budget
+from task_execution_journal import Refused
+from secrets_engine.approval_claim import binding_from_check_request
+from secrets_engine.approval_consume import _expected_request
+from secrets_engine.authorization import approval_binding_digest
+from secrets_engine.execution_contract import ExecutionContract
+
+
+@contextmanager
+def whole(owner,directory,*,window_seconds=60):
+ # OpenBao, Approval HTTPS, TokenReview TLS and actual Access CLI are all up
+ # before proposing an immutable descriptor/deadline or issuing approval.
+ with trusted_providers(directory) as provider:
+ native,journal,binding,call,compiler,descriptor=install_exact_owner_descriptor(owner,window_seconds=window_seconds)
+ snapshot={path:compiler.read(Path(path)) for path in compiler.pins}
+ entry=compiler._catalogue_entry(snapshot)
+ approval_id=str(uuid.uuid4())
+ entry=replace(entry,approval={**entry.approval,'authorization_id':approval_id})
+ request=_expected_request(provider.cfg,entry,'execute-ccr',execution_contract=ExecutionContract.parse(descriptor))
+ from secrets_engine.task_execution_handoff import prepare_task_approval
+ preparation=prepare_task_approval(provider.cfg,entry,execution_contract=ExecutionContract.parse(descriptor))
+ normalization=preparation.as_owner_handoff()
+ assert normalization['original_request']==request
+ assert 'attributes' not in request['subject']
+ assert normalization['approval_binding_digest']!=normalization['submitted_request_digest']
+ now=datetime.now(timezone.utc)
+ body={'id':approval_id,'binding':normalization['native_binding'],'pdp_digest':normalization['approval_binding_digest'],
+ 'pdp_path':True,'human_control':True,'required_count':1,
+ 'validity':{'not_before':(now-timedelta(minutes=1)).isoformat(),'expires_at':(now+timedelta(minutes=3)).isoformat()}}
+ assert http(provider.cfg.approval_url,'POST','/v1/approvals',body,'synthetic-whole-service')[0]==201
+ assert http(provider.cfg.approval_url,'POST','/v1/approvals/'+approval_id+'/entries',{},provider.human_token)[0]==200
+ yield SimpleNamespace(native=native,journal=journal,call=call,compiler=compiler,descriptor=descriptor,
+ entry=entry,provider=provider,cfg=provider.cfg,approval_id=approval_id,request=request,normalization=normalization,preparation=preparation)
+
+
+def invoke(fixture):
+ return run_native_fixture_task(compiler=fixture.compiler,journal=fixture.journal,
+ plan_request=fixture.native.meta['request'],owner_descriptor=fixture.native.admission.descriptor,
+ secrets_cfg=fixture.cfg,entry=fixture.entry,confirmation=fixture.provider.confirmation,
+ native_admission=fixture.native.admission)
+
+
+def untouched(fixture):
+ assert fixture.call('GET','sys/policies/acl/'+fixture.native.policy_name)==(404,{'errors':[]})
+ for ordinal in [1,4]:
+ path=fixture.compiler.before_call(fixture.native.plan,ordinal).path
+ assert fixture.call('GET',path)==(404,{'errors':[]})
+
+
+def test_actual_successor_allow_strict_cas_confirm_six_calls_readback_cleanup(owner,tmp_path,monkeypatch):
+ with whole(owner,tmp_path) as fixture:
+ from secrets_engine.task_execution_handoff import consume_task_execution
+ with pytest.raises(Exception):consume_task_execution(fixture.cfg,fixture.preparation)
+ assert not any(item==('consumer','POST','/v1/approvals/'+fixture.approval_id+'/consume') for item in fixture.provider.calls)
+ trace=[];private_task=[];issued_native=[]
+ original=NativeFixtureAdapter._http
+ def observed(native,method,path,token,body=b''):
+ result=original(native,method,path,token,body)
+ trace.append((method,path,result[0]))
+ if method=='POST' and path=='auth/token/create' and result[0]==200:
+ private_task.append((result[1]['auth']['accessor'],result[1]['auth']['client_token']))
+ issued_native.append(native)
+ return result
+ monkeypatch.setattr(NativeFixtureAdapter,'_http',observed)
+ try:result=invoke(fixture)
+ except Exception as error:
+ from urllib.error import HTTPError
+ cause=error.__cause__
+ if isinstance(cause,HTTPError):
+ raw=cause.read(4097)
+ known={b'trusted Approval source refused\n':'approval-source-correspondence',
+ b'service task execution input refused\n':'service-input-correspondence'}
+ diagnostic={'status':cause.code,'constant_reason':known.get(raw,'other-closed-classification'),
+ 'native_operations':fixture.journal.db.execute('SELECT COUNT(*) FROM operations').fetchone()[0]}
+ (tmp_path/'closed-source-refusal.json').write_text(json.dumps(diagnostic))
+ print(json.dumps(diagnostic))
+ raise
+ assert result['native_apply']=='verified' and result['native_calls_completed']==6
+ assert result['cleanup_confirmed'] and result['business_state']=='cleanup-confirmed'
+ assert fixture.provider.engine.get(fixture.approval_id).status=='consumed'
+ records=[json.loads(line) for line in fixture.provider.record.read_bytes().splitlines()]
+ assert len(records)==2 and [x['effect'] for x in records]==['deny','allow']
+ assert all(x['signature']['mode']=='signed' for x in records)
+ normalization_record,record=records
+ assert normalization_record['id']==fixture.normalization['decision_id']
+ assert record['id']==result['decision_id']
+ assert record['lifetime']['expires_at']==fixture.descriptor['operation_deadline']
+ assert record['provenance']['policy_package']=='secrets-engine.task-execution.service-pilot'
+ assert record['provenance']['policy_version']=='v2'
+ assert record['provenance']['approval_observation']['source_id']=='synthetic-whole-actual-approval'
+ assert record['binding']['context']['execution_contract']==fixture.descriptor
+ native_calls=fixture.journal.db.execute('SELECT ordinal,outcome FROM calls ORDER BY ordinal').fetchall()
+ assert native_calls[:2]==[(0,'complete'),(1,'complete')] and len(native_calls)==13
+ assert all(value=='complete' for _,value in native_calls)
+ assert fixture.provider.calls.count(('consumer','POST','/v1/approvals/'+fixture.approval_id+'/consume'))==1
+ assert fixture.provider.calls.count(('consumer','GET','/v1/approvals/'+fixture.approval_id))==1
+ assert any(item==('source','GET','/v1/approvals/'+fixture.approval_id+'/claim') for item in fixture.provider.calls)
+ expected=fixture.compiler.proposal()['operations']
+ assert [(method,path) for method,path,_ in trace if path in {item['path'] for item in expected}]==[(x['method'],x['path']) for x in expected]
+ assert len(private_task)==1
+ assert issued_native[0]._task_wall_deadline<issued_native[0].deadline
+ assert issued_native[0]._task_monotonic_deadline<issued_native[0].monotonic_deadline
+ for accessor in [private_task[0][0],fixture.native.admission.identity_session.accessor]:
+ assert fixture.call('POST','auth/token/lookup-accessor',{'accessor':accessor})[0]==400
+ assert fixture.call('GET','sys/policies/acl/'+fixture.native.policy_name)==(404,{'errors':[]})
+ raw=(fixture.journal.directory/'journal.sqlite').read_bytes()
+ for secret in [*private_task[0],fixture.native.admission.owner_token,fixture.native.admission.identity_session.token]:assert secret.encode() not in raw
+ before=list(trace)
+ with pytest.raises(Exception):invoke(fixture)
+ assert trace==before
+ assert fixture.provider.calls.count(('consumer','POST','/v1/approvals/'+fixture.approval_id+'/consume'))==1
+ # Public evidence is an allowlist: never serialize native admission,
+ # credentials, private handles, arbitrary errors or provider config.
+ evidence={'schema':'railiance-platform.disposable-wholechain-proof.v1',
+ 'source_effects':[x['effect'] for x in records],
+ 'signed_source_records':all(x['signature']['mode']=='signed' for x in records),
+ 'retained_registry_normalized':fixture.normalization['approval_binding_digest']!=fixture.normalization['submitted_request_digest'],
+ 'approval_state':fixture.provider.engine.get(fixture.approval_id).status,
+ 'consume_count':1,'independent_confirmation_get_count':1,
+ 'native_calls_completed':result['native_calls_completed'],
+ 'business_state':result['business_state'],'cleanup_confirmed':result['cleanup_confirmed'],
+ 'durable_completed_calls':len(native_calls),'retry_refused':True,
+ 'credential_expiry_narrows_operation_deadline':True,
+ 'private_material_absent_from_public_journal':True,
+ 'immediate_cas':True,'artificial_clock_or_cas_wait':False,
+ 'production_admission':False}
+ (tmp_path/'wholechain-public-receipt.json').write_text(json.dumps(evidence,indent=2)+'\n')
+
+
+@pytest.mark.parametrize('case',['source-drift','foreign-caller','revoked','source-reader-denied','registry-drift'])
+def test_actual_successor_refusals_never_consume_or_touch_backend(owner,tmp_path,case):
+ with whole(owner,tmp_path) as fixture:
+ expected_paths=[fixture.compiler.before_call(fixture.native.plan,x).path for x in [1,4]]
+ if case=='source-drift':
+ fixture.compiler.validator.write_bytes(fixture.compiler.validator.read_bytes()+b'\n# changed source\n')
+ elif case=='foreign-caller':fixture.provider.state.principal='system:serviceaccount:other:other'
+ elif case=='revoked':
+ assert http(fixture.cfg.approval_url,'POST','/v1/approvals/'+fixture.approval_id+'/revoke',{},fixture.provider.human_token)[0]==200
+ elif case=='source-reader-denied':private(fixture.provider.reader_file,'synthetic-unknown-reader')
+ elif case=='registry-drift':fixture.provider.restart_registry()
+ with pytest.raises(Exception):invoke(fixture)
+ assert not any(item==('consumer','POST','/v1/approvals/'+fixture.approval_id+'/consume') for item in fixture.provider.calls)
+ assert fixture.journal.db.execute('SELECT COUNT(*) FROM operations').fetchone()[0]==0
+ records=[json.loads(line) for line in fixture.provider.record.read_bytes().splitlines()]
+ assert [x['effect'] for x in records]==['deny']
+ assert fixture.call('GET','sys/policies/acl/'+fixture.native.policy_name)==(404,{'errors':[]})
+ for path in expected_paths:assert fixture.call('GET',path)==(404,{'errors':[]})
+
+
+def test_actual_revoke_between_prepare_and_cas_keeps_native_untouched(owner,tmp_path,monkeypatch):
+ import secrets_engine.task_execution_handoff as strict
+ with whole(owner,tmp_path) as fixture:
+ original=strict.consume_task_execution
+ def revoked(cfg,authorization):
+ assert http(cfg.approval_url,'POST','/v1/approvals/'+fixture.approval_id+'/revoke',{},fixture.provider.human_token)[0]==200
+ return original(cfg,authorization)
+ monkeypatch.setattr(strict,'consume_task_execution',revoked)
+ with pytest.raises(Refused,match='confirmation unconfirmed'):invoke(fixture)
+ assert fixture.journal.db.execute('SELECT state FROM operations').fetchone()[0]=='unknown'
+ untouched(fixture)
+ assert fixture.provider.engine.get(fixture.approval_id).status=='revoked'
+ assert [json.loads(line)['effect'] for line in fixture.provider.record.read_bytes().splitlines()]==['deny','allow']
+
+
+
+def test_expired_contract_refuses_preparation_before_approval_or_native(owner,tmp_path):
+ from secrets_engine.task_execution_handoff import prepare_task_approval
+ with trusted_providers(tmp_path) as provider:
+ native,journal,binding,call,compiler,descriptor=install_exact_owner_descriptor(owner,window_seconds=-1)
+ snapshot={path:compiler.read(Path(path)) for path in compiler.pins}
+ entry=compiler._catalogue_entry(snapshot)
+ with pytest.raises(Exception):prepare_task_approval(provider.cfg,entry,execution_contract=ExecutionContract.parse(descriptor))
+ assert not provider.record.exists() or not provider.record.read_bytes()
+ assert not any(len(item)==3 and item[1]=='POST' for item in provider.calls)
+ assert journal.db.execute('SELECT COUNT(*) FROM operations').fetchone()[0]==0
+ assert call('GET','sys/policies/acl/'+native.policy_name)==(404,{'errors':[]})
diff --git a/docs/task-execution-native-fixture.md b/docs/task-execution-native-fixture.md
index c4292db..255c7e2 100644
--- a/docs/task-execution-native-fixture.md
+++ b/docs/task-execution-native-fixture.md
@@ -258,3 +258,17 @@ Actual native fixed grant/metadata/readback/cleanup remains a separate positive
compatibility check. There is no successful actual normalized provider-chain
claim in this slice; source-managed pre-approval normalization and immediate CAS
precision must pass the follow-up full-stack proof first.
+
+2026-10-16 T12 actual v2 successor source proof: retained registry signed DENY
+normalization from the real Secrets prepare_task_approval builder precedes actual
+Approval issue/human approval. Actual Access v2 then signs ALLOW; strict Secrets
+CAS and independent confirmation GET precede six disposable native calls, exact
+readback and confirmed token/policy cleanup. Approval precision source permits
+immediate CAS without sleeps/backdating. The 60-second immutable deadline,
+30-second backend TTL, 120-second synthetic observation and existing cleanup
+reserve remain bound; issued credential expiry narrows business time. Source,
+caller, revoke, source-reader, registry drift and expiry refusals cause no native
+effects; revoke between ALLOW/CAS retains unknown without business dispatch.
+This owner fixture uses separately installed synthetic identity/key/trust and
+loopback OpenBao, grants no production authority and keeps T12 progress. Prior
+failed temporal/normalization proofs remain retained.
diff --git a/workplans/RPF-WP-0035-credential-lane-implementation.md b/workplans/RPF-WP-0035-credential-lane-implementation.md
index 92a3f3f..0c4eace 100644
--- a/workplans/RPF-WP-0035-credential-lane-implementation.md
+++ b/workplans/RPF-WP-0035-credential-lane-implementation.md
@@ -1316,3 +1316,17 @@ authorization/effects. No deadline/TTL extension, remint or generic service adde
Retained registry actual-provider proof still refuses Approval correspondence
before CAS/native; normalized pre-approval + precise immediate CAS whole-stack
positive remains live owner follow-up under CUST-WP-0080. T12 stays progress.
+
+2026-10-16 T12 actual v2 successor source proof: retained registry signed DENY
+normalization from the real Secrets prepare_task_approval builder precedes actual
+Approval issue/human approval. Actual Access v2 then signs ALLOW; strict Secrets
+CAS and independent confirmation GET precede six disposable native calls, exact
+readback and confirmed token/policy cleanup. Approval precision source permits
+immediate CAS without sleeps/backdating. The 60-second immutable deadline,
+30-second backend TTL, 120-second synthetic observation and existing cleanup
+reserve remain bound; issued credential expiry narrows business time. Source,
+caller, revoke, source-reader, registry drift and expiry refusals cause no native
+effects; revoke between ALLOW/CAS retains unknown without business dispatch.
+This owner fixture uses separately installed synthetic identity/key/trust and
+loopback OpenBao, grants no production authority and keeps T12 progress. Prior
+failed temporal/normalization proofs remain retained.

View file

@ -0,0 +1,32 @@
{
"schema": "railiance-platform.owner-source-review.v1",
"reviewer_role": "railiance-platform-owner",
"agent": "railiance-platform (codex)",
"proposal_id": "RPF35-ACTUAL-V2-WHOLECHAIN-20261016-R1",
"reviewed_revision_sha256": "a26c68ff224663129757fd43d0a5364762180131030e4dda22975935be89f99d",
"disposition": "approve-source-only",
"basis": "RPF-WP-0035-T12; existing owner INTENT and delegated user authorization for isolated full-chain source proof",
"assessment": "Actual producer paths and retained registry feed normalized preapproval facts into actual Approval; real strict consumer and separate read confirmation precede owner fixed native adapter. Deadline, TTL and cleanup reserve remain enforced; no generic Access policy, empty registry, injected expected digest, remint, clock waiver or delayed CAS. Private handles remain excluded from public journal and packet.",
"preserved": [
"frozen signer proposal",
"frozen recovery R2",
"frozen budget-source slice",
"failed temporal/retained-registry evidence",
"legacy ec215 proof",
"CCR-2026-0048",
"CCR-2026-0049",
"WORK-RECORDS.md"
],
"limits": [
"disposable loopback OpenBao only",
"synthetic separately installed caller/Approval identities, signer and public trust",
"no live key/custody/native grant or production role admission",
"no native atomic CAS or external exclusive target custody claim",
"source fixture hashes/paths require exact replacement when integrating dependencies"
],
"cost": {
"source_preparation_incremental_eur": 0,
"production_cost": "unadmitted"
},
"authority_granted": false
}

View file

@ -0,0 +1,260 @@
# Executable native metadata/grant/cleanup preparation
RPF-WP-0035-T12, 2026-10-16, second source package after the cleanup journal.
The fixed `NativeFixtureAdapter` actually dispatches to disposable loopback OpenBao.
It requires a live owner-spawned OpenBao dev-process handle with the exact loopback
port and dev/no-store-token arguments, and refuses every non-loopback origin.
Production is deliberately not implemented. This is a guard against accidental
misrouting inside the trusted fixture runtime, not a Python security sandbox.
No estate credentials, grants, approvals or policy packages are read or changed.
The tests start in-memory dev servers with synthetic root/session credentials,
synthetic owner admission, grant/state/source digests and a synthetic postconsume
journal transition. This proves native primitives, not authenticated admission.
The owner Compiler now deterministically includes the operation-specific temporary
policy name and cleanup call commitments in each serialized Plan. Commitments
include identity/task credential slots, exact native revoke/verify or delete/verify
method/path pairs, and the narrow no-issue/no-create skip branches. The full
execution descriptor can be bound into that Plan only by byte equality with an
independently installed owner descriptor, with operation/actor/CCR/catalogue/six
operations correspondence. Compiler revalidation preserves both descriptor and
cleanup content. The descriptor's SHA is preparation provenance; no opaque digest
or local file becomes approval or verified canonical request correspondence.
The adapter needs the exact compiler/Plan/binding and separately installed owner
fixture inputs. All handles stay in owner memory with hidden representations. It
requires the same live consumed journal admission and exact grant-issuing state.
It refuses an existing per-operation grant policy, creates only the two compiler
metadata endpoints plus revoke-self, verifies HCL, mints one parented service token
with only this policy, no default/identity policies, nonrenewable TTL <=120 seconds
and twelve uses, then verifies native token bounds through the owner. Six ordered
metadata calls use independently recompiled source/method/path/body. A task use is
reserved for revoke; native token limits also enforce the ceiling. Business calls
check wall and monotonic deadlines before effect, with a five-second cleanup reserve.
No retry/remint, caller HCL/JSON/path, mount/AppRole/KV/SecretID or shell API exists.
Wave 5 below adds exact sequential snapshot acceptance; native baseline CAS remains absent.
Every business/grant request records durable intent and closed status evidence.
Native cleanup has its own durable per-request intents beneath the original
committed obligation; accessor verification cannot erase an uncertain revoke.
The native adapter compares the exact committed cleanup sequence before request.
Self-revoke success must be followed by owner lookup-accessor's specific invalid
accessor response; generic HTTP400/403, timeout and policy deletion do not prove
revocation. The two explicitly accepted error encodings cover OpenBao's plain and
wrapped single-invalid-accessor forms. Native error text is never logged or hashed.
Cleanup bypasses expired business deadlines under the retained explicit obligation,
never source-driven business repair. Independent cleanup continues after failure;
retirement never overwrites the original unknown/failed business outcome.
Current actual native tests cover policy/body and K8s-role application/readback,
owner-parented TTL/use policy bounds, native sibling/KV/token/mount/SecretID capability
denial, both token accessor-invalid proofs, policy absence, deadline-expired cleanup,
source drift, no preconsume materialization, changed descriptor/cleanup commitments,
nonloopback refusal, actual write followed by simulated lost response, and actual
policy retirement with failed task revocation. The lost response is fault injection
after a successful real native write; it does not pretend to be an actual network
partition. The failed-revoke fixture independently confirms the still-existing
native token, then performs explicit synthetic owner recovery. No blind retry occurs.
## Minimal remaining decisions and integration
1. Define/admit `task-execution-grant` in the owner CCR validator. Bind the exact
cleanup commitments, temporary policy/token bodies and source revisions into its
immutable projection, referenced by the existing descriptor grant SHA. The
descriptor currently has no cleanup field; no engine schema extension is needed
if this owner grant binding is made explicit. The immutable projection/excluded
append-only fields are not implemented in the current owner validator. This is
the concrete owner schema/admission decision, not a need for a new service.
2. Have the existing authoritative engines supply authenticated initiating principal,
exact canonical request + served claim/PDP/package and independent signed CAS
consume verification to the owner mediator before its journal transition.
The native fixture's local transition supplies none of that evidence.
3. Install exact origin/cluster/source/grant/baseline/readback inputs under the
attended owner runtime. The current binder compares independently installed
descriptor bytes, but does not authenticate source-bundle paths, grant digest or
compute immutable CCR projection. The six-operation plan still comes from pinned
actual owner source. Production origin authentication and deadline bounds against
served approval/PDP remain separate.
4. Finish expected-state normalization and conflict handling for native K8s/OIDC
defaults; add per-policy/role approved baseline comparison and exact readback
verification. The fixture checks known fields, not total production equality.
OpenBao metadata PUT has no native atomic CAS; the owner must choose exclusive
admitted target custody or an honest conflict/recovery protocol.
5. Wire the fixed adapter to that mediator with bounded runtime isolation,
owner-private uncertain-issuance recovery, external audit outbox and exact
founder-scoped attended trial. No production caller or protected recovery channel
exists here. The existing production readiness gate remains refused.
No umbrella broker, general executor, new credential catalogue or standing service
admin entitlement is introduced. Approval CAS prevents approval replay; it cannot
make uncertain native effects exactly-once. RPF T12 and Secrets WP13 remain open.
### Exact disposable metadata acceptance (2026-10-16, wave 5)
The owner-installed `FixtureAdmission` now carries canonical JSON before/after
snapshot bytes. Their SHA-256 values must equal the existing descriptor's
`expected_before` and `expected_after`, and both snapshots must name exactly the
two compiled metadata paths. Explicit JSON null means absence. Only the actual
OpenBao empty-errors 404 is accepted as absence; denied/malformed reads fail.
These inputs contain metadata, never KV values or private token handles.
Policy comparison retains name, HCL, version and cas_required. Only the
modification timestamp is excluded, after requiring an aware timestamp and the
closed observed policy field set. Role comparison retains every returned field,
including aliases, token defaults and unexpected additions, using canonical JSON
bytes (therefore false cannot equal zero). The disposable Kubernetes fixture pins
the complete OpenBao 2.5.5 response shape. Another plugin/version/default requires
an independently reviewed owner snapshot; nothing infers new accepted defaults.
Each baseline gates its corresponding write. Policy readback occurs before role
baseline/write/readback. A role conflict can therefore leave the policy applied;
that operation remains unknown. This sequence has no native CAS, aggregate
baseline gate, concurrent-writer exclusion, or simultaneous final-state proof.
After all six calls match the committed snapshots, the same-live adapter records
readback and verified with a value-free evidence digest. The mediator reports
native_apply verified; cleanup-confirmed requires all separate cleanup receipts.
Readback success with unconfirmed cleanup cannot reach cleanup-confirmed.
Reopening SQLite cannot dispatch, reconsume, restore token/accessor handles or
retry cleanup. Native effects can survive process loss. Cleanup handles remain
memory-only: no independently authorized recovery mechanism is implemented here.
This closes disposable sequential apply acceptance only, not TSA03 or native
production recovery/admission.
## Independent cleanup recovery source successor
`task_execution_recovery.py` adds separately installed fixture-only owner custody
and fresh recovery permits. Optional `recovery_custody` on NativeFixtureAdapter
persists the original binding/cleanup commitments, exact operation policy, backend
port and identity accessor before any grant effect; it commits mint intent before
token creation and task accessor immediately after a valid issued response.
Bearer credentials are never persisted. The custody directory is separate from
the public journal, owner-only 0700/0600, bounded at 64KiB, no-follow, with atomic
replacement and fsync. It is real process-independent persistence for synthetic
fixtures. It does not isolate same-UID hostile execution, encrypt at rest, select
production storage or establish a production secret policy. Those are explicit
native custody/deployment gates, not supplied by Python types or file modes.
The independent owner reads its private custody revision and installs a fresh
bounded permit, current owner credential and retained live disposable Popen.
Permit revocation is durably owner-recorded. The recovery runner has no mint,
consume, policy-apply or business method. It dispatches only fixed exact-accessor
revoke/readback and exact `task-exec-<operation>` policy read/delete/readback.
A differently recreated policy is never deleted. Backend admission remains the
same loopback-only owner-spawned dev process; no estate backend can be selected.
Accessor-based recovery is an independently admitted alternate cleanup projection,
not a reinterpretation of the original revoke-self call digest. Production needs
exact owner-reviewed recovery scope; original business grant does not imply it.
Fresh recovery holds the journal process lock but never sets business admission
or consumed-admission fields. Separate recovery attempt/call/result tables retain
original cleanup statuses before reconciliation, write native intent before calls,
and preserve failed/pending outcomes. Proven cleanup alone updates the exact
original cleanup obligation to confirmed. Business state and original pending
call uncertainty remain unchanged. Reusing the original approval, continuing
metadata calls and reusing a recovery permit refuse. Failure needs another
independent owner permit; there is no automatic retry. Expired/revoked permits
or changed private custody revision refuse before calls.
A token-create response lost before accessor custody remains unresolved token
cleanup debt: policy deletion/identity revocation can be independently confirmed,
but cannot imply task-token revocation. Neither expiry guessed from wall clock
nor policy absence fabricates token absence. A production successor must admit
an independent exact ambiguous-issuance reconciliation mechanism or prove bounded
expiry from owner backend evidence. No such new grant is implemented here.
`test_task_execution_recovery.py` forks a real executor and terminates it without
finally cleanup after issue, business effect/lost response and revoke/lost response.
The independent parent, holding the backend and separately installed custody,
recovers specific obligations and verifies actual token/policy absence. Additional
cases cover lost-mint response, revoked credential, permit revocation/expiry,
revision drift, namespace policy-name reuse and private-file/binding refusals.
This is source fixture proof only. Native custody, isolation, exact recovery-role
permissions, retention/encryption, clock admission, audit delivery and human scope
approval remain missing. No generic broker, production reader, grant or CLI is added.
### Recovery R2 exact absence and authenticated capsule refinement
R1 overclaimed policy absence for arbitrary 404 bodies. R2 requires exactly
`{"errors":[]}` for both initial GET and post-DELETE GET. Other 404 bodies,
permission denials, extra data, malformed/duplicate-key/nonfinite JSON and failed
readback cannot confirm policy cleanup. A successful DELETE status alone cannot
confirm absence. Recovery native policy comparison admits precisely one optional
terminal LF, matching the retained OpenBao HCL source/readback convention; spaces,
tabs, CR, extra terminal LF and any other byte change are refused. This is a
narrow declared normalization, not full whitespace or semantic equivalence.
Private fixture capsules now carry HMAC-SHA256 authentication under an exactly
32-byte integrity key supplied independently by the recovery owner. The key is
never stored in the capsule or public journal, and is not a bearer credential.
The fixture owner retains this key independently across executor death; a fresh
owner custody object must supply the same key. Wrong-key or damaged accessor/HCL
bytes refuse before admission. This is integrity protection, not encryption or
same-UID isolation; production key custody, rotation/retention and recovery actor
selection remain explicitly unadmitted.
After authentication the reader enforces exact top-level and nested binding/
cleanup key sets and types, validated Binding/Cleanup formats and the ordered
source-derived cleanup_specs operation digests. Duplicate JSON keys, nonfinite
numbers, malformed phase/revision combinations and type-coercible values refuse.
The reader bounds bytes, rejects indirect/nonprivate files and detects stat/size
changes during its single read. A valid older prepared capsule still cannot
prove a task token absent when durable journal state/calls reached issuance:
that remains unknown cleanup debt. Authentication alone is not anti-rollback.
No business or consume admission is restored and no production scope is granted.
## Absolute budgets, effective credential expiry and exact source bytes
This source successor tightens the disposable native owner adapter; it does not
establish actual v2 whole-stack ALLOW/native acceptance. The retained-registry
Approval correspondence failure is preserved in the coordinator artifact. The
new real provider fixture is retained separately as unfinished follow-up, while
the existing ec215-based fixture evidence remains its original scoped proof.
Every native business/grant method now runs inside the earlier wall/monotonic
operation deadline minus the five-second reserve. After exact owner token lookup,
the adapter requires aware expire_time and retains the earlier actual expiry and
conservative mint-start + declared TTL bound, in wall and monotonic coordinates.
Every subsequent business call uses the minimum of global and credential bounds.
The descriptor and signed global deadline are never extended or rewritten.
Missing/expired credential evidence refuses issuance acceptance. Cleanup continues
under its original separate obligation after either business bound expires.
The fixture owner main-thread execution profile uses absolute SIGALRM budgets,
not socket inactivity timeouts alone. Each native transport and cleanup native
call has a two-second execution deadline, including intent/source checking and
send/header/body/close work. Larger compiler reconstruction remains within the
earlier operation/credential window. A 50ms alarm backstop keeps blocked teardown
interruptible after timeout; it cannot authorize another business effect. Nested
timers retain the shorter outer deadline rather than refreshing it. Unsupported
threads, invalid budgets and conflicting ambient timers refuse. This fixed Unix
fixture profile does not select a production process supervisor or provide a
shared arbitrary-thread execution service. Native rollout still needs exact owner
runtime/admission, trusted clock and independent recovery/custody enforcement.
The actual v2 package/version selects additional mediator checks over the exact
independently installed descriptor source_bundle before authorization and each
freshness gate, including pre-native grant entry. It reads bounded regular,
files without group/world write, non-indirect paths, compares exact source hashes and stat stability,
and applies an absolute read budget. The existing compiler's own pin set does not
implicitly authenticate other source_bundle roles; these explicit checks fill
that gap. No requester-selected path, approval flag or source boolean can enable
this path. Legacy synthetic fixture proof remains separately scoped and is not
used to satisfy v2 acceptance.
Native policy/grant absence now requires exact empty-errors404 and policy HCL
comparison admits only the already reviewed single optional terminal LF, reusing
frozen recovery R2 helpers. Native response JSON rejects duplicate/nonfinite data.
The independent slice tests cover actual header/body drips, stalled close after
timeout, slow compiler and shorter actual credential expiry before any HTTP
effect, nested/conflicting/foreign-thread budgets, private source hash drift,
unsafe modes/indirection/FIFO/oversize refusal, and bounded cleanup after expiry.
Actual native fixed grant/metadata/readback/cleanup remains a separate positive
compatibility check. There is no successful actual normalized provider-chain
claim in this slice; source-managed pre-approval normalization and immediate CAS
precision must pass the follow-up full-stack proof first.

View file

@ -0,0 +1,22 @@
{
"schema": "railiance-platform.disposable-wholechain-proof.v1",
"source_effects": [
"deny",
"allow"
],
"signed_source_records": true,
"retained_registry_normalized": true,
"approval_state": "consumed",
"consume_count": 1,
"independent_confirmation_get_count": 1,
"native_calls_completed": 6,
"business_state": "cleanup-confirmed",
"cleanup_confirmed": true,
"durable_completed_calls": 13,
"retry_refused": true,
"credential_expiry_narrows_operation_deadline": true,
"private_material_absent_from_public_journal": true,
"immediate_cas": true,
"artificial_clock_or_cas_wait": false,
"production_admission": false
}

View file

@ -0,0 +1,37 @@
{
"schema": "railiance-platform.source-validation.v1",
"patch_sha256": "a26c68ff224663129757fd43d0a5364762180131030e4dda22975935be89f99d",
"tests": [
{
"command": "SECRETS_ENGINE_SOURCE_ROOT=/tmp/netkingdom-secrets-trusted-successor PYTHONPATH=/tmp/netkingdom-secrets-trusted-successor/src:/tmp/netkingdom-approval-timestamp-precision-20261016 pytest -q tests/test_task_execution_trusted_whole.py",
"result": "8 passed in 117.24s",
"source_stage": "before public receipt output and provenance label refinement"
},
{
"command": "same environment pytest -q tests/test_task_execution_trusted_whole.py::test_actual_successor_allow_strict_cas_confirm_six_calls_readback_cleanup --basetemp=/tmp/platform-fullchain-verification-r1",
"result": "1 passed in 22.46s",
"source_stage": "final frozen patch"
}
],
"apply_check": "pass against exact base file preimages/new-file absence",
"history": [
{
"result": "actual full pipeline completed, then assertion KeyError expires_at",
"correction": "test uses actual record lifetime.expires_at; no source verifier/time changes"
}
],
"meaningful_cases": [
"retained-registry DENY facts cannot consume",
"actual ALLOW strict CAS independent GET six native calls readback cleanup",
"same operation retry refuses without consume or native calls",
"source bundle byte drift refuses pre-PDP",
"server-reviewed foreign caller refuses",
"actual human revoke refuses",
"fixed source read identity denied refuses",
"retained registry drift refuses original approval joins",
"expired descriptor refuses before Approval issue",
"actual revoke after ALLOW before CAS leaves journal unknown and native untouched"
],
"public_receipt_sha256": "a79838775b161f71cae7afa15427edcdbaf0c878c1e9c5d7ac8265a26bef91ee",
"authority_granted": false
}

View file

@ -0,0 +1,207 @@
# Executable native metadata/grant/cleanup preparation
RPF-WP-0035-T12, 2026-10-16, second source package after the cleanup journal.
The fixed `NativeFixtureAdapter` actually dispatches to disposable loopback OpenBao.
It requires a live owner-spawned OpenBao dev-process handle with the exact loopback
port and dev/no-store-token arguments, and refuses every non-loopback origin.
Production is deliberately not implemented. This is a guard against accidental
misrouting inside the trusted fixture runtime, not a Python security sandbox.
No estate credentials, grants, approvals or policy packages are read or changed.
The tests start in-memory dev servers with synthetic root/session credentials,
synthetic owner admission, grant/state/source digests and a synthetic postconsume
journal transition. This proves native primitives, not authenticated admission.
The owner Compiler now deterministically includes the operation-specific temporary
policy name and cleanup call commitments in each serialized Plan. Commitments
include identity/task credential slots, exact native revoke/verify or delete/verify
method/path pairs, and the narrow no-issue/no-create skip branches. The full
execution descriptor can be bound into that Plan only by byte equality with an
independently installed owner descriptor, with operation/actor/CCR/catalogue/six
operations correspondence. Compiler revalidation preserves both descriptor and
cleanup content. The descriptor's SHA is preparation provenance; no opaque digest
or local file becomes approval or verified canonical request correspondence.
The adapter needs the exact compiler/Plan/binding and separately installed owner
fixture inputs. All handles stay in owner memory with hidden representations. It
requires the same live consumed journal admission and exact grant-issuing state.
It refuses an existing per-operation grant policy, creates only the two compiler
metadata endpoints plus revoke-self, verifies HCL, mints one parented service token
with only this policy, no default/identity policies, nonrenewable TTL <=120 seconds
and twelve uses, then verifies native token bounds through the owner. Six ordered
metadata calls use independently recompiled source/method/path/body. A task use is
reserved for revoke; native token limits also enforce the ceiling. Business calls
check wall and monotonic deadlines before effect, with a five-second cleanup reserve.
No retry/remint, caller HCL/JSON/path, mount/AppRole/KV/SecretID or shell API exists.
Wave 5 below adds exact sequential snapshot acceptance; native baseline CAS remains absent.
Every business/grant request records durable intent and closed status evidence.
Native cleanup has its own durable per-request intents beneath the original
committed obligation; accessor verification cannot erase an uncertain revoke.
The native adapter compares the exact committed cleanup sequence before request.
Self-revoke success must be followed by owner lookup-accessor's specific invalid
accessor response; generic HTTP400/403, timeout and policy deletion do not prove
revocation. The two explicitly accepted error encodings cover OpenBao's plain and
wrapped single-invalid-accessor forms. Native error text is never logged or hashed.
Cleanup bypasses expired business deadlines under the retained explicit obligation,
never source-driven business repair. Independent cleanup continues after failure;
retirement never overwrites the original unknown/failed business outcome.
Current actual native tests cover policy/body and K8s-role application/readback,
owner-parented TTL/use policy bounds, native sibling/KV/token/mount/SecretID capability
denial, both token accessor-invalid proofs, policy absence, deadline-expired cleanup,
source drift, no preconsume materialization, changed descriptor/cleanup commitments,
nonloopback refusal, actual write followed by simulated lost response, and actual
policy retirement with failed task revocation. The lost response is fault injection
after a successful real native write; it does not pretend to be an actual network
partition. The failed-revoke fixture independently confirms the still-existing
native token, then performs explicit synthetic owner recovery. No blind retry occurs.
## Minimal remaining decisions and integration
1. Define/admit `task-execution-grant` in the owner CCR validator. Bind the exact
cleanup commitments, temporary policy/token bodies and source revisions into its
immutable projection, referenced by the existing descriptor grant SHA. The
descriptor currently has no cleanup field; no engine schema extension is needed
if this owner grant binding is made explicit. The immutable projection/excluded
append-only fields are not implemented in the current owner validator. This is
the concrete owner schema/admission decision, not a need for a new service.
2. Have the existing authoritative engines supply authenticated initiating principal,
exact canonical request + served claim/PDP/package and independent signed CAS
consume verification to the owner mediator before its journal transition.
The native fixture's local transition supplies none of that evidence.
3. Install exact origin/cluster/source/grant/baseline/readback inputs under the
attended owner runtime. The current binder compares independently installed
descriptor bytes, but does not authenticate source-bundle paths, grant digest or
compute immutable CCR projection. The six-operation plan still comes from pinned
actual owner source. Production origin authentication and deadline bounds against
served approval/PDP remain separate.
4. Finish expected-state normalization and conflict handling for native K8s/OIDC
defaults; add per-policy/role approved baseline comparison and exact readback
verification. The fixture checks known fields, not total production equality.
OpenBao metadata PUT has no native atomic CAS; the owner must choose exclusive
admitted target custody or an honest conflict/recovery protocol.
5. Wire the fixed adapter to that mediator with bounded runtime isolation,
owner-private uncertain-issuance recovery, external audit outbox and exact
founder-scoped attended trial. No production caller or protected recovery channel
exists here. The existing production readiness gate remains refused.
No umbrella broker, general executor, new credential catalogue or standing service
admin entitlement is introduced. Approval CAS prevents approval replay; it cannot
make uncertain native effects exactly-once. RPF T12 and Secrets WP13 remain open.
### Exact disposable metadata acceptance (2026-10-16, wave 5)
The owner-installed `FixtureAdmission` now carries canonical JSON before/after
snapshot bytes. Their SHA-256 values must equal the existing descriptor's
`expected_before` and `expected_after`, and both snapshots must name exactly the
two compiled metadata paths. Explicit JSON null means absence. Only the actual
OpenBao empty-errors 404 is accepted as absence; denied/malformed reads fail.
These inputs contain metadata, never KV values or private token handles.
Policy comparison retains name, HCL, version and cas_required. Only the
modification timestamp is excluded, after requiring an aware timestamp and the
closed observed policy field set. Role comparison retains every returned field,
including aliases, token defaults and unexpected additions, using canonical JSON
bytes (therefore false cannot equal zero). The disposable Kubernetes fixture pins
the complete OpenBao 2.5.5 response shape. Another plugin/version/default requires
an independently reviewed owner snapshot; nothing infers new accepted defaults.
Each baseline gates its corresponding write. Policy readback occurs before role
baseline/write/readback. A role conflict can therefore leave the policy applied;
that operation remains unknown. This sequence has no native CAS, aggregate
baseline gate, concurrent-writer exclusion, or simultaneous final-state proof.
After all six calls match the committed snapshots, the same-live adapter records
readback and verified with a value-free evidence digest. The mediator reports
native_apply verified; cleanup-confirmed requires all separate cleanup receipts.
Readback success with unconfirmed cleanup cannot reach cleanup-confirmed.
Reopening SQLite cannot dispatch, reconsume, restore token/accessor handles or
retry cleanup. Native effects can survive process loss. Cleanup handles remain
memory-only: no independently authorized recovery mechanism is implemented here.
This closes disposable sequential apply acceptance only, not TSA03 or native
production recovery/admission.
## Independent cleanup recovery source successor
`task_execution_recovery.py` adds separately installed fixture-only owner custody
and fresh recovery permits. Optional `recovery_custody` on NativeFixtureAdapter
persists the original binding/cleanup commitments, exact operation policy, backend
port and identity accessor before any grant effect; it commits mint intent before
token creation and task accessor immediately after a valid issued response.
Bearer credentials are never persisted. The custody directory is separate from
the public journal, owner-only 0700/0600, bounded at 64KiB, no-follow, with atomic
replacement and fsync. It is real process-independent persistence for synthetic
fixtures. It does not isolate same-UID hostile execution, encrypt at rest, select
production storage or establish a production secret policy. Those are explicit
native custody/deployment gates, not supplied by Python types or file modes.
The independent owner reads its private custody revision and installs a fresh
bounded permit, current owner credential and retained live disposable Popen.
Permit revocation is durably owner-recorded. The recovery runner has no mint,
consume, policy-apply or business method. It dispatches only fixed exact-accessor
revoke/readback and exact `task-exec-<operation>` policy read/delete/readback.
A differently recreated policy is never deleted. Backend admission remains the
same loopback-only owner-spawned dev process; no estate backend can be selected.
Accessor-based recovery is an independently admitted alternate cleanup projection,
not a reinterpretation of the original revoke-self call digest. Production needs
exact owner-reviewed recovery scope; original business grant does not imply it.
Fresh recovery holds the journal process lock but never sets business admission
or consumed-admission fields. Separate recovery attempt/call/result tables retain
original cleanup statuses before reconciliation, write native intent before calls,
and preserve failed/pending outcomes. Proven cleanup alone updates the exact
original cleanup obligation to confirmed. Business state and original pending
call uncertainty remain unchanged. Reusing the original approval, continuing
metadata calls and reusing a recovery permit refuse. Failure needs another
independent owner permit; there is no automatic retry. Expired/revoked permits
or changed private custody revision refuse before calls.
A token-create response lost before accessor custody remains unresolved token
cleanup debt: policy deletion/identity revocation can be independently confirmed,
but cannot imply task-token revocation. Neither expiry guessed from wall clock
nor policy absence fabricates token absence. A production successor must admit
an independent exact ambiguous-issuance reconciliation mechanism or prove bounded
expiry from owner backend evidence. No such new grant is implemented here.
`test_task_execution_recovery.py` forks a real executor and terminates it without
finally cleanup after issue, business effect/lost response and revoke/lost response.
The independent parent, holding the backend and separately installed custody,
recovers specific obligations and verifies actual token/policy absence. Additional
cases cover lost-mint response, revoked credential, permit revocation/expiry,
revision drift, namespace policy-name reuse and private-file/binding refusals.
This is source fixture proof only. Native custody, isolation, exact recovery-role
permissions, retention/encryption, clock admission, audit delivery and human scope
approval remain missing. No generic broker, production reader, grant or CLI is added.
### Recovery R2 exact absence and authenticated capsule refinement
R1 overclaimed policy absence for arbitrary 404 bodies. R2 requires exactly
`{"errors":[]}` for both initial GET and post-DELETE GET. Other 404 bodies,
permission denials, extra data, malformed/duplicate-key/nonfinite JSON and failed
readback cannot confirm policy cleanup. A successful DELETE status alone cannot
confirm absence. Recovery native policy comparison admits precisely one optional
terminal LF, matching the retained OpenBao HCL source/readback convention; spaces,
tabs, CR, extra terminal LF and any other byte change are refused. This is a
narrow declared normalization, not full whitespace or semantic equivalence.
Private fixture capsules now carry HMAC-SHA256 authentication under an exactly
32-byte integrity key supplied independently by the recovery owner. The key is
never stored in the capsule or public journal, and is not a bearer credential.
The fixture owner retains this key independently across executor death; a fresh
owner custody object must supply the same key. Wrong-key or damaged accessor/HCL
bytes refuse before admission. This is integrity protection, not encryption or
same-UID isolation; production key custody, rotation/retention and recovery actor
selection remain explicitly unadmitted.
After authentication the reader enforces exact top-level and nested binding/
cleanup key sets and types, validated Binding/Cleanup formats and the ordered
source-derived cleanup_specs operation digests. Duplicate JSON keys, nonfinite
numbers, malformed phase/revision combinations and type-coercible values refuse.
The reader bounds bytes, rejects indirect/nonprivate files and detects stat/size
changes during its single read. A valid older prepared capsule still cannot
prove a task token absent when durable journal state/calls reached issuance:
that remains unknown cleanup debt. Authentication alone is not anti-rollback.
No business or consume admission is restored and no production scope is granted.

View file

@ -0,0 +1,184 @@
"""Owner mediator seam for the existing strict Secrets handoff and Approval GET.
Only the disposable NativeFixtureAdapter is callable here. No production backend,
caller-supplied authorizer/transport, approval boolean or portable receipt exists.
The strict Secrets module is an explicit external dependency: absence refuses
before credential-bearing requests or native effects, never a permissive fallback.
"""
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime,timezone
import importlib
import json
from pathlib import Path
from urllib.parse import urlsplit
from task_execution_cleanup import run_cleanup
from task_execution_journal import Binding,Cleanup,Journal,Refused
from task_execution_plan import Compiler,encoded,content_hash
from task_execution_native_fixture import FixtureAdmission,NativeFixtureAdapter
@dataclass(frozen=True)
class ConfirmationConfig:
"""Owner-installed independent read credential/route, never request fields."""
origin: str
token_file: Path
timeout_seconds: float = 3
_HANDOFF_FIELDS={'execution_contract','approval_id','request_digest','decision_id',
'native_binding','approval_binding_digest','submitted_request_digest',
'decision_not_before','decision_expires_at','approval_expires_at'}
def _strict_dependency():
try:
module=importlib.import_module('secrets_engine.task_execution_handoff')
for name in ['TaskExecutionAuthorization','TaskExecutionConsumption',
'authorize_task_execution','consume_task_execution']:
if not hasattr(module,name):raise ImportError()
return module
except ImportError:
raise Refused('strict Secrets task execution handoff dependency unavailable') from None
def _fresh(cfg,handoff,descriptor):
from secrets_engine.application_time import read_window,validity_bounds
lower,upper=validity_bounds(window=read_window(cfg))
try:
parse=lambda value:datetime.fromisoformat(value.replace('Z','+00:00'))
deadline=parse(descriptor['operation_deadline'])
approval=parse(handoff['approval_expires_at']);decision=parse(handoff['decision_expires_at'])
start=parse(handoff['decision_not_before']) if handoff['decision_not_before'] else None
if any(value.tzinfo is None for value in [deadline,approval,decision]) or (start is not None and start.tzinfo is None):raise ValueError()
if upper>=min(deadline,approval,decision) or deadline>min(approval,decision) or (start is not None and lower<start):raise ValueError()
except (KeyError,TypeError,ValueError,AttributeError):
raise Refused('original task authority deadline refused') from None
def _intent(journal,binding,kind,fields):
digest=content_hash(encoded({'kind':kind,**fields}))
ordinal=journal.next_call_ordinal(binding)
journal.before_call(binding,ordinal,digest)
return ordinal,digest
def _unknown(journal,binding):
state=journal.observe(binding)['state']
if state not in {'unknown','failed','cleanup-confirmed'}:
journal.transition(binding,state,'unknown')
def run_native_fixture_task(*,compiler:Compiler,journal:Journal,plan_request:dict,
owner_descriptor:bytes,secrets_cfg,entry,
confirmation:ConfirmationConfig,
native_admission:FixtureAdmission):
"""Authorize inside owner boundary, reserve, consume once, independently GET.
Strict authorization is executed here through the actual Secrets-owned module;
an inbound TaskExecutionAuthorization or arbitrary callable is never accepted.
This function returns closed metadata, not tokens or raw approval/decision data.
Positive execution requires the module's real signed PDP path; actual unadmitted
Access policy must still deny. Fixture policy/identity admission is independent
synthetic setup and cannot become production acceptance.
"""
strict=_strict_dependency()
from approval_engine.execution_confirmation import ExecutionConfirmationClient
from secrets_engine.execution_contract import ExecutionContract
if type(confirmation) is not ConfirmationConfig or type(native_admission) is not FixtureAdmission:
raise Refused('independent owner provider configuration required')
try:
origin=urlsplit(confirmation.origin)
if origin.scheme not in {'http','https'} or origin.hostname!='127.0.0.1' or not origin.port or origin.username or origin.password or origin.path or origin.query or origin.fragment:
raise ValueError()
if confirmation.origin!=str(getattr(secrets_cfg,'approval_url','')).rstrip('/'):
raise ValueError()
descriptor=ExecutionContract.parse(json.loads(owner_descriptor)).as_dict()
if encoded(descriptor)!=owner_descriptor or native_admission.descriptor!=owner_descriptor:
raise ValueError()
except Exception:
raise Refused('exact owner descriptor or provider route refused') from None
# An independently pinned owner catalogue must match every engine field. Its
# approval IDs are source references, never runtime authority.
proposal=compiler.proposal()
target={key:getattr(entry,key) for key in proposal['catalog_target']}
if encoded(target)!=encoded(proposal['catalog_target']) or entry.id!=proposal['catalog_id'] or entry.stage!='prod':
raise Refused('owner catalogue does not match strict task request')
snapshot={path:compiler.read(Path(path)) for path in compiler.pins}
owner_entry=compiler._catalogue_entry(snapshot)
# Only the served approval pointer is added after issue. Purpose or other
# source approval semantics cannot be substituted through an inbound entry.
without_reference=lambda value:{key:item for key,item in value.items() if key!='authorization_id'}
if encoded(without_reference(entry.approval))!=encoded(without_reference(owner_entry.approval)):
raise Refused('owner approval metadata differs from source')
client=ExecutionConfirmationClient(confirmation.origin,confirmation.token_file,
timeout_seconds=confirmation.timeout_seconds)
with journal.locked():
authorization=strict.authorize_task_execution(secrets_cfg,entry,
execution_contract=ExecutionContract.parse(descriptor))
if type(authorization) is not strict.TaskExecutionAuthorization:
raise Refused('strict task authorization missing')
handoff=authorization.as_owner_handoff()
if type(handoff) is not dict or set(handoff)!=_HANDOFF_FIELDS or encoded(handoff['execution_contract'])!=owner_descriptor:
raise Refused('strict task handoff does not match owner descriptor')
_fresh(secrets_cfg,handoff,descriptor)
request=dict(plan_request,request_digest=handoff['request_digest'])
plan=compiler.bind_execution(compiler.compile(request),descriptor,owner_descriptor)
binding=Binding(handoff['approval_id'],handoff['request_digest'],descriptor['operation_id'],plan.content_digest)
# Constructor validates fixed native origin/fixture process/grant sources
# but performs no native HTTP request or credential acquisition.
native=NativeFixtureAdapter(compiler,plan,journal,binding,native_admission)
journal.reserve(binding,native.cleanup)
fields={key:handoff[key] for key in ['approval_id','request_digest','decision_id']}
try:
ordinal,digest=_intent(journal,binding,'strict-secrets-task-consume',fields)
consumed=strict.consume_task_execution(secrets_cfg,authorization)
if type(consumed) is not strict.TaskExecutionConsumption:
raise Refused('strict task consumption missing')
receipt={key:getattr(consumed,key) for key in ['approval_id','request_digest','decision_id','consumed_at','idempotent']}
if any(receipt[key]!=value for key,value in fields.items()) or receipt['idempotent'] is not False:
raise Refused('historical or mismatched task consumption refused')
stamp=datetime.fromisoformat(receipt['consumed_at'].replace('Z','+00:00'))
if stamp.tzinfo is None:raise Refused('consumption timestamp missing')
journal.after_call(binding,ordinal,digest,content_hash(encoded(receipt)))
_fresh(secrets_cfg,handoff,descriptor)
ordinal,digest=_intent(journal,binding,'independent-approval-consumption-read',fields)
confirmed=client.confirm_consumption(binding.approval_id,binding.request_digest,
handoff['decision_id'],expected_binding=handoff['native_binding'],
expected_pdp_digest=handoff['approval_binding_digest'])
if confirmed!={key:receipt[key] for key in ['approval_id','request_digest','decision_id','consumed_at']}:
raise Refused('independent consumption correspondence refused')
journal.after_call(binding,ordinal,digest,content_hash(encoded(confirmed)))
_fresh(secrets_cfg,handoff,descriptor)
journal.transition(binding,'reserved','consume-confirmed',content_hash(encoded(confirmed)))
except Exception:
_unknown(journal,binding)
raise Refused('task consumption or independent confirmation unconfirmed; no native execution') from None
result={'operation_id':binding.operation_id,'request_digest':binding.request_digest,
'decision_id':handoff['decision_id'],'native_apply':'unverified',
'native_calls_completed':0,'cleanup_confirmed':False}
try:
journal.transition(binding,'consume-confirmed','authenticating')
# Fixture identity is independently seeded synthetic setup, not CCR38
# provenance proof or a production service login.
journal.transition(binding,'authenticating','grant-issuing')
native.materialize_grant()
journal.transition(binding,'grant-issued','applying')
for ordinal in range(6):
_fresh(secrets_cfg,handoff,descriptor)
native.metadata_call(ordinal)
result['native_calls_completed']+=1
result['readback_evidence_digest']=native.verify_metadata()
result['native_apply']='verified'
except Exception:
_unknown(journal,binding)
result['native_apply']='unknown'
finally:
result['cleanup_confirmed']=run_cleanup(journal,binding,native)['confirmed']
if result['native_apply']=='verified' and result['cleanup_confirmed']:
journal.transition(binding,'verified','cleanup-confirmed',result['readback_evidence_digest'])
# Readback and cleanup are independent outcomes; no recovery authority
# or native compare-and-swap is implied by either fixture result.
result['business_state']=journal.observe(binding)['state']
return result

View file

@ -0,0 +1,311 @@
"""Fixed native metadata/grant/cleanup adapter for disposable loopback OpenBao.
This adapter deliberately admits only a disposable loopback fixture. It supplies
real native primitive coverage while production grant schema, identity/consume and
origin/source admission are unfinished. It cannot open an estate backend. Owner
bearer credentials stay in memory; optional owner-private fixture custody persists
accessors for independently admitted cleanup recovery; no redirects or ambient proxy.
Python privacy is not credential isolation; production needs the owner runtime.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime, timezone
import hashlib
import http.client
import json
import subprocess
import time
from pathlib import Path
from urllib.parse import urlsplit
from task_execution_cleanup import CleanupResult
from task_execution_journal import Binding, Cleanup, Journal, Refused
from task_execution_plan import Compiler, Plan, cleanup_specs, encoded, content_hash
@dataclass(frozen=True)
class PrivateSession:
token: str = field(repr=False)
accessor: str = field(repr=False)
@dataclass(frozen=True)
class FixtureAdmission:
"""Independently installed owner fixture inputs, not a request schema."""
descriptor: bytes
expected_before: bytes
expected_after: bytes
owner_token: str = field(repr=False)
identity_session: PrivateSession = field(repr=False)
server_process: subprocess.Popen = field(repr=False)
class NativeFixtureAdapter:
def __init__(self, compiler: Compiler, plan: Plan, journal: Journal,
binding: Binding, admission: FixtureAdmission, *, recovery_custody=None):
if type(admission) is not FixtureAdmission or type(admission.descriptor) is not bytes:
raise Refused('independent fixture admission required')
self.compiler,self.plan,self.journal,self.binding=compiler,plan,journal,binding
self.admission=admission
self.meta=plan.metadata()
if self.meta.get('execution_grant',{}).get('status')!='approved':
raise Refused('independently source-bound synthetic approved grant required')
descriptor=json.loads(admission.descriptor)
fresh=compiler.bind_execution(compiler.compile(self.meta['request']),descriptor,admission.descriptor)
if fresh.serialized != plan.serialized or binding.plan_digest != plan.content_digest:
raise Refused('owner bound plan mismatch')
if binding.operation_id != descriptor['operation_id'] or binding.request_digest != self.meta['request']['request_digest']:
raise Refused('owner operation mismatch')
origin=urlsplit(descriptor['backend_origin'])
if origin.scheme!='http' or origin.hostname!='127.0.0.1' or not origin.port or origin.username or origin.password or origin.path or origin.query or origin.fragment:
raise Refused('disposable loopback backend only')
self.host,self.port=origin.hostname,origin.port
process=admission.server_process
if (type(process) is not subprocess.Popen or process.poll() is not None
or type(process.args) is not list or '-dev' not in process.args
or '-dev-no-store-token' not in process.args
or '-dev-listen-address=127.0.0.1:'+str(self.port) not in process.args):
raise Refused('live owner-spawned disposable dev process required')
budget=descriptor['backend_budget']
if set(budget)!={'ttl_seconds','uses'} or type(budget['ttl_seconds']) is not int or not 30<=budget['ttl_seconds']<=120 or type(budget['uses']) is not int or budget['uses']!=12:
raise Refused('fixed native task budget required')
self.ttl=budget['ttl_seconds']; self.uses=budget['uses']; self.used=0
self.deadline=datetime.fromisoformat(descriptor['operation_deadline'].replace('Z','+00:00'))
if self.deadline.tzinfo is None: raise Refused('aware operation deadline required')
remaining=(self.deadline-datetime.now(timezone.utc)).total_seconds()
self.monotonic_deadline=time.monotonic()+remaining
self.policy_name='task-exec-'+binding.operation_id
calls=[compiler.before_call(plan,i) for i in range(6)]
paths=sorted({call.path for call in calls})
self.hcl=''.join('path '+json.dumps(path)+' {\n capabilities = ["read", "create", "update"]\n}\n' for path in paths)
self.hcl+='path "auth/token/revoke-self" {\n capabilities = ["update"]\n}\n'
if self.meta['execution_grant']['openbao']['policy']!={'name':self.policy_name,'hcl':self.hcl}:
raise Refused('native grant body differs from bound owner projection')
# Installed owner snapshots use the existing descriptor commitments. No
# request-supplied expectation or response may authorize itself.
self.expected={}
for phase in ('before','after'):
raw=getattr(admission,'expected_'+phase)
if type(raw) is not bytes or 'sha256:'+content_hash(raw)!=descriptor['expected_'+phase]:
raise Refused('owner expected-state digest mismatch')
value=json.loads(raw)
if type(value) is not dict or set(value)!=set(paths) or encoded(value)!=raw:
raise Refused('canonical exact target snapshot required')
if any(item is not None and type(item) is not dict for item in value.values()):
raise Refused('metadata snapshot object or explicit absence required')
if phase=='after' and any(item is None for item in value.values()):
raise Refused('present final metadata required')
self.expected[phase]=value
self._observed={'before':{},'after':{}}
self.cleanup=tuple(Cleanup(item['kind'],item['call_digest']) for item in cleanup_specs(binding.operation_id))
if self.meta['cleanup'] != cleanup_specs(binding.operation_id): raise Refused('cleanup plan mismatch')
self.task=None; self._mint_attempted=False; self._policy_attempted=False; self._ordinal=0; self._next_metadata=0
self._cleanup_steps={item.kind:0 for item in self.cleanup}
self.recovery_custody=recovery_custody
if recovery_custody is not None:
from task_execution_recovery import FixtureRecoveryCustody
if type(recovery_custody) is not FixtureRecoveryCustody or journal.observe(binding) is not None:
raise Refused('fresh independent fixture recovery custody required')
recovery_custody.prepare(binding,self.cleanup,self.port,self.hcl,admission.identity_session.accessor)
def _live(self):
if not self.journal._held or self.journal._admission!=self.binding or self.journal._consumed_admission!=self.binding:
raise Refused('same live consumed owner admission required')
def _fresh(self):
self._live()
if min((self.deadline-datetime.now(timezone.utc)).total_seconds(),self.monotonic_deadline-time.monotonic())<=5:
raise Refused('business deadline exhausted')
# The constructor independently rebuilt the complete immutable Plan and
# grant. Recheck every pinned byte before each native effect. Metadata
# dispatch additionally reconstructs its exact ordinal/body below; grant
# creation reconstructs and compares its fixed body before issuance.
for path in self.compiler.pins:self.compiler.read(Path(path))
def _http(self, method, path, token, body=b''):
# Fixed caller-generated paths only; http.client has no proxy or redirect
# following. Never surface backend body/header or transport exception text.
if self.admission.server_process.poll() is not None:
raise Refused('disposable backend exited; outcome unconfirmed')
conn=http.client.HTTPConnection(self.host,self.port,timeout=2)
try:
conn.request(method,'/v1/'+path,body=body or None,
headers={'X-Vault-Token':token,'Content-Type':'application/json'})
response=conn.getresponse(); raw=response.read(1024*1024+1)
if len(raw)>1024*1024: raise Refused('oversize native response')
data=json.loads(raw) if raw else {}
if type(data) is not dict: raise Refused('invalid native response')
return response.status,data
except Exception:
raise Refused('native transport or response unconfirmed') from None
finally: conn.close()
def _business(self, method, path, token, payload=b'', task=False):
self._fresh()
if task:
if self.used>=self.uses-1: raise Refused('task cleanup use reserved')
self.used+=1
evidence_payload = encoded({'token':'owner-retained-task-token'}) if path=='auth/token/lookup' else payload
digest=content_hash(encoded({'method':method,'path':path,'payload_sha256':'sha256:'+content_hash(evidence_payload),'credential_slot':'task' if task else 'owner'}))
ordinal=self.journal.next_call_ordinal(self.binding)
self.journal.before_call(self.binding,ordinal,digest);self._ordinal=ordinal+1
try: status,data=self._http(method,path,token,payload)
except Exception:
state=self.journal.observe(self.binding)['state']
if state!='unknown': self.journal.transition(self.binding,state,'unknown')
raise
# Native responses may contain tokens/accessors; evidence is closed status
# metadata rather than raw bytes or a guessable secret-value fingerprint.
evidence=content_hash(encoded({'call_digest':digest,'http_status':status}))
self.journal.after_call(self.binding,ordinal,digest,evidence)
return status,data
@staticmethod
def _ok(status,data,allowed):
if status not in allowed or data.get('errors'):
raise Refused('native request refused or unverified')
def materialize_grant(self):
self._fresh()
if self.journal.observe(self.binding)['state']!='grant-issuing' or self._policy_attempted:
raise Refused('fresh grant issuing state required')
if min((self.deadline-datetime.now(timezone.utc)).total_seconds(),self.monotonic_deadline-time.monotonic())<self.ttl+5:
raise Refused('insufficient lifetime for exact fixed task TTL')
root=self.admission.owner_token; path='sys/policies/acl/'+self.policy_name
status,data=self._business('GET',path,root)
if status!=404 or data.get('data'):
raise Refused('per-operation grant policy must be absent')
self._policy_attempted=True
status,data=self._business('PUT',path,root,encoded({'policy':self.hcl}));self._ok(status,data,{200,204})
status,data=self._business('GET',path,root);self._ok(status,data,{200})
if data.get('data',{}).get('policy','').rstrip()!=self.hcl.rstrip(): raise Refused('grant policy readback mismatch')
body={'policies':[self.policy_name],'no_default_policy':True,'renewable':False,
'ttl':str(self.ttl)+'s','explicit_max_ttl':str(self.ttl)+'s','num_uses':self.uses,
'type':'service','display_name':'task-exec-'+self.binding.operation_id,'no_parent':False}
if body!=self.meta['execution_grant']['openbao']['token_create_body']:
raise Refused('native token body differs from bound owner projection')
if self.recovery_custody is not None:
self.recovery_custody.checkpoint(self.binding,'mint-intent')
self._mint_attempted=True
status,data=self._business('POST','auth/token/create',root,encoded(body));self._ok(status,data,{200})
auth=data.get('auth',{})
if type(auth.get('client_token')) is not str or not auth['client_token'] or type(auth.get('accessor')) is not str or not auth['accessor']:
raise Refused('native token issue uncertain')
# Retain handles before validation so finally cleanup can revoke even a
# malformed overbroad issue response. Never return token-bearing metadata.
self.task=PrivateSession(auth['client_token'],auth['accessor'])
if self.recovery_custody is not None:
self.recovery_custody.checkpoint(self.binding,'issued',self.task.accessor)
status,data=self._business('POST','auth/token/lookup',root,encoded({'token':self.task.token}));self._ok(status,data,{200})
info=data.get('data',{})
if (info.get('policies')!=[self.policy_name] or info.get('identity_policies',[]) or info.get('renewable') is not False
or info.get('num_uses')!=self.uses or not 0<info.get('ttl',0)<=self.ttl
or info.get('explicit_max_ttl')!=self.ttl or info.get('orphan') is not False):
raise Refused('native task token bounds mismatch')
self.journal.transition(self.binding,'grant-issuing','grant-issued')
return {'issued':True,'policy_name':self.policy_name,'ttl_ceiling_seconds':self.ttl,'use_ceiling':self.uses}
def metadata_call(self, ordinal):
self._fresh()
if self.task is None or self.journal.observe(self.binding)['state']!='applying' or type(ordinal) is not int or not 0<=ordinal<6 or ordinal!=self._next_metadata:
raise Refused('ordered fixed metadata call required')
call=self.compiler.before_call(self.plan,ordinal)
status,data=self._business(call.method,call.path,self.task.token,call.payload,task=True)
self._next_metadata+=1
try:
self._ok(status,data,{200,204,404} if ordinal in {0,3} else {200,204})
if ordinal in {0,2,3,5}:
phase='before' if ordinal in {0,3} else 'after'
value=self._metadata_snapshot(call.path,status,data)
if encoded(value)!=encoded(self.expected[phase][call.path]):
raise Refused('native exact expected-state mismatch')
self._observed[phase][call.path]=value
except Exception:
state=self.journal.observe(self.binding)['state']
if state!='unknown': self.journal.transition(self.binding,state,'unknown')
raise Refused('native baseline or readback unconfirmed') from None
return status,data
@staticmethod
def _metadata_snapshot(path,status,response):
# HTTP errors/denials are never absence. OpenBao's exact empty 404
# metadata response is the only absent representation admitted here.
if status==404 and response=={'errors':[]}:
return None
if status!=200 or response.get('errors') or type(response.get('data')) is not dict:
raise Refused('metadata read not confirmed')
value=dict(response['data'])
if path.startswith('sys/policies/acl/'):
if set(value)!={'cas_required','modified','name','policy','version'}:
raise Refused('unrecognized policy metadata fields')
modified=value.pop('modified')
if type(modified) is not str or datetime.fromisoformat(modified.replace('Z','+00:00')).tzinfo is None:
raise Refused('invalid policy modification timestamp')
# All role fields, aliases, defaults and unknown additions participate in
# exact canonical JSON comparison. True never equals integer 1 here.
return value
def verify_metadata(self):
self._fresh()
if self._next_metadata!=6 or self.journal.observe(self.binding)['state']!='applying':
raise Refused('complete same-live metadata sequence required')
for phase in ('before','after'):
if encoded(self._observed[phase])!=encoded(self.expected[phase]):
raise Refused('complete expected metadata evidence required')
evidence=content_hash(encoded({'expected_before':'sha256:'+content_hash(self.admission.expected_before),
'expected_after':'sha256:'+content_hash(self.admission.expected_after),
'scope':'per-target-sequential-readback-no-native-cas'}))
self.journal.transition(self.binding,'applying','readback',evidence)
self.journal.transition(self.binding,'readback','verified',evidence)
return evidence
def _cleanup_admitted(self, kind, digest):
self._live()
expected=next(item for item in self.cleanup if item.kind==kind)
status=self.journal.cleanup_status(self.binding)
actual=next(item for item in status['obligations'] if item['kind']==kind)
if digest!=expected.call_digest or actual['call_digest']!=digest or actual['outcome']!='pending':
raise Refused('exact pending cleanup obligation required')
def _cleanup_http(self,kind,method,path,token,body=b''):
ordinal=next(i for i,item in enumerate(self.cleanup) if item.kind==kind)
obligation=self.cleanup[ordinal];step=self._cleanup_steps[kind]
spec=cleanup_specs(self.binding.operation_id)[ordinal]
call_index=step + (1 if kind=='grant-policy-delete' and not self._policy_attempted else 0)
if call_index>=len(spec['calls']) or spec['calls'][call_index]!={'method':method,'path':path}:
raise Refused('cleanup native method/target differs from commitment')
# Commit shape/slot only: private accessor/token bytes never enter hashes
# or durable evidence. Their exact identity is owner-retained custody.
digest=content_hash(encoded({'obligation_digest':obligation.call_digest,'step':step,
'method':method,'path':path}))
self.journal.before_cleanup_call(self.binding,ordinal,obligation,step,digest)
self._cleanup_steps[kind]+=1
status,data=self._http(method,path,token,body)
evidence=content_hash(encoded({'call_digest':digest,'http_status':status}))
self.journal.after_cleanup_call(self.binding,ordinal,obligation,step,digest,evidence)
return status,data
def _revoke(self, kind, digest, session, issue_uncertain=False):
self._cleanup_admitted(kind,digest)
if session is None:
return CleanupResult(not issue_uncertain,content_hash(encoded({'kind':kind,'issue_uncertain':issue_uncertain})))
status,data=self._cleanup_http(kind,'POST','auth/token/revoke-self',session.token)
self._ok(status,data,{200,204})
status,data=self._cleanup_http(kind,'POST','auth/token/lookup-accessor',self.admission.owner_token,encoded({'accessor':session.accessor}))
confirmed=status==400 and data.get('errors') in (['invalid accessor'],['1 error occurred:\n\t* invalid accessor\n\n'])
return CleanupResult(confirmed,content_hash(encoded({'kind':kind,'revocation_confirmed':confirmed})))
def revoke_task_token(self, digest):
return self._revoke('task-token-revoke',digest,self.task,self._mint_attempted and self.task is None)
def revoke_identity_token(self, digest):
return self._revoke('identity-token-revoke',digest,self.admission.identity_session)
def delete_grant_policy(self, digest):
self._cleanup_admitted('grant-policy-delete',digest)
path='sys/policies/acl/'+self.policy_name
if self._policy_attempted:
status,data=self._cleanup_http('grant-policy-delete','DELETE',path,self.admission.owner_token);self._ok(status,data,{200,204})
status,data=self._cleanup_http('grant-policy-delete','GET',path,self.admission.owner_token)
confirmed=status==404 and not data.get('data')
return CleanupResult(confirmed,content_hash(encoded({'kind':'grant-policy-delete','absence_confirmed':confirmed})))

View file

@ -0,0 +1,22 @@
{
"schema": "railiance-platform.task-temporal-contract-review.v1",
"owner_work_item": "RPF-WP-0035-T12",
"canonical_clauses": {
"secrets_contract": "docs/task-scoped-openbao-execution-contract.md:158 task TTL=min(120s, approval_remaining, decision_remaining, operation_remaining) minus clock/cleanup reserve; reject below30s.",
"secrets_deadline": "same:68,279\u2013283 operation deadline no later than approval/PDP/task expiry. ExecutionContract ttl integer30..120; require_execution_deadline enforces served upper bounds.",
"platform": "NativeFixtureAdapter.materialize_grant requires remaining>=backendTTL+5 before grant issuance; _fresh requires remaining>5 before every business operation."
},
"original_incompatibility": "15s decision TTL cannot contain minimum30s backend TTL plus5s reserve. Faster startup cannot repair it.",
"coherent_source_successor": {
"access_owner_packet": "access-native-source/b2-b3/r2/manifest.json",
"hard_maximum_seconds": 120,
"expiry": "Exact immutable operation_deadline, bounded by caller and both original/fresh approval facts after source I/O.",
"positive_fixture_backend_ttl_seconds": 30,
"positive_fixture_proposal_deadline_seconds": 60,
"approval_fixture_explicit_freshness_seconds": 120,
"startup": "All dev backend and actual provider server startup before immutable proposal timestamp.",
"bounds_unchanged": true
},
"native_absolute_bounds": "Whole native business methods bound by earlier wall/monotonic operation deadline minus5s; each native HTTP path includes send/headers/body/close under2s absolute budget; compiler reads inside operation window; independent cleanup2s per native call. Unsupported thread/conflicting ambient alarms refuse.",
"native_admission": false
}

View file

@ -0,0 +1,838 @@
diff --git a/docs/task-execution-native-fixture.md b/docs/task-execution-native-fixture.md
new file mode 100644
index 0000000..7096f1a
--- /dev/null
+++ b/docs/task-execution-native-fixture.md
@@ -0,0 +1,91 @@
+# Executable native metadata/grant/cleanup preparation
+
+RPF-WP-0035-T12, 2026-10-16, second source package after the cleanup journal.
+
+The fixed `NativeFixtureAdapter` actually dispatches to disposable loopback OpenBao.
+It requires a live owner-spawned OpenBao dev-process handle with the exact loopback
+port and dev/no-store-token arguments, and refuses every non-loopback origin.
+Production is deliberately not implemented. This is a guard against accidental
+misrouting inside the trusted fixture runtime, not a Python security sandbox.
+No estate credentials, grants, approvals or policy packages are read or changed.
+The tests start in-memory dev servers with synthetic root/session credentials,
+synthetic owner admission, grant/state/source digests and a synthetic postconsume
+journal transition. This proves native primitives, not authenticated admission.
+
+The owner Compiler now deterministically includes the operation-specific temporary
+policy name and cleanup call commitments in each serialized Plan. Commitments
+include identity/task credential slots, exact native revoke/verify or delete/verify
+method/path pairs, and the narrow no-issue/no-create skip branches. The full
+execution descriptor can be bound into that Plan only by byte equality with an
+independently installed owner descriptor, with operation/actor/CCR/catalogue/six
+operations correspondence. Compiler revalidation preserves both descriptor and
+cleanup content. The descriptor's SHA is preparation provenance; no opaque digest
+or local file becomes approval or verified canonical request correspondence.
+
+The adapter needs the exact compiler/Plan/binding and separately installed owner
+fixture inputs. All handles stay in owner memory with hidden representations. It
+requires the same live consumed journal admission and exact grant-issuing state.
+It refuses an existing per-operation grant policy, creates only the two compiler
+metadata endpoints plus revoke-self, verifies HCL, mints one parented service token
+with only this policy, no default/identity policies, nonrenewable TTL <=120 seconds
+and twelve uses, then verifies native token bounds through the owner. Six ordered
+metadata calls use independently recompiled source/method/path/body. A task use is
+reserved for revoke; native token limits also enforce the ceiling. Business calls
+check wall and monotonic deadlines before effect, with a five-second cleanup reserve.
+No retry/remint, caller HCL/JSON/path, mount/AppRole/KV/SecretID or shell API exists.
+The adapter cannot claim complete baseline CAS or normalized readback acceptance.
+
+Every business/grant request records durable intent and closed status evidence.
+Native cleanup has its own durable per-request intents beneath the original
+committed obligation; accessor verification cannot erase an uncertain revoke.
+The native adapter compares the exact committed cleanup sequence before request.
+Self-revoke success must be followed by owner lookup-accessor's specific invalid
+accessor response; generic HTTP400/403, timeout and policy deletion do not prove
+revocation. The two explicitly accepted error encodings cover OpenBao's plain and
+wrapped single-invalid-accessor forms. Native error text is never logged or hashed.
+Cleanup bypasses expired business deadlines under the retained explicit obligation,
+never source-driven business repair. Independent cleanup continues after failure;
+retirement never overwrites the original unknown/failed business outcome.
+
+Current actual native tests cover policy/body and K8s-role application/readback,
+owner-parented TTL/use policy bounds, native sibling/KV/token/mount/SecretID capability
+denial, both token accessor-invalid proofs, policy absence, deadline-expired cleanup,
+source drift, no preconsume materialization, changed descriptor/cleanup commitments,
+nonloopback refusal, actual write followed by simulated lost response, and actual
+policy retirement with failed task revocation. The lost response is fault injection
+after a successful real native write; it does not pretend to be an actual network
+partition. The failed-revoke fixture independently confirms the still-existing
+native token, then performs explicit synthetic owner recovery. No blind retry occurs.
+
+## Minimal remaining decisions and integration
+
+1. Define/admit `task-execution-grant` in the owner CCR validator. Bind the exact
+ cleanup commitments, temporary policy/token bodies and source revisions into its
+ immutable projection, referenced by the existing descriptor grant SHA. The
+ descriptor currently has no cleanup field; no engine schema extension is needed
+ if this owner grant binding is made explicit. The immutable projection/excluded
+ append-only fields are not implemented in the current owner validator. This is
+ the concrete owner schema/admission decision, not a need for a new service.
+2. Have the existing authoritative engines supply authenticated initiating principal,
+ exact canonical request + served claim/PDP/package and independent signed CAS
+ consume verification to the owner mediator before its journal transition.
+ The native fixture's local transition supplies none of that evidence.
+3. Install exact origin/cluster/source/grant/baseline/readback inputs under the
+ attended owner runtime. The current binder compares independently installed
+ descriptor bytes, but does not authenticate source-bundle paths, grant digest or
+ compute immutable CCR projection. The six-operation plan still comes from pinned
+ actual owner source. Production origin authentication and deadline bounds against
+ served approval/PDP remain separate.
+4. Finish expected-state normalization and conflict handling for native K8s/OIDC
+ defaults; add per-policy/role approved baseline comparison and exact readback
+ verification. The fixture checks known fields, not total production equality.
+ OpenBao metadata PUT has no native atomic CAS; the owner must choose exclusive
+ admitted target custody or an honest conflict/recovery protocol.
+5. Wire the fixed adapter to that mediator with bounded runtime isolation,
+ owner-private uncertain-issuance recovery, external audit outbox and exact
+ founder-scoped attended trial. No production caller or protected recovery channel
+ exists here. The existing production readiness gate remains refused.
+
+No umbrella broker, general executor, new credential catalogue or standing service
+admin entitlement is introduced. Approval CAS prevents approval replay; it cannot
+make uncertain native effects exactly-once. RPF T12 and Secrets WP13 remain open.
diff --git a/scripts/task_execution_journal.py b/scripts/task_execution_journal.py
index 7c54ac8..a16291b 100644
--- a/scripts/task_execution_journal.py
+++ b/scripts/task_execution_journal.py
@@ -103,6 +103,7 @@ class Journal:
self.db.execute("CREATE TABLE IF NOT EXISTS operations (approval TEXT PRIMARY KEY, request TEXT NOT NULL, operation TEXT UNIQUE NOT NULL, plan TEXT NOT NULL, state TEXT NOT NULL, evidence TEXT)")
self.db.execute("CREATE TABLE IF NOT EXISTS calls (operation TEXT NOT NULL, ordinal INTEGER NOT NULL, call_digest TEXT NOT NULL, outcome TEXT NOT NULL, evidence TEXT, PRIMARY KEY(operation,ordinal))")
self.db.execute("CREATE TABLE IF NOT EXISTS cleanup (operation TEXT NOT NULL, ordinal INTEGER NOT NULL, kind TEXT NOT NULL, call_digest TEXT NOT NULL, outcome TEXT NOT NULL, evidence TEXT, PRIMARY KEY(operation,ordinal), UNIQUE(operation,kind))")
+ self.db.execute("CREATE TABLE IF NOT EXISTS cleanup_calls (operation TEXT NOT NULL, obligation INTEGER NOT NULL, step INTEGER NOT NULL, call_digest TEXT NOT NULL, outcome TEXT NOT NULL, evidence TEXT, PRIMARY KEY(operation,obligation,step))")
self._held = False
self._admission = None
self._consumed_admission = None
@@ -226,6 +227,7 @@ class Journal:
# Readback cannot create a live admission, even for an interrupted state.
pending = self.db.execute("SELECT COUNT(*) FROM calls WHERE operation=? AND outcome='pending'", (binding.operation_id,)).fetchone()[0]
pending += self.db.execute("SELECT COUNT(*) FROM cleanup WHERE operation=? AND outcome='pending'", (binding.operation_id,)).fetchone()[0]
+ pending += self.db.execute("SELECT COUNT(*) FROM cleanup_calls WHERE operation=? AND outcome='pending'", (binding.operation_id,)).fetchone()[0]
return {"state": row[3], "evidence_digest": row[4], "dispatch_allowed": False,
"external_outcome_unknown": bool(pending)}
@@ -318,6 +320,8 @@ class Journal:
raise Refused("closed cleanup evidence required")
try:
self.db.execute("BEGIN IMMEDIATE")
+ if confirmed and self.db.execute("SELECT COUNT(*) FROM cleanup_calls WHERE operation=? AND obligation=? AND outcome='pending'", (binding.operation_id,ordinal)).fetchone()[0]:
+ raise Refused("pending cleanup native call cannot confirm")
changed = self.db.execute("UPDATE cleanup SET outcome=?, evidence=? WHERE operation=? AND ordinal=? AND kind=? AND call_digest=? AND outcome='pending'",
('confirmed' if confirmed else 'unconfirmed', evidence_digest,
binding.operation_id, ordinal, obligation.kind, obligation.call_digest)).rowcount
@@ -329,6 +333,41 @@ class Journal:
if self.db.in_transaction: self.db.execute("ROLLBACK")
raise
+ def before_cleanup_call(self, binding, ordinal, obligation, step, call_digest):
+ """Journal each native revoke/delete AND its independent verification."""
+ self._cleanup_admission(binding,ordinal,obligation)
+ if type(step) is not int or not 0<=step<2 or type(call_digest) is not str or not re.fullmatch(r"[0-9a-f]{64}",call_digest):
+ raise Refused("exact bounded cleanup native call required")
+ try:
+ self.db.execute("BEGIN IMMEDIATE")
+ parent=self.db.execute("SELECT kind,call_digest,outcome FROM cleanup WHERE operation=? AND ordinal=?",(binding.operation_id,ordinal)).fetchone()
+ count,pending=self.db.execute("SELECT COUNT(*),COALESCE(SUM(outcome='pending'),0) FROM cleanup_calls WHERE operation=? AND obligation=?",(binding.operation_id,ordinal)).fetchone()
+ if parent!=(obligation.kind,obligation.call_digest,'pending') or step!=count or pending:
+ raise Refused("cleanup call unadmitted, replayed or uncertain")
+ self.db.execute("INSERT INTO cleanup_calls VALUES (?,?,?,?,'pending',NULL)",(binding.operation_id,ordinal,step,call_digest))
+ self.db.execute("COMMIT");self._sync_directory()
+ except BaseException:
+ if self.db.in_transaction:self.db.execute("ROLLBACK")
+ raise
+
+ def after_cleanup_call(self,binding,ordinal,obligation,step,call_digest,evidence_digest):
+ self._cleanup_admission(binding,ordinal,obligation)
+ if (type(step) is not int or not 0<=step<2 or type(call_digest) is not str or not re.fullmatch(r"[0-9a-f]{64}",call_digest)
+ or type(evidence_digest) is not str or not re.fullmatch(r"[0-9a-f]{64}",evidence_digest)):
+ raise Refused("exact cleanup native evidence required")
+ try:
+ self.db.execute("BEGIN IMMEDIATE")
+ parent=self.db.execute("SELECT kind,call_digest,outcome FROM cleanup WHERE operation=? AND ordinal=?",(binding.operation_id,ordinal)).fetchone()
+ if parent!=(obligation.kind,obligation.call_digest,'pending'):
+ raise Refused("cleanup parent is no longer pending")
+ changed=self.db.execute("UPDATE cleanup_calls SET outcome='complete',evidence=? WHERE operation=? AND obligation=? AND step=? AND call_digest=? AND outcome='pending'",
+ (evidence_digest,binding.operation_id,ordinal,step,call_digest)).rowcount
+ if changed!=1:raise Refused("cleanup native confirmation mismatch")
+ self.db.execute("COMMIT");self._sync_directory()
+ except BaseException:
+ if self.db.in_transaction:self.db.execute("ROLLBACK")
+ raise
+
def close(self):
if hasattr(self, "db"):
self.db.close()
diff --git a/scripts/task_execution_native_fixture.py b/scripts/task_execution_native_fixture.py
new file mode 100644
index 0000000..7a4d247
--- /dev/null
+++ b/scripts/task_execution_native_fixture.py
@@ -0,0 +1,229 @@
+"""Fixed native metadata/grant/cleanup adapter for disposable loopback OpenBao.
+
+This adapter deliberately admits only a disposable loopback fixture. It supplies
+real native primitive coverage while production grant schema, identity/consume and
+origin/source admission are unfinished. It cannot open an estate backend. Owner
+credentials/handles stay in memory; no subprocess, redirects or ambient proxy.
+Python privacy is not credential isolation; production needs the owner runtime.
+"""
+from __future__ import annotations
+from dataclasses import dataclass, field
+from datetime import datetime, timezone
+import hashlib
+import http.client
+import json
+import subprocess
+import time
+from urllib.parse import urlsplit
+
+from task_execution_cleanup import CleanupResult
+from task_execution_journal import Binding, Cleanup, Journal, Refused
+from task_execution_plan import Compiler, Plan, cleanup_specs, encoded, content_hash
+
+
+@dataclass(frozen=True)
+class PrivateSession:
+ token: str = field(repr=False)
+ accessor: str = field(repr=False)
+
+
+@dataclass(frozen=True)
+class FixtureAdmission:
+ """Independently installed owner fixture inputs, not a request schema."""
+ descriptor: bytes
+ owner_token: str = field(repr=False)
+ identity_session: PrivateSession = field(repr=False)
+ server_process: subprocess.Popen = field(repr=False)
+
+
+class NativeFixtureAdapter:
+ def __init__(self, compiler: Compiler, plan: Plan, journal: Journal,
+ binding: Binding, admission: FixtureAdmission):
+ if type(admission) is not FixtureAdmission or type(admission.descriptor) is not bytes:
+ raise Refused('independent fixture admission required')
+ self.compiler,self.plan,self.journal,self.binding=compiler,plan,journal,binding
+ self.admission=admission
+ self.meta=plan.metadata()
+ descriptor=json.loads(admission.descriptor)
+ fresh=compiler.bind_execution(compiler.compile(self.meta['request']),descriptor,admission.descriptor)
+ if fresh.serialized != plan.serialized or binding.plan_digest != plan.content_digest:
+ raise Refused('owner bound plan mismatch')
+ if binding.operation_id != descriptor['operation_id'] or binding.request_digest != self.meta['request']['request_digest']:
+ raise Refused('owner operation mismatch')
+ origin=urlsplit(descriptor['backend_origin'])
+ if origin.scheme!='http' or origin.hostname!='127.0.0.1' or not origin.port or origin.username or origin.password or origin.path or origin.query or origin.fragment:
+ raise Refused('disposable loopback backend only')
+ self.host,self.port=origin.hostname,origin.port
+ process=admission.server_process
+ if (type(process) is not subprocess.Popen or process.poll() is not None
+ or type(process.args) is not list or '-dev' not in process.args
+ or '-dev-no-store-token' not in process.args
+ or '-dev-listen-address=127.0.0.1:'+str(self.port) not in process.args):
+ raise Refused('live owner-spawned disposable dev process required')
+ budget=descriptor['backend_budget']
+ if set(budget)!={'ttl_seconds','uses'} or type(budget['ttl_seconds']) is not int or not 30<=budget['ttl_seconds']<=120 or type(budget['uses']) is not int or budget['uses']!=12:
+ raise Refused('fixed native task budget required')
+ self.ttl=budget['ttl_seconds']; self.uses=budget['uses']; self.used=0
+ self.deadline=datetime.fromisoformat(descriptor['operation_deadline'].replace('Z','+00:00'))
+ if self.deadline.tzinfo is None: raise Refused('aware operation deadline required')
+ remaining=(self.deadline-datetime.now(timezone.utc)).total_seconds()
+ self.monotonic_deadline=time.monotonic()+remaining
+ self.policy_name='task-exec-'+binding.operation_id
+ calls=[compiler.before_call(plan,i) for i in range(6)]
+ paths=sorted({call.path for call in calls})
+ self.hcl=''.join('path '+json.dumps(path)+' {\n capabilities = ["read", "create", "update"]\n}\n' for path in paths)
+ self.hcl+='path "auth/token/revoke-self" {\n capabilities = ["update"]\n}\n'
+ self.cleanup=tuple(Cleanup(item['kind'],item['call_digest']) for item in cleanup_specs(binding.operation_id))
+ if self.meta['cleanup'] != cleanup_specs(binding.operation_id): raise Refused('cleanup plan mismatch')
+ self.task=None; self._mint_attempted=False; self._policy_attempted=False; self._ordinal=0; self._next_metadata=0
+ self._cleanup_steps={item.kind:0 for item in self.cleanup}
+
+ def _live(self):
+ if not self.journal._held or self.journal._admission!=self.binding or self.journal._consumed_admission!=self.binding:
+ raise Refused('same live consumed owner admission required')
+
+ def _fresh(self):
+ self._live()
+ if min((self.deadline-datetime.now(timezone.utc)).total_seconds(),self.monotonic_deadline-time.monotonic())<=5:
+ raise Refused('business deadline exhausted')
+ self.compiler.before_call(self.plan,0)
+
+ def _http(self, method, path, token, body=b''):
+ # Fixed caller-generated paths only; http.client has no proxy or redirect
+ # following. Never surface backend body/header or transport exception text.
+ if self.admission.server_process.poll() is not None:
+ raise Refused('disposable backend exited; outcome unconfirmed')
+ conn=http.client.HTTPConnection(self.host,self.port,timeout=2)
+ try:
+ conn.request(method,'/v1/'+path,body=body or None,
+ headers={'X-Vault-Token':token,'Content-Type':'application/json'})
+ response=conn.getresponse(); raw=response.read(1024*1024+1)
+ if len(raw)>1024*1024: raise Refused('oversize native response')
+ data=json.loads(raw) if raw else {}
+ if type(data) is not dict: raise Refused('invalid native response')
+ return response.status,data
+ except Exception:
+ raise Refused('native transport or response unconfirmed') from None
+ finally: conn.close()
+
+ def _business(self, method, path, token, payload=b'', task=False):
+ self._fresh()
+ if task:
+ if self.used>=self.uses-1: raise Refused('task cleanup use reserved')
+ self.used+=1
+ evidence_payload = encoded({'token':'owner-retained-task-token'}) if path=='auth/token/lookup' else payload
+ digest=content_hash(encoded({'method':method,'path':path,'payload_sha256':'sha256:'+content_hash(evidence_payload),'credential_slot':'task' if task else 'owner'}))
+ ordinal=self._ordinal
+ self.journal.before_call(self.binding,ordinal,digest);self._ordinal+=1
+ try: status,data=self._http(method,path,token,payload)
+ except Exception:
+ state=self.journal.observe(self.binding)['state']
+ if state!='unknown': self.journal.transition(self.binding,state,'unknown')
+ raise
+ # Native responses may contain tokens/accessors; evidence is closed status
+ # metadata rather than raw bytes or a guessable secret-value fingerprint.
+ evidence=content_hash(encoded({'call_digest':digest,'http_status':status}))
+ self.journal.after_call(self.binding,ordinal,digest,evidence)
+ return status,data
+
+ @staticmethod
+ def _ok(status,data,allowed):
+ if status not in allowed or data.get('errors'):
+ raise Refused('native request refused or unverified')
+
+ def materialize_grant(self):
+ self._fresh()
+ if self.journal.observe(self.binding)['state']!='grant-issuing' or self._policy_attempted:
+ raise Refused('fresh grant issuing state required')
+ if min((self.deadline-datetime.now(timezone.utc)).total_seconds(),self.monotonic_deadline-time.monotonic())<self.ttl+5:
+ raise Refused('insufficient lifetime for exact fixed task TTL')
+ root=self.admission.owner_token; path='sys/policies/acl/'+self.policy_name
+ status,data=self._business('GET',path,root)
+ if status!=404 or data.get('data'):
+ raise Refused('per-operation grant policy must be absent')
+ self._policy_attempted=True
+ status,data=self._business('PUT',path,root,encoded({'policy':self.hcl}));self._ok(status,data,{200,204})
+ status,data=self._business('GET',path,root);self._ok(status,data,{200})
+ if data.get('data',{}).get('policy','').rstrip()!=self.hcl.rstrip(): raise Refused('grant policy readback mismatch')
+ body={'policies':[self.policy_name],'no_default_policy':True,'renewable':False,
+ 'ttl':str(self.ttl)+'s','explicit_max_ttl':str(self.ttl)+'s','num_uses':self.uses,
+ 'type':'service','display_name':'task-exec-'+self.binding.operation_id,'no_parent':False}
+ self._mint_attempted=True
+ status,data=self._business('POST','auth/token/create',root,encoded(body));self._ok(status,data,{200})
+ auth=data.get('auth',{})
+ if type(auth.get('client_token')) is not str or not auth['client_token'] or type(auth.get('accessor')) is not str or not auth['accessor']:
+ raise Refused('native token issue uncertain')
+ # Retain handles before validation so finally cleanup can revoke even a
+ # malformed overbroad issue response. Never return token-bearing metadata.
+ self.task=PrivateSession(auth['client_token'],auth['accessor'])
+ status,data=self._business('POST','auth/token/lookup',root,encoded({'token':self.task.token}));self._ok(status,data,{200})
+ info=data.get('data',{})
+ if (info.get('policies')!=[self.policy_name] or info.get('identity_policies',[]) or info.get('renewable') is not False
+ or info.get('num_uses')!=self.uses or not 0<info.get('ttl',0)<=self.ttl
+ or info.get('explicit_max_ttl')!=self.ttl or info.get('orphan') is not False):
+ raise Refused('native task token bounds mismatch')
+ self.journal.transition(self.binding,'grant-issuing','grant-issued')
+ return {'issued':True,'policy_name':self.policy_name,'ttl_ceiling_seconds':self.ttl,'use_ceiling':self.uses}
+
+ def metadata_call(self, ordinal):
+ self._fresh()
+ if self.task is None or self.journal.observe(self.binding)['state']!='applying' or type(ordinal) is not int or not 0<=ordinal<6 or ordinal!=self._next_metadata:
+ raise Refused('ordered fixed metadata call required')
+ call=self.compiler.before_call(self.plan,ordinal)
+ status,data=self._business(call.method,call.path,self.task.token,call.payload,task=True)
+ self._next_metadata+=1
+ self._ok(status,data,{200,204,404} if ordinal in {0,3} else {200,204})
+ # Metadata only. The trusted owner caller must compare native normalized
+ # baseline/readback to separately admitted expected state. Not KV values.
+ return status,data
+
+ def _cleanup_admitted(self, kind, digest):
+ self._live()
+ expected=next(item for item in self.cleanup if item.kind==kind)
+ status=self.journal.cleanup_status(self.binding)
+ actual=next(item for item in status['obligations'] if item['kind']==kind)
+ if digest!=expected.call_digest or actual['call_digest']!=digest or actual['outcome']!='pending':
+ raise Refused('exact pending cleanup obligation required')
+
+ def _cleanup_http(self,kind,method,path,token,body=b''):
+ ordinal=next(i for i,item in enumerate(self.cleanup) if item.kind==kind)
+ obligation=self.cleanup[ordinal];step=self._cleanup_steps[kind]
+ spec=cleanup_specs(self.binding.operation_id)[ordinal]
+ call_index=step + (1 if kind=='grant-policy-delete' and not self._policy_attempted else 0)
+ if call_index>=len(spec['calls']) or spec['calls'][call_index]!={'method':method,'path':path}:
+ raise Refused('cleanup native method/target differs from commitment')
+ # Commit shape/slot only: private accessor/token bytes never enter hashes
+ # or durable evidence. Their exact identity is owner-retained custody.
+ digest=content_hash(encoded({'obligation_digest':obligation.call_digest,'step':step,
+ 'method':method,'path':path}))
+ self.journal.before_cleanup_call(self.binding,ordinal,obligation,step,digest)
+ self._cleanup_steps[kind]+=1
+ status,data=self._http(method,path,token,body)
+ evidence=content_hash(encoded({'call_digest':digest,'http_status':status}))
+ self.journal.after_cleanup_call(self.binding,ordinal,obligation,step,digest,evidence)
+ return status,data
+
+ def _revoke(self, kind, digest, session, issue_uncertain=False):
+ self._cleanup_admitted(kind,digest)
+ if session is None:
+ return CleanupResult(not issue_uncertain,content_hash(encoded({'kind':kind,'issue_uncertain':issue_uncertain})))
+ status,data=self._cleanup_http(kind,'POST','auth/token/revoke-self',session.token)
+ self._ok(status,data,{200,204})
+ status,data=self._cleanup_http(kind,'POST','auth/token/lookup-accessor',self.admission.owner_token,encoded({'accessor':session.accessor}))
+ confirmed=status==400 and data.get('errors') in (['invalid accessor'],['1 error occurred:\n\t* invalid accessor\n\n'])
+ return CleanupResult(confirmed,content_hash(encoded({'kind':kind,'revocation_confirmed':confirmed})))
+
+ def revoke_task_token(self, digest):
+ return self._revoke('task-token-revoke',digest,self.task,self._mint_attempted and self.task is None)
+
+ def revoke_identity_token(self, digest):
+ return self._revoke('identity-token-revoke',digest,self.admission.identity_session)
+
+ def delete_grant_policy(self, digest):
+ self._cleanup_admitted('grant-policy-delete',digest)
+ path='sys/policies/acl/'+self.policy_name
+ if self._policy_attempted:
+ status,data=self._cleanup_http('grant-policy-delete','DELETE',path,self.admission.owner_token);self._ok(status,data,{200,204})
+ status,data=self._cleanup_http('grant-policy-delete','GET',path,self.admission.owner_token)
+ confirmed=status==404 and not data.get('data')
+ return CleanupResult(confirmed,content_hash(encoded({'kind':'grant-policy-delete','absence_confirmed':confirmed})))
diff --git a/scripts/task_execution_plan.py b/scripts/task_execution_plan.py
index d8cb3e5..f3234fc 100644
--- a/scripts/task_execution_plan.py
+++ b/scripts/task_execution_plan.py
@@ -77,6 +77,26 @@ class Call:
return content_hash(encoded({'ordinal':self.ordinal,'kind':self.kind,'method':self.method,
'path':self.path,'payload_sha256':self.payload_sha256}))
+
+def cleanup_specs(operation_id):
+ """Deterministic owner cleanup plan; native handles stay in owner memory."""
+ try: need(str(UUID(operation_id)) == operation_id, 'operation_id_invalid')
+ except (ValueError, AttributeError, TypeError): raise Refused('operation_id_invalid') from None
+ policy_name = 'task-exec-' + operation_id
+ specs = []
+ for kind, slot, calls in [
+ ('task-token-revoke', 'task', [('POST','auth/token/revoke-self'), ('POST','auth/token/lookup-accessor')]),
+ ('identity-token-revoke', 'identity', [('POST','auth/token/revoke-self'), ('POST','auth/token/lookup-accessor')]),
+ ('grant-policy-delete', 'owner', [('DELETE','sys/policies/acl/'+policy_name), ('GET','sys/policies/acl/'+policy_name)]),
+ ]:
+ value = {'kind':kind, 'credential_slot':slot, 'operation_id':operation_id,
+ 'calls':[{'method':method,'path':path} for method,path in calls]}
+ if kind == 'task-token-revoke': value['confirm_not_created_if_no_issue_attempt'] = True
+ if kind == 'grant-policy-delete': value['skip_delete_if_no_create_attempt'] = True
+ value['call_digest'] = content_hash(encoded(value))
+ specs.append(value)
+ return specs
+
class Compiler:
def __init__(self, config: OwnerConfig):
need(type(config) is OwnerConfig,'owner_configuration_required')
@@ -232,14 +252,47 @@ class Compiler:
return Plan(encoded({'schema':'platform-owner-typed-plan/v1','action':'execute-ccr','operation':'metadata-apply-ccr',
'request':request,'catalog_target':expected['catalog_target'],
'source_pins':dict(self.pins),'task_token_max_ttl':120,'task_token_max_uses':12,
- 'cleanup_uses_reserved':1}))
+ 'cleanup_uses_reserved':1,'cleanup':cleanup_specs(request['operation_id'])}))
+
+ def bind_execution(self, plan, descriptor, owner_descriptor):
+ """Bind exact engine descriptor to owner plan; no authorization is created.
+
+ owner_descriptor must be installed independently by the owner, never copied
+ from the request. The engine must separately validate its closed schema and
+ full canonical request digest/claim/PDP/consume correspondence.
+ """
+ need(type(plan) is Plan and type(descriptor) is dict and type(owner_descriptor) is bytes,
+ 'independent_owner_descriptor_required')
+ meta=plan.metadata()
+ need(self.compile(meta['request']).serialized == plan.serialized, 'immutable_plan_changed')
+ need(encoded(descriptor) == owner_descriptor, 'owner_descriptor_mismatch')
+ ccr,_,_=self._derive()
+ expected = {'contract_version':'SECRETS-EXEC-20261011-v1',
+ 'operation_id':meta['request']['operation_id'],
+ 'operation':'metadata-apply-ccr','execution_actor':'service:secrets-engine',
+ 'ccr_id':ccr['id'],'catalog_sha256':meta['request']['catalog_sha256'],
+ 'operations':meta['request']['operations']}
+ need(all(descriptor.get(k)==v for k,v in expected.items()),'execution_descriptor_plan_mismatch')
+ bound = dict(meta, execution_descriptor=descriptor,
+ execution_descriptor_sha256=tagged(owner_descriptor))
+ # The full descriptor's independently admitted grant immutable digest must
+ # cover these cleanup commitments. No grant schema exists here yet, so this
+ # source binding cannot itself admit a grant/native production execution.
+ return Plan(encoded(bound))
def before_call(self,plan,ordinal):
need(type(plan) is Plan and type(ordinal) is int and 0<=ordinal<6,'fixed_plan_ordinal_required')
- try: meta=plan.metadata();fresh=self.compile(meta['request'])
+ try:
+ meta=plan.metadata();fresh=self.compile(meta['request'])
+ if 'execution_descriptor' in meta:
+ fresh=self.bind_execution(fresh,meta['execution_descriptor'],encoded(meta['execution_descriptor']))
except Refused: raise
except BaseException: raise Refused('invalid_plan') from None
need(fresh.serialized==plan.serialized,'immutable_plan_changed')
# Immediately re-derive owner payload bytes, and reconfirm immutable snapshot after derivation.
- _,_,calls=self._derive();need(self.compile(meta['request']).serialized==plan.serialized,'immutable_plan_changed')
+ _,_,calls=self._derive()
+ final=self.compile(meta['request'])
+ if 'execution_descriptor' in meta:
+ final=self.bind_execution(final,meta['execution_descriptor'],encoded(meta['execution_descriptor']))
+ need(final.serialized==plan.serialized,'immutable_plan_changed')
return calls[ordinal]
diff --git a/tests/test_task_execution_cleanup_calls.py b/tests/test_task_execution_cleanup_calls.py
new file mode 100644
index 0000000..814ab29
--- /dev/null
+++ b/tests/test_task_execution_cleanup_calls.py
@@ -0,0 +1,57 @@
+"""Real SQLite subcall durability for native cleanup/verification pairs."""
+import multiprocessing
+import os
+from pathlib import Path
+import sys
+import uuid
+
+import pytest
+sys.path.insert(0,str(Path(__file__).resolve().parents[1]/'scripts'))
+from task_execution_journal import Binding,Cleanup,Journal,Refused
+
+
+def child_crash(directory,binding,obligation):
+ journal=Journal(Path(directory))
+ with journal.locked():
+ journal.reserve(binding,(obligation,))
+ journal.transition(binding,'reserved','consume-confirmed')
+ journal.before_cleanup(binding,0,obligation)
+ journal.before_cleanup_call(binding,0,obligation,0,'c'*64)
+ os._exit(29)
+
+
+def test_subcall_crash_persists_and_never_restores_admission(tmp_path):
+ binding=Binding('fixture-approval','sha256:'+'a'*64,str(uuid.uuid4()),'b'*64)
+ obligation=Cleanup('task-token-revoke','d'*64)
+ os.chmod(tmp_path,0o700)
+ context=multiprocessing.get_context('fork')
+ process=context.Process(target=child_crash,args=(str(tmp_path),binding,obligation))
+ process.start();process.join(10)
+ if process.is_alive():process.terminate();process.join();pytest.fail('crash fixture exceeded bound')
+ assert process.exitcode==29
+ journal=Journal(tmp_path)
+ try:
+ assert journal.observe(binding)['external_outcome_unknown']
+ assert not journal.cleanup_status(binding)['confirmed']
+ with journal.locked():
+ with pytest.raises(Refused):journal.before_cleanup_call(binding,0,obligation,1,'c'*64)
+ finally:journal.close()
+
+
+def test_pending_native_subcall_never_confirms_parent_and_survives_unconfirmed(tmp_path):
+ os.chmod(tmp_path,0o700);journal=Journal(tmp_path)
+ binding=Binding('fixture-approval','sha256:'+'a'*64,str(uuid.uuid4()),'b'*64)
+ obligation=Cleanup('task-token-revoke','d'*64)
+ try:
+ with journal.locked():
+ journal.reserve(binding,(obligation,));journal.transition(binding,'reserved','consume-confirmed')
+ journal.before_cleanup(binding,0,obligation)
+ journal.before_cleanup_call(binding,0,obligation,0,'c'*64)
+ with pytest.raises(Refused):journal.after_cleanup(binding,0,obligation,confirmed=True,evidence_digest='e'*64)
+ with pytest.raises(Refused):journal.before_cleanup_call(binding,0,obligation,1,'c'*64)
+ for step in [False,0.0,'0',-1,2]:
+ with pytest.raises(Refused):journal.after_cleanup_call(binding,0,obligation,step,'c'*64,'e'*64)
+ journal.after_cleanup(binding,0,obligation,confirmed=False,evidence_digest='e'*64)
+ assert journal.observe(binding)['external_outcome_unknown']
+ with pytest.raises(Refused):journal.after_cleanup_call(binding,0,obligation,0,'c'*64,'e'*64)
+ finally:journal.close()
diff --git a/tests/test_task_execution_journal.py b/tests/test_task_execution_journal.py
index b1a76fc..654c024 100644
--- a/tests/test_task_execution_journal.py
+++ b/tests/test_task_execution_journal.py
@@ -7,10 +7,8 @@ import unittest
import uuid
from pathlib import Path
-spec = importlib.util.spec_from_file_location('task_execution_journal', Path(__file__).resolve().parents[1] / 'scripts/task_execution_journal.py')
-module = importlib.util.module_from_spec(spec)
-sys.modules[spec.name] = module
-spec.loader.exec_module(module)
+sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'scripts'))
+import task_execution_journal as module
Binding, Journal, Refused = module.Binding, module.Journal, module.Refused
Cleanup = module.Cleanup
diff --git a/tests/test_task_execution_native_fixture.py b/tests/test_task_execution_native_fixture.py
new file mode 100644
index 0000000..382b236
--- /dev/null
+++ b/tests/test_task_execution_native_fixture.py
@@ -0,0 +1,232 @@
+"""Actual disposable OpenBao effects; all identities/approvals are synthetic.
+
+These tests do not contact estate OpenBao or claim production owner acceptance.
+"""
+import hashlib
+import http.client
+import json
+import shutil
+import socket
+import subprocess
+import time
+from datetime import datetime,timedelta,timezone
+from pathlib import Path
+import sys
+
+import pytest
+
+sys.path.insert(0,str(Path(__file__).resolve().parents[1]/'scripts'))
+from test_task_execution_plan import compiler,request,m
+from task_execution_journal import Binding,Cleanup,Journal,Refused
+from task_execution_cleanup import run_cleanup
+from task_execution_native_fixture import FixtureAdmission,NativeFixtureAdapter,PrivateSession
+
+
+@pytest.fixture
+def bao_dev():
+ executable=shutil.which('bao')
+ if not executable: pytest.skip('OpenBao executable unavailable')
+ with socket.socket() as sock:
+ sock.bind(('127.0.0.1',0));port=sock.getsockname()[1]
+ root='synthetic-disposable-fixture-root'
+ process=subprocess.Popen([executable,'server','-dev','-dev-no-store-token',
+ '-dev-root-token-id='+root,'-dev-listen-address=127.0.0.1:'+str(port)],
+ stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL)
+ def call(method,path,body=None,token=root):
+ conn=http.client.HTTPConnection('127.0.0.1',port,timeout=2)
+ try:
+ conn.request(method,'/v1/'+path,body=json.dumps(body) if body is not None else None,
+ headers={'X-Vault-Token':token,'Content-Type':'application/json'})
+ response=conn.getresponse();raw=response.read()
+ return response.status,json.loads(raw) if raw else {}
+ finally: conn.close()
+ for _ in range(100):
+ if process.poll() is not None: pytest.fail('disposable backend exited')
+ try:
+ if call('GET','sys/health')[0]==200: break
+ except OSError: pass
+ time.sleep(0.05)
+ else:
+ process.kill();process.wait();pytest.fail('disposable backend failed to start')
+ try:
+ yield 'http://127.0.0.1:'+str(port),root,call,process
+ finally:
+ process.terminate()
+ try: process.wait(timeout=5)
+ except subprocess.TimeoutExpired: process.kill();process.wait()
+
+
+@pytest.fixture
+def owner(compiler,bao_dev,tmp_path):
+ origin,root,call,process=bao_dev
+ assert call('POST','sys/auth/kubernetes',{'type':'kubernetes'})[0] in {200,204}
+ hcl='path "auth/token/revoke-self" { capabilities = ["update"] }'
+ assert call('PUT','sys/policies/acl/fixture-self-only',{'policy':hcl})[0] in {200,204}
+ status,result=call('POST','auth/token/create',{'policies':['fixture-self-only'],'no_default_policy':True,'renewable':False,'ttl':'300s','num_uses':8})
+ assert status==200
+ identity=PrivateSession(result['auth']['client_token'],result['auth']['accessor'])
+ req=request(compiler);plan=compiler.compile(req)
+ descriptor={'contract_version':'SECRETS-EXEC-20261011-v1','operation_id':req['operation_id'],
+ 'operation':'metadata-apply-ccr','execution_actor':'service:secrets-engine',
+ 'ccr_id':compiler._derive()[0]['id'],'ccr_immutable_sha256':req['ccr_sha256'],
+ 'execution_grant_ccr_id':'FIXTURE-GRANT-NOT-PRODUCTION','grant_immutable_sha256':'sha256:'+'f'*64,
+ 'catalog_sha256':req['catalog_sha256'],
+ 'source_bundle':{role:{'path':'/fixture/'+role,'sha256':'sha256:'+'d'*64} for role in ['engine_dispatcher','platform_mediator','typed_ccr','policy_auth']},
+ 'backend_origin':origin,'cluster_uid':'disposable-fixture-only',
+ 'operations':req['operations'],'operation_deadline':(datetime.now(timezone.utc)+timedelta(seconds=240)).isoformat(),
+ 'backend_budget':{'ttl_seconds':120,'uses':12},'expected_before':'sha256:'+'0'*64,'expected_after':'sha256:'+'1'*64}
+ # Exercise the actual consumer closed descriptor parser as well; source pins,
+ # grant/expected-state digests remain deliberately synthetic owner fixtures.
+ from secrets_engine.execution_contract import ExecutionContract
+ descriptor=ExecutionContract.parse(descriptor).as_dict()
+ installed=m.encoded(descriptor)
+ plan=compiler.bind_execution(plan,descriptor,installed)
+ binding=Binding('SYNTHETIC-APPROVAL-NOT-PRODUCTION',req['request_digest'],req['operation_id'],plan.content_digest)
+ state=tmp_path/'private-journal';state.mkdir(mode=0o700);journal=Journal(state)
+ admission=FixtureAdmission(installed,root,identity,process)
+ native=NativeFixtureAdapter(compiler,plan,journal,binding,admission)
+ try: yield native,journal,binding,call,compiler,descriptor
+ finally: journal.close()
+
+
+def admit(native,journal,binding):
+ # Synthetic postconsume transition is fixture setup, not served approval proof.
+ journal.reserve(binding,native.cleanup)
+ journal.transition(binding,'reserved','consume-confirmed')
+ journal.transition(binding,'consume-confirmed','authenticating')
+ journal.transition(binding,'authenticating','grant-issuing')
+
+
+def test_native_fixed_grant_metadata_denials_and_confirmed_cleanup(owner):
+ native,journal,binding,call,compiler,descriptor=owner
+ with journal.locked():
+ admit(native,journal,binding)
+ info=native.materialize_grant()
+ assert info['issued'] and info['ttl_ceiling_seconds']==120 and info['use_ceiling']==12
+ journal.transition(binding,'grant-issued','applying')
+ baseline=native.metadata_call(0);assert baseline[0]==404
+ assert native.metadata_call(1)[0] in {200,204}
+ policy=native.metadata_call(2);assert policy[0]==200
+ assert policy[1]['data']['policy'].rstrip()==json.loads(compiler.before_call(native.plan,1).payload)['policy'].rstrip()
+ assert native.metadata_call(3)[0]==404
+ assert native.metadata_call(4)[0] in {200,204}
+ role=native.metadata_call(5);assert role[0]==200
+ # Actual native role metadata correspondence for fixture's known K8s body.
+ expected=json.loads(compiler.before_call(native.plan,4).payload)
+ assert role[1]['data']['bound_service_account_names']==expected['bound_service_account_names']
+ assert role[1]['data']['bound_service_account_namespaces']==expected['bound_service_account_namespaces']
+ assert role[1]['data']['token_policies']==expected['policies'].split(',')
+ # Root probes native capabilities without spending the limited task token.
+ for path in ['sys/policies/acl/sibling','auth/kubernetes/role/sibling','secret/data/sibling','auth/token/create','sys/mounts/secret','auth/approle/role/sibling/secret-id']:
+ status,data=call('POST','sys/capabilities',{'token':native.task.token,'paths':[path]})
+ assert status==200 and data['capabilities']==['deny']
+ task_accessor=native.task.accessor;identity_accessor=native.admission.identity_session.accessor
+ result=run_cleanup(journal,binding,native)
+ # Assert native absence before summary so fixture failures identify protocol semantics.
+ for accessor in [task_accessor,identity_accessor]:
+ status,data=call('POST','auth/token/lookup-accessor',{'accessor':accessor})
+ assert status==400 and data.get('errors') in (['invalid accessor'],['1 error occurred:\n\t* invalid accessor\n\n'])
+ assert result['confirmed']
+ assert call('GET','sys/policies/acl/'+native.policy_name)[0]==404
+ assert journal.observe(binding)['state']=='applying' # native success is not full owner verification
+ with pytest.raises(Refused): native.metadata_call(6)
+ with pytest.raises(Refused): run_cleanup(journal,binding,native)
+
+
+def test_no_consume_no_native_grant_and_changed_digest_no_cleanup(owner):
+ native,journal,binding,call,*_=owner
+ with journal.locked():
+ journal.reserve(binding,native.cleanup)
+ with pytest.raises(Refused):native.materialize_grant()
+ assert call('GET','sys/policies/acl/'+native.policy_name)[0]==404
+ with pytest.raises(Refused):native.delete_grant_policy('0'*64)
+
+
+def test_native_cleanup_after_deadline_and_pending_business_uncertainty(owner):
+ native,journal,binding,call,*_=owner
+ with journal.locked():
+ admit(native,journal,binding);native.materialize_grant()
+ journal.transition(binding,'grant-issued','applying')
+ journal.before_call(binding,native._ordinal,'a'*64)
+ journal.transition(binding,'applying','unknown')
+ native.monotonic_deadline=time.monotonic()-1
+ with pytest.raises(Refused):native.metadata_call(0)
+ assert run_cleanup(journal,binding,native)['confirmed']
+ assert journal.observe(binding)['state']=='unknown'
+ assert journal.observe(binding)['external_outcome_unknown']
+
+
+def test_whole_descriptor_and_cleanup_commitment_are_bound(owner):
+ native,journal,binding,call,compiler,descriptor=owner
+ base=compiler.compile(native.meta['request'])
+ changed=dict(descriptor,cluster_uid='different-cluster')
+ with pytest.raises(m.Refused):compiler.bind_execution(base,changed,native.admission.descriptor)
+ data=native.plan.metadata();data['cleanup'][0]['call_digest']='9'*64
+ with pytest.raises(m.Refused):compiler.before_call(m.Plan(m.encoded(data)),0)
+ with pytest.raises(Refused):NativeFixtureAdapter(compiler,m.Plan(m.encoded(data)),journal,binding,native.admission)
+
+
+def test_source_drift_after_admission_prevents_mint_but_allows_native_cleanup(owner):
+ native,journal,binding,call,compiler,*_=owner
+ with journal.locked():
+ admit(native,journal,binding)
+ compiler.ccr.write_bytes(compiler.ccr.read_bytes()+b'\n# changed owner source\n')
+ with pytest.raises(m.Refused):native.materialize_grant()
+ assert call('GET','sys/policies/acl/'+native.policy_name)[0]==404
+ assert run_cleanup(journal,binding,native)['confirmed']
+
+
+def test_actual_native_write_then_lost_response_stays_unknown_and_cleans(owner):
+ native,journal,binding,call,compiler,*_=owner
+ with journal.locked():
+ admit(native,journal,binding);native.materialize_grant()
+ journal.transition(binding,'grant-issued','applying')
+ assert native.metadata_call(0)[0]==404
+ original=native._http
+ target=compiler.before_call(native.plan,1).path
+ def lost_response(method,path,token,body=b''):
+ result=original(method,path,token,body)
+ if method=='PUT' and path==target:
+ raise Refused('synthetic lost response after real native write')
+ return result
+ native._http=lost_response
+ with pytest.raises(Refused):native.metadata_call(1)
+ assert call('GET',target)[0]==200 # actual side effect despite missing confirmation
+ assert journal.observe(binding)['state']=='unknown'
+ assert journal.observe(binding)['external_outcome_unknown']
+ assert run_cleanup(journal,binding,native)['confirmed']
+ assert journal.observe(binding)['state']=='unknown'
+ with pytest.raises(Refused):native.metadata_call(1)
+ assert call('GET',target)[0]==200 # cleanup never rolls back/reapplies business state
+
+
+def test_actual_native_policy_retirement_does_not_prove_failed_token_revoke(owner):
+ native,journal,binding,call,*_=owner
+ with journal.locked():
+ admit(native,journal,binding);native.materialize_grant()
+ original=native._http
+ def refuse_task_revoke(method,path,token,body=b''):
+ if method=='POST' and path=='auth/token/revoke-self' and token==native.task.token:
+ return 503,{'errors':['synthetic cleanup transport failure']}
+ return original(method,path,token,body)
+ native._http=refuse_task_revoke
+ result=run_cleanup(journal,binding,native)
+ assert not result['confirmed']
+ assert [item['outcome'] for item in result['obligations']]==['unconfirmed','confirmed','confirmed']
+ assert call('GET','sys/policies/acl/'+native.policy_name)[0]==404
+ # Actual owner lookup demonstrates token still exists despite deleted ACL.
+ status,data=call('POST','auth/token/lookup-accessor',{'accessor':native.task.accessor})
+ assert status==200 and data['data']['policies']==[native.policy_name]
+ with pytest.raises(Refused):run_cleanup(journal,binding,native)
+ # Explicit synthetic owner recovery, not adapter retry/resumption.
+ assert call('POST','auth/token/revoke-accessor',{'accessor':native.task.accessor})[0] in {200,204}
+
+
+def test_nonloopback_estate_origin_is_refused_before_native_transport(owner):
+ native,journal,binding,call,compiler,descriptor=owner
+ changed=dict(descriptor,backend_origin='https://estate.invalid:8200')
+ installed=m.encoded(changed)
+ plan=compiler.bind_execution(compiler.compile(native.meta['request']),changed,installed)
+ changed_binding=Binding(binding.approval_id,binding.request_digest,binding.operation_id,plan.content_digest)
+ admission=FixtureAdmission(installed,native.admission.owner_token,native.admission.identity_session,native.admission.server_process)
+ with pytest.raises(Refused):NativeFixtureAdapter(compiler,plan,journal,changed_binding,admission)
diff --git a/tests/test_task_execution_plan.py b/tests/test_task_execution_plan.py
index 95e4e62..bb04079 100644
--- a/tests/test_task_execution_plan.py
+++ b/tests/test_task_execution_plan.py
@@ -2,8 +2,8 @@ import importlib.util,json,hashlib,shutil,sys,uuid,os
from pathlib import Path
import pytest,yaml
SOURCE=Path(__file__).resolve().parents[1]/'scripts/task_execution_plan.py'
-spec=importlib.util.spec_from_file_location('task_execution_plan',SOURCE)
-m=importlib.util.module_from_spec(spec);sys.modules[spec.name]=m;spec.loader.exec_module(m)
+sys.path.insert(0,str(SOURCE.parent))
+import task_execution_plan as m
PLATFORM=Path(os.environ.get('PLATFORM_PLAN_TEST_SOURCE_ROOT',str(SOURCE.parents[1])))
SECRETS=Path(os.environ.get('SECRETS_ENGINE_SOURCE_ROOT',str(PLATFORM.parent/'secrets-engine')))
@pytest.fixture
diff --git a/workplans/RPF-WP-0035-credential-lane-implementation.md b/workplans/RPF-WP-0035-credential-lane-implementation.md
index 269ad08..0fd7ccc 100644
--- a/workplans/RPF-WP-0035-credential-lane-implementation.md
+++ b/workplans/RPF-WP-0035-credential-lane-implementation.md
@@ -1239,3 +1239,15 @@ No native adapter or production mediator exists; full grant/request/cleanup bind
authenticated consume/identity, ephemeral token issuance, deadline/use checks,
native effect/revoke/absence, audit and protected recovery remain prerequisites.
T12 remains progress. No CCR38 default/entitlement or live grant was changed.
+
+2026-10-16 T12 second source package: Compiler Plans now bind deterministic cleanup
+commitments and can bind the complete independently installed engine execution
+descriptor. A fixed disposable-loopback native adapter actually materializes exact
+per-operation metadata ACL/task token, executes six source-derived metadata calls,
+and verifies self-revocation/accessor invalidation and policy retirement. Each
+cleanup request has its own durable intent. Native fault tests cover lost response
+after real write and policy deletion while task revoke fails. All initiating
+identity/approval/consume/grant/state/source admission in this fixture is synthetic;
+no production authority or complete native owner acceptance is claimed. Exact
+remaining owner schema/admission and mediator work: docs/task-execution-native-fixture.md.
+T12 stays progress; no live grant, credential/default change or owner sync performed.

View file

@ -0,0 +1,44 @@
{
"reviewer": "railiance-platform-owner (agent railiance-platform, codex)",
"decision": "approved",
"owner_work_item": "RPF-WP-0035-T12",
"review_scope": "Source-only deterministic cleanup Plan binding and disposable native OpenBao adapter. Native fixture proof is not production admission.",
"comment": "Platform INTENT Secure Custody, Stable Interfaces and Durable by Default support fixed owner-held ephemeral native execution and explicit uncertain outcomes. Reviewed exact modified file hashes below. The adapter admits only a live owner-spawned disposable loopback dev process, has no production caller and creates no source authority from synthetic approval/grant/state inputs. This source review does not grant human approval or native production acceptance.",
"reviewed_files_sha256": {
"docs/task-execution-native-fixture.md": "7de76b6e2d6e7ae02191cfb970c787c0e541387323d7292d32847e2e4e09e687",
"scripts/task_execution_journal.py": "e13c21a7649321ab8ba2748ae6bd704e55c0802ea2c936317d312e0479dc060d",
"scripts/task_execution_native_fixture.py": "f335a8fd7d9a156b136a3465b3aaa9144d23475fc4b1fea52b54b89880d92b29",
"scripts/task_execution_plan.py": "f799a6320e92722c0cbe452f58ac793b55da47f97938efe7f1de851231849d37",
"tests/test_task_execution_cleanup_calls.py": "b74d417827b73a73fbe93d5e11a196014d772fe3ff0e3c06c9327eef34062b17",
"tests/test_task_execution_journal.py": "b51a4f5b8122221c69ad2486d6e1751298bd4665ac37c94f363dd5a27c5a014c",
"tests/test_task_execution_native_fixture.py": "a973b4873f6313ea2a7dca56b71a31a693dbd5d2022e2e2dbf793b8c9edd46ab",
"tests/test_task_execution_plan.py": "679bfe0450e7975b8ad6747cd210afce1de33da54acdf64bc9f00b3603772b95",
"workplans/RPF-WP-0035-credential-lane-implementation.md": "24b2ef20eba123c511575ec4694b26dcc8f4c5854fe6a4a30dd24cd0ae784e60"
},
"patch_sha256": "c419ac0f6dba3e36f15dc51a6a36fbd87fe7d255cce40c33c7f4589d80355580",
"base_local_commit": "774cf8755ef429ada616d993b3df8fa1b44fe177",
"first_package_files_exactly_preserved_as_base": true,
"independent_from_implementation": false,
"production_admission": "changes_required",
"minimal_grant_work": {
"existing_type": "task-execution-grant (already proposed by Platform/Secrets contracts; not admitted in current CCR validator)",
"existing_descriptor_join": "execution_grant_ccr_id + grant_immutable_sha256; no new engine descriptor field/schema",
"immutable_projection": "Define exact CCR immutable fields and exclude only explicitly owner-defined append-only reviews/evidence; never exclude policy/token/body/target/deadline/source/cleanup bytes.",
"binding_contents": [
"operation/action/actor/catalogue/tenant and target CCR immutable identity",
"owner-admitted origin/cluster and exact source revisions",
"six generated metadata method/path/body commitments and native before/after state expectations",
"exact generated per-operation grant ACL, parent custody, nonrenewable no-default/no-identity policy TTL/use constraints and reserved cleanup use",
"Compiler cleanup_specs(operation_id), including fixed credential slots, revoke/verify and delete/verify sequence plus explicit no-issue/no-create branches",
"business deadline and separately retained cleanup obligation; no arbitrary token/accessor handles in record"
],
"validation": "Validator reconstructs every generated field from owner sources and compares; mediator checks authoritative grant immutable digest against exact admitted descriptor before reserve/consume/native requests."
},
"other_open_gates": [
"Authenticated initiating identity and independent claim/PDP/package/signed consume confirmation",
"Owner runtime origin/source/isolation and granted protected custody",
"Complete native baseline/readback normalization and concurrency/conflict handling",
"Trusted per-call served deadline/accounting plus audit outbox and protected crash recovery",
"Exact founder-scoped attended production trial and owner-native acceptance"
]
}

View file

@ -0,0 +1,4 @@
SECRETS_ENGINE_SOURCE_ROOT=/home/worsch/secrets-engine PYTHONPATH=/home/worsch/secrets-engine/src python3 -m pytest -xq tests/test_task_execution_plan.py tests/test_task_execution_journal.py tests/test_task_execution_cleanup.py tests/test_task_execution_cleanup_calls.py tests/test_task_execution_native_fixture.py
83 passed in 113.85s (0:01:53), final frozen source
OpenBao: OpenBao v2.5.5 (028992583c693c4de6350b8aa52ff85e30375a99), built 2026-06-17T11:18:48Z
Synthetic local admission only; no production acceptance.

View file

@ -0,0 +1,46 @@
{
"owner_repository": "/home/worsch/railiance-platform",
"isolated_checkout": "/tmp/netkingdom-platform-wave3-20261016",
"base_local_commit": "72475813d444e269af6dd9d4afec8458308edcaf",
"base_wave2_patch_sha256": "c419ac0f6dba3e36f15dc51a6a36fbd87fe7d255cce40c33c7f4589d80355580",
"base_wave2_files_sha256": {
"docs/task-execution-native-fixture.md": "7de76b6e2d6e7ae02191cfb970c787c0e541387323d7292d32847e2e4e09e687",
"scripts/task_execution_journal.py": "e13c21a7649321ab8ba2748ae6bd704e55c0802ea2c936317d312e0479dc060d",
"scripts/task_execution_native_fixture.py": "f335a8fd7d9a156b136a3465b3aaa9144d23475fc4b1fea52b54b89880d92b29",
"scripts/task_execution_plan.py": "f799a6320e92722c0cbe452f58ac793b55da47f97938efe7f1de851231849d37",
"tests/test_task_execution_cleanup_calls.py": "b74d417827b73a73fbe93d5e11a196014d772fe3ff0e3c06c9327eef34062b17",
"tests/test_task_execution_journal.py": "b51a4f5b8122221c69ad2486d6e1751298bd4665ac37c94f363dd5a27c5a014c",
"tests/test_task_execution_native_fixture.py": "a973b4873f6313ea2a7dca56b71a31a693dbd5d2022e2e2dbf793b8c9edd46ab",
"tests/test_task_execution_plan.py": "679bfe0450e7975b8ad6747cd210afce1de33da54acdf64bc9f00b3603772b95",
"workplans/RPF-WP-0035-credential-lane-implementation.md": "24b2ef20eba123c511575ec4694b26dcc8f4c5854fe6a4a30dd24cd0ae784e60"
},
"base_wave2_files_match": {
"docs/task-execution-native-fixture.md": true,
"scripts/task_execution_journal.py": true,
"scripts/task_execution_native_fixture.py": true,
"scripts/task_execution_plan.py": true,
"tests/test_task_execution_cleanup_calls.py": true,
"tests/test_task_execution_journal.py": true,
"tests/test_task_execution_native_fixture.py": true,
"tests/test_task_execution_plan.py": true,
"workplans/RPF-WP-0035-credential-lane-implementation.md": true
},
"patch_sha256": "d4ba4d531414af85667b5ac68b139eabe1ced8e405878a913989ba6b813c2186",
"files_sha256": {
"docs/task-execution-grant-projection.md": "643e9769ad895795179e86f52c74a826b9619d1e6a4f8c6d411243b4139ad33d",
"scripts/task_execution_grant.py": "b76d7dce419c0548f33be3efaec2589e50eec48b9e277c90ab0e4615b9c0f3e7",
"scripts/task_execution_native_fixture.py": "8607321e2947d2ec1ca9c69ac24a4d54d50819ee40e28f9eaf4ee1d865beec0a",
"scripts/task_execution_plan.py": "cd984055ca8a1d51ef563bb7534b30c2fc9e326ed89a459362419b407397b9dc",
"tests/test_task_execution_grant.py": "209c9998f34def06fb9588374901574bca6bf206b73163961d762f53683d7e5b",
"tests/test_task_execution_native_fixture.py": "b5e1b5ed27f777c9d2cdb5b1880d2cb0d53031ce6f725cf013f09b2c11528d0f",
"workplans/RPF-WP-0035-credential-lane-implementation.md": "00532e2ba330348479f3787615516ee1a474da9ae0254a66ec57fcaf0e7a6285"
},
"work_item": "RPF-WP-0035-T12",
"existing_consumer_join": "SECRETS-WP-0013 execution_grant_ccr_id + grant_immutable_sha256",
"test_status": "final frozen source/native suite running; 35 direct projection/binding tests previously passed",
"production_admission": false,
"owner_checkout_written": false,
"source_delivery": "Proposed CCR projection source with actual Compiler and disposable native adapter binding; normal legacy applier unchanged",
"estate_grants_or_policies_created": false,
"disposable_native_policies_created": true
}

View file

@ -0,0 +1,586 @@
diff --git a/docs/task-execution-grant-projection.md b/docs/task-execution-grant-projection.md
new file mode 100644
index 0000000..b5c7647
--- /dev/null
+++ b/docs/task-execution-grant-projection.md
@@ -0,0 +1,85 @@
+# Proposed task-execution-grant immutable projection
+
+RPF-WP-0035-T12 source preparation, 2026-10-16. This proposes and implements the
+owner's pure projection/binding code; it approves no grant and registers no native
+or legacy delegated applier. Existing production readiness remains refused.
+
+Use the existing `task-execution-grant` CCR type proposed by Platform and
+SECRETS-WP-0013. Use the existing execution descriptor's
+`execution_grant_ccr_id` and `grant_immutable_sha256` join. No second consumer
+execution descriptor, authorization token or broker is introduced.
+
+`scripts/task_execution_grant.py` defines a closed CCR root with existing identity,
+status/date/requester/review/target fields, effectful `execution` and `openbao`
+objects, and an explicitly typed evidence event list. `immutable_digest` uses
+SHA256 over sorted compact ASCII JSON of the complete validated projection.
+It includes status, updated, title, requester, review requirements/approvers,
+target coordinates, all execution inputs, generated HCL, token body and cleanup.
+It excludes exactly `review.comments` and `evidence.events` by replacing those
+lists with empty lists. No generic evidence-prefix stripping or caller-selected
+exclusion exists. Unknown root/review/evidence fields refuse. Fields nested in
+execution/openbao remain fully hashed and must exactly match independently
+reconstructed effects when bound. Projection alone is not semantic admission.
+
+Reviews have exactly at/reviewer/decision/comment; evidence events have exactly
+at/kind/result/reference_sha256. These append-only annotations cannot supply a
+runtime approval or become policy inputs. `require_append_only` independently
+checks that each old list is an exact prefix of the new list and that the
+immutable projection is unchanged. Removal, replacement and reordering refuse.
+The owner must use known history when checking an update; a current file cannot
+prove its own history. Updating `updated` or status changes the digest and requires
+a new exact join. There is no guessed exclusion for convenience.
+
+The compiler's optional independently installed `OwnerConfig.grant_filename`
+selects one different CCR file under credential-change-requests. It adds exactly
+that record and the pure validator source to the closed owner source-pin set.
+The compiler reads verified bytes and executes the pinned validator source directly,
+without a module-cache substitute. It reconstructs the entire effectful grant:
+
+- The existing descriptor, excluding only its own `grant_immutable_sha256` to avoid
+ self-reference, plus action/catalogue and the actual owner source pins. The
+ record's own raw file hash is omitted from its own source-pin projection; all
+ other source hashes, including the validator, remain included.
+- Deterministic `cleanup_specs(operation_id)`, its fixed private credential slots,
+ exact revoke/verification and delete/verification paths/methods, declared
+ no-issue/no-create branches, one reserved cleanup use and the cleanup obligation
+ after business expiry.
+- Exact operation-specific task policy HCL on the two independently compiled
+ metadata endpoints plus revoke-self, and the exact parented service token body:
+ nonrenewable, no default policies, no identity policies, 30–120s TTL and twelve
+ uses. No caller HCL, body, handle, wildcard or mount administration is accepted.
+
+It compares those structures with the owner record and compares the resulting
+immutable SHA and grant ID with the existing descriptor fields before returning
+a bound Plan. Unknown or changed effectful fields fail equality even if the caller
+re-pins the record and recomputes its digest. The complete descriptor must still
+match independently installed owner descriptor bytes. Raw source file hashes and
+immutable hashes serve different purposes: even an annotation-only file edit
+requires owner source-pin refresh and Plan reconstruction; it does not silently
+change the grant immutable digest or restore an admission.
+
+The disposable native adapter now refuses missing/unbound or proposed grants,
+rebuilds the exact bound Plan from pinned owner sources at construction, checks
+all source pins immediately before each native request, reconstructs each actual
+metadata body via the compiler and compares actual token/ACL bodies with the bound
+projection before issuing them. It still requires the same live postconsume
+journal admission and the live owner-spawned disposable OpenBao process handle.
+Native fault tests explicitly use synthetic approved copies in temporary directories;
+no real owner CCR status, policy or grant is created or changed.
+
+The normal credential-change CLI deliberately continues to reject this type for
+execution. Adding it to LANE_MODULES would expose generic applier methods and is
+not part of this source preparation. The pure validator is actually called by
+the compiler/native fixture path; it is not a paper-only schema. An owner can
+review this projection and later integrate source validation/recording into the
+CCR tooling without enabling execution.
+
+Remaining production work is now specific: approve/adopt this owner CCR projection;
+verify owner source/grant admission and immutable target CCR semantics; integrate
+authenticated principal, served exact claim/PDP/package and independent signed
+consume; finish total native baseline/readback normalization/conflict control;
+install bounded isolated owner runtime, trusted served deadlines/use accounting,
+audit and protected recovery; then run the exact founder-scoped attended trial.
+Neither an approved string nor this projection authenticates human approval.
+Approval replay prevention and uncertain backend-effect reconciliation remain
+separate. T12 and Secrets WP13 stay open.
diff --git a/scripts/task_execution_grant.py b/scripts/task_execution_grant.py
new file mode 100644
index 0000000..ad5ae87
--- /dev/null
+++ b/scripts/task_execution_grant.py
@@ -0,0 +1,115 @@
+"""Proposed task-execution-grant CCR immutable projection; no native admission.
+
+The only excluded paths are typed append-only review.comments and evidence.events.
+Status, updated, review requirements, effectful bytes and source revisions remain
+immutable. A digest is provenance, never served approval or execution authority.
+"""
+from __future__ import annotations
+import copy
+import hashlib
+import json
+import re
+from datetime import datetime
+
+
+class Refused(Exception): pass
+
+ROOT_KEYS={'id','kind','schema_version','request_type','title','status','created','updated',
+ 'requester','review','target','execution','openbao','evidence'}
+APPEND_ONLY_PATHS=(('review','comments'),('evidence','events'))
+HASH=re.compile(r'sha256:[0-9a-f]{64}')
+
+
+def need(value):
+ if not value:raise Refused('invalid task execution grant')
+
+
+def encoded(value):
+ def json_value(item):
+ if type(item) is dict:
+ need(all(type(key) is str for key in item))
+ for child in item.values():json_value(child)
+ elif type(item) is list:
+ for child in item:json_value(child)
+ else:need(item is None or type(item) in {str,int,float,bool})
+ try:
+ json_value(value)
+ return json.dumps(value,sort_keys=True,separators=(',',':'),ensure_ascii=True,allow_nan=False).encode()
+ except (TypeError,ValueError,RecursionError):raise Refused('invalid task execution grant') from None
+
+
+def closed(value,keys):
+ need(type(value) is dict and set(value)==set(keys));return value
+
+
+def text(value):
+ need(type(value) is str and 0<len(value)<=4096 and '\x00' not in value);return value
+
+
+def timestamp(value):
+ try:need(datetime.fromisoformat(text(value).replace('Z','+00:00')).tzinfo is not None)
+ except (ValueError,TypeError):raise Refused('invalid task execution grant') from None
+
+
+def projection(record):
+ """Validate and capture every field except the two explicit append-only lists.
+
+ Unknown keys never become implicitly excluded evidence. The independently
+ derived exact execution/openbao comparison belongs to bind(), below.
+ """
+ closed(record,ROOT_KEYS)
+ need(record['kind']=='credential-change-request' and type(record['schema_version']) is int
+ and record['schema_version']==1 and record['request_type']=='task-execution-grant')
+ need(type(record['id']) is str and re.fullmatch(r'[A-Z0-9][A-Z0-9_.-]*',record['id']))
+ need(type(record['status']) is str and record['status'] in {'proposed','approved','denied','cancelled','superseded'})
+ for field in ('title','created','updated'):text(record[field])
+ requester=closed(record['requester'],{'agent','reason'})
+ for value in requester.values():text(value)
+ review=closed(record['review'],{'required','required_approvers','comments'})
+ need(review['required'] is True and type(review['required_approvers']) is list
+ and review['required_approvers']==['railiance-platform-owner','secrets-engine-owner','access-engine-owner','approval-engine-owner'])
+ need(type(review['comments']) is list and len(review['comments'])<=1024)
+ for comment in review['comments']:
+ closed(comment,{'at','reviewer','decision','comment'})
+ timestamp(comment['at']);text(comment['reviewer']);text(comment['comment'])
+ need(type(comment['decision']) is str and comment['decision'] in {'approved','changes_requested','declined'})
+ target=closed(record['target'],{'domain','tenant','workload','environment','purpose'})
+ need(target['tenant']=='platform' and target['workload']=='secrets-engine' and target['environment']=='production')
+ for value in target.values():text(value)
+ need(type(record['execution']) is dict and type(record['openbao']) is dict)
+ evidence=closed(record['evidence'],{'events'})
+ need(type(evidence['events']) is list and len(evidence['events'])<=1024)
+ for event in evidence['events']:
+ closed(event,{'at','kind','result','reference_sha256'})
+ timestamp(event['at']);text(event['kind'])
+ need(type(event['result']) is str and event['result'] in {'pending','passed','failed','unknown'})
+ need(type(event['reference_sha256']) is str and HASH.fullmatch(event['reference_sha256']))
+ # Deep capture refuses NaN/non-JSON structures rather than silently coercing.
+ value=json.loads(encoded(record))
+ for parent,leaf in APPEND_ONLY_PATHS:value[parent][leaf]=[]
+ return value
+
+
+def immutable_digest(record):
+ return 'sha256:'+hashlib.sha256(encoded(projection(record))).hexdigest()
+
+
+def require_append_only(previous,current):
+ need(immutable_digest(previous)==immutable_digest(current))
+ for parent,leaf in APPEND_ONLY_PATHS:
+ old=previous[parent][leaf];new=current[parent][leaf]
+ need(len(new)>=len(old) and new[:len(old)]==old)
+
+
+def bind(record,*,grant_id,expected_execution,expected_openbao,expected_digest):
+ """Compare an owner source record with independently reconstructed effects.
+
+ Does not trust status/comments as approval, read source files, contact engines,
+ register a delegated applier or issue any backend request.
+ """
+ value=projection(record)
+ need(record['id']==grant_id and type(expected_digest) is str and HASH.fullmatch(expected_digest))
+ need(encoded(record['execution'])==encoded(expected_execution))
+ need(encoded(record['openbao'])==encoded(expected_openbao))
+ need(immutable_digest(record)==expected_digest)
+ return value
diff --git a/scripts/task_execution_native_fixture.py b/scripts/task_execution_native_fixture.py
index 7a4d247..29d168d 100644
--- a/scripts/task_execution_native_fixture.py
+++ b/scripts/task_execution_native_fixture.py
@@ -14,6 +14,7 @@ import http.client
import json
import subprocess
import time
+from pathlib import Path
from urllib.parse import urlsplit
from task_execution_cleanup import CleanupResult
@@ -44,6 +45,8 @@ class NativeFixtureAdapter:
self.compiler,self.plan,self.journal,self.binding=compiler,plan,journal,binding
self.admission=admission
self.meta=plan.metadata()
+ if self.meta.get('execution_grant',{}).get('status')!='approved':
+ raise Refused('independently source-bound synthetic approved grant required')
descriptor=json.loads(admission.descriptor)
fresh=compiler.bind_execution(compiler.compile(self.meta['request']),descriptor,admission.descriptor)
if fresh.serialized != plan.serialized or binding.plan_digest != plan.content_digest:
@@ -73,6 +76,8 @@ class NativeFixtureAdapter:
paths=sorted({call.path for call in calls})
self.hcl=''.join('path '+json.dumps(path)+' {\n capabilities = ["read", "create", "update"]\n}\n' for path in paths)
self.hcl+='path "auth/token/revoke-self" {\n capabilities = ["update"]\n}\n'
+ if self.meta['execution_grant']['openbao']['policy']!={'name':self.policy_name,'hcl':self.hcl}:
+ raise Refused('native grant body differs from bound owner projection')
self.cleanup=tuple(Cleanup(item['kind'],item['call_digest']) for item in cleanup_specs(binding.operation_id))
if self.meta['cleanup'] != cleanup_specs(binding.operation_id): raise Refused('cleanup plan mismatch')
self.task=None; self._mint_attempted=False; self._policy_attempted=False; self._ordinal=0; self._next_metadata=0
@@ -86,7 +91,11 @@ class NativeFixtureAdapter:
self._live()
if min((self.deadline-datetime.now(timezone.utc)).total_seconds(),self.monotonic_deadline-time.monotonic())<=5:
raise Refused('business deadline exhausted')
- self.compiler.before_call(self.plan,0)
+ # The constructor independently rebuilt the complete immutable Plan and
+ # grant. Recheck every pinned byte before each native effect. Metadata
+ # dispatch additionally reconstructs its exact ordinal/body below; grant
+ # creation reconstructs and compares its fixed body before issuance.
+ for path in self.compiler.pins:self.compiler.read(Path(path))
def _http(self, method, path, token, body=b''):
# Fixed caller-generated paths only; http.client has no proxy or redirect
@@ -148,6 +157,8 @@ class NativeFixtureAdapter:
body={'policies':[self.policy_name],'no_default_policy':True,'renewable':False,
'ttl':str(self.ttl)+'s','explicit_max_ttl':str(self.ttl)+'s','num_uses':self.uses,
'type':'service','display_name':'task-exec-'+self.binding.operation_id,'no_parent':False}
+ if body!=self.meta['execution_grant']['openbao']['token_create_body']:
+ raise Refused('native token body differs from bound owner projection')
self._mint_attempted=True
status,data=self._business('POST','auth/token/create',root,encoded(body));self._ok(status,data,{200})
auth=data.get('auth',{})
diff --git a/scripts/task_execution_plan.py b/scripts/task_execution_plan.py
index f3234fc..4c086b4 100644
--- a/scripts/task_execution_plan.py
+++ b/scripts/task_execution_plan.py
@@ -53,6 +53,7 @@ class OwnerConfig:
catalog_directory: Path
# Exact path/hash pairs, configured independently by the owner. No request paths.
source_pins: tuple[tuple[str,str], ...]
+ grant_filename: str | None = None
@dataclass(frozen=True)
class Plan:
@@ -111,11 +112,18 @@ class Compiler:
self.validator=config.platform_repo/'scripts/credential-change.py'
self.catalog_code=config.secrets_repo/'src/secrets_engine/catalog.py'
self.dependencies=[config.secrets_repo/'src/secrets_engine'/name for name in ('__init__.py','errors.py','redact.py')]
+ self.grant_record=None;self.grant_validator=None
+ if config.grant_filename is not None:
+ need(type(config.grant_filename) is str and re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9_.-]*\.yaml',config.grant_filename)
+ and config.grant_filename!=config.ccr_filename,'owner_grant_filename_invalid')
+ self.grant_record=config.platform_repo/'credential-change-requests'/config.grant_filename
+ self.grant_validator=Path(__file__).resolve().parent/'task_execution_grant.py'
pins=dict(config.source_pins)
self.pins=types.MappingProxyType(pins)
need(len(self.pins)==len(config.source_pins) and all(type(k) is str and digest_ok(v) for k,v in self.pins.items()),'owner_pins_invalid')
# Compiler source is part of the owner plan, not just generated body hashes.
required={str(p) for p in [Path(__file__).resolve(),self.ccr,self.catalog,self.validator,self.catalog_code,*self.dependencies]}
+ if self.grant_record is not None:required.update({str(self.grant_record),str(self.grant_validator)})
need(required <= set(self.pins),'owner_source_pin_missing')
extra=set(self.pins)-required
need(len(extra)==1,'closed_owner_source_set_required')
@@ -254,6 +262,51 @@ class Compiler:
'source_pins':dict(self.pins),'task_token_max_ttl':120,'task_token_max_uses':12,
'cleanup_uses_reserved':1,'cleanup':cleanup_specs(request['operation_id'])}))
+ def grant_effects(self,plan,descriptor):
+ """Independently derive the one proposed grant's effectful immutable body."""
+ need(type(plan) is Plan and type(descriptor) is dict,'typed_grant_inputs_required')
+ meta=plan.metadata();req=meta['request']
+ need(self.compile(req).serialized==plan.serialized,'immutable_plan_changed')
+ _,_,calls=self._derive()
+ budget=descriptor['backend_budget']
+ need(type(budget) is dict and set(budget)=={'ttl_seconds','uses'}
+ and type(budget['ttl_seconds']) is int and 30<=budget['ttl_seconds']<=120
+ and type(budget['uses']) is int and budget['uses']==12,'fixed_grant_budget_required')
+ operation_id=req['operation_id'];policy_name='task-exec-'+operation_id
+ paths=sorted({call.path for call in calls})
+ hcl=''.join('path '+json.dumps(path)+' {\n capabilities = ["read", "create", "update"]\n}\n' for path in paths)
+ hcl+='path "auth/token/revoke-self" {\n capabilities = ["update"]\n}\n'
+ token_body={'policies':[policy_name],'no_default_policy':True,'renewable':False,
+ 'ttl':str(budget['ttl_seconds'])+'s','explicit_max_ttl':str(budget['ttl_seconds'])+'s',
+ 'num_uses':12,'type':'service','display_name':policy_name,'no_parent':False}
+ # A grant cannot include its own immutable hash or raw file SHA. The
+ # existing descriptor joins it by ID+immutable SHA after this projection.
+ descriptor_binding={k:v for k,v in descriptor.items() if k!='grant_immutable_sha256'}
+ pins={path:digest for path,digest in self.pins.items() if self.grant_record is None or path!=str(self.grant_record)}
+ execution={'action':'execute-ccr','catalog_id':req['catalog_id'],
+ 'descriptor_binding':descriptor_binding,'source_pins':pins,
+ 'cleanup':cleanup_specs(operation_id),'cleanup_uses_reserved':1,
+ 'cleanup_after_business_deadline':True}
+ openbao={'policy':{'name':policy_name,'hcl':hcl},'token_create_body':token_body,
+ 'owner_parent_custody':'platform','identity_policies':[]}
+ return execution,openbao
+
+ def _bind_grant(self,plan,descriptor):
+ need(self.grant_record is not None,'owner_grant_source_required')
+ # Execute exactly the pinned pure projection source, never a cached module.
+ module=types.ModuleType('_owner_task_execution_grant')
+ raw=self.read(self.grant_validator)
+ exec(compile(raw,str(self.grant_validator),'exec'),module.__dict__)
+ record=yaml.load(self.read(self.grant_record),Loader=UniqueLoader)
+ execution,openbao=self.grant_effects(plan,descriptor)
+ try:
+ projection=module.bind(record,grant_id=descriptor['execution_grant_ccr_id'],
+ expected_execution=execution,expected_openbao=openbao,
+ expected_digest=descriptor['grant_immutable_sha256'])
+ except Exception:raise Refused('owner_grant_binding_mismatch') from None
+ need(self.read(self.grant_validator)==raw,'owner_grant_source_changed')
+ return projection
+
def bind_execution(self, plan, descriptor, owner_descriptor):
"""Bind exact engine descriptor to owner plan; no authorization is created.
@@ -275,9 +328,11 @@ class Compiler:
need(all(descriptor.get(k)==v for k,v in expected.items()),'execution_descriptor_plan_mismatch')
bound = dict(meta, execution_descriptor=descriptor,
execution_descriptor_sha256=tagged(owner_descriptor))
- # The full descriptor's independently admitted grant immutable digest must
- # cover these cleanup commitments. No grant schema exists here yet, so this
- # source binding cannot itself admit a grant/native production execution.
+ if self.grant_record is not None:
+ bound['execution_grant']=self._bind_grant(plan,descriptor)
+ bound['grant_immutable_sha256']=descriptor['grant_immutable_sha256']
+ # Without an independently pinned grant this remains preparation only.
+ # The native fixture adapter refuses to materialize such an unbound plan.
return Plan(encoded(bound))
def before_call(self,plan,ordinal):
diff --git a/tests/test_task_execution_grant.py b/tests/test_task_execution_grant.py
new file mode 100644
index 0000000..13298e8
--- /dev/null
+++ b/tests/test_task_execution_grant.py
@@ -0,0 +1,98 @@
+"""Pure proposed grant projection: source provenance, never approval/native proof."""
+import copy
+from pathlib import Path
+import sys
+import pytest
+sys.path.insert(0,str(Path(__file__).resolve().parents[1]/'scripts'))
+import task_execution_grant as grant
+
+
+@pytest.fixture
+def record():
+ return {'id':'CCR-FIXTURE-GRANT','kind':'credential-change-request','schema_version':1,
+ 'request_type':'task-execution-grant','title':'Proposed source fixture','status':'proposed',
+ 'created':'2026-10-16','updated':'2026-10-16','requester':{'agent':'fixture','reason':'Local proof'},
+ 'review':{'required':True,'required_approvers':['railiance-platform-owner','secrets-engine-owner','access-engine-owner','approval-engine-owner'],'comments':[]},
+ 'target':{'domain':'infotech','tenant':'platform','workload':'secrets-engine','environment':'production','purpose':'fixture'},
+ 'execution':{'action':'execute-ccr','descriptor_binding':{'operation_deadline':'2026-10-16T12:00:00Z','ccr_immutable_sha256':'sha256:'+'a'*64},
+ 'source_pins':{'/fixture/source':'sha256:'+'b'*64},'cleanup':[{'kind':'task-token-revoke','call_digest':'c'*64}]},
+ 'openbao':{'policy':{'name':'task-exec-fixture','hcl':'exact owner generated HCL'},
+ 'token_create_body':{'ttl':'120s','num_uses':12,'renewable':False}},'evidence':{'events':[]}}
+
+
+def comment():return {'at':'2026-10-16T12:00:00Z','reviewer':'railiance-platform-owner','decision':'approved','comment':'Source only; no execution authority'}
+def event():return {'at':'2026-10-16T12:00:00Z','kind':'test','result':'unknown','reference_sha256':'sha256:'+'e'*64}
+
+
+def test_only_typed_review_comments_and_evidence_events_are_excluded(record):
+ changed=copy.deepcopy(record);changed['review']['comments'].append(comment());changed['evidence']['events'].append(event())
+ assert grant.immutable_digest(changed)==grant.immutable_digest(record)
+ grant.require_append_only(record,changed)
+ assert record['review']['comments']==[] # detached immutable projection
+
+
+@pytest.mark.parametrize('field',['status','updated','title','requester','target','execution','openbao','review'])
+def test_every_other_root_or_effectful_field_changes_digest(record,field):
+ changed=copy.deepcopy(record)
+ if field=='status':changed[field]='approved'
+ elif field=='updated':changed[field]='2026-10-17'
+ elif field=='title':changed[field]='different title'
+ elif field=='requester':changed[field]['reason']='different requester reason'
+ elif field=='target':changed[field]['purpose']='different purpose'
+ elif field=='execution':changed[field]['source_pins']['/fixture/source']='sha256:'+'f'*64
+ elif field=='openbao':changed[field]['policy']['hcl']='different HCL'
+ elif field=='review':changed[field]['required_approvers'].reverse()
+ if field=='review':
+ with pytest.raises(grant.Refused):grant.immutable_digest(changed)
+ else:
+ assert grant.immutable_digest(changed)!=grant.immutable_digest(record)
+ with pytest.raises(grant.Refused):grant.require_append_only(record,changed)
+
+
+@pytest.mark.parametrize('location,key',[('root','audit'),('root','receipts'),('root','evidence_exclusions'),('evidence','files'),('evidence','policy'),('review','approval_ref'),('review','exclusions')])
+def test_guessed_evidence_exclusions_or_unknown_fields_refuse(record,location,key):
+ changed=copy.deepcopy(record)
+ (changed if location=='root' else changed[location])[key]=[]
+ with pytest.raises(grant.Refused):grant.immutable_digest(changed)
+
+
+def test_append_only_rejects_removal_rewrite_and_reordering(record):
+ previous=copy.deepcopy(record);previous['review']['comments']=[comment(),dict(comment(),comment='Second')]
+ previous['evidence']['events']=[event()]
+ for field,value in [('comments',[]),('comments',[dict(comment(),decision='declined')]),('comments',list(reversed(previous['review']['comments']))),('events',[])]:
+ changed=copy.deepcopy(previous);changed['review' if field=='comments' else 'evidence'][field]=value
+ assert grant.immutable_digest(changed)==grant.immutable_digest(previous)
+ with pytest.raises(grant.Refused):grant.require_append_only(previous,changed)
+
+
+@pytest.mark.parametrize('change',['policy','token_budget','cleanup','source','deadline','target_ccr','wrong_hash','grant_id'])
+def test_bind_reconstructs_effects_and_exact_existing_descriptor_join(record,change):
+ expected=copy.deepcopy(record);changed=copy.deepcopy(record);digest=grant.immutable_digest(record);grant_id=record['id']
+ if change=='policy':changed['openbao']['policy']['hcl']='path "*" {}'
+ elif change=='token_budget':changed['openbao']['token_create_body']['num_uses']=13
+ elif change=='cleanup':changed['execution']['cleanup'][0]['kind']='kv-delete'
+ elif change=='source':changed['execution']['source_pins']['/fixture/source']='sha256:'+'f'*64
+ elif change=='deadline':changed['execution']['descriptor_binding']['operation_deadline']='2027-01-01T00:00:00Z'
+ elif change=='target_ccr':changed['execution']['descriptor_binding']['ccr_immutable_sha256']='sha256:'+'f'*64
+ elif change=='wrong_hash':digest='sha256:'+'f'*64
+ elif change=='grant_id':grant_id='CCR-OTHER'
+ with pytest.raises(grant.Refused):grant.bind(changed,grant_id=grant_id,expected_execution=expected['execution'],expected_openbao=expected['openbao'],expected_digest=digest)
+
+
+def test_proposed_source_record_can_bind_without_becoming_approved(record):
+ projection=grant.bind(record,grant_id=record['id'],expected_execution=record['execution'],expected_openbao=record['openbao'],expected_digest=grant.immutable_digest(record))
+ assert projection['status']=='proposed'
+
+
+@pytest.mark.parametrize('change',['schema_bool','comment_extra','event_extra','unaware_comment','event_missing','nan','tuple','integer_key','status_list'])
+def test_closed_types_refuse_instead_of_silent_coercion(record,change):
+ if change=='schema_bool':record['schema_version']=True
+ elif change=='comment_extra':record['review']['comments']=[dict(comment(),policy='unreviewed')]
+ elif change=='event_extra':record['evidence']['events']=[dict(event(),token='never accepted')]
+ elif change=='unaware_comment':record['review']['comments']=[dict(comment(),at='2026-10-16T12:00:00')]
+ elif change=='event_missing':record['evidence']['events']=[{'kind':'test'}]
+ elif change=='nan':record['execution']['value']=float('nan')
+ elif change=='tuple':record['execution']['value']=(1,2)
+ elif change=='integer_key':record['execution'][1]='invalid'
+ elif change=='status_list':record['status']=[]
+ with pytest.raises(grant.Refused):grant.immutable_digest(record)
diff --git a/tests/test_task_execution_native_fixture.py b/tests/test_task_execution_native_fixture.py
index 382b236..f315bd5 100644
--- a/tests/test_task_execution_native_fixture.py
+++ b/tests/test_task_execution_native_fixture.py
@@ -3,6 +3,8 @@
These tests do not contact estate OpenBao or claim production owner acceptance.
"""
import hashlib
+from dataclasses import replace
+import yaml
import http.client
import json
import shutil
@@ -20,6 +22,7 @@ from test_task_execution_plan import compiler,request,m
from task_execution_journal import Binding,Cleanup,Journal,Refused
from task_execution_cleanup import run_cleanup
from task_execution_native_fixture import FixtureAdmission,NativeFixtureAdapter,PrivateSession
+import task_execution_grant as grant
@pytest.fixture
@@ -79,8 +82,29 @@ def owner(compiler,bao_dev,tmp_path):
# grant/expected-state digests remain deliberately synthetic owner fixtures.
from secrets_engine.execution_contract import ExecutionContract
descriptor=ExecutionContract.parse(descriptor).as_dict()
+ # Install an independently pinned synthetic grant source and its pure
+ # validator. No real CCR is written or approved in the owner checkout.
+ grantfile=compiler.config.platform_repo/'credential-change-requests/CCR-FIXTURE-GRANT.yaml'
+ grantfile.write_text('placeholder: true\n')
+ grantcode=Path(grant.__file__).resolve()
+ pins=dict(compiler.config.source_pins)
+ pins.update({str(grantfile):m.tagged(grantfile.read_bytes()),str(grantcode):m.tagged(grantcode.read_bytes())})
+ compiler=m.Compiler(replace(compiler.config,source_pins=tuple(pins.items()),grant_filename=grantfile.name))
+ plan=compiler.compile(req)
+ expected_execution,expected_openbao=compiler.grant_effects(plan,descriptor)
+ record={'id':descriptor['execution_grant_ccr_id'],'kind':'credential-change-request',
+ 'schema_version':1,'request_type':'task-execution-grant','title':'Synthetic disposable fixture grant',
+ 'status':'approved','created':'2026-10-16','updated':'2026-10-16',
+ 'requester':{'agent':'fixture','reason':'Synthetic disposable proof; no founder approval'},
+ 'review':{'required':True,'required_approvers':['railiance-platform-owner','secrets-engine-owner','access-engine-owner','approval-engine-owner'],'comments':[]},
+ 'target':{'domain':'infotech','tenant':'platform','workload':'secrets-engine','environment':'production','purpose':'Synthetic metadata primitive proof'},
+ 'execution':expected_execution,'openbao':expected_openbao,'evidence':{'events':[]}}
+ grantfile.write_text(yaml.safe_dump(record,sort_keys=False))
+ pins[str(grantfile)]=m.tagged(grantfile.read_bytes())
+ compiler=m.Compiler(replace(compiler.config,source_pins=tuple(pins.items())))
+ descriptor['grant_immutable_sha256']=grant.immutable_digest(record)
installed=m.encoded(descriptor)
- plan=compiler.bind_execution(plan,descriptor,installed)
+ plan=compiler.bind_execution(compiler.compile(req),descriptor,installed)
binding=Binding('SYNTHETIC-APPROVAL-NOT-PRODUCTION',req['request_digest'],req['operation_id'],plan.content_digest)
state=tmp_path/'private-journal';state.mkdir(mode=0o700);journal=Journal(state)
admission=FixtureAdmission(installed,root,identity,process)
@@ -230,3 +254,48 @@ def test_nonloopback_estate_origin_is_refused_before_native_transport(owner):
changed_binding=Binding(binding.approval_id,binding.request_digest,binding.operation_id,plan.content_digest)
admission=FixtureAdmission(installed,native.admission.owner_token,native.admission.identity_session,native.admission.server_process)
with pytest.raises(Refused):NativeFixtureAdapter(compiler,plan,journal,changed_binding,admission)
+
+
+def test_changed_grant_immutable_join_refuses_before_any_native_grant(owner):
+ native,journal,binding,call,compiler,descriptor=owner
+ changed=dict(descriptor,grant_immutable_sha256='sha256:'+'0'*64)
+ with pytest.raises(m.Refused):compiler.bind_execution(compiler.compile(native.meta['request']),changed,m.encoded(changed))
+ assert call('GET','sys/policies/acl/'+native.policy_name)[0]==404
+
+
+def test_grant_body_repin_cannot_replace_independently_derived_effects(owner):
+ native,journal,binding,call,compiler,descriptor=owner
+ record=yaml.safe_load(compiler.grant_record.read_text())
+ record['openbao']['policy']['hcl']='path "*" { capabilities = ["sudo"] }'
+ compiler.grant_record.write_text(yaml.safe_dump(record))
+ pins=dict(compiler.config.source_pins);pins[str(compiler.grant_record)]=m.tagged(compiler.grant_record.read_bytes())
+ changed_compiler=m.Compiler(replace(compiler.config,source_pins=tuple(pins.items())))
+ changed_descriptor=dict(descriptor,grant_immutable_sha256=grant.immutable_digest(record))
+ with pytest.raises(m.Refused):changed_compiler.bind_execution(changed_compiler.compile(native.meta['request']),changed_descriptor,m.encoded(changed_descriptor))
+ assert call('GET','sys/policies/acl/'+native.policy_name)[0]==404
+
+
+def test_proposed_source_grant_never_materializes_even_with_matching_digest(owner):
+ native,journal,binding,call,compiler,descriptor=owner
+ record=yaml.safe_load(compiler.grant_record.read_text());record['status']='proposed'
+ compiler.grant_record.write_text(yaml.safe_dump(record))
+ pins=dict(compiler.config.source_pins);pins[str(compiler.grant_record)]=m.tagged(compiler.grant_record.read_bytes())
+ changed_compiler=m.Compiler(replace(compiler.config,source_pins=tuple(pins.items())))
+ changed_descriptor=dict(descriptor,grant_immutable_sha256=grant.immutable_digest(record))
+ installed=m.encoded(changed_descriptor)
+ plan=changed_compiler.bind_execution(changed_compiler.compile(native.meta['request']),changed_descriptor,installed)
+ changed_binding=Binding(binding.approval_id,binding.request_digest,binding.operation_id,plan.content_digest)
+ admission=FixtureAdmission(installed,native.admission.owner_token,native.admission.identity_session,native.admission.server_process)
+ with pytest.raises(Refused):NativeFixtureAdapter(changed_compiler,plan,journal,changed_binding,admission)
+ assert call('GET','sys/policies/acl/'+native.policy_name)[0]==404
+
+
+def test_grant_source_drift_after_admission_prevents_protected_effect(owner):
+ native,journal,binding,call,compiler,*_=owner
+ with journal.locked():
+ admit(native,journal,binding)
+ record=yaml.safe_load(compiler.grant_record.read_text());record['execution']['cleanup'][0]['call_digest']='f'*64
+ compiler.grant_record.write_text(yaml.safe_dump(record))
+ with pytest.raises(m.Refused):native.materialize_grant()
+ assert call('GET','sys/policies/acl/'+native.policy_name)[0]==404
+ assert run_cleanup(journal,binding,native)['confirmed']
diff --git a/workplans/RPF-WP-0035-credential-lane-implementation.md b/workplans/RPF-WP-0035-credential-lane-implementation.md
index 0fd7ccc..f9af000 100644
--- a/workplans/RPF-WP-0035-credential-lane-implementation.md
+++ b/workplans/RPF-WP-0035-credential-lane-implementation.md
@@ -1251,3 +1251,14 @@ identity/approval/consume/grant/state/source admission in this fixture is synthe
no production authority or complete native owner acceptance is claimed. Exact
remaining owner schema/admission and mediator work: docs/task-execution-native-fixture.md.
T12 stays progress; no live grant, credential/default change or owner sync performed.
+
+2026-10-16 T12 third source package: implemented proposed task-execution-grant
+immutable projection and exact compiler/native-fixture integration through the
+existing descriptor grant ID/SHA fields. Only typed review.comments/evidence.events
+are excluded; separate append-only checks reject removal/rewrite/reordering and
+unknown guessed evidence exclusions refuse. Owner-pinned grant bytes and validator
+source must match independently reconstructed descriptor/source/cleanup/ACL/token
+bodies before a native fixture can materialize. Proposed source records gain no
+execution authority; no real grant, policy or legacy applier registration added.
+Details and remaining production gates: docs/task-execution-grant-projection.md.
+T12 remains progress; no owner sync or production effect performed by this package.

View file

@ -0,0 +1,602 @@
diff --git a/docs/task-execution-grant-projection.md b/docs/task-execution-grant-projection.md
new file mode 100644
index 0000000..b5c7647
--- /dev/null
+++ b/docs/task-execution-grant-projection.md
@@ -0,0 +1,85 @@
+# Proposed task-execution-grant immutable projection
+
+RPF-WP-0035-T12 source preparation, 2026-10-16. This proposes and implements the
+owner's pure projection/binding code; it approves no grant and registers no native
+or legacy delegated applier. Existing production readiness remains refused.
+
+Use the existing `task-execution-grant` CCR type proposed by Platform and
+SECRETS-WP-0013. Use the existing execution descriptor's
+`execution_grant_ccr_id` and `grant_immutable_sha256` join. No second consumer
+execution descriptor, authorization token or broker is introduced.
+
+`scripts/task_execution_grant.py` defines a closed CCR root with existing identity,
+status/date/requester/review/target fields, effectful `execution` and `openbao`
+objects, and an explicitly typed evidence event list. `immutable_digest` uses
+SHA256 over sorted compact ASCII JSON of the complete validated projection.
+It includes status, updated, title, requester, review requirements/approvers,
+target coordinates, all execution inputs, generated HCL, token body and cleanup.
+It excludes exactly `review.comments` and `evidence.events` by replacing those
+lists with empty lists. No generic evidence-prefix stripping or caller-selected
+exclusion exists. Unknown root/review/evidence fields refuse. Fields nested in
+execution/openbao remain fully hashed and must exactly match independently
+reconstructed effects when bound. Projection alone is not semantic admission.
+
+Reviews have exactly at/reviewer/decision/comment; evidence events have exactly
+at/kind/result/reference_sha256. These append-only annotations cannot supply a
+runtime approval or become policy inputs. `require_append_only` independently
+checks that each old list is an exact prefix of the new list and that the
+immutable projection is unchanged. Removal, replacement and reordering refuse.
+The owner must use known history when checking an update; a current file cannot
+prove its own history. Updating `updated` or status changes the digest and requires
+a new exact join. There is no guessed exclusion for convenience.
+
+The compiler's optional independently installed `OwnerConfig.grant_filename`
+selects one different CCR file under credential-change-requests. It adds exactly
+that record and the pure validator source to the closed owner source-pin set.
+The compiler reads verified bytes and executes the pinned validator source directly,
+without a module-cache substitute. It reconstructs the entire effectful grant:
+
+- The existing descriptor, excluding only its own `grant_immutable_sha256` to avoid
+ self-reference, plus action/catalogue and the actual owner source pins. The
+ record's own raw file hash is omitted from its own source-pin projection; all
+ other source hashes, including the validator, remain included.
+- Deterministic `cleanup_specs(operation_id)`, its fixed private credential slots,
+ exact revoke/verification and delete/verification paths/methods, declared
+ no-issue/no-create branches, one reserved cleanup use and the cleanup obligation
+ after business expiry.
+- Exact operation-specific task policy HCL on the two independently compiled
+ metadata endpoints plus revoke-self, and the exact parented service token body:
+ nonrenewable, no default policies, no identity policies, 30–120s TTL and twelve
+ uses. No caller HCL, body, handle, wildcard or mount administration is accepted.
+
+It compares those structures with the owner record and compares the resulting
+immutable SHA and grant ID with the existing descriptor fields before returning
+a bound Plan. Unknown or changed effectful fields fail equality even if the caller
+re-pins the record and recomputes its digest. The complete descriptor must still
+match independently installed owner descriptor bytes. Raw source file hashes and
+immutable hashes serve different purposes: even an annotation-only file edit
+requires owner source-pin refresh and Plan reconstruction; it does not silently
+change the grant immutable digest or restore an admission.
+
+The disposable native adapter now refuses missing/unbound or proposed grants,
+rebuilds the exact bound Plan from pinned owner sources at construction, checks
+all source pins immediately before each native request, reconstructs each actual
+metadata body via the compiler and compares actual token/ACL bodies with the bound
+projection before issuing them. It still requires the same live postconsume
+journal admission and the live owner-spawned disposable OpenBao process handle.
+Native fault tests explicitly use synthetic approved copies in temporary directories;
+no real owner CCR status, policy or grant is created or changed.
+
+The normal credential-change CLI deliberately continues to reject this type for
+execution. Adding it to LANE_MODULES would expose generic applier methods and is
+not part of this source preparation. The pure validator is actually called by
+the compiler/native fixture path; it is not a paper-only schema. An owner can
+review this projection and later integrate source validation/recording into the
+CCR tooling without enabling execution.
+
+Remaining production work is now specific: approve/adopt this owner CCR projection;
+verify owner source/grant admission and immutable target CCR semantics; integrate
+authenticated principal, served exact claim/PDP/package and independent signed
+consume; finish total native baseline/readback normalization/conflict control;
+install bounded isolated owner runtime, trusted served deadlines/use accounting,
+audit and protected recovery; then run the exact founder-scoped attended trial.
+Neither an approved string nor this projection authenticates human approval.
+Approval replay prevention and uncertain backend-effect reconciliation remain
+separate. T12 and Secrets WP13 stay open.
diff --git a/scripts/task_execution_grant.py b/scripts/task_execution_grant.py
new file mode 100644
index 0000000..ad5ae87
--- /dev/null
+++ b/scripts/task_execution_grant.py
@@ -0,0 +1,115 @@
+"""Proposed task-execution-grant CCR immutable projection; no native admission.
+
+The only excluded paths are typed append-only review.comments and evidence.events.
+Status, updated, review requirements, effectful bytes and source revisions remain
+immutable. A digest is provenance, never served approval or execution authority.
+"""
+from __future__ import annotations
+import copy
+import hashlib
+import json
+import re
+from datetime import datetime
+
+
+class Refused(Exception): pass
+
+ROOT_KEYS={'id','kind','schema_version','request_type','title','status','created','updated',
+ 'requester','review','target','execution','openbao','evidence'}
+APPEND_ONLY_PATHS=(('review','comments'),('evidence','events'))
+HASH=re.compile(r'sha256:[0-9a-f]{64}')
+
+
+def need(value):
+ if not value:raise Refused('invalid task execution grant')
+
+
+def encoded(value):
+ def json_value(item):
+ if type(item) is dict:
+ need(all(type(key) is str for key in item))
+ for child in item.values():json_value(child)
+ elif type(item) is list:
+ for child in item:json_value(child)
+ else:need(item is None or type(item) in {str,int,float,bool})
+ try:
+ json_value(value)
+ return json.dumps(value,sort_keys=True,separators=(',',':'),ensure_ascii=True,allow_nan=False).encode()
+ except (TypeError,ValueError,RecursionError):raise Refused('invalid task execution grant') from None
+
+
+def closed(value,keys):
+ need(type(value) is dict and set(value)==set(keys));return value
+
+
+def text(value):
+ need(type(value) is str and 0<len(value)<=4096 and '\x00' not in value);return value
+
+
+def timestamp(value):
+ try:need(datetime.fromisoformat(text(value).replace('Z','+00:00')).tzinfo is not None)
+ except (ValueError,TypeError):raise Refused('invalid task execution grant') from None
+
+
+def projection(record):
+ """Validate and capture every field except the two explicit append-only lists.
+
+ Unknown keys never become implicitly excluded evidence. The independently
+ derived exact execution/openbao comparison belongs to bind(), below.
+ """
+ closed(record,ROOT_KEYS)
+ need(record['kind']=='credential-change-request' and type(record['schema_version']) is int
+ and record['schema_version']==1 and record['request_type']=='task-execution-grant')
+ need(type(record['id']) is str and re.fullmatch(r'[A-Z0-9][A-Z0-9_.-]*',record['id']))
+ need(type(record['status']) is str and record['status'] in {'proposed','approved','denied','cancelled','superseded'})
+ for field in ('title','created','updated'):text(record[field])
+ requester=closed(record['requester'],{'agent','reason'})
+ for value in requester.values():text(value)
+ review=closed(record['review'],{'required','required_approvers','comments'})
+ need(review['required'] is True and type(review['required_approvers']) is list
+ and review['required_approvers']==['railiance-platform-owner','secrets-engine-owner','access-engine-owner','approval-engine-owner'])
+ need(type(review['comments']) is list and len(review['comments'])<=1024)
+ for comment in review['comments']:
+ closed(comment,{'at','reviewer','decision','comment'})
+ timestamp(comment['at']);text(comment['reviewer']);text(comment['comment'])
+ need(type(comment['decision']) is str and comment['decision'] in {'approved','changes_requested','declined'})
+ target=closed(record['target'],{'domain','tenant','workload','environment','purpose'})
+ need(target['tenant']=='platform' and target['workload']=='secrets-engine' and target['environment']=='production')
+ for value in target.values():text(value)
+ need(type(record['execution']) is dict and type(record['openbao']) is dict)
+ evidence=closed(record['evidence'],{'events'})
+ need(type(evidence['events']) is list and len(evidence['events'])<=1024)
+ for event in evidence['events']:
+ closed(event,{'at','kind','result','reference_sha256'})
+ timestamp(event['at']);text(event['kind'])
+ need(type(event['result']) is str and event['result'] in {'pending','passed','failed','unknown'})
+ need(type(event['reference_sha256']) is str and HASH.fullmatch(event['reference_sha256']))
+ # Deep capture refuses NaN/non-JSON structures rather than silently coercing.
+ value=json.loads(encoded(record))
+ for parent,leaf in APPEND_ONLY_PATHS:value[parent][leaf]=[]
+ return value
+
+
+def immutable_digest(record):
+ return 'sha256:'+hashlib.sha256(encoded(projection(record))).hexdigest()
+
+
+def require_append_only(previous,current):
+ need(immutable_digest(previous)==immutable_digest(current))
+ for parent,leaf in APPEND_ONLY_PATHS:
+ old=previous[parent][leaf];new=current[parent][leaf]
+ need(len(new)>=len(old) and new[:len(old)]==old)
+
+
+def bind(record,*,grant_id,expected_execution,expected_openbao,expected_digest):
+ """Compare an owner source record with independently reconstructed effects.
+
+ Does not trust status/comments as approval, read source files, contact engines,
+ register a delegated applier or issue any backend request.
+ """
+ value=projection(record)
+ need(record['id']==grant_id and type(expected_digest) is str and HASH.fullmatch(expected_digest))
+ need(encoded(record['execution'])==encoded(expected_execution))
+ need(encoded(record['openbao'])==encoded(expected_openbao))
+ need(immutable_digest(record)==expected_digest)
+ return value
diff --git a/scripts/task_execution_native_fixture.py b/scripts/task_execution_native_fixture.py
index 7a4d247..29d168d 100644
--- a/scripts/task_execution_native_fixture.py
+++ b/scripts/task_execution_native_fixture.py
@@ -14,6 +14,7 @@ import http.client
import json
import subprocess
import time
+from pathlib import Path
from urllib.parse import urlsplit
from task_execution_cleanup import CleanupResult
@@ -44,6 +45,8 @@ class NativeFixtureAdapter:
self.compiler,self.plan,self.journal,self.binding=compiler,plan,journal,binding
self.admission=admission
self.meta=plan.metadata()
+ if self.meta.get('execution_grant',{}).get('status')!='approved':
+ raise Refused('independently source-bound synthetic approved grant required')
descriptor=json.loads(admission.descriptor)
fresh=compiler.bind_execution(compiler.compile(self.meta['request']),descriptor,admission.descriptor)
if fresh.serialized != plan.serialized or binding.plan_digest != plan.content_digest:
@@ -73,6 +76,8 @@ class NativeFixtureAdapter:
paths=sorted({call.path for call in calls})
self.hcl=''.join('path '+json.dumps(path)+' {\n capabilities = ["read", "create", "update"]\n}\n' for path in paths)
self.hcl+='path "auth/token/revoke-self" {\n capabilities = ["update"]\n}\n'
+ if self.meta['execution_grant']['openbao']['policy']!={'name':self.policy_name,'hcl':self.hcl}:
+ raise Refused('native grant body differs from bound owner projection')
self.cleanup=tuple(Cleanup(item['kind'],item['call_digest']) for item in cleanup_specs(binding.operation_id))
if self.meta['cleanup'] != cleanup_specs(binding.operation_id): raise Refused('cleanup plan mismatch')
self.task=None; self._mint_attempted=False; self._policy_attempted=False; self._ordinal=0; self._next_metadata=0
@@ -86,7 +91,11 @@ class NativeFixtureAdapter:
self._live()
if min((self.deadline-datetime.now(timezone.utc)).total_seconds(),self.monotonic_deadline-time.monotonic())<=5:
raise Refused('business deadline exhausted')
- self.compiler.before_call(self.plan,0)
+ # The constructor independently rebuilt the complete immutable Plan and
+ # grant. Recheck every pinned byte before each native effect. Metadata
+ # dispatch additionally reconstructs its exact ordinal/body below; grant
+ # creation reconstructs and compares its fixed body before issuance.
+ for path in self.compiler.pins:self.compiler.read(Path(path))
def _http(self, method, path, token, body=b''):
# Fixed caller-generated paths only; http.client has no proxy or redirect
@@ -148,6 +157,8 @@ class NativeFixtureAdapter:
body={'policies':[self.policy_name],'no_default_policy':True,'renewable':False,
'ttl':str(self.ttl)+'s','explicit_max_ttl':str(self.ttl)+'s','num_uses':self.uses,
'type':'service','display_name':'task-exec-'+self.binding.operation_id,'no_parent':False}
+ if body!=self.meta['execution_grant']['openbao']['token_create_body']:
+ raise Refused('native token body differs from bound owner projection')
self._mint_attempted=True
status,data=self._business('POST','auth/token/create',root,encoded(body));self._ok(status,data,{200})
auth=data.get('auth',{})
diff --git a/scripts/task_execution_plan.py b/scripts/task_execution_plan.py
index f3234fc..4c086b4 100644
--- a/scripts/task_execution_plan.py
+++ b/scripts/task_execution_plan.py
@@ -53,6 +53,7 @@ class OwnerConfig:
catalog_directory: Path
# Exact path/hash pairs, configured independently by the owner. No request paths.
source_pins: tuple[tuple[str,str], ...]
+ grant_filename: str | None = None
@dataclass(frozen=True)
class Plan:
@@ -111,11 +112,18 @@ class Compiler:
self.validator=config.platform_repo/'scripts/credential-change.py'
self.catalog_code=config.secrets_repo/'src/secrets_engine/catalog.py'
self.dependencies=[config.secrets_repo/'src/secrets_engine'/name for name in ('__init__.py','errors.py','redact.py')]
+ self.grant_record=None;self.grant_validator=None
+ if config.grant_filename is not None:
+ need(type(config.grant_filename) is str and re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9_.-]*\.yaml',config.grant_filename)
+ and config.grant_filename!=config.ccr_filename,'owner_grant_filename_invalid')
+ self.grant_record=config.platform_repo/'credential-change-requests'/config.grant_filename
+ self.grant_validator=Path(__file__).resolve().parent/'task_execution_grant.py'
pins=dict(config.source_pins)
self.pins=types.MappingProxyType(pins)
need(len(self.pins)==len(config.source_pins) and all(type(k) is str and digest_ok(v) for k,v in self.pins.items()),'owner_pins_invalid')
# Compiler source is part of the owner plan, not just generated body hashes.
required={str(p) for p in [Path(__file__).resolve(),self.ccr,self.catalog,self.validator,self.catalog_code,*self.dependencies]}
+ if self.grant_record is not None:required.update({str(self.grant_record),str(self.grant_validator)})
need(required <= set(self.pins),'owner_source_pin_missing')
extra=set(self.pins)-required
need(len(extra)==1,'closed_owner_source_set_required')
@@ -254,6 +262,51 @@ class Compiler:
'source_pins':dict(self.pins),'task_token_max_ttl':120,'task_token_max_uses':12,
'cleanup_uses_reserved':1,'cleanup':cleanup_specs(request['operation_id'])}))
+ def grant_effects(self,plan,descriptor):
+ """Independently derive the one proposed grant's effectful immutable body."""
+ need(type(plan) is Plan and type(descriptor) is dict,'typed_grant_inputs_required')
+ meta=plan.metadata();req=meta['request']
+ need(self.compile(req).serialized==plan.serialized,'immutable_plan_changed')
+ _,_,calls=self._derive()
+ budget=descriptor['backend_budget']
+ need(type(budget) is dict and set(budget)=={'ttl_seconds','uses'}
+ and type(budget['ttl_seconds']) is int and 30<=budget['ttl_seconds']<=120
+ and type(budget['uses']) is int and budget['uses']==12,'fixed_grant_budget_required')
+ operation_id=req['operation_id'];policy_name='task-exec-'+operation_id
+ paths=sorted({call.path for call in calls})
+ hcl=''.join('path '+json.dumps(path)+' {\n capabilities = ["read", "create", "update"]\n}\n' for path in paths)
+ hcl+='path "auth/token/revoke-self" {\n capabilities = ["update"]\n}\n'
+ token_body={'policies':[policy_name],'no_default_policy':True,'renewable':False,
+ 'ttl':str(budget['ttl_seconds'])+'s','explicit_max_ttl':str(budget['ttl_seconds'])+'s',
+ 'num_uses':12,'type':'service','display_name':policy_name,'no_parent':False}
+ # A grant cannot include its own immutable hash or raw file SHA. The
+ # existing descriptor joins it by ID+immutable SHA after this projection.
+ descriptor_binding={k:v for k,v in descriptor.items() if k!='grant_immutable_sha256'}
+ pins={path:digest for path,digest in self.pins.items() if self.grant_record is None or path!=str(self.grant_record)}
+ execution={'action':'execute-ccr','catalog_id':req['catalog_id'],
+ 'descriptor_binding':descriptor_binding,'source_pins':pins,
+ 'cleanup':cleanup_specs(operation_id),'cleanup_uses_reserved':1,
+ 'cleanup_after_business_deadline':True}
+ openbao={'policy':{'name':policy_name,'hcl':hcl},'token_create_body':token_body,
+ 'owner_parent_custody':'platform','identity_policies':[]}
+ return execution,openbao
+
+ def _bind_grant(self,plan,descriptor):
+ need(self.grant_record is not None,'owner_grant_source_required')
+ # Execute exactly the pinned pure projection source, never a cached module.
+ module=types.ModuleType('_owner_task_execution_grant')
+ raw=self.read(self.grant_validator)
+ exec(compile(raw,str(self.grant_validator),'exec'),module.__dict__)
+ record=yaml.load(self.read(self.grant_record),Loader=UniqueLoader)
+ execution,openbao=self.grant_effects(plan,descriptor)
+ try:
+ projection=module.bind(record,grant_id=descriptor['execution_grant_ccr_id'],
+ expected_execution=execution,expected_openbao=openbao,
+ expected_digest=descriptor['grant_immutable_sha256'])
+ except Exception:raise Refused('owner_grant_binding_mismatch') from None
+ need(self.read(self.grant_validator)==raw,'owner_grant_source_changed')
+ return projection
+
def bind_execution(self, plan, descriptor, owner_descriptor):
"""Bind exact engine descriptor to owner plan; no authorization is created.
@@ -275,9 +328,11 @@ class Compiler:
need(all(descriptor.get(k)==v for k,v in expected.items()),'execution_descriptor_plan_mismatch')
bound = dict(meta, execution_descriptor=descriptor,
execution_descriptor_sha256=tagged(owner_descriptor))
- # The full descriptor's independently admitted grant immutable digest must
- # cover these cleanup commitments. No grant schema exists here yet, so this
- # source binding cannot itself admit a grant/native production execution.
+ if self.grant_record is not None:
+ bound['execution_grant']=self._bind_grant(plan,descriptor)
+ bound['grant_immutable_sha256']=descriptor['grant_immutable_sha256']
+ # Without an independently pinned grant this remains preparation only.
+ # The native fixture adapter refuses to materialize such an unbound plan.
return Plan(encoded(bound))
def before_call(self,plan,ordinal):
diff --git a/tests/test_task_execution_grant.py b/tests/test_task_execution_grant.py
new file mode 100644
index 0000000..13298e8
--- /dev/null
+++ b/tests/test_task_execution_grant.py
@@ -0,0 +1,98 @@
+"""Pure proposed grant projection: source provenance, never approval/native proof."""
+import copy
+from pathlib import Path
+import sys
+import pytest
+sys.path.insert(0,str(Path(__file__).resolve().parents[1]/'scripts'))
+import task_execution_grant as grant
+
+
+@pytest.fixture
+def record():
+ return {'id':'CCR-FIXTURE-GRANT','kind':'credential-change-request','schema_version':1,
+ 'request_type':'task-execution-grant','title':'Proposed source fixture','status':'proposed',
+ 'created':'2026-10-16','updated':'2026-10-16','requester':{'agent':'fixture','reason':'Local proof'},
+ 'review':{'required':True,'required_approvers':['railiance-platform-owner','secrets-engine-owner','access-engine-owner','approval-engine-owner'],'comments':[]},
+ 'target':{'domain':'infotech','tenant':'platform','workload':'secrets-engine','environment':'production','purpose':'fixture'},
+ 'execution':{'action':'execute-ccr','descriptor_binding':{'operation_deadline':'2026-10-16T12:00:00Z','ccr_immutable_sha256':'sha256:'+'a'*64},
+ 'source_pins':{'/fixture/source':'sha256:'+'b'*64},'cleanup':[{'kind':'task-token-revoke','call_digest':'c'*64}]},
+ 'openbao':{'policy':{'name':'task-exec-fixture','hcl':'exact owner generated HCL'},
+ 'token_create_body':{'ttl':'120s','num_uses':12,'renewable':False}},'evidence':{'events':[]}}
+
+
+def comment():return {'at':'2026-10-16T12:00:00Z','reviewer':'railiance-platform-owner','decision':'approved','comment':'Source only; no execution authority'}
+def event():return {'at':'2026-10-16T12:00:00Z','kind':'test','result':'unknown','reference_sha256':'sha256:'+'e'*64}
+
+
+def test_only_typed_review_comments_and_evidence_events_are_excluded(record):
+ changed=copy.deepcopy(record);changed['review']['comments'].append(comment());changed['evidence']['events'].append(event())
+ assert grant.immutable_digest(changed)==grant.immutable_digest(record)
+ grant.require_append_only(record,changed)
+ assert record['review']['comments']==[] # detached immutable projection
+
+
+@pytest.mark.parametrize('field',['status','updated','title','requester','target','execution','openbao','review'])
+def test_every_other_root_or_effectful_field_changes_digest(record,field):
+ changed=copy.deepcopy(record)
+ if field=='status':changed[field]='approved'
+ elif field=='updated':changed[field]='2026-10-17'
+ elif field=='title':changed[field]='different title'
+ elif field=='requester':changed[field]['reason']='different requester reason'
+ elif field=='target':changed[field]['purpose']='different purpose'
+ elif field=='execution':changed[field]['source_pins']['/fixture/source']='sha256:'+'f'*64
+ elif field=='openbao':changed[field]['policy']['hcl']='different HCL'
+ elif field=='review':changed[field]['required_approvers'].reverse()
+ if field=='review':
+ with pytest.raises(grant.Refused):grant.immutable_digest(changed)
+ else:
+ assert grant.immutable_digest(changed)!=grant.immutable_digest(record)
+ with pytest.raises(grant.Refused):grant.require_append_only(record,changed)
+
+
+@pytest.mark.parametrize('location,key',[('root','audit'),('root','receipts'),('root','evidence_exclusions'),('evidence','files'),('evidence','policy'),('review','approval_ref'),('review','exclusions')])
+def test_guessed_evidence_exclusions_or_unknown_fields_refuse(record,location,key):
+ changed=copy.deepcopy(record)
+ (changed if location=='root' else changed[location])[key]=[]
+ with pytest.raises(grant.Refused):grant.immutable_digest(changed)
+
+
+def test_append_only_rejects_removal_rewrite_and_reordering(record):
+ previous=copy.deepcopy(record);previous['review']['comments']=[comment(),dict(comment(),comment='Second')]
+ previous['evidence']['events']=[event()]
+ for field,value in [('comments',[]),('comments',[dict(comment(),decision='declined')]),('comments',list(reversed(previous['review']['comments']))),('events',[])]:
+ changed=copy.deepcopy(previous);changed['review' if field=='comments' else 'evidence'][field]=value
+ assert grant.immutable_digest(changed)==grant.immutable_digest(previous)
+ with pytest.raises(grant.Refused):grant.require_append_only(previous,changed)
+
+
+@pytest.mark.parametrize('change',['policy','token_budget','cleanup','source','deadline','target_ccr','wrong_hash','grant_id'])
+def test_bind_reconstructs_effects_and_exact_existing_descriptor_join(record,change):
+ expected=copy.deepcopy(record);changed=copy.deepcopy(record);digest=grant.immutable_digest(record);grant_id=record['id']
+ if change=='policy':changed['openbao']['policy']['hcl']='path "*" {}'
+ elif change=='token_budget':changed['openbao']['token_create_body']['num_uses']=13
+ elif change=='cleanup':changed['execution']['cleanup'][0]['kind']='kv-delete'
+ elif change=='source':changed['execution']['source_pins']['/fixture/source']='sha256:'+'f'*64
+ elif change=='deadline':changed['execution']['descriptor_binding']['operation_deadline']='2027-01-01T00:00:00Z'
+ elif change=='target_ccr':changed['execution']['descriptor_binding']['ccr_immutable_sha256']='sha256:'+'f'*64
+ elif change=='wrong_hash':digest='sha256:'+'f'*64
+ elif change=='grant_id':grant_id='CCR-OTHER'
+ with pytest.raises(grant.Refused):grant.bind(changed,grant_id=grant_id,expected_execution=expected['execution'],expected_openbao=expected['openbao'],expected_digest=digest)
+
+
+def test_proposed_source_record_can_bind_without_becoming_approved(record):
+ projection=grant.bind(record,grant_id=record['id'],expected_execution=record['execution'],expected_openbao=record['openbao'],expected_digest=grant.immutable_digest(record))
+ assert projection['status']=='proposed'
+
+
+@pytest.mark.parametrize('change',['schema_bool','comment_extra','event_extra','unaware_comment','event_missing','nan','tuple','integer_key','status_list'])
+def test_closed_types_refuse_instead_of_silent_coercion(record,change):
+ if change=='schema_bool':record['schema_version']=True
+ elif change=='comment_extra':record['review']['comments']=[dict(comment(),policy='unreviewed')]
+ elif change=='event_extra':record['evidence']['events']=[dict(event(),token='never accepted')]
+ elif change=='unaware_comment':record['review']['comments']=[dict(comment(),at='2026-10-16T12:00:00')]
+ elif change=='event_missing':record['evidence']['events']=[{'kind':'test'}]
+ elif change=='nan':record['execution']['value']=float('nan')
+ elif change=='tuple':record['execution']['value']=(1,2)
+ elif change=='integer_key':record['execution'][1]='invalid'
+ elif change=='status_list':record['status']=[]
+ with pytest.raises(grant.Refused):grant.immutable_digest(record)
diff --git a/tests/test_task_execution_native_fixture.py b/tests/test_task_execution_native_fixture.py
index 382b236..2a8e419 100644
--- a/tests/test_task_execution_native_fixture.py
+++ b/tests/test_task_execution_native_fixture.py
@@ -3,6 +3,8 @@
These tests do not contact estate OpenBao or claim production owner acceptance.
"""
import hashlib
+from dataclasses import replace
+import yaml
import http.client
import json
import shutil
@@ -20,6 +22,7 @@ from test_task_execution_plan import compiler,request,m
from task_execution_journal import Binding,Cleanup,Journal,Refused
from task_execution_cleanup import run_cleanup
from task_execution_native_fixture import FixtureAdmission,NativeFixtureAdapter,PrivateSession
+import task_execution_grant as grant
@pytest.fixture
@@ -79,8 +82,29 @@ def owner(compiler,bao_dev,tmp_path):
# grant/expected-state digests remain deliberately synthetic owner fixtures.
from secrets_engine.execution_contract import ExecutionContract
descriptor=ExecutionContract.parse(descriptor).as_dict()
+ # Install an independently pinned synthetic grant source and its pure
+ # validator. No real CCR is written or approved in the owner checkout.
+ grantfile=compiler.config.platform_repo/'credential-change-requests/CCR-FIXTURE-GRANT.yaml'
+ grantfile.write_text('placeholder: true\n')
+ grantcode=Path(grant.__file__).resolve()
+ pins=dict(compiler.config.source_pins)
+ pins.update({str(grantfile):m.tagged(grantfile.read_bytes()),str(grantcode):m.tagged(grantcode.read_bytes())})
+ compiler=m.Compiler(replace(compiler.config,source_pins=tuple(pins.items()),grant_filename=grantfile.name))
+ plan=compiler.compile(req)
+ expected_execution,expected_openbao=compiler.grant_effects(plan,descriptor)
+ record={'id':descriptor['execution_grant_ccr_id'],'kind':'credential-change-request',
+ 'schema_version':1,'request_type':'task-execution-grant','title':'Synthetic disposable fixture grant',
+ 'status':'approved','created':'2026-10-16','updated':'2026-10-16',
+ 'requester':{'agent':'fixture','reason':'Synthetic disposable proof; no founder approval'},
+ 'review':{'required':True,'required_approvers':['railiance-platform-owner','secrets-engine-owner','access-engine-owner','approval-engine-owner'],'comments':[]},
+ 'target':{'domain':'infotech','tenant':'platform','workload':'secrets-engine','environment':'production','purpose':'Synthetic metadata primitive proof'},
+ 'execution':expected_execution,'openbao':expected_openbao,'evidence':{'events':[]}}
+ grantfile.write_text(yaml.safe_dump(record,sort_keys=False))
+ pins[str(grantfile)]=m.tagged(grantfile.read_bytes())
+ compiler=m.Compiler(replace(compiler.config,source_pins=tuple(pins.items())))
+ descriptor['grant_immutable_sha256']=grant.immutable_digest(record)
installed=m.encoded(descriptor)
- plan=compiler.bind_execution(plan,descriptor,installed)
+ plan=compiler.bind_execution(compiler.compile(req),descriptor,installed)
binding=Binding('SYNTHETIC-APPROVAL-NOT-PRODUCTION',req['request_digest'],req['operation_id'],plan.content_digest)
state=tmp_path/'private-journal';state.mkdir(mode=0o700);journal=Journal(state)
admission=FixtureAdmission(installed,root,identity,process)
@@ -225,8 +249,62 @@ def test_actual_native_policy_retirement_does_not_prove_failed_token_revoke(owne
def test_nonloopback_estate_origin_is_refused_before_native_transport(owner):
native,journal,binding,call,compiler,descriptor=owner
changed=dict(descriptor,backend_origin='https://estate.invalid:8200')
+ # The owner grant now also binds origin. Build a matching synthetic grant so
+ # this case reaches the adapter's independent nonloopback refusal; ordinary
+ # origin substitution is already refused earlier by the grant join.
+ record=yaml.safe_load(compiler.grant_record.read_text())
+ record['execution'],record['openbao']=compiler.grant_effects(compiler.compile(native.meta['request']),changed)
+ compiler.grant_record.write_text(yaml.safe_dump(record))
+ pins=dict(compiler.config.source_pins);pins[str(compiler.grant_record)]=m.tagged(compiler.grant_record.read_bytes())
+ changed_compiler=m.Compiler(replace(compiler.config,source_pins=tuple(pins.items())))
+ changed['grant_immutable_sha256']=grant.immutable_digest(record)
installed=m.encoded(changed)
- plan=compiler.bind_execution(compiler.compile(native.meta['request']),changed,installed)
+ plan=changed_compiler.bind_execution(changed_compiler.compile(native.meta['request']),changed,installed)
changed_binding=Binding(binding.approval_id,binding.request_digest,binding.operation_id,plan.content_digest)
admission=FixtureAdmission(installed,native.admission.owner_token,native.admission.identity_session,native.admission.server_process)
- with pytest.raises(Refused):NativeFixtureAdapter(compiler,plan,journal,changed_binding,admission)
+ with pytest.raises(Refused):NativeFixtureAdapter(changed_compiler,plan,journal,changed_binding,admission)
+
+
+def test_changed_grant_immutable_join_refuses_before_any_native_grant(owner):
+ native,journal,binding,call,compiler,descriptor=owner
+ changed=dict(descriptor,grant_immutable_sha256='sha256:'+'0'*64)
+ with pytest.raises(m.Refused):compiler.bind_execution(compiler.compile(native.meta['request']),changed,m.encoded(changed))
+ assert call('GET','sys/policies/acl/'+native.policy_name)[0]==404
+
+
+def test_grant_body_repin_cannot_replace_independently_derived_effects(owner):
+ native,journal,binding,call,compiler,descriptor=owner
+ record=yaml.safe_load(compiler.grant_record.read_text())
+ record['openbao']['policy']['hcl']='path "*" { capabilities = ["sudo"] }'
+ compiler.grant_record.write_text(yaml.safe_dump(record))
+ pins=dict(compiler.config.source_pins);pins[str(compiler.grant_record)]=m.tagged(compiler.grant_record.read_bytes())
+ changed_compiler=m.Compiler(replace(compiler.config,source_pins=tuple(pins.items())))
+ changed_descriptor=dict(descriptor,grant_immutable_sha256=grant.immutable_digest(record))
+ with pytest.raises(m.Refused):changed_compiler.bind_execution(changed_compiler.compile(native.meta['request']),changed_descriptor,m.encoded(changed_descriptor))
+ assert call('GET','sys/policies/acl/'+native.policy_name)[0]==404
+
+
+def test_proposed_source_grant_never_materializes_even_with_matching_digest(owner):
+ native,journal,binding,call,compiler,descriptor=owner
+ record=yaml.safe_load(compiler.grant_record.read_text());record['status']='proposed'
+ compiler.grant_record.write_text(yaml.safe_dump(record))
+ pins=dict(compiler.config.source_pins);pins[str(compiler.grant_record)]=m.tagged(compiler.grant_record.read_bytes())
+ changed_compiler=m.Compiler(replace(compiler.config,source_pins=tuple(pins.items())))
+ changed_descriptor=dict(descriptor,grant_immutable_sha256=grant.immutable_digest(record))
+ installed=m.encoded(changed_descriptor)
+ plan=changed_compiler.bind_execution(changed_compiler.compile(native.meta['request']),changed_descriptor,installed)
+ changed_binding=Binding(binding.approval_id,binding.request_digest,binding.operation_id,plan.content_digest)
+ admission=FixtureAdmission(installed,native.admission.owner_token,native.admission.identity_session,native.admission.server_process)
+ with pytest.raises(Refused):NativeFixtureAdapter(changed_compiler,plan,journal,changed_binding,admission)
+ assert call('GET','sys/policies/acl/'+native.policy_name)[0]==404
+
+
+def test_grant_source_drift_after_admission_prevents_protected_effect(owner):
+ native,journal,binding,call,compiler,*_=owner
+ with journal.locked():
+ admit(native,journal,binding)
+ record=yaml.safe_load(compiler.grant_record.read_text());record['execution']['cleanup'][0]['call_digest']='f'*64
+ compiler.grant_record.write_text(yaml.safe_dump(record))
+ with pytest.raises(m.Refused):native.materialize_grant()
+ assert call('GET','sys/policies/acl/'+native.policy_name)[0]==404
+ assert run_cleanup(journal,binding,native)['confirmed']
diff --git a/workplans/RPF-WP-0035-credential-lane-implementation.md b/workplans/RPF-WP-0035-credential-lane-implementation.md
index 0fd7ccc..f9af000 100644
--- a/workplans/RPF-WP-0035-credential-lane-implementation.md
+++ b/workplans/RPF-WP-0035-credential-lane-implementation.md
@@ -1251,3 +1251,14 @@ identity/approval/consume/grant/state/source admission in this fixture is synthe
no production authority or complete native owner acceptance is claimed. Exact
remaining owner schema/admission and mediator work: docs/task-execution-native-fixture.md.
T12 stays progress; no live grant, credential/default change or owner sync performed.
+
+2026-10-16 T12 third source package: implemented proposed task-execution-grant
+immutable projection and exact compiler/native-fixture integration through the
+existing descriptor grant ID/SHA fields. Only typed review.comments/evidence.events
+are excluded; separate append-only checks reject removal/rewrite/reordering and
+unknown guessed evidence exclusions refuse. Owner-pinned grant bytes and validator
+source must match independently reconstructed descriptor/source/cleanup/ACL/token
+bodies before a native fixture can materialize. Proposed source records gain no
+execution authority; no real grant, policy or legacy applier registration added.
+Details and remaining production gates: docs/task-execution-grant-projection.md.
+T12 remains progress; no owner sync or production effect performed by this package.

View file

@ -0,0 +1,37 @@
{
"reviewer": "railiance-platform-owner (agent railiance-platform, codex)",
"decision": "approved",
"scope": "Proposed owner grant immutable projection and actual compiler/disposable-native-fixture binding only",
"owner_work_item": "RPF-WP-0035-T12",
"reviewed_files_sha256": {
"docs/task-execution-grant-projection.md": "643e9769ad895795179e86f52c74a826b9619d1e6a4f8c6d411243b4139ad33d",
"scripts/task_execution_grant.py": "b76d7dce419c0548f33be3efaec2589e50eec48b9e277c90ab0e4615b9c0f3e7",
"scripts/task_execution_native_fixture.py": "8607321e2947d2ec1ca9c69ac24a4d54d50819ee40e28f9eaf4ee1d865beec0a",
"scripts/task_execution_plan.py": "cd984055ca8a1d51ef563bb7534b30c2fc9e326ed89a459362419b407397b9dc",
"tests/test_task_execution_grant.py": "209c9998f34def06fb9588374901574bca6bf206b73163961d762f53683d7e5b",
"tests/test_task_execution_native_fixture.py": "0127b2cb84570e27c9fd70560e8088a7ad531497ee4a83a8917632e8c02982c2",
"workplans/RPF-WP-0035-credential-lane-implementation.md": "00532e2ba330348479f3787615516ee1a474da9ae0254a66ec57fcaf0e7a6285"
},
"patch_sha256": "d91fdbd68b328d430e0e2b0b7f44d552095b8688ae4942d398d2e126b3648ad5",
"base_local_commit": "72475813d444e269af6dd9d4afec8458308edcaf",
"comment": "INTENT Secure Custody and Stable Interfaces require generated owner effects and cleanup to be included in the same immutable grant joined by the existing consumer descriptor. This source implements that projection and comparison before disposable native issuance; it does not authenticate human approval or authorize production. Unknown guessed evidence exclusions fail rather than weaken bindings.",
"independent_from_implementation": false,
"projection_exclusions": [
"review.comments",
"evidence.events"
],
"projection_inclusions": "Every other closed CCR root field; all effectful nested bytes, source revisions, targets, status, updated, generated ACL/token body, descriptor binding and cleanup commitments.",
"append_only_enforcement": "Both excluded lists must retain previous prefix exactly and immutable projection must remain unchanged; current record cannot attest its own history.",
"existing_consumer_join": "execution_grant_ccr_id + grant_immutable_sha256; own grant SHA is not embedded in its own projection",
"legacy_applier_enabled": false,
"real_grant_or_policy_approved": false,
"production_acceptance": false,
"remaining": [
"Owner adoption/approval of proposed CCR projection and source validation/recording integration; no legacy executor registration implied",
"Authenticated initiating principal and served exact approval/PDP/package/signed consume correspondence",
"Authenticated owner grant/source/origin admission and target CCR immutable semantics",
"Complete native expected-state normalization, conflict handling, isolated runtime, per-call served bounds, audit and protected crash recovery",
"Exact founder-scoped attended production trial"
],
"refinement": "Same source-review trial: updated existing origin-negative fixture for the stronger preceding grant-binding refusal. No implementation/authority change."
}

View file

@ -0,0 +1,9 @@
122 distinct checks passed across two runs; only an outdated test expectation changed between runs; implementation source unchanged
SECRETS_ENGINE_SOURCE_ROOT=/home/worsch/secrets-engine PYTHONPATH=/home/worsch/secrets-engine/src python3 -m pytest -xq tests/test_task_execution_plan.py tests/test_task_execution_journal.py tests/test_task_execution_cleanup.py tests/test_task_execution_cleanup_calls.py tests/test_task_execution_grant.py tests/test_task_execution_native_fixture.py
117 passed, 1 failed in 190.35s: previous origin-negative test expected adapter refusal, but exact new grant join correctly refused changed origin earlier. Remaining four cases were not run due -x.
SECRETS_ENGINE_SOURCE_ROOT=/home/worsch/secrets-engine PYTHONPATH=/home/worsch/secrets-engine/src python3 -m pytest -xq tests/test_task_execution_native_fixture.py::test_nonloopback_estate_origin_is_refused_before_native_transport tests/test_task_execution_native_fixture.py::test_changed_grant_immutable_join_refuses_before_any_native_grant tests/test_task_execution_native_fixture.py::test_grant_body_repin_cannot_replace_independently_derived_effects tests/test_task_execution_native_fixture.py::test_proposed_source_grant_never_materializes_even_with_matching_digest tests/test_task_execution_native_fixture.py::test_grant_source_drift_after_admission_prevents_protected_effect
5 passed in 49.05s
No production acceptance; no estate grants or credentials.