historicalEnter($context,true,array_merge(self::nativeOutcomeSpecs(),self::nativeAckSpecs())); $secret=$this->nativeRemoteSecret(); $rows=$this->rows('SELECT CAST(request_id AS CHAR) AS request_id,generation,operation_id,SHA2(response,256) AS receipt_digest FROM '.$this->table('native_remote_outcomes_v4').' WHERE install_id=? AND namespace=? AND response<>? ORDER BY request_id LIMIT 1001',array($context['install'],$context['suppression_namespace'],'')); if(count($rows)>1000){return false;} foreach($rows as $r){ $id=self::nativeAttemptInteger($r['request_id']); if($r['generation']!==$context['generation']){return false;} $ack=$this->nativeAckRow(array($context['install'],$context['suppression_namespace'],$id),$r['generation'],$r['operation_id'],$r['receipt_digest'],$secret); if($ack===null||$ack['state']!=='confirmed'){return false;} } $this->commit();return true; }catch(Throwable $e){return false;}finally{$this->finish();} } /** Bounded discovery only. Every selected receipt is independently verified * by claim. The cursor is scheduling progress, never erasure authority. */ public function nativeReceiptAckCandidates(array $context,int $after=0): array { try { if($after<0){throw new OG_Privacy_Case_Refusal('native_ack_cursor');} $this->historicalEnter($context,true,array_merge(self::nativeOutcomeSpecs(),self::nativeAckSpecs())); $rows=$this->rows('SELECT CAST(request_id AS CHAR) AS request_id FROM '.$this->table('native_remote_outcomes_v4').' WHERE install_id=? AND namespace=? AND request_id>? AND response<>? ORDER BY CAST(request_id AS UNSIGNED) LIMIT 5',array($context['install'],$context['suppression_namespace'],$after,'')); $ids=array();foreach($rows as $row){$id=self::nativeAttemptInteger($row['request_id']);if($id<=$after){throw new OG_Privacy_Case_Refusal('native_ack_cursor');}$ids[]=$id;$after=$id;} $this->commit();return array('status'=>'native_ack_candidates','request_ids'=>$ids,'next_cursor'=>$ids?$after:0,'erasure_complete'=>false); }catch(Throwable $e){return $this->failure($e,'ack_inventory');}finally{$this->finish();} } private static function nativeAckSpecs(): array { return array('native_receipt_acks_v1'=>array( array('install_id'=>'char(32)','namespace'=>'char(32)','request_id'=>'bigint unsigned', 'generation'=>'char(32)','operation_id'=>'char(64)','receipt_digest'=>'char(64)', 'attempt'=>'smallint unsigned','token'=>'char(64)','lease_until'=>'bigint unsigned', 'next_at'=>'bigint unsigned','state'=>'varchar(24)','body_hmac'=>'char(64)'), array('PRIMARY'=>array(0,array('install_id','namespace','request_id'))))); } public function installNativeAckSchema(): array { try { $this->connect(1);$this->validateSchema(); if($this->rows('SELECT TABLE_NAME FROM information_schema.TABLES WHERE TABLE_SCHEMA=? AND TABLE_NAME=?',array($this->profile['database'],$this->prefix.'native_receipt_acks_v1'))){throw new OG_Privacy_Case_Refusal('native_ack_schema_exists');} $this->sql('CREATE TABLE '.$this->table('native_receipt_acks_v1').' (install_id CHAR(32) NOT NULL,namespace CHAR(32) NOT NULL,request_id BIGINT UNSIGNED NOT NULL,generation CHAR(32) NOT NULL,operation_id CHAR(64) NOT NULL,receipt_digest CHAR(64) NOT NULL,attempt SMALLINT UNSIGNED NOT NULL,token CHAR(64) NOT NULL,lease_until BIGINT UNSIGNED NOT NULL,next_at BIGINT UNSIGNED NOT NULL,state VARCHAR(24) NOT NULL,body_hmac CHAR(64) NOT NULL,PRIMARY KEY(install_id,namespace,request_id)) ENGINE=InnoDB DEFAULT CHARSET=ascii COLLATE=ascii_bin'); $this->validateTableSpecs(self::nativeAckSpecs()); return array('status'=>'native_ack_schema_installed','erasure_complete'=>false); }catch(Throwable $e){return $this->failure($e,'ack_install');}finally{$this->finish();} } private static function nativeAckMac(array $row,string $secret): string { unset($row['body_hmac']); return hash_hmac('sha256',"og-native-receipt-ack-journal-v1\n".json_encode($row,JSON_THROW_ON_ERROR),hex2bin($secret)); } private function nativeAckRow(array $args,string $generation,string $operation,string $digest,string $secret): ?array { $rows=$this->rows('SELECT generation,operation_id,receipt_digest,CAST(attempt AS CHAR) AS attempt,token,CAST(lease_until AS CHAR) AS lease_until,CAST(next_at AS CHAR) AS next_at,state,body_hmac FROM '.$this->table('native_receipt_acks_v1').' WHERE install_id=? AND namespace=? AND request_id=? LIMIT 2',$args); if(!$rows){return null;}$r=$rows[0]; if(count($rows)!==1||$r['generation']!==$generation||$r['operation_id']!==$operation||$r['receipt_digest']!==$digest|| !preg_match('/^[1-6]$/D',$r['attempt'])||!preg_match('/^[a-f0-9]{64}$/D',$r['token'])|| !in_array($r['state'],array('pending','confirmed','attention'),true)|| !hash_equals(self::nativeAckMac($r,$secret),$r['body_hmac'])){throw new OG_Privacy_Case_Refusal('native_ack_journal_damaged');} if($r['lease_until']!=='0'){self::nativeAttemptInteger($r['lease_until']);} self::nativeAttemptInteger($r['next_at']); return $r; } /** Claim commits an uncertain-dispatch lease and backoff BEFORE network. * Settle may only refer to that exact token; confirmed is supplied only by * the fixed transport after checking its bound canonical ACK response. */ public function nativeReceiptAckAttempt(array $context,$requestId,string $event,string $token='',array $keys=array(),string $environment='production',array $history=array(),array $revoked=array(),bool $confirmed=false,?int $httpStatus=null,?int $retryAfter=null): array { try { if(!in_array($event,array('claim','settle'),true)||($event==='settle'&&!preg_match('/^[a-f0-9]{64}$/D',$token))|| ($event==='claim'&&($token!==''||$confirmed||$httpStatus!==null||$retryAfter!==null))||!in_array($environment,array('production','review'),true)){ throw new OG_Privacy_Case_Refusal('native_ack_input'); } $keys=OG_Native_Receipt_Keys::active($keys,$revoked); $binding=$this->nativeScopeBinding($context,$requestId,array_merge(self::nativeRemoteSpecs(),self::nativeOutcomeSpecs(),self::nativeAckSpecs())); $secret=$this->nativeRemoteSecret();$operation=$this->nativeRemoteRow($binding,$context['generation'],$secret); if($operation===null){throw new OG_Privacy_Case_Refusal('native_ack_operation_missing');} $saved=$this->nativeOutcomeRow($binding,$context['generation'],$operation,$secret,$keys,$environment,$history,$revoked); if($saved===null||$saved['response']===''){throw new OG_Privacy_Case_Refusal('native_ack_receipt_required');} $digest=hash('sha256',$saved['response']);$args=array($binding['install'],$binding['namespace'],$requestId); $old=$this->nativeAckRow($args,$context['generation'],$operation['operation_id'],$digest,$secret); $clock=$this->rows('SELECT CAST(UNIX_TIMESTAMP() AS CHAR) AS now_seconds');$now=self::nativeAttemptInteger($clock[0]['now_seconds']??null); if($event==='claim'){ if($old!==null&&$old['state']==='confirmed'){$this->commit();return array('status'=>'native_ack_confirmed','erasure_complete'=>false);} if($old!==null&&($old['state']==='attention'||(int)$old['attempt']>=6)){$this->commit();return array('status'=>'native_ack_attention_required','erasure_complete'=>false);} if($old!==null&&((int)$old['lease_until']>$now+60||(int)$old['next_at']>$now+86460)){throw new OG_Privacy_Case_Refusal('native_ack_clock_anomaly');} if($old!==null&&max((int)$old['lease_until'],(int)$old['next_at'])>$now){$this->commit();return array('status'=>'native_ack_wait','not_before'=>max((int)$old['lease_until'],(int)$old['next_at']),'erasure_complete'=>false);} $attempt=$old===null?1:(int)$old['attempt']+1; $decision=OG_Native_Ack_Policy::decide($attempt,$now,false); $row=array('generation'=>$context['generation'],'operation_id'=>$operation['operation_id'],'receipt_digest'=>$digest, 'attempt'=>(string)$attempt,'token'=>bin2hex(random_bytes(32)),'lease_until'=>(string)($now+60), 'next_at'=>(string)($decision['not_before']??$now),'state'=>$attempt===6?'attention':'pending'); }else{ if($old===null||!hash_equals($old['token'],$token)||$old['state']==='confirmed'||$old['lease_until']==='0'){throw new OG_Privacy_Case_Refusal('native_ack_stale');} $decision=OG_Native_Ack_Policy::decide((int)$old['attempt'],$now,$confirmed,$httpStatus,$retryAfter); $row=$old;$row['state']=$confirmed?'confirmed':($decision['status']==='ack_attention_required'?'attention':'pending'); $row['lease_until']='0'; $row['next_at']=(string)max((int)$old['next_at'],$decision['not_before']??$now); } $row['body_hmac']=self::nativeAckMac($row,$secret); if($old===null){ if($this->sql('INSERT INTO '.$this->table('native_receipt_acks_v1').' (install_id,namespace,request_id,generation,operation_id,receipt_digest,attempt,token,lease_until,next_at,state,body_hmac) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)',array_merge($args,array_values($row)))!==1){throw new OG_Privacy_Case_Refusal('native_ack_conflict');} }elseif($this->sql('UPDATE '.$this->table('native_receipt_acks_v1').' SET generation=?,operation_id=?,receipt_digest=?,attempt=?,token=?,lease_until=?,next_at=?,state=?,body_hmac=? WHERE install_id=? AND namespace=? AND request_id=? AND body_hmac=?',array_merge(array_values($row),$args,array($old['body_hmac'])))!==1){throw new OG_Privacy_Case_Refusal('native_ack_conflict');} if($this->nativeAckRow($args,$context['generation'],$operation['operation_id'],$digest,$secret)!==$row){throw new OG_Privacy_Case_Refusal('native_ack_journal_damaged');} $payload=OG_Native_Remote_Binding::canonical(array('version'=>'og-native-receipt-ack-v1','request'=>json_decode($operation['payload'],true,32,JSON_THROW_ON_ERROR),'receipt_digest'=>$digest)); $this->commit(); $result=array('status'=>$event==='claim'?'native_ack_claimed':($confirmed?'native_ack_confirmed':'native_ack_settled'),'token'=>$row['token'],'state'=>$row['state'],'not_before'=>(int)$row['next_at'],'erasure_complete'=>false); if($event==='claim'){$result['payload']=$payload;} return $result; }catch(Throwable $e){return array_merge($this->failure($e,'ack_journal'),array('erasure_complete'=>false));}finally{$this->finish();} } }