#!/usr/bin/env python3 """Read-only verification of generated R03 files; does not import the composer.""" from __future__ import annotations import argparse,copy,hashlib,io,json,sys,zipfile from pathlib import Path CANDIDATE='c3bd53d775752494d982183cc71b551af6b4bf2c879416d6866a859e64fd28ac' V4='d4e75170b791b4458d60ea39f35fa165a81b89fce236c12f15f40af038a95d62' GL='controls/VPL_V5_06_GUIDELINE_REGISTRY_R02.json' AG='controls/VPL_V5_06_AGENT_REGISTRY_R02.json' O1='O1_ADOPTED_AMENDMENTS.md' def sha(b):return hashlib.sha256(b).hexdigest() def must(v,s): if not v:raise ValueError(s) def j(b):return json.loads(b) def getrow(d,a,id): rows=[x for x in d[a] if x['id']==id];must(len(rows)==1,'row missing/duplicate');return rows[0] def verify(root:Path,generated:Path): b=root/'prior_reviewed_R01';raw=(b/'input/VPL_V5_R08_HF03_T01_R01.zip').read_bytes() must(sha(raw)==CANDIDATE,'wrong frozen candidate') with zipfile.ZipFile(io.BytesIO(raw)) as z: d={i.filename.split('/',1)[1]:z.read(i) for i in z.infolist() if not i.is_dir()} must(len(d)==451,'candidate count differs') entries=j(d['SEMANTIC_INPUTS_R08.json'])['files'];must(len(entries)==35,'expected 35 source contexts') inv=j((generated/'EFFECTIVE_CONTEXT_INVENTORY_R03.json').read_bytes()) must([x['path'] for x in inv]==[x['path'] for x in entries],'context source order differs') must({p.relative_to(generated/'effective_context').as_posix() for p in (generated/'effective_context').rglob('*') if p.is_file()}=={e['path'] for e in entries},'missing/extra effective file') owner=(root/'OWNER_PERSISTENCE_AMENDMENT_R03.md').read_bytes() decision=j((root/'OWNER_DECISION_RECEIPT_R03.json').read_bytes()) must(decision['owner_reply_verbatim']=='approve' and decision['status']=='ADOPTED_DESIGN_ONLY','no owner design decision') must(decision['amendment_sha256']==sha(owner),'decision binding failed') v4=(b/'input/v4_authority/VPL_V4_SEMANTIC_LOAD_BUNDLE_REV006.md').read_bytes();must(sha(v4)==V4,'wrong V4 source') manifest=j((b/'input/v4_authority/VPL_V4_EQUAL_LOAD_MANIFEST_REV006.json').read_bytes()) slot=next(x for x in manifest['canonical_sources'] if x['name']=='V4_LOCKED_GUIDELINES.json') start=v4.index(b'# BEGIN CANONICAL SOURCE: V4_LOCKED_GUIDELINES.json\n\n')+len(b'# BEGIN CANONICAL SOURCE: V4_LOCKED_GUIDELINES.json\n\n') guidelines=v4[start:start+slot['bytes']];must(sha(guidelines)==slot['sha256'],'V4 guideline block mismatch') v4req=getrow(j(guidelines),'rules','VPL-GL-15')['requirement'] oldreq=getrow(j(d[GL]),'rules','VPL-GL-15')['requirement'] fullreq=('The V4 REV006 VPL-GL-15 requirement is retained in full and remains mandatory. The following V5 contract is additive, not a replacement; none of its wording waives an inherited V4 safeguard.\n\n'+v4req+'\n\nAdditional V5 contract:\n'+oldreq) eff={};changed=[] for old,new in zip(entries,inv): n=old['path'];v=(generated/'effective_context'/n).read_bytes();eff[n]=v must(new['sha256']==sha(v) and new['size_bytes']==len(v) and new['base_sha256']==sha(d[n]),'inventory binding failed: '+n) actual_change=v!=d[n];must(new['changed']==actual_change,'false change classification') if actual_change:changed.append(n) if n==GL: expected=j(d[n]);getrow(expected,'rules','VPL-GL-15')['requirement']=fullreq must(j(v)==expected,'GL15 changed beyond exact additive correction') elif n==AG: expected=j(d[n]);getrow(expected,'agents','VPL-A01')['historical_rev006_design']['contract_authority']='build_framework_V4.0 Final_Rev6.html#persistence' must(j(v)==expected,'A01 changed beyond exact citation correction') elif n==O1: must(v==d[n]+'\n\n---\n\n## Later owner amendment — September 28, 2026 / R03\n\n'.encode()+owner,'O1 original not preserved with exact appended decision') else:must(v==d[n],'unrelated source changed: '+n) must(set(changed)=={GL,AG,O1},'unexpected changed set') bundle=(generated/'EFFECTIVE_CONTEXT_BUNDLE_R03.md').read_bytes() header=bundle.split(b'\n\n## Source 1: ',1)[0] must(b'Only the named design choice is newly adopted' in header and b'On an unresolved conflict: STOP' in header,'precedence limitation missing') reconstructed=header for i,e in enumerate(entries,1): n=e['path'];v=eff[n] reconstructed+=(f'\n\n## Source {i}: {n}\n\nSHA-256: {sha(v)}\n\n\n').encode()+v+b'\n\n' must(reconstructed==bundle,'effective context cannot be reconstructed') rules_raw=(generated/'SEMANTIC_RULES_R03.json').read_bytes();rules=j(rules_raw) base_rules=j((b/'rules/INVARIANT_MAPPINGS_R01.json').read_bytes()) must(rules['approval_status']=='OWNER_DESIGN_ADOPTED_SEMANTIC_ADEQUACY_PENDING','false semantic approval') must(len(rules['span_mappings'])==328,'span inventory incomplete') vl=v4.splitlines(keepends=True);bl=bundle.splitlines(keepends=True) must(bundle.count(v4)==1,'V4 not unique in effective bundle') offset=bundle[:bundle.index(v4)].count(b'\n') for before,after in zip(base_rules['span_mappings'],rules['span_mappings']): old=copy.deepcopy(before);new=copy.deepcopy(after);a=old.pop('destination_span');s=new.pop('destination_span') must(old==new,'non-destination mapping data changed') src=after['source_span'] must(s=={**a,'file':'EFFECTIVE_CONTEXT_BUNDLE_R03.md','start_line':src['start_line']+offset,'end_line':src['end_line']+offset},'bad updated range') x=b''.join(vl[src['start_line']-1:src['end_line']]);y=b''.join(bl[s['start_line']-1:s['end_line']]) must(x==y and sha(x)==src['span_sha256'],'span not preserved') r=j((generated/'OWNER_CLOSEOUT_MECHANICAL_RECEIPT_R03.json').read_bytes()) must(r['semantic_resolved']==0 and r['semantic_unresolved']==328 and r['r2_m01_closed'] is False,'unearned closure') must(r['effective_context_bundle_sha256']==sha(bundle) and r['revised_rules_sha256']==sha(rules_raw),'receipt source binding mismatch') must(rules['effective_context_bundle_sha256']==sha(bundle),'rules context binding mismatch') must(rules['owner_design_binding']['record_sha256']==sha((root/'OWNER_DECISION_RECEIPT_R03.json').read_bytes()),'rules owner binding mismatch') for literal in ['Treat model persistence as untrusted.','immediate behavioral test','selective retrieval is not evidence']: must(literal in fullreq,'retained invariant missing') return {'kind':'OWNER_CLOSEOUT_SAVED_DATA_CHECK_R03','result':'PASS_SAVED_DATA_ONLY','original_candidate_files':451,'complete_context_sources':35,'unchanged_sources':32,'changed_sources':changed,'source_spans':328,'full_V4_GL15_and_full_old_V5_GL15_preserved':True,'three_missing_clauses_retained':True,'owner_design_adopted':True,'semantic_approval':False,'semantic_resolved':0,'semantic_unresolved':328,'r2_m01_closed':False,'context_bundle_sha256':sha(bundle),'rules_sha256':sha(rules_raw),'composer_imported':False,'independent_review':False} def main(): p=argparse.ArgumentParser(description=__doc__);p.add_argument('--root',type=Path,default=Path(__file__).resolve().parents[1]);p.add_argument('--generated',type=Path,required=True);p.add_argument('--output',type=Path) a=p.parse_args() try: if a.output:must(not a.output.exists(),'refusing overwrite') result=verify(a.root,a.generated);raw=(json.dumps(result,sort_keys=True,indent=2)+'\n').encode() if a.output: with a.output.open('xb') as f:f.write(raw) print(raw.decode());return 0 except (OSError,ValueError,KeyError,TypeError) as e:print(json.dumps({'result':'REJECTED','error':str(e)}));return 2 if __name__=='__main__':sys.exit(main())