array( array('install_id'=>'char(32)','namespace'=>'char(32)','request_id'=>'bigint unsigned','payload'=>'mediumtext','body_hmac'=>'char(64)'), array('PRIMARY'=>array(0,array('install_id','namespace','request_id'))))); } public function installNativeLocalFlagsSchema(): 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_local_flag_manifests_v1'))){throw new OG_Privacy_Case_Refusal('native_flag_schema_exists');} $this->sql('CREATE TABLE '.$this->table('native_local_flag_manifests_v1').' (install_id CHAR(32) NOT NULL,namespace CHAR(32) NOT NULL,request_id BIGINT UNSIGNED NOT NULL,payload MEDIUMTEXT 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::localFlagSpecs());return array('status'=>'native_flag_schema_installed','erasure_complete'=>false); }catch(Throwable $e){return $this->failure($e,'local_flag_install');}finally{$this->finish();} } private static function localFlagColumns(): array { return array('id'=>'bigint unsigned','name'=>'varchar(191)','email'=>'varchar(191)','phone'=>'varchar(64)','email_hash'=>'char(64)','phone_hash'=>'char(64)','level'=>'varchar(20)','reason'=>'varchar(191)','order_ref'=>'varchar(64)','source'=>'varchar(20)','created_at'=>'datetime','updated_at'=>'datetime'); } private static function localFlagSelect(): string {return 'CAST(id AS CHAR) AS id,`'.implode('`,`',array_slice(array_keys(self::localFlagColumns()),1)).'`';} private function localFlagTable(): string { if(!preg_match('/^([a-zA-Z0-9_]{1,23})og_owned_$/D',$this->prefix,$m)){throw new OG_Privacy_Case_Refusal('native_flag_source_invalid');} $name=$m[1].'orderguard_flags'; $table=$this->rows('SELECT ENGINE,TABLE_TYPE FROM information_schema.TABLES WHERE TABLE_SCHEMA=? AND TABLE_NAME=?',array($this->profile['database'],$name)); if(count($table)!==1||$table[0]['ENGINE']!=='InnoDB'||$table[0]['TABLE_TYPE']!=='BASE TABLE'){throw new OG_Privacy_Case_Refusal('native_flag_source_unavailable');} $columns=$this->rows('SELECT COLUMN_NAME,COLUMN_TYPE,IS_NULLABLE,EXTRA FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=? AND TABLE_NAME=? ORDER BY ORDINAL_POSITION',array($this->profile['database'],$name));$actual=array(); foreach($columns as $c){if($c['IS_NULLABLE']!=='NO'||stripos($c['EXTRA'],'generated')!==false){throw new OG_Privacy_Case_Refusal('native_flag_source_invalid');}$actual[$c['COLUMN_NAME']]=preg_replace('/bigint\([0-9]+\)/','bigint',strtolower($c['COLUMN_TYPE']));} $pk=$this->rows("SELECT COLUMN_NAME FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=? AND TABLE_NAME=? AND INDEX_NAME='PRIMARY' ORDER BY SEQ_IN_INDEX",array($this->profile['database'],$name)); $expected=self::localFlagColumns();ksort($actual,SORT_STRING);ksort($expected,SORT_STRING); if($actual!==$expected||$pk!==array(array('COLUMN_NAME'=>'id'))){throw new OG_Privacy_Case_Refusal('native_flag_source_invalid');}return '`'.$name.'`'; } private static function localFlagRow(array $row): array { if(array_keys($row)!==array_keys(self::localFlagColumns())){throw new OG_Privacy_Case_Refusal('native_flag_row_invalid');} foreach($row as $v){if(!is_string($v)||strlen($v)>1024){throw new OG_Privacy_Case_Refusal('native_flag_row_invalid');}} if(self::nativeAttemptInteger($row['id'])<1){throw new OG_Privacy_Case_Refusal('native_flag_row_invalid');} if(($row['email']===''?$row['email_hash']!=='':$row['email_hash']!==hash('sha256',strtolower(trim($row['email']))))||!in_array($row['source'],array('order','manual'),true)){throw new OG_Privacy_Case_Refusal('native_flag_row_invalid');} foreach(array('created_at','updated_at') as $key){$d=DateTimeImmutable::createFromFormat('!Y-m-d H:i:s',$row[$key],new DateTimeZone('UTC'));if(!$d||$d->format('Y-m-d H:i:s')!==$row[$key]||$d->getTimestamp()<1){throw new OG_Privacy_Case_Refusal('native_flag_row_invalid');}} return $row; } private static function localFlagMac(string $purpose,string $payload,string $secret): string {return hash_hmac('sha256',"og-native-local-flag-v1\n".$purpose."\n".$payload,hex2bin($secret));} private static function localFlagCount($value): int { if(!is_string($value)||!preg_match('/^(0|[1-9][0-9]{0,18})$/D',$value)||(string)(int)$value!==$value){throw new OG_Privacy_Case_Refusal('native_flag_source_invalid');} return (int)$value; } private function localFlagContext(array $context,$requestId): array { $binding=$this->nativeScopeBinding($context,$requestId,array_merge(self::localFlagSpecs(),self::nativeRemoteSpecs(),self::nativePageSpecs(),self::nativeOrderFenceSpecs())); $secret=$this->nativeRemoteSecret();$op=$this->nativeRemoteRow($binding,$context['generation'],$secret); if($op===null){throw new OG_Privacy_Case_Refusal('native_flag_operation_required');} $state=$this->nativePages($binding);$fences=$this->nativeOrderFenceRows($binding);$request=json_decode($op['payload'],true,12,JSON_THROW_ON_ERROR); if(!$state['observed_end']||$request['evidence_digest']!==$state['last_digest']||array_values($fences)!==$state['pages']){throw new OG_Privacy_Case_Refusal('native_flag_scope_changed');} $header=array('version'=>5,'install'=>$binding['install'],'namespace'=>$binding['namespace'],'request_id'=>(string)$requestId,'generation'=>$context['generation'],'operation_id'=>$op['operation_id'],'evidence_digest'=>$state['last_digest']); return array($binding,$secret,$op,$request,$header,$state); } private function localFlagManifest(array $binding,array $header,string $secret): ?array { $args=array($binding['install'],$binding['namespace'],$binding['request_id']);$where=' WHERE install_id=? AND namespace=? AND request_id=? LIMIT 2'; $meta=$this->rows('SELECT OCTET_LENGTH(payload) AS size FROM '.$this->table('native_local_flag_manifests_v1').$where,$args);if(!$meta){return null;} if(count($meta)!==1||(int)$meta[0]['size']>196608){throw new OG_Privacy_Case_Refusal('native_flag_manifest_damaged');} $r=$this->rows('SELECT payload,body_hmac FROM '.$this->table('native_local_flag_manifests_v1').$where,$args)[0]; try{$body=json_decode($r['payload'],true,16,JSON_THROW_ON_ERROR); $counts=array('common_email_hashes','common_email_rows','common_phone_hashes','common_phone_rows','overflow_link_rows','overflow_alias_rows'); if(!is_array($body)||array_keys($body)!==array_merge(array_keys($header),array_merge($counts,array('rows','retained_order_links','retained_aliases')))||array_intersect_key($body,$header)!==$header||!hash_equals(self::localFlagMac('manifest',$r['payload'],$secret),$r['body_hmac'])){throw new RuntimeException('shape');} foreach($counts as $key){if(!is_int($body[$key])||$body[$key]<0||$body[$key]>PHP_INT_MAX){throw new RuntimeException('count');}} foreach(array('email','phone') as $kind){if($body['common_'.$kind.'_hashes']>22500||($body['common_'.$kind.'_hashes']===0)!==($body['common_'.$kind.'_rows']===0)){throw new RuntimeException('common');}} $seen=array();foreach(array('rows','retained_order_links','retained_aliases') as $kind){if(!is_array($body[$kind])||!array_is_list($body[$kind])){throw new RuntimeException('list');}$last=0;foreach($body[$kind] as $item){$simple=$kind!=='retained_order_links';if(!is_array($item)||array_keys($item)!==($simple?array('id','digest'):array('id','digest','detached_digest'))||!is_string($item['id'])||self::nativeAttemptInteger($item['id'])<=$last||isset($seen[$item['id']])||!is_string($item['digest'])||!preg_match('/^[a-f0-9]{64}$/D',$item['digest'])){throw new RuntimeException('row');}if(!$simple&&(!is_string($item['detached_digest'])||!preg_match('/^[a-f0-9]{64}$/D',$item['detached_digest']))){throw new RuntimeException('detached');}$last=(int)$item['id'];$seen[$item['id']]=true;}} if(count($seen)>1024){throw new RuntimeException('capacity');} if(json_encode($body,JSON_THROW_ON_ERROR)!==$r['payload']){throw new RuntimeException('canonical');}return $body; }catch(Throwable $e){throw new OG_Privacy_Case_Refusal('native_flag_manifest_damaged');} } /** Counts only; the manifest remains authenticated and private. */ public function readNativeLocalFlagPlan(array $context,$requestId): array { try{list($b,$secret,$op,$request,$header)=$this->localFlagContext($context,$requestId);$manifest=$this->localFlagManifest($b,$header,$secret); if($manifest===null){throw new OG_Privacy_Case_Refusal('native_flag_manifest_required');} $this->commit();return array('status'=>'native_flag_plan','delete_pages'=>max(1,(int)ceil(count($manifest['rows'])/25)),'link_pages'=>max(1,(int)ceil(count($manifest['retained_order_links'])/25)),'alias_review_count'=>count($manifest['retained_aliases']),'common_email_hashes'=>$manifest['common_email_hashes'],'common_email_rows'=>$manifest['common_email_rows'],'common_phone_hashes'=>$manifest['common_phone_hashes'],'common_phone_rows'=>$manifest['common_phone_rows'],'overflow_link_rows'=>$manifest['overflow_link_rows'],'overflow_alias_rows'=>$manifest['overflow_alias_rows'],'erasure_complete'=>false); }catch(Throwable $e){return array_merge($this->failure($e,'local_flag_plan'),array('erasure_complete'=>false));}finally{$this->finish();} } private function requireEarlierFlagWork(array $manifest,array $binding,string $secret,string $table,string $event,int $page): void { $deleteLimit=$event==='delete'?($page-1)*25:count($manifest['rows']); foreach(array_slice($manifest['rows'],0,$deleteLimit) as $item){if($this->rows('SELECT id FROM '.$table.' WHERE id=? LIMIT 1',array($item['id']))){throw new OG_Privacy_Case_Refusal('native_flag_sequence');}} if($event!=='detach_links'){return;} foreach(array_slice($manifest['retained_order_links'],0,($page-1)*25) as $item){ $rows=$this->rows('SELECT '.self::localFlagSelect().' FROM '.$table.' WHERE id=? LIMIT 2',array($item['id']));if(!$rows){continue;} if(count($rows)!==1||!hash_equals($item['detached_digest'],self::localFlagMac('row',json_encode(self::localFlagRow($rows[0]),JSON_THROW_ON_ERROR),$secret))){throw new OG_Privacy_Case_Refusal('native_flag_sequence');} } } /** Read-only admission check used before page-one order capture or markers. */ public function preflightNativeLocalFlagRequest(array $context,$requestId,$callerAssertsLegacyWritersDrained): array { try{if($callerAssertsLegacyWritersDrained!==true){throw new OG_Privacy_Case_Refusal('native_flag_takeover_required');} $binding=$this->nativeScopeBinding($context,$requestId,self::localFlagSpecs());$table=$this->localFlagTable(); $rows=$this->rows('SELECT COUNT(*) AS matches FROM '.$table.' WHERE BINARY email_hash=BINARY ?',array($binding['email_hash'])); if(count($rows)!==1){throw new OG_Privacy_Case_Refusal('native_flag_source_invalid');}$matches=self::localFlagCount((string)$rows[0]['matches']); if($matches>50){throw new OG_Privacy_Case_Refusal('native_flag_request_email_common');} $this->commit();return array('status'=>'native_flag_request_admitted','erasure_complete'=>false); }catch(Throwable $e){return array_merge($this->failure($e,'local_flag_preflight'),array('erasure_complete'=>false));}finally{$this->finish();} } /** Trusted internal caller must authorise request and actually drain every legacy flag writer. */ public function captureNativeLocalFlags(array $context,$requestId,$callerAssertsLegacyWritersDrained): array { try{if($callerAssertsLegacyWritersDrained!==true){throw new OG_Privacy_Case_Refusal('native_flag_takeover_required');} list($b,$secret,$op,$request,$header,$state)=$this->localFlagContext($context,$requestId); $old=$this->localFlagManifest($b,$header,$secret);if($old!==null){$this->commit();return array('status'=>'native_flag_manifest_saved','replayed'=>true,'count'=>count($old['rows']),'retained_order_links'=>count($old['retained_order_links']),'alias_review_count'=>count($old['retained_aliases']),'common_email_hashes'=>$old['common_email_hashes'],'common_email_rows'=>$old['common_email_rows'],'common_phone_hashes'=>$old['common_phone_hashes'],'common_phone_rows'=>$old['common_phone_rows'],'overflow_link_rows'=>$old['overflow_link_rows'],'overflow_alias_rows'=>$old['overflow_alias_rows'],'erasure_complete'=>false);} $table=$this->localFlagTable();$refs=array_column($request['orders'],'ref');$refSet=array_fill_keys($refs,true);$emailAliases=array();$phoneAliases=array(); foreach($state['pages'] as $page){foreach($page['orders'] as $order){$hashes=$order['identity_hashes'];$roles=$order['source_roles']??null; if($roles===null){foreach($hashes as $hash){if($hash!==$b['email_hash']){$emailAliases[$hash]=true;$phoneAliases[$hash]=true;}}continue;} if($roles['billing_email']!==null&&$hashes[$roles['billing_email']]!==$b['email_hash']){$emailAliases[$hashes[$roles['billing_email']]]=true;} foreach($roles['billing_phone'] as $index){if($hashes[$index]!==$b['email_hash']){$phoneAliases[$hashes[$index]]=true;}} }} $emailAliases=array_keys($emailAliases);sort($emailAliases,SORT_STRING);$phoneAliases=array_keys($phoneAliases);sort($phoneAliases,SORT_STRING); // The exact request email is deletion authority only while it remains // sufficiently selective. Placeholder request addresses are refused. $ownedCount=$this->rows('SELECT COUNT(*) AS matches FROM '.$table.' WHERE BINARY email_hash=BINARY ?',array($b['email_hash'])); if(count($ownedCount)!==1){throw new OG_Privacy_Case_Refusal('native_flag_source_invalid');}$ownedMatches=self::localFlagCount((string)$ownedCount[0]['matches']); if($ownedMatches>50){throw new OG_Privacy_Case_Refusal('native_flag_request_email_common');} $coreRows=$this->rows('SELECT '.self::localFlagSelect().' FROM '.$table.' WHERE BINARY email_hash=BINARY ? ORDER BY CAST(id AS UNSIGNED) LIMIT 51',array($b['email_hash'])); if(count($coreRows)!==$ownedMatches){throw new OG_Privacy_Case_Refusal('native_flag_source_invalid');} $items=array();$retained=array();$aliasRows=array(); foreach($coreRows as $row){$row=self::localFlagRow($row);$items[]=array('id'=>$row['id'],'digest'=>self::localFlagMac('row',json_encode($row,JSON_THROW_ON_ERROR),$secret));} // Captured-order provenance is retained, but excess rows are counted // for review rather than turning a bounded manifest into a hard stop. $overflowLinks=0; if($refs){$marks=implode(',',array_fill(0,count($refs),'?'));$linkParams=array_merge($refs,array($b['email_hash'])); $linkWhere="BINARY source=BINARY 'order' AND BINARY order_ref IN (".$marks.') AND NOT (BINARY email_hash=BINARY ?)'; $linkCount=$this->rows('SELECT COUNT(*) AS matches FROM '.$table.' WHERE '.$linkWhere,$linkParams); if(count($linkCount)!==1){throw new OG_Privacy_Case_Refusal('native_flag_source_invalid');}$linkMatches=self::localFlagCount((string)$linkCount[0]['matches']); $available=1024-count($items);$take=min($available,$linkMatches);$linkRows=$take>0?$this->rows('SELECT '.self::localFlagSelect().' FROM '.$table.' WHERE '.$linkWhere.' ORDER BY CAST(id AS UNSIGNED) LIMIT '.$take,$linkParams):array(); if(count($linkRows)!==$take){throw new OG_Privacy_Case_Refusal('native_flag_source_invalid');} foreach($linkRows as $row){$row=self::localFlagRow($row);if(!isset($refSet[$row['order_ref']])){throw new OG_Privacy_Case_Refusal('native_flag_source_invalid');}$item=array('id'=>$row['id'],'digest'=>self::localFlagMac('row',json_encode($row,JSON_THROW_ON_ERROR),$secret));$detached=$row;$detached['order_ref']='';$item['detached_digest']=self::localFlagMac('row',json_encode($detached,JSON_THROW_ON_ERROR),$secret);$retained[]=$item;} $overflowLinks=$linkMatches-$take; } $commonEmails=array();$commonEmailRows=0;$commonPhones=array();$commonPhoneRows=0; foreach(array(array('email_hash',$emailAliases,&$commonEmails,&$commonEmailRows),array('phone_hash',$phoneAliases,&$commonPhones,&$commonPhoneRows)) as &$spec){ $set=array_fill_keys($spec[1],true);foreach(array_chunk($spec[1],100) as $chunk){$marks=implode(',',array_fill(0,count($chunk),'?')); foreach($this->rows('SELECT '.$spec[0].',COUNT(*) AS matches FROM '.$table.' WHERE BINARY '.$spec[0].' IN ('.$marks.') GROUP BY '.$spec[0].' HAVING COUNT(*)>50',$chunk) as $row){ if(!is_string($row[$spec[0]])||!isset($set[$row[$spec[0]]])||self::localFlagCount((string)$row['matches'])<51){throw new OG_Privacy_Case_Refusal('native_flag_source_invalid');} $spec[2][$row[$spec[0]]]=true;$spec[3]+=self::localFlagCount((string)$row['matches']); } } }unset($spec); $safeEmails=array_values(array_diff($emailAliases,array_keys($commonEmails)));$safePhones=array_values(array_diff($phoneAliases,array_keys($commonPhones)));$aliasParts=array();$aliasParams=array(); if($safeEmails){$aliasParts[]='BINARY email_hash IN ('.implode(',',array_fill(0,count($safeEmails),'?')).')';$aliasParams=array_merge($aliasParams,$safeEmails);} if($safePhones){$aliasParts[]='BINARY phone_hash IN ('.implode(',',array_fill(0,count($safePhones),'?')).')';$aliasParams=array_merge($aliasParams,$safePhones);} $overflowAliases=0; if($aliasParts){$aliasWhere='('.implode(' OR ',$aliasParts).') AND NOT (BINARY email_hash=BINARY ?)';$aliasParams[]=$b['email_hash']; if($refs){$aliasWhere.=" AND NOT (BINARY source=BINARY 'order' AND BINARY order_ref IN (".implode(',',array_fill(0,count($refs),'?')).'))';$aliasParams=array_merge($aliasParams,$refs);} $aliasCount=$this->rows('SELECT COUNT(*) AS matches FROM '.$table.' WHERE '.$aliasWhere,$aliasParams);if(count($aliasCount)!==1){throw new OG_Privacy_Case_Refusal('native_flag_source_invalid');}$aliasMatches=self::localFlagCount((string)$aliasCount[0]['matches']); $remaining=1024-count($items)-count($retained);$take=min($remaining,$aliasMatches);$rows=$take>0?$this->rows('SELECT '.self::localFlagSelect().' FROM '.$table.' WHERE '.$aliasWhere.' ORDER BY CAST(id AS UNSIGNED) LIMIT '.$take,$aliasParams):array(); if(count($rows)!==$take){throw new OG_Privacy_Case_Refusal('native_flag_source_invalid');} foreach($rows as $row){$row=self::localFlagRow($row);$aliasRows[]=array('id'=>$row['id'],'digest'=>self::localFlagMac('row',json_encode($row,JSON_THROW_ON_ERROR),$secret));} $overflowAliases=$aliasMatches-$take; } $body=$header+array('common_email_hashes'=>count($commonEmails),'common_email_rows'=>$commonEmailRows,'common_phone_hashes'=>count($commonPhones),'common_phone_rows'=>$commonPhoneRows,'overflow_link_rows'=>$overflowLinks,'overflow_alias_rows'=>$overflowAliases,'rows'=>$items,'retained_order_links'=>$retained,'retained_aliases'=>$aliasRows);$payload=json_encode($body,JSON_THROW_ON_ERROR); if($this->sql('INSERT INTO '.$this->table('native_local_flag_manifests_v1').' (install_id,namespace,request_id,payload,body_hmac) VALUES (?,?,?,?,?)',array($b['install'],$b['namespace'],$requestId,$payload,self::localFlagMac('manifest',$payload,$secret)))!==1||$this->localFlagManifest($b,$header,$secret)!==$body){throw new OG_Privacy_Case_Refusal('native_flag_manifest_damaged');} $this->commit();return array('status'=>'native_flag_manifest_saved','replayed'=>false,'count'=>count($items),'retained_order_links'=>count($retained),'alias_review_count'=>count($aliasRows),'common_email_hashes'=>count($commonEmails),'common_email_rows'=>$commonEmailRows,'common_phone_hashes'=>count($commonPhones),'common_phone_rows'=>$commonPhoneRows,'overflow_link_rows'=>$overflowLinks,'overflow_alias_rows'=>$overflowAliases,'erasure_complete'=>false); }catch(Throwable $e){return array_merge($this->failure($e,'local_flag_capture'),array('erasure_complete'=>false));}finally{$this->finish();} } public function deleteNativeLocalFlagPage(array $context,$requestId,$page,$callerAssertsLegacyWritersDrained,array $keys,string $environment='production',array $history=array(),array $revoked=array()): array { try{if($callerAssertsLegacyWritersDrained!==true||!is_int($page)||$page<1||$page>41){throw new OG_Privacy_Case_Refusal('native_flag_delete_input');} $keys=OG_Native_Receipt_Keys::active($keys,$revoked); list($b,$secret,$op,$request,$header)=$this->localFlagContext($context,$requestId); $this->legalCleanupAdmission($context,$requestId,'delete'); $this->validateTableSpecs(self::nativeOutcomeSpecs());$outcome=$this->nativeOutcomeRow($b,$context['generation'],$op,$secret,$keys,$environment,$history,$revoked); if($outcome===null||$outcome['response']===''){throw new OG_Privacy_Case_Refusal('native_flag_receipt_required');} $manifest=$this->localFlagManifest($b,$header,$secret);if($manifest===null){throw new OG_Privacy_Case_Refusal('native_flag_manifest_required');} $pages=max(1,(int)ceil(count($manifest['rows'])/25));if($page>$pages){throw new OG_Privacy_Case_Refusal('native_flag_page_missing');} $table=$this->localFlagTable();$this->requireEarlierFlagWork($manifest,$b,$secret,$table,'delete',$page);$deleted=0;$absent=0; foreach(array_slice($manifest['rows'],($page-1)*25,25) as $item){ $rows=$this->rows('SELECT '.self::localFlagSelect().' FROM '.$table.' WHERE id=? LIMIT 2',array($item['id']));if(!$rows){$absent++;continue;} $row=self::localFlagRow($rows[0]);if(count($rows)!==1||!hash_equals($item['digest'],self::localFlagMac('row',json_encode($row,JSON_THROW_ON_ERROR),$secret))){throw new OG_Privacy_Case_Refusal('native_flag_row_changed');} $terms=array();foreach(array_keys($row) as $key){$terms[]='BINARY `'.$key.'`=BINARY ?';} if($this->sql('DELETE FROM '.$table.' WHERE '.implode(' AND ',$terms),array_values($row))!==1){throw new OG_Privacy_Case_Refusal('native_flag_delete_conflict');}$deleted++; } $this->commit();return array('status'=>'native_flag_page_deleted','page'=>$page,'pages'=>$pages,'deleted'=>$deleted,'already_absent'=>$absent,'retained_order_links'=>count($manifest['retained_order_links']),'erasure_complete'=>false,'cache_invalidation_required'=>true); }catch(Throwable $e){return array_merge($this->failure($e,'local_flag_delete'),array('erasure_complete'=>false));}finally{$this->finish();} } /** Detach only frozen order provenance; never delete or reclassify another person's flag. */ public function detachNativeLocalFlagLinks(array $context,$requestId,$page,$callerAssertsLegacyWritersDrained,array $keys,string $environment='production',array $history=array(),array $revoked=array()): array { try{if($callerAssertsLegacyWritersDrained!==true||!is_int($page)||$page<1||$page>41){throw new OG_Privacy_Case_Refusal('native_flag_link_input');} $keys=OG_Native_Receipt_Keys::active($keys,$revoked); list($b,$secret,$op,$request,$header)=$this->localFlagContext($context,$requestId); $this->legalCleanupAdmission($context,$requestId,'detach_links'); $this->validateTableSpecs(self::nativeOutcomeSpecs());$outcome=$this->nativeOutcomeRow($b,$context['generation'],$op,$secret,$keys,$environment,$history,$revoked); if($outcome===null||$outcome['response']===''){throw new OG_Privacy_Case_Refusal('native_flag_receipt_required');} $manifest=$this->localFlagManifest($b,$header,$secret);if($manifest===null){throw new OG_Privacy_Case_Refusal('native_flag_manifest_required');} $pages=max(1,(int)ceil(count($manifest['retained_order_links'])/25));if($page>$pages){throw new OG_Privacy_Case_Refusal('native_flag_page_missing');} $table=$this->localFlagTable();$this->requireEarlierFlagWork($manifest,$b,$secret,$table,'detach_links',$page);$detached=0;$absent=0;$replayed=0; foreach(array_slice($manifest['retained_order_links'],($page-1)*25,25) as $item){ $rows=$this->rows('SELECT '.self::localFlagSelect().' FROM '.$table.' WHERE id=? LIMIT 2',array($item['id'])); if(!$rows){$absent++;continue;}$row=self::localFlagRow($rows[0]); $digest=self::localFlagMac('row',json_encode($row,JSON_THROW_ON_ERROR),$secret); if(count($rows)===1&&$row['order_ref']===''&&hash_equals($item['detached_digest'],$digest)){$replayed++;continue;} if(count($rows)!==1||$row['source']!=='order'||$row['email_hash']===$b['email_hash']||!hash_equals($item['digest'],$digest)){throw new OG_Privacy_Case_Refusal('native_flag_link_changed');} $terms=array();foreach(array_keys($row) as $key){$terms[]='BINARY `'.$key.'`=BINARY ?';} // Pin timestamp explicitly: detaching provenance must not look like a new merchant decision. if($this->sql("UPDATE ".$table." SET order_ref='',updated_at=? WHERE ".implode(' AND ',$terms),array_merge(array($row['updated_at']),array_values($row)))!==1){throw new OG_Privacy_Case_Refusal('native_flag_link_conflict');} $after=$this->rows('SELECT '.self::localFlagSelect().' FROM '.$table.' WHERE id=? LIMIT 2',array($item['id'])); if(count($after)!==1||!hash_equals($item['detached_digest'],self::localFlagMac('row',json_encode(self::localFlagRow($after[0]),JSON_THROW_ON_ERROR),$secret))){throw new OG_Privacy_Case_Refusal('native_flag_link_conflict');} $detached++; } $this->commit();return array('status'=>'native_flag_links_detached','page'=>$page,'pages'=>$pages,'detached'=>$detached,'already_detached'=>$replayed,'already_absent'=>$absent,'review_count'=>count($manifest['retained_order_links']),'erasure_complete'=>false,'cache_invalidation_required'=>true); }catch(Throwable $e){return array_merge($this->failure($e,'local_flag_links'),array('erasure_complete'=>false));}finally{$this->finish();} } /** Caller-authorised, IDs and classifications only. No dependency on the WP request post. * A classification is a point-in-time comparison, not an edit capability or completion. */ public function readNativeFlagReview(array $context,$requestId,$page): array { try{if(!is_int($page)||$page<1||$page>41){throw new OG_Privacy_Case_Refusal('native_flag_review_input');} list($b,$secret,$op,$request,$header)=$this->localFlagContext($context,$requestId); $manifest=$this->localFlagManifest($b,$header,$secret);if($manifest===null){throw new OG_Privacy_Case_Refusal('native_flag_manifest_required');} $items=$manifest['retained_order_links'];$pages=max(1,(int)ceil(count($items)/25)); if($page>$pages){throw new OG_Privacy_Case_Refusal('native_flag_page_missing');} $table=$this->localFlagTable();$entries=array(); foreach(array_slice($items,($page-1)*25,25) as $item){ $rows=$this->rows('SELECT '.self::localFlagSelect().' FROM '.$table.' WHERE id=? LIMIT 2',array($item['id'])); if(!$rows){$state='absent';} else{if(count($rows)!==1){throw new OG_Privacy_Case_Refusal('native_flag_source_invalid');} try{$row=self::localFlagRow($rows[0]);$digest=self::localFlagMac('row',json_encode($row,JSON_THROW_ON_ERROR),$secret); $state=hash_equals($item['detached_digest'],$digest)?'detached':(hash_equals($item['digest'],$digest)?'linked':'changed'); }catch(OG_Privacy_Case_Refusal $e){if($e->getMessage()!=='native_flag_row_invalid'){throw $e;}$state='changed';} } $entries[]=array('flag_id'=>$item['id'],'state'=>$state); } $this->commit();return array('status'=>'native_flag_review_page','entries'=>$entries,'page'=>$page,'pages'=>$pages,'total'=>count($items),'erasure_complete'=>false); }catch(Throwable $e){return array_merge($this->failure($e,'flag_review'),array('erasure_complete'=>false));}finally{$this->finish();} } /** Alias-only matches are retained unchanged. The view discloses IDs and drift states only. */ public function readNativeFlagAliasReview(array $context,$requestId,$page): array { try{if(!is_int($page)||$page<1||$page>41){throw new OG_Privacy_Case_Refusal('native_flag_review_input');} list($b,$secret,$op,$request,$header)=$this->localFlagContext($context,$requestId); $manifest=$this->localFlagManifest($b,$header,$secret);if($manifest===null){throw new OG_Privacy_Case_Refusal('native_flag_manifest_required');} $items=$manifest['retained_aliases'];$pages=max(1,(int)ceil(count($items)/25));if($page>$pages){throw new OG_Privacy_Case_Refusal('native_flag_page_missing');} $table=$this->localFlagTable();$entries=array(); foreach(array_slice($items,($page-1)*25,25) as $item){$rows=$this->rows('SELECT '.self::localFlagSelect().' FROM '.$table.' WHERE id=? LIMIT 2',array($item['id'])); if(!$rows){$state='absent';}else{if(count($rows)!==1){throw new OG_Privacy_Case_Refusal('native_flag_source_invalid');} try{$row=self::localFlagRow($rows[0]);$digest=self::localFlagMac('row',json_encode($row,JSON_THROW_ON_ERROR),$secret);$state=hash_equals($item['digest'],$digest)?'captured':'changed';} catch(OG_Privacy_Case_Refusal $e){if($e->getMessage()!=='native_flag_row_invalid'){throw $e;}$state='changed';}} $entries[]=array('flag_id'=>$item['id'],'state'=>$state); } $this->commit();return array('status'=>'native_flag_alias_review_page','entries'=>$entries,'page'=>$page,'pages'=>$pages,'total'=>count($items),'common_email_hashes'=>$manifest['common_email_hashes'],'common_email_rows'=>$manifest['common_email_rows'],'common_phone_hashes'=>$manifest['common_phone_hashes'],'common_phone_rows'=>$manifest['common_phone_rows'],'overflow_link_rows'=>$manifest['overflow_link_rows'],'overflow_alias_rows'=>$manifest['overflow_alias_rows'],'erasure_complete'=>false); }catch(Throwable $e){return array_merge($this->failure($e,'flag_alias_review'),array('erasure_complete'=>false));}finally{$this->finish();} } }