the-custodian/docs/changes/netkingdom-target-2026-10-16/protected-execution-wave3/platform-grant-projection.patch
codex a192d24c9a
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
docs: preserve reviewed NetKingdom session artifacts
2026-11-01 23:14:27 +01:00

602 lines
38 KiB
Diff
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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.