65535 || !in_array($profile['tls'], array('plain','verified','wordpress'), true) || !preg_match('/^[a-zA-Z0-9_-]{1,64}$/D', $profile['database']) || substr($profile['host'], 0, 2) === 'p:' || strlen($profile['host']) > 253 || ($profile['socket'] !== '' && ($profile['socket'][0] !== '/' || $profile['host'] !== 'localhost'))) { throw new OG_Privacy_Case_Refusal('invalid_profile'); } if ($profile['tls'] === 'verified' && (empty($profile['ca']) || !is_readable($profile['ca']) || $profile['socket'] !== '')) { throw new OG_Privacy_Case_Refusal('tls_configuration'); } if ($profile['tls']==='wordpress' && (!is_int($profile['client_flags']??null) || ($profile['client_flags'] & MYSQLI_CLIENT_SSL)!==MYSQLI_CLIENT_SSL || ($profile['client_flags'] & ~(MYSQLI_CLIENT_SSL | MYSQLI_CLIENT_SSL_DONT_VERIFY_SERVER_CERT))!==0)) { throw new OG_Privacy_Case_Refusal('tls_configuration'); } $this->profile = $profile; $this->prefix = $prefix; } private function connect(int $wait): void { $this->db = mysqli_init(); // phpcs:ignore WordPress.DB.RestrictedFunctions.mysql_mysqli_init -- isolated transaction/lock connection with explicit timeouts; the shared WordPress connection cannot own these transactions safely. $this->db->options(MYSQLI_OPT_CONNECT_TIMEOUT, 2); $this->db->options(MYSQLI_OPT_READ_TIMEOUT, 6); $flags = 0; if ($this->profile['tls']==='wordpress') { $flags=$this->profile['client_flags']; } if ($this->profile['tls'] === 'verified') { $this->db->ssl_set($this->profile['client_key'] ?? null, $this->profile['client_cert'] ?? null, $this->profile['ca'], null, null); $this->db->options(MYSQLI_OPT_SSL_VERIFY_SERVER_CERT, true); $flags = MYSQLI_CLIENT_SSL; } $p = $this->profile; try { $connected = @$this->db->real_connect($p['host'], $p['user'], $p['password'], $p['database'], $p['port'], $p['socket'] === '' ? null : $p['socket'], $flags); } catch (mysqli_sql_exception $e) { // Only connection establishment is normalised. Do not reclassify // a later statement failure or an ambiguous COMMIT as connection loss. throw new OG_Privacy_Case_Refusal('connection_unavailable'); } if (!$connected) { throw new OG_Privacy_Case_Refusal('connection_unavailable'); } if (!$this->db->set_charset('utf8mb4')) { throw new OG_Privacy_Case_Refusal('charset_unavailable'); } $this->sql("SET SESSION time_zone = '+00:00'"); $this->sql('SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ'); $this->sql('SET SESSION innodb_lock_wait_timeout = ' . $wait); // Both families expose different isolation-variable names. This is a // server handshake identifier, not a caller-selected SQL fragment. $maria = stripos($this->db->server_info, 'MariaDB') !== false; $check = $this->rows('SELECT DATABASE() AS db, @@session.innodb_lock_wait_timeout AS wait_seconds, '. ($maria ? '@@session.tx_isolation' : '@@session.transaction_isolation'). ' AS isolation_level, @@session.time_zone AS session_zone'); $this->verifyProfile($check, $wait); if ($p['tls'] !== 'plain') { $cipher = $this->rows("SHOW SESSION STATUS LIKE 'Ssl_cipher'"); if (count($cipher) !== 1 || $cipher[0]['Value'] === '') { throw new OG_Privacy_Case_Refusal('tls_unavailable'); } } } private function table(string $name): string { return '`' . $this->prefix . $name . '`'; } private function verifyProfile(array $check, int $wait): void { if (count($check) !== 1 || ($check[0]['db'] ?? null) !== $this->profile['database'] || (string)($check[0]['wait_seconds'] ?? '') !== (string)$wait || ($check[0]['isolation_level'] ?? null) !== 'REPEATABLE-READ' || ($check[0]['session_zone'] ?? null) !== '+00:00') { throw new OG_Privacy_Case_Refusal('profile_mismatch'); } } /** Native protocol: MySQL cannot prepare START TRANSACTION. Check false too. */ private function transactionControl(string $kind): void { if ($kind === 'begin') { $ok = $this->db->begin_transaction(); } elseif ($kind === 'commit') { $ok = $this->db->commit(); } elseif ($kind === 'rollback') { $ok = $this->db->rollback(); } else { throw new OG_Privacy_Case_Refusal('invalid_transaction_control'); } if ($ok !== true) { throw new OG_Privacy_Case_Refusal('transaction_control_unconfirmed'); } } // Maintainer guards over private fixed templates, NOT a general SQL parser. // No public SQL/callback entry point; values use bound parameters. private function sql(string $sql, array $args = []): int { $sql = trim($sql); if ($this->transaction && preg_match('/^(?:CREATE TABLE|SET SESSION)\b/i',$sql)) {throw new OG_Privacy_Case_Refusal('transaction_template_refused');} if (preg_match('/^(INSERT|UPDATE|DELETE|REPLACE|CREATE)\b/i', $sql) && preg_match('/\bSELECT\b/i', $sql)) { throw new OG_Privacy_Case_Refusal('embedded_read_refused'); } $caseTable='`'.preg_quote($this->prefix,'/').'(?:privacy_cases|privacy_case_links|privacy_case_keys|privacy_namespaces)`'; if (preg_match('/^(?:UPDATE|DELETE FROM|REPLACE INTO)\s+'.$caseTable.'/i', $sql) || (preg_match('/^INSERT INTO\s+'.$caseTable.'/i', $sql) && preg_match('/\bON\s+DUPLICATE\s+KEY\s+UPDATE\b/i', $sql))) { throw new OG_Privacy_Case_Refusal('case_mutation_refused'); } if (stripos($sql, 'WITH CONSISTENT SNAPSHOT') !== false) { throw new OG_Privacy_Case_Refusal('early_snapshot_refused'); } if (preg_match('/^(START|BEGIN|COMMIT|ROLLBACK)\b/i', $sql)) { throw new OG_Privacy_Case_Refusal('prepared_transaction_refused'); } if (!preg_match('/^(INSERT INTO |UPDATE |DELETE FROM |CREATE TABLE |SET SESSION )/D', $sql)) { throw new OG_Privacy_Case_Refusal('template_refused'); } $s = $this->db->prepare($sql); if ($s === false) { throw new OG_Privacy_Case_Refusal('statement_unavailable'); } try { if ($args) { $types = str_repeat('s', count($args)); $s->bind_param($types, ...$args); } if (!$s->execute()) { throw new mysqli_sql_exception('statement_failed', $s->errno); } return $s->affected_rows; } finally { $s->close(); } } private function rows(string $sql, array $args = []): array { $sql = trim($sql); $controlReads = array( 'SELECT install_id, generation, state, secret, schema_version FROM '.$this->table('control').' WHERE singleton = 1 FOR UPDATE', 'SELECT install_id, generation, state, secret, schema_version FROM '.$this->table('control').' WHERE singleton = 1 LOCK IN SHARE MODE' ); if ($this->transaction && !$this->lockGranted && !in_array($sql, $controlReads, true)) { throw new OG_Privacy_Case_Refusal('read_before_lock'); } if (preg_match('/\bFOR\s+(UPDATE|SHARE)\b|\bLOCK\s+IN\s+SHARE\s+MODE\b/i', $sql)) { $initial = $this->transaction && !$this->lockGranted && in_array($sql,$controlReads,true) && !$args; $operation = $this->transaction && $this->lockGranted && $args === $this->existingOperation && $sql === 'SELECT digest,expires_at,state FROM '.$this->table('operations').' WHERE generation = ? AND operation_id = ? FOR UPDATE'; $receipt = $this->transaction && $this->lockGranted && $args === $this->existingReceipt && $sql === 'SELECT digest,outcome FROM '.$this->table('receipts').' WHERE generation = ? AND operation_id = ? FOR UPDATE'; $legalHold = $this->transaction && $this->lockGranted && count($args)===3 && $sql === 'SELECT CAST(created_at AS CHAR) AS created_at,body_hmac FROM '.$this->table('native_legal_holds_v1').' WHERE token=? AND request_id=? AND event=? FOR UPDATE'; if (!$initial && !$operation && !$receipt && !$legalHold) { throw new OG_Privacy_Case_Refusal('locking_template_refused'); } } $s = $this->db->prepare($sql); if ($s === false) { throw new OG_Privacy_Case_Refusal('read_unavailable'); } try { if ($args) { $types = str_repeat('s', count($args)); $s->bind_param($types, ...$args); } if (!$s->execute()) { throw new mysqli_sql_exception('read_failed', $s->errno); } $r = $s->get_result(); if ($r === false) { throw new OG_Privacy_Case_Refusal('read_unavailable'); } return $r->fetch_all(MYSQLI_ASSOC); } finally { $s->close(); } } private static function token(string $token): void { if (!preg_match('/^[a-f0-9]{32}$/D', $token)) { throw new OG_Privacy_Case_Refusal('invalid_identity'); } } private function validateSchema(): void { $names = array($this->prefix.'control', $this->prefix.'operations', $this->prefix.'receipts'); $rows = $this->rows('SELECT TABLE_NAME, ENGINE FROM information_schema.TABLES WHERE TABLE_SCHEMA = ? AND TABLE_NAME IN (?,?,?)', array_merge(array($this->profile['database']), $names)); if (count($rows) !== 3) { throw new OG_Privacy_Case_Refusal('schema_unavailable'); } foreach ($rows as $row) { if ($row['ENGINE'] !== 'InnoDB') { throw new OG_Privacy_Case_Refusal('engine_unavailable'); } } // Exact column/index fingerprints are checked after creation and before use. foreach (array('control'=>array('singleton'=>'tinyint unsigned','install_id'=>'char(32)','generation'=>'char(32)', 'state'=>'varchar(8)','secret'=>'char(64)','schema_version'=>'smallint unsigned'), 'operations'=>array('generation'=>'char(32)','operation_id'=>'char(32)','digest'=>'char(64)', 'expires_at'=>'bigint unsigned','state'=>'varchar(12)'), 'receipts'=>array('generation'=>'char(32)','operation_id'=>'char(32)','digest'=>'char(64)','outcome'=>'varchar(16)')) as $name=>$columns) { $actual = $this->rows('SHOW FULL COLUMNS FROM '.$this->table($name)); if (array_column($actual, 'Field') !== array_keys($columns)) { throw new OG_Privacy_Case_Refusal('schema_mismatch'); } foreach ($actual as $column) { $type = preg_replace('/\b(tinyint|smallint|bigint)\([0-9]+\)/', '$1', strtolower($column['Type'])); if ($columns[$column['Field']] !== $type || $column['Null'] !== 'NO' || $column['Extra'] !== '' || (strpos($type,'char') !== false && $column['Collation'] !== 'ascii_bin')) { throw new OG_Privacy_Case_Refusal('schema_mismatch'); } } $indexes = $this->rows('SHOW INDEX FROM '.$this->table($name)); $keys = $name === 'control' ? array('singleton') : array('generation','operation_id'); if (array_column($indexes, 'Column_name') !== $keys || array_unique(array_column($indexes, 'Key_name')) !== array('PRIMARY')) { throw new OG_Privacy_Case_Refusal('index_mismatch'); } } } private function enter(string $install, string $generation, bool $exclusive): array { // Schema reads happen OUTSIDE the transaction and cannot establish its snapshot. $this->validateSchema(); $this->validateBlocks(); $this->transactionControl('begin'); $this->transaction = true; // First table read is always this lock. No consistent snapshot before it. $r = $this->rows('SELECT install_id, generation, state, secret, schema_version FROM '.$this->table('control'). ' WHERE singleton = 1 '.($exclusive ? 'FOR UPDATE' : 'LOCK IN SHARE MODE')); $this->lockGranted = true; if (count($r) !== 1 || $r[0]['install_id'] !== $install || $r[0]['generation'] !== $generation || (int)$r[0]['schema_version'] !== 1) { throw new OG_Privacy_Case_Refusal('authority_changed'); } return $r[0]; } private function validateBlocks(): void { foreach (array('flags'=>array('subject_hash','level'), 'block_revision'=>array('singleton','revision'), 'block_backup'=>array('singleton','revision','payload')) as $name=>$fields) { $engine=$this->rows('SELECT ENGINE FROM information_schema.TABLES WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ?', array($this->profile['database'],$this->prefix.$name)); if (count($engine)!==1 || $engine[0]['ENGINE']!=='InnoDB') { throw new OG_Privacy_Case_Refusal('block_schema_unavailable'); } $columns=$this->rows('SHOW FULL COLUMNS FROM '.$this->table($name)); if (array_column($columns,'Field')!==$fields) { throw new OG_Privacy_Case_Refusal('block_schema_mismatch'); } foreach ($columns as $column) { $type=preg_replace('/\btinyint\([0-9]+\)/','tinyint',strtolower($column['Type'])); $expected=array('singleton'=>'tinyint unsigned','subject_hash'=>'char(64)','level'=>'varchar(4)', 'revision'=>'char(32)','payload'=>'text'); if ($type!==$expected[$column['Field']] || $column['Null']!=='NO' || $column['Extra']!=='' || ($column['Field']!=='singleton' && $column['Collation']!=='ascii_bin')) { throw new OG_Privacy_Case_Refusal('block_schema_mismatch'); } } $indexes=$this->rows('SHOW INDEX FROM '.$this->table($name)); $keys=array(); foreach ($indexes as $index) { $keys[$index['Key_name']][]=$index['Column_name']; } $expected=$name==='flags' ? array('PRIMARY'=>array('subject_hash'),'hard_scan'=>array('level','subject_hash')) : array('PRIMARY'=>array('singleton')); ksort($keys); ksort($expected); if ($keys!==$expected) { throw new OG_Privacy_Case_Refusal('block_index_mismatch'); } foreach ($indexes as $index) { if ($index['Key_name']==='PRIMARY' && (int)$index['Non_unique']!==0) { throw new OG_Privacy_Case_Refusal('block_index_mismatch'); } } } } /** Internal authorised-adapter API, not an HTTP endpoint. Expected revision * binds a delayed add/remove to the state shown to the merchant. No remote * signal is dispatched here. Flags survive generation rotation by design. */ public function blockChange(string $install, string $generation, string $expected, string $hash, string $level): array { $phase='validate'; try { self::token($install); self::token($generation); self::token($expected); if (!preg_match('/^[a-f0-9]{64}$/D',$hash) || !in_array($level,array('hard','cod','remove'),true)) { throw new OG_Privacy_Case_Refusal('invalid_block'); } $phase='connect'; $this->connect(4); $phase='admit'; $control=$this->enter($install,$generation,true); if ($control['state']!=='open') { throw new OG_Privacy_Case_Refusal('closed'); } $phase='effect'; $revision=$this->rows('SELECT revision FROM '.$this->table('block_revision').' WHERE singleton = 1'); if (count($revision)!==1 || $revision[0]['revision']!==$expected) { throw new OG_Privacy_Case_Refusal('block_revision_conflict'); } $before=$this->rows('SELECT level FROM '.$this->table('flags').' WHERE subject_hash = ?',array($hash)); if (count($before)>1 || ($before && !in_array($before[0]['level'],array('cod','hard'),true))) { throw new OG_Privacy_Case_Refusal('invalid_block_row'); } $unchanged=($level==='remove' && !$before) || ($before && $before[0]['level']===$level); // Even a same-level save or an absent remove is a new authorised touch. $this->recordBlockTouchIfEnabled($hash); if (!$unchanged) { if ($level==='remove') { $this->sql('DELETE FROM '.$this->table('flags').' WHERE subject_hash = ?',array($hash)); } elseif ($before) { $this->sql('UPDATE '.$this->table('flags').' SET level = ? WHERE subject_hash = ?',array($level,$hash)); } else { $this->sql('INSERT INTO '.$this->table('flags').' (subject_hash,level) VALUES (?,?)',array($hash,$level)); } } // Rebuild even for a no-op: a missing/corrupt backup is not silently accepted. $hard=$this->rows('SELECT DISTINCT subject_hash FROM '.$this->table('flags')." WHERE level = 'hard' ORDER BY subject_hash LIMIT 501"); $next=bin2hex(random_bytes(16)); $backup=OG_Backup_Transition::project($hard,$next); $payload=json_encode($backup,JSON_THROW_ON_ERROR); if (strlen($payload)>40000) { throw new OG_Privacy_Case_Refusal('backup_too_large'); } if ($this->sql('UPDATE '.$this->table('block_backup').' SET revision = ?, payload = ? WHERE singleton = 1',array($next,$payload))!==1 || $this->sql('UPDATE '.$this->table('block_revision').' SET revision = ? WHERE singleton = 1 AND revision = ?',array($next,$expected))!==1) { throw new OG_Privacy_Case_Refusal('backup_publication_unconfirmed'); } $phase='commit'; $this->commit(); return array('status'=>'committed','revision'=>$next,'complete'=>$backup['complete']); } catch (Throwable $e) { return $this->failure($e,$phase); } finally { $this->finish(); } } private function commit(): void { $this->committing = true; $this->transactionControl('commit'); $this->transaction = false; $this->committing = false; } /** Fixed disposable proof effect, NOT a callback API or production transport. * collect writes one HMAC receipt; suppress publishes the bounded hash set. * Permission/nonce and durable operation issuance remain caller obligations. */ public function subjectEffect(string $kind,array $context,array $identity,string $operation=''): array { $phase='validate'; try { if (!in_array($kind,array('collect','suppress'),true)) { throw new OG_Privacy_Case_Refusal('invalid_subject_effect'); } foreach(array('install','generation','suppression_namespace') as $key) { self::token($context[$key]??''); } if ($kind==='collect') { self::token($operation); } $phase='connect'; $this->connect($kind==='suppress'?4:1); $phase='admit'; $control=$this->enter($context['install'],$context['generation'],$kind==='suppress'); if ($control['state']!=='open') { throw new OG_Privacy_Case_Refusal('closed'); } // enter() has acquired the control-row lock before any consistent // read in this transaction. Never accept a checkout preflight here. $authority=OG_Checkout_Subject_Reader::lookup(new OG_Checkout_Native_Read_Bridge($this->db),$this->prefix,$context,$identity); $phase='subject_admission'; if (!in_array($authority['suppression'],array('clear','suppressed'),true)) { throw new OG_Privacy_Case_Refusal('suppression_unknown'); } if ($kind==='collect') { if ($authority['suppression']!=='clear') { throw new OG_Privacy_Case_Refusal('suppressed'); } if ($authority['staff']!=='not_allowed') { throw new OG_Privacy_Case_Refusal('staff_excluded_or_unknown'); } $digest=hash_hmac('sha256',json_encode(array('staff'=>$identity['staff_hashes'],'suppression'=>$identity['block_hashes']),JSON_THROW_ON_ERROR),$control['secret']); try { $this->sql('INSERT INTO '.$this->table('receipts').' (generation,operation_id,digest,outcome) VALUES (?,?,?,?)', array($context['generation'],$operation,$digest,'proof_only')); } catch(mysqli_sql_exception $e) { if ($e->getCode()!==1062) { throw $e; } } $this->existingReceipt=array($context['generation'],$operation); $receipt=$this->rows('SELECT digest,outcome FROM '.$this->table('receipts').' WHERE generation = ? AND operation_id = ? FOR UPDATE',$this->existingReceipt); if (count($receipt)!==1 || !hash_equals($receipt[0]['digest'],$digest) || $receipt[0]['outcome']!=='proof_only') { throw new OG_Privacy_Case_Refusal('operation_conflict'); } } else { // Exclusive lifecycle lock orders this publication against all // collect calls that use this API; no expiry or generation reset. foreach($identity['block_hashes'] as $hash) { try { $this->sql('INSERT INTO '.$this->table('subject_authority').' (namespace,kind,subject_hash) VALUES (?,?,?)',array($context['suppression_namespace'],'suppression',$hash)); } catch(mysqli_sql_exception $e) { if ($e->getCode()!==1062) { throw $e; } } } $this->sql('UPDATE '.$this->table('subject_authority_state').' SET revision=? WHERE singleton=1',array(bin2hex(random_bytes(16)))); } $phase='commit'; $this->commit(); return array('status'=>'committed','effect'=>$kind==='collect'?'proof_receipt':'suppression'); } catch(Throwable $error) { return $this->failure($error,$phase); } finally { $this->finish(); } } /** Fixed command API. Explicit new issuance is not a retry/reconstruction API. */ public function command(string $kind, string $install, string $generation, string $operation = '', string $value = ''): array { $phase = 'validate'; try { self::token($install); self::token($generation); if (!in_array($kind, array('issue','execute','reconcile','close','reopen'), true)) { throw new OG_Privacy_Case_Refusal('invalid_command'); } if (!in_array($kind, array('close','reopen'), true)) { self::token($operation); } if (strlen($value) > 256) { throw new OG_Privacy_Case_Refusal('value_too_large'); } $phase = 'connect'; $this->connect(in_array($kind, array('close','reopen'), true) ? 4 : 1); $hasOutbox=false; if (in_array($kind,array('close','reopen'),true)) { $tables=$this->rows('SELECT TABLE_NAME FROM information_schema.TABLES WHERE TABLE_SCHEMA=? AND TABLE_NAME IN (?,?)',array($this->profile['database'],$this->prefix.'transport_operations',$this->prefix.'transport_subjects')); if ($tables) {$this->validateCases($this->outboxSpecs());$hasOutbox=true;} } $phase = 'admit'; $control = $this->enter($install, $generation, in_array($kind, array('close','reopen'), true)); $phase = 'effect'; if ($hasOutbox) { // Conservative until closed-generation adoption/cancellation exists: // Preserve the current secret for every current-generation unbound // source. Older orphan recovery needs a separate adoption protocol. $pending=$this->rows('SELECT operation_id FROM '.$this->table('transport_operations').' WHERE privacy_case_id IS NULL AND generation=? LIMIT 1',array($control['generation'])); if ($pending) {throw new OG_Privacy_Case_Refusal('unbound_outbox_requires_attention');} } if ($kind === 'close') { $this->sql('UPDATE '.$this->table('control').' SET state = ? WHERE singleton = 1 AND generation = ?', array('closed',$generation)); $result = array('status'=>'closed'); } elseif ($kind === 'reopen') { if ($control['state'] !== 'closed') { throw new OG_Privacy_Case_Refusal('not_closed'); } $next = bin2hex(random_bytes(16)); $this->sql('UPDATE '.$this->table('control').' SET state = ?, generation = ?, secret = ? WHERE singleton = 1 AND generation = ?', array('open',$next,bin2hex(random_bytes(32)),$generation)); $result = array('status'=>'reopened','generation'=>$next); } else { if ($control['state'] !== 'open') { throw new OG_Privacy_Case_Refusal('closed'); } $time = $this->rows('SELECT UNIX_TIMESTAMP() AS now_seconds'); $now = (int)$time[0]['now_seconds']; $digest = hash_hmac('sha256', $value, $control['secret']); if ($kind === 'issue') { try { $this->sql('INSERT INTO '.$this->table('operations'). ' (generation,operation_id,digest,expires_at,state) VALUES (?,?,?,?,?)', array($generation,$operation,$digest,(string)($now+3600),'issued')); } catch (mysqli_sql_exception $e) { if ($e->getCode() !== 1062) { throw $e; } } } // Existing-key current read after insert; no absent-key lock-read on execute. $op = $this->rows('SELECT digest,expires_at,state FROM '.$this->table('operations'). ' WHERE generation = ? AND operation_id = ?', array($generation,$operation)); if (count($op) !== 1) { throw new OG_Privacy_Case_Refusal('authority_missing'); } $this->existingOperation = array($generation,$operation); if ($kind === 'execute') { // Existing operation row serialises duplicate execution; // do not let receipt/operation lock upgrades deadlock each other. $op = $this->rows('SELECT digest,expires_at,state FROM '.$this->table('operations'). ' WHERE generation = ? AND operation_id = ? FOR UPDATE', array($generation,$operation)); if (count($op) !== 1) { throw new OG_Privacy_Case_Refusal('authority_missing'); } } if (!hash_equals($op[0]['digest'], $digest)) { throw new OG_Privacy_Case_Refusal('body_conflict'); } if ($now >= (int)$op[0]['expires_at']) { throw new OG_Privacy_Case_Refusal('expired'); } if ($kind === 'execute') { try { $this->sql('INSERT INTO '.$this->table('receipts'). ' (generation,operation_id,digest,outcome) VALUES (?,?,?,?)', array($generation,$operation,$digest,'recorded')); } catch (mysqli_sql_exception $e) { if ($e->getCode() !== 1062) { throw $e; } } $this->existingReceipt = array($generation,$operation); $this->sql('UPDATE '.$this->table('operations').' SET state = ? WHERE generation = ? AND operation_id = ? AND digest = ?', array('confirmed',$generation,$operation,$digest)); } $receipt = $this->rows('SELECT digest,outcome FROM '.$this->table('receipts'). ' WHERE generation = ? AND operation_id = ?'.($kind === 'execute' ? ' FOR UPDATE' : ''), array($generation,$operation)); if ($receipt && !hash_equals($receipt[0]['digest'], $digest)) { throw new OG_Privacy_Case_Refusal('receipt_conflict'); } $result = array('status'=>$receipt ? 'confirmed' : 'issued', 'expires_at'=>(int)$op[0]['expires_at']); } $phase = 'commit'; $this->commit(); return $result; } catch (Throwable $e) { return $this->failure($e,$phase); } finally { $this->finish(); } } /** Trusted explicit fresh-install tool ONLY. No WordPress hook calls this. */ public function install(string $install, string $generation): array { try { self::token($install); self::token($generation); $this->connect(1); $existing = $this->rows('SELECT TABLE_NAME FROM information_schema.TABLES WHERE TABLE_SCHEMA = ? AND TABLE_NAME IN (?,?,?,?,?,?)', array($this->profile['database'],$this->prefix.'control',$this->prefix.'operations',$this->prefix.'receipts', $this->prefix.'flags',$this->prefix.'block_revision',$this->prefix.'block_backup')); if ($existing) { throw new OG_Privacy_Case_Refusal('existing_schema_refused'); } $this->sql('CREATE TABLE '.$this->table('control'). ' (singleton TINYINT UNSIGNED NOT NULL PRIMARY KEY, install_id CHAR(32) NOT NULL, generation CHAR(32) NOT NULL, state VARCHAR(8) NOT NULL, secret CHAR(64) NOT NULL, schema_version SMALLINT UNSIGNED NOT NULL) ENGINE=InnoDB DEFAULT CHARSET=ascii COLLATE=ascii_bin'); $this->sql('CREATE TABLE '.$this->table('operations'). ' (generation CHAR(32) NOT NULL, operation_id CHAR(32) NOT NULL, digest CHAR(64) NOT NULL, expires_at BIGINT UNSIGNED NOT NULL, state VARCHAR(12) NOT NULL, PRIMARY KEY(generation,operation_id)) ENGINE=InnoDB DEFAULT CHARSET=ascii COLLATE=ascii_bin'); $this->sql('CREATE TABLE '.$this->table('receipts'). ' (generation CHAR(32) NOT NULL, operation_id CHAR(32) NOT NULL, digest CHAR(64) NOT NULL, outcome VARCHAR(16) NOT NULL, PRIMARY KEY(generation,operation_id)) ENGINE=InnoDB DEFAULT CHARSET=ascii COLLATE=ascii_bin'); $this->validateSchema(); $this->sql('CREATE TABLE '.$this->table('flags'). ' (subject_hash CHAR(64) NOT NULL PRIMARY KEY, level VARCHAR(4) NOT NULL, KEY hard_scan(level,subject_hash)) ENGINE=InnoDB DEFAULT CHARSET=ascii COLLATE=ascii_bin'); $this->sql('CREATE TABLE '.$this->table('block_revision'). ' (singleton TINYINT UNSIGNED NOT NULL PRIMARY KEY, revision CHAR(32) NOT NULL) ENGINE=InnoDB DEFAULT CHARSET=ascii COLLATE=ascii_bin'); $this->sql('CREATE TABLE '.$this->table('block_backup'). ' (singleton TINYINT UNSIGNED NOT NULL PRIMARY KEY, revision CHAR(32) NOT NULL, payload TEXT NOT NULL) ENGINE=InnoDB DEFAULT CHARSET=ascii COLLATE=ascii_bin'); $this->validateBlocks(); $this->transactionControl('begin'); $this->transaction=true; $revision=bin2hex(random_bytes(16)); $this->sql('INSERT INTO '.$this->table('block_revision').' (singleton,revision) VALUES (1,?)',array($revision)); $this->sql('INSERT INTO '.$this->table('block_backup').' (singleton,revision,payload) VALUES (1,?,?)', array($revision,json_encode(OG_Backup_Transition::project(array(),$revision),JSON_THROW_ON_ERROR))); $this->sql('INSERT INTO '.$this->table('control').' (singleton,install_id,generation,state,secret,schema_version) VALUES (1,?,?,?,?,1)', array($install,$generation,'open',bin2hex(random_bytes(32)))); $this->commit(); return array('status'=>'installed'); } catch (Throwable $e) { return array('status'=>$this->committing ? 'unknown' : 'unavailable','reason'=>$e instanceof OG_Privacy_Case_Refusal ? $e->getMessage() : 'installation_unconfirmed'); } finally { $this->finish(); } } /** Explicit fresh-schema composition. Unregistered: callers must establish * fresh-site eligibility; this method never adopts existing/partial tables. * DDL is not atomic. Authority rows publish only after all schema validates. * Staff/suppression remain unknown until their source adapters qualify. */ public function installComposition(string $install, string $generation, string $namespace, ?string $wordpressPrefix=null, ?string $wordpressBasePrefix=null): array { try { self::token($install); self::token($generation); self::token($namespace); $this->connect(1); $names=array('control','operations','receipts','flags','block_revision','block_backup', 'privacy_cases','privacy_case_links','privacy_case_keys','privacy_namespaces', 'transport_operations','transport_subjects','transport_generations','transport_disposals', 'subject_authority_state','subject_authority'); $tables=array_map(function($name){return $this->prefix.$name;},$names); $existing=$this->rows('SELECT TABLE_NAME FROM information_schema.TABLES WHERE TABLE_SCHEMA=? AND TABLE_NAME IN ('. implode(',',array_fill(0,count($tables),'?')).')',array_merge(array($this->profile['database']),$tables)); if ($existing) {throw new OG_Privacy_Case_Refusal('existing_composition_refused');} if ($wordpressPrefix!==null) { $this->refuseLegacyWordPress($wordpressPrefix,$wordpressBasePrefix); } $this->sql('CREATE TABLE '.$this->table('control'). ' (singleton TINYINT UNSIGNED NOT NULL PRIMARY KEY, install_id CHAR(32) NOT NULL, generation CHAR(32) NOT NULL, state VARCHAR(8) NOT NULL, secret CHAR(64) NOT NULL, schema_version SMALLINT UNSIGNED NOT NULL) ENGINE=InnoDB DEFAULT CHARSET=ascii COLLATE=ascii_bin'); $this->sql('CREATE TABLE '.$this->table('operations'). ' (generation CHAR(32) NOT NULL, operation_id CHAR(32) NOT NULL, digest CHAR(64) NOT NULL, expires_at BIGINT UNSIGNED NOT NULL, state VARCHAR(12) NOT NULL, PRIMARY KEY(generation,operation_id)) ENGINE=InnoDB DEFAULT CHARSET=ascii COLLATE=ascii_bin'); $this->sql('CREATE TABLE '.$this->table('receipts'). ' (generation CHAR(32) NOT NULL, operation_id CHAR(32) NOT NULL, digest CHAR(64) NOT NULL, outcome VARCHAR(16) NOT NULL, PRIMARY KEY(generation,operation_id)) ENGINE=InnoDB DEFAULT CHARSET=ascii COLLATE=ascii_bin'); $this->validateSchema(); $this->sql('CREATE TABLE '.$this->table('flags'). ' (subject_hash CHAR(64) NOT NULL PRIMARY KEY, level VARCHAR(4) NOT NULL, KEY hard_scan(level,subject_hash)) ENGINE=InnoDB DEFAULT CHARSET=ascii COLLATE=ascii_bin'); $this->sql('CREATE TABLE '.$this->table('block_revision'). ' (singleton TINYINT UNSIGNED NOT NULL PRIMARY KEY, revision CHAR(32) NOT NULL) ENGINE=InnoDB DEFAULT CHARSET=ascii COLLATE=ascii_bin'); $this->sql('CREATE TABLE '.$this->table('block_backup'). ' (singleton TINYINT UNSIGNED NOT NULL PRIMARY KEY, revision CHAR(32) NOT NULL, payload TEXT NOT NULL) ENGINE=InnoDB DEFAULT CHARSET=ascii COLLATE=ascii_bin'); $this->validateBlocks(); foreach ($this->caseDefinitions() as $name=>$definition) { $this->sql('CREATE TABLE '.$this->table($name).' '.$definition); } $ddl=file_get_contents(__DIR__.'/outbox-schema.sql'); if ($ddl===false) {throw new OG_Privacy_Case_Refusal('outbox_schema_unavailable');} $ddl=preg_replace('/^--.*$/m','',$ddl); foreach(explode(';',str_replace('{{prefix}}',$this->prefix,$ddl)) as $statement) { if(trim($statement)!=='') {$this->sql(trim($statement));} } $this->sql('CREATE TABLE '.$this->table('transport_generations').' (generation CHAR(32) NOT NULL,install_id CHAR(32) NOT NULL,secret CHAR(64) NOT NULL,created_at BIGINT UNSIGNED NOT NULL,row_count SMALLINT UNSIGNED NOT NULL,commitment CHAR(64) NOT NULL,PRIMARY KEY(generation)) ENGINE=InnoDB DEFAULT CHARSET=ascii COLLATE=ascii_bin'); $this->sql('CREATE TABLE '.$this->table('transport_disposals').' (generation CHAR(32) NOT NULL,install_id CHAR(32) NOT NULL,format_version SMALLINT UNSIGNED NOT NULL,disposed_at BIGINT UNSIGNED NOT NULL,PRIMARY KEY(generation)) ENGINE=InnoDB DEFAULT CHARSET=ascii COLLATE=ascii_bin'); $this->sql('CREATE TABLE '.$this->table('subject_authority_state').' (singleton SMALLINT UNSIGNED NOT NULL PRIMARY KEY,install_id CHAR(32) NOT NULL,generation CHAR(32) NOT NULL,suppression_namespace CHAR(32) NOT NULL,revision CHAR(32) NOT NULL,staff_ready SMALLINT UNSIGNED NOT NULL,suppression_ready SMALLINT UNSIGNED NOT NULL) ENGINE=InnoDB DEFAULT CHARSET=ascii COLLATE=ascii_bin'); $this->sql('CREATE TABLE '.$this->table('subject_authority').' (namespace CHAR(32) NOT NULL,kind VARCHAR(11) NOT NULL,subject_hash CHAR(64) NOT NULL,PRIMARY KEY(namespace,kind,subject_hash)) ENGINE=InnoDB DEFAULT CHARSET=ascii COLLATE=ascii_bin'); // All schema verification precedes the first authority publication. $this->validateCases(array_merge($this->historySpecs(),self::subjectSpecs())); $this->validateSchema(); $this->validateBlocks(); $this->transactionControl('begin'); $this->transaction=true; $revision=bin2hex(random_bytes(16)); $this->sql('INSERT INTO '.$this->table('block_revision').' (singleton,revision) VALUES (1,?)',array($revision)); $this->sql('INSERT INTO '.$this->table('block_backup').' (singleton,revision,payload) VALUES (1,?,?)', array($revision,json_encode(OG_Backup_Transition::project(array(),$revision),JSON_THROW_ON_ERROR))); $this->sql('INSERT INTO '.$this->table('control').' (singleton,install_id,generation,state,secret,schema_version) VALUES (1,?,?,?,?,1)', array($install,$generation,'open',bin2hex(random_bytes(32)))); $this->sql('INSERT INTO '.$this->table('subject_authority_state').' (singleton,install_id,generation,suppression_namespace,revision,staff_ready,suppression_ready) VALUES (1,?,?,?,?,0,0)',array($install,$generation,$namespace,bin2hex(random_bytes(16)))); $this->sql('INSERT INTO '.$this->table('privacy_namespaces').' (namespace,format_version) VALUES (?,1)',array($namespace)); $this->commit(); return array('status'=>'installed','authority_ready'=>false,'dispatch_enabled'=>false); } catch (Throwable $e) { return array('status'=>$this->committing ? 'unknown' : 'unavailable','reason'=>$e instanceof OG_Privacy_Case_Refusal ? $e->getMessage() : 'installation_unconfirmed'); } finally { $this->finish(); } } /** Disposable fresh-schema tool, not activation or upgrade code. */ /** Conservative key/table preflight, not migration or concurrent cutover. * The fresh package must call this before starting any legacy writer. */ /** Read-only source capture on the private handle after the lifecycle lock. * It never asserts completeness, writes rows, or publishes readiness. * Existing ungated exclusion writers still require a supervised freeze. */ public function captureExclusionSource(array $context,string $wordpressPrefix): array { $phase='validate'; try { foreach(array('install','generation','suppression_namespace') as $key){self::token($context[$key]??'');} if(!preg_match('/^[a-zA-Z0-9_]{1,24}$/D',$wordpressPrefix) || $this->prefix!==$wordpressPrefix.'og_owned_'){throw new OG_Privacy_Case_Refusal('wordpress_prefix_mismatch');} $phase='connect';$this->connect(1);$this->validateCases(self::subjectSpecs()); $phase='admit';$control=$this->enter($context['install'],$context['generation'],false); if($control['state']!=='open'){throw new OG_Privacy_Case_Refusal('closed');} $state=$this->rows('SELECT install_id,generation,suppression_namespace,revision FROM '.$this->table('subject_authority_state').' WHERE singleton=1'); if(count($state)!==1 || $state[0]['install_id']!==$context['install'] || $state[0]['generation']!==$context['generation'] || $state[0]['suppression_namespace']!==$context['suppression_namespace']){throw new OG_Privacy_Case_Refusal('source_binding_changed');} $phase='exclusion_source'; [$hashes,$digest]=$this->readExclusionSource($context,$wordpressPrefix,$control); $phase='commit';$this->commit(); return array('status'=>'exclusion_snapshot','source'=>'orderguard_exclusions','staff_hashes'=>$hashes,'source_hmac'=>$digest,'source_revision'=>$state[0]['revision'], 'complete'=>false,'dispatch_enabled'=>false); }catch(Throwable $e){return $this->failure($e,$phase);}finally{$this->finish();} } /** Must be called only after enter(), on the same transaction/handle. */ private function readExclusionSource(array $context,string $wordpressPrefix,array $control): array { $rows=$this->rows('SELECT option_name,OCTET_LENGTH(option_value) AS bytes,LEFT(option_value,1048577) AS value FROM `'.$wordpressPrefix.'options` WHERE option_name IN (?,?) ORDER BY option_name LIMIT 3',array('orderguard_exclusions','og_exclusions')); if(count($rows)>1){throw new OG_Privacy_Case_Refusal('exclusion_legacy_conflict');} $raw=null; if($rows){ if($rows[0]['option_name']!=='orderguard_exclusions'){throw new OG_Privacy_Case_Refusal('exclusion_legacy_requires_migration');} if(OG_Transport_Database_Row::unsigned($rows[0]['bytes'])>OG_Exclusion_Source::MAX_BYTES){throw new OG_Privacy_Case_Refusal('exclusion_source_too_large');} $raw=$rows[0]['value']; } try{$hashes=OG_Exclusion_Source::decode($raw);}catch(Throwable $e){throw new OG_Privacy_Case_Refusal($e->getMessage()==='exclusion_source_too_large'?'exclusion_source_too_large':'exclusion_source_invalid');} $digest=hash_hmac('sha256',"og-exclusion-capture-v1\n".json_encode(array($context['install'],$context['generation'],$context['suppression_namespace'],$raw),JSON_THROW_ON_ERROR),hex2bin($control['secret'])); return array($hashes,$digest,$raw); } /** Unregistered replacement writer for the shipping exclusion option. * Caller supplies authorised canonical option bytes and must stop ALL old * writers through takeover. This boolean does not establish that freeze. * Source semantics remain staff/test exemption, not historical erasure. * No remote erasure, cache invalidation, receipt, or readiness promotion. */ public function replaceExclusionSource(array $context,string $wordpressPrefix,array $snapshot,string $replacement,$callerAssertsLegacyWritersStopped=false): array { return $this->writeExclusionSource($context,$wordpressPrefix,$snapshot,$replacement,$callerAssertsLegacyWritersStopped); } /** Local exclusion component only, NOT complete uninstall or erasure. * Caller must authorise deletion, hold the legacy lifecycle lock and run * the FULL pending-erasure guard. This assertion is not authorisation. */ public function removeExclusionSource(array $context,string $wordpressPrefix,array $snapshot,$callerAssertsLegacyWritersStopped=false,$callerAssertsPendingErasureClear=false): array { if($callerAssertsPendingErasureClear!==true){return array('status'=>'unavailable','reason'=>'uninstall_guard_not_asserted','phase'=>'validate');} return $this->writeExclusionSource($context,$wordpressPrefix,$snapshot,null,$callerAssertsLegacyWritersStopped); } private function writeExclusionSource(array $context,string $wordpressPrefix,array $snapshot,?string $replacement,$callerAssertsLegacyWritersStopped): array { $phase='validate'; try { if($callerAssertsLegacyWritersStopped!==true){throw new OG_Privacy_Case_Refusal('source_freeze_not_asserted');} foreach(array('install','generation','suppression_namespace') as $key){self::token($context[$key]??'');} if(!preg_match('/^[a-zA-Z0-9_]{1,24}$/D',$wordpressPrefix) || $this->prefix!==$wordpressPrefix.'og_owned_'){throw new OG_Privacy_Case_Refusal('wordpress_prefix_mismatch');} if(($snapshot['status']??null)!=='exclusion_snapshot' || ($snapshot['source']??null)!=='orderguard_exclusions' || !is_string($snapshot['source_hmac']??null) || !preg_match('/^[a-f0-9]{64}$/D',$snapshot['source_hmac'])){throw new OG_Privacy_Case_Refusal('source_snapshot_invalid');} self::token($snapshot['source_revision']??''); try{$nextHashes=OG_Exclusion_Source::decode($replacement);}catch(Throwable $e){throw new OG_Privacy_Case_Refusal('exclusion_source_invalid');} $phase='connect';$this->connect(4);$this->validateCases(self::subjectSpecs()); // The option and owned projection must both roll back on failure. $tables=$this->rows('SELECT TABLE_TYPE,ENGINE FROM information_schema.TABLES WHERE TABLE_SCHEMA=? AND TABLE_NAME=?',array($this->profile['database'],$wordpressPrefix.'options')); $index=$this->rows('SELECT INDEX_NAME FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=? AND TABLE_NAME=? GROUP BY INDEX_NAME HAVING MAX(NON_UNIQUE)=0 AND COUNT(*)=1 AND MAX(COLUMN_NAME)=? AND MAX(SUB_PART) IS NULL',array($this->profile['database'],$wordpressPrefix.'options','option_name')); if(count($tables)!==1 || $tables[0]['TABLE_TYPE']!=='BASE TABLE' || strcasecmp((string)$tables[0]['ENGINE'],'InnoDB')!==0 || !$index){throw new OG_Privacy_Case_Refusal('exclusion_storage_unavailable');} $phase='admit';$control=$this->enter($context['install'],$context['generation'],true); if($control['state']!=='open'){throw new OG_Privacy_Case_Refusal('closed');} $states=$this->rows('SELECT install_id,generation,suppression_namespace,revision,staff_ready,suppression_ready FROM '.$this->table('subject_authority_state').' WHERE singleton=1'); if(count($states)!==1 || $states[0]['install_id']!==$context['install'] || $states[0]['generation']!==$context['generation'] || $states[0]['suppression_namespace']!==$context['suppression_namespace']){throw new OG_Privacy_Case_Refusal('source_binding_changed');} $state=$states[0]; if((string)$state['staff_ready']!=='1' || (string)$state['suppression_ready']!=='1'){throw new OG_Privacy_Case_Refusal('source_not_ready');} if($state['revision']!==$snapshot['source_revision']){throw new OG_Privacy_Case_Refusal('source_revision_changed');} $phase='exclusion_source'; [$oldHashes,$digest,$raw]=$this->readExclusionSource($context,$wordpressPrefix,$control); if(!hash_equals($digest,$snapshot['source_hmac'])){throw new OG_Privacy_Case_Refusal('exclusion_source_changed');} $owned=$this->rows('SELECT subject_hash FROM '.$this->table('subject_authority').' WHERE namespace=? AND kind=? ORDER BY subject_hash LIMIT 501',array($context['generation'],'staff')); if(array_column($owned,'subject_hash')!==$oldHashes){throw new OG_Privacy_Case_Refusal('exclusion_projection_changed');} if($replacement===null){ // Removal cannot discard a pending erasure even if a caller // passes a stale or false assertion about the global guard. foreach($raw===null?array():unserialize($raw,array('allowed_classes'=>false)) as $entry){ if(!empty($entry['erase_pending']) && !(!empty($entry['erase_waiting_for_receipt_key']) && array_key_exists('erase_attempts',$entry) && (int)$entry['erase_attempts']===0)){ throw new OG_Privacy_Case_Refusal('exclusion_erasure_pending'); } } if($this->rows('SELECT subject_hash FROM '.$this->table('subject_authority').' WHERE kind=? AND namespace<>? LIMIT 1',array('staff',$context['generation']))){throw new OG_Privacy_Case_Refusal('historical_exemptions_require_attention');} } $changed=$raw!==$replacement;$revision=$state['revision']; if($changed){ $phase='exclusion_write'; if($replacement===null){ if($this->sql('DELETE FROM `'.$wordpressPrefix.'options` WHERE option_name=? AND BINARY option_value=?',array('orderguard_exclusions',$raw))!==1){throw new OG_Privacy_Case_Refusal('exclusion_write_conflict');} }elseif($raw===null){ $this->sql('INSERT INTO `'.$wordpressPrefix.'options` (option_name,option_value,autoload) VALUES (?,?,?)',array('orderguard_exclusions',$replacement,'off')); }else{ // Current-read byte CAS: a concurrent change cannot be silently overwritten. if($this->sql('UPDATE `'.$wordpressPrefix.'options` SET option_value=?,autoload=? WHERE option_name=? AND BINARY option_value=?',array($replacement,'off','orderguard_exclusions',$raw))!==1){throw new OG_Privacy_Case_Refusal('exclusion_write_conflict');} } if($this->sql('DELETE FROM '.$this->table('subject_authority').' WHERE namespace=? AND kind=?',array($context['generation'],'staff'))!==count($oldHashes)){throw new OG_Privacy_Case_Refusal('exclusion_projection_changed');} foreach($nextHashes as $hash){$this->sql('INSERT INTO '.$this->table('subject_authority').' (namespace,kind,subject_hash) VALUES (?,?,?)',array($context['generation'],'staff',$hash));} $revision=bin2hex(random_bytes(16)); if($this->sql('UPDATE '.$this->table('subject_authority_state').' SET revision=? WHERE singleton=1 AND revision=?',array($revision,$state['revision']))!==1){throw new OG_Privacy_Case_Refusal('source_publication_conflict');} } $phase='commit';$this->commit(); return array('status'=>$replacement===null?'exclusion_source_removed':'exclusion_source_saved','changed'=>$changed,'revision'=>$revision,'shared'=>false,'erasure_complete'=>false,'dispatch_enabled'=>false); }catch(Throwable $e){return $this->failure($e,$phase);}finally{$this->finish();} } /** Trusted-producer primitive only. Neither boolean verifies completeness or a freeze. * Legacy writers must remain stopped until their owned replacement takes over. * No production route may call this until suppression provenance is qualified. */ public function publishCapturedExclusions(array $context,string $wordpressPrefix,array $snapshot,array $suppression,$callerAssertsComplete=false,$callerAssertsLegacyWritersStopped=false): array { if($callerAssertsLegacyWritersStopped!==true){return array('status'=>'unavailable','reason'=>'source_freeze_not_asserted','phase'=>'validate');} if(!preg_match('/^[a-zA-Z0-9_]{1,24}$/D',$wordpressPrefix) || $this->prefix!==$wordpressPrefix.'og_owned_'){return array('status'=>'unavailable','reason'=>'wordpress_prefix_mismatch','phase'=>'validate');} if(($snapshot['status']??null)!=='exclusion_snapshot' || ($snapshot['source']??null)!=='orderguard_exclusions' || !is_string($snapshot['source_hmac']??null) || !preg_match('/^[a-f0-9]{64}$/D',$snapshot['source_hmac']) || !is_string($snapshot['source_revision']??null)){ return array('status'=>'unavailable','reason'=>'source_snapshot_invalid','phase'=>'validate'); } return $this->publishSources($context,$snapshot['source_revision'],array(),$suppression,$callerAssertsComplete,array($wordpressPrefix,$snapshot['source_hmac'])); } /** Additive local fence. Caller authorises and supplies the complete alias set. * No deletion, network request, erasure completion or readiness publication. * Records protection at 0/0, effective from 1/1; unknown already denies writes. */ public function addSuppression(array $context,array $hashes): array { $phase='validate'; try { foreach(array('install','generation','suppression_namespace') as $key){self::token($context[$key]??'');} if(!array_is_list($hashes) || count($hashes)<1 || count($hashes)>8){throw new OG_Privacy_Case_Refusal('suppression_set_invalid');} foreach($hashes as $hash){if(!is_string($hash) || !preg_match('/^[a-f0-9]{64}$/D',$hash)){throw new OG_Privacy_Case_Refusal('suppression_set_invalid');}} if(count(array_unique($hashes))!==count($hashes)){throw new OG_Privacy_Case_Refusal('suppression_set_invalid');} sort($hashes,SORT_STRING); $phase='connect';$this->connect(4);$this->validateCases(self::subjectSpecs()); $phase='admit';$control=$this->enter($context['install'],$context['generation'],true); if($control['state']!=='open'){throw new OG_Privacy_Case_Refusal('closed');} $state=$this->rows('SELECT install_id,generation,suppression_namespace,revision,staff_ready,suppression_ready FROM '.$this->table('subject_authority_state').' WHERE singleton=1'); if(count($state)!==1 || $state[0]['install_id']!==$context['install'] || $state[0]['generation']!==$context['generation'] || $state[0]['suppression_namespace']!==$context['suppression_namespace']){throw new OG_Privacy_Case_Refusal('source_binding_changed');} foreach(array('staff_ready','suppression_ready') as $key){if(!in_array((string)$state[0][$key],array('0','1'),true)){throw new OG_Privacy_Case_Refusal('source_state_invalid');}} self::token($state[0]['revision']);$added=0;$phase='suppression_rows'; foreach($hashes as $hash){ try{$added+=$this->sql('INSERT INTO '.$this->table('subject_authority').' (namespace,kind,subject_hash) VALUES (?,?,?)',array($context['suppression_namespace'],'suppression',$hash));} catch(mysqli_sql_exception $e){if($e->getCode()!==1062){throw $e;}} } $revision=$state[0]['revision']; if($added>0){ $revision=bin2hex(random_bytes(16)); if($this->sql('UPDATE '.$this->table('subject_authority_state').' SET revision=? WHERE singleton=1 AND revision=?',array($revision,$state[0]['revision']))!==1){throw new OG_Privacy_Case_Refusal('source_publication_conflict');} } $phase='commit';$this->commit(); return array('status'=>'suppression_saved','added'=>$added,'revision'=>$revision,'erasure_complete'=>false,'dispatch_enabled'=>false); }catch(Throwable $e){return $this->failure($e,$phase);}finally{$this->finish();} } private static function subjectSpecs(): array { return array( 'subject_authority_state'=>array(array('singleton'=>'smallint unsigned','install_id'=>'char(32)','generation'=>'char(32)','suppression_namespace'=>'char(32)','revision'=>'char(32)','staff_ready'=>'smallint unsigned','suppression_ready'=>'smallint unsigned'),array('PRIMARY'=>array(0,array('singleton')))), 'subject_authority'=>array(array('namespace'=>'char(32)','kind'=>'varchar(11)','subject_hash'=>'char(64)'),array('PRIMARY'=>array(0,array('namespace','kind','subject_hash')))) ); } /** One-time bounded source publication, NOT a source scanner or consent API. * A trusted producer must prove completeness and authorise this call. * Existing suppression rows are preserved, never replaced or cleared. * Production publication stays disabled until that producer is qualified. */ public function initialiseSubjectSources(array $context,string $expectedRevision,array $staff,array $suppression,$callerAssertsComplete=false): array { return $this->publishSources($context,$expectedRevision,$staff,$suppression,$callerAssertsComplete,null); } private function publishSources(array $context,string $expectedRevision,array $staff,array $suppression,$callerAssertsComplete,?array $capture): array { $phase='validate'; try { if ($callerAssertsComplete!==true) {throw new OG_Privacy_Case_Refusal('source_completeness_not_asserted');} foreach(array('install','generation','suppression_namespace') as $key) {self::token($context[$key]??'');} self::token($expectedRevision); foreach(array($staff,$suppression) as $hashes) { if (count($hashes)>500 || !array_is_list($hashes)) {throw new OG_Privacy_Case_Refusal('source_set_invalid');} foreach($hashes as $hash) { if (!is_string($hash) || !preg_match('/^[a-f0-9]{64}$/D',$hash)) {throw new OG_Privacy_Case_Refusal('source_set_invalid');} } if(count(array_unique($hashes))!==count($hashes)){throw new OG_Privacy_Case_Refusal('source_set_invalid');} } $phase='connect';$this->connect(4); $this->validateCases(self::subjectSpecs()); $phase='admit';$control=$this->enter($context['install'],$context['generation'],true); if($control['state']!=='open'){throw new OG_Privacy_Case_Refusal('closed');} $state=$this->rows('SELECT install_id,generation,suppression_namespace,revision,staff_ready,suppression_ready FROM '.$this->table('subject_authority_state').' WHERE singleton=1'); if(count($state)!==1 || $state[0]['install_id']!==$context['install'] || $state[0]['generation']!==$context['generation'] || $state[0]['suppression_namespace']!==$context['suppression_namespace']) {throw new OG_Privacy_Case_Refusal('source_binding_changed');} if($state[0]['revision']!==$expectedRevision){throw new OG_Privacy_Case_Refusal('source_revision_changed');} if((string)$state[0]['staff_ready']!=='0' || (string)$state[0]['suppression_ready']!=='0') {throw new OG_Privacy_Case_Refusal('source_already_initialised');} if($capture!==null){ $phase='exclusion_source'; [$staff,$observedDigest]=$this->readExclusionSource($context,$capture[0],$control); if(!hash_equals($capture[1],$observedDigest)){throw new OG_Privacy_Case_Refusal('exclusion_source_changed');} } $phase='source_rows'; foreach(array('staff'=>$staff,'suppression'=>$suppression) as $kind=>$hashes) { $namespace=$kind==='staff'?$context['generation']:$context['suppression_namespace']; foreach($hashes as $hash) { try {$this->sql('INSERT INTO '.$this->table('subject_authority').' (namespace,kind,subject_hash) VALUES (?,?,?)',array($namespace,$kind,$hash));} catch(mysqli_sql_exception $e){if($e->getCode()!==1062){throw $e;}} } } $next=bin2hex(random_bytes(16));$phase='source_ready'; $changed=$this->sql('UPDATE '.$this->table('subject_authority_state').' SET staff_ready=1,suppression_ready=1,revision=? WHERE singleton=1 AND revision=?',array($next,$expectedRevision)); if($changed!==1){throw new OG_Privacy_Case_Refusal('source_publication_conflict');} $phase='commit';$this->commit(); return array('status'=>'sources_initialised','revision'=>$next,'dispatch_enabled'=>false); } catch(Throwable $e){return $this->failure($e,$phase);} finally{$this->finish();} } private function refuseLegacyWordPress(string $prefix,?string $basePrefix=null): void { if (!preg_match('/^[a-zA-Z0-9_]{1,24}$/D',$prefix) || $this->prefix!==$prefix.'og_owned_') { throw new OG_Privacy_Case_Refusal('wordpress_prefix_mismatch'); } $basePrefix=$basePrefix ?? $prefix; if (!preg_match('/^[a-zA-Z0-9_]{1,24}$/D',$basePrefix)) { throw new OG_Privacy_Case_Refusal('wordpress_prefix_mismatch'); } $pattern=static function($value){return strtr($value,array('='=>'==','_'=>'=_','%'=>'=%')).'%';}; $tables=$this->rows("SELECT TABLE_NAME FROM information_schema.TABLES WHERE TABLE_SCHEMA=? AND (TABLE_NAME LIKE ? ESCAPE '=' OR TABLE_NAME LIKE ? ESCAPE '=') AND TABLE_NAME NOT LIKE ? ESCAPE '=' LIMIT 1", array($this->profile['database'],$pattern($prefix.'orderguard_'),$pattern($prefix.'og_'),$pattern($prefix.'og_owned_'))); if ($tables) {throw new OG_Privacy_Case_Refusal('migration_required');} $optionPrefixes=OG_Legacy_Prefixes::OPTIONS; $where=implode(' OR ',array_fill(0,count($optionPrefixes),"option_name LIKE ? ESCAPE '='")); $options=$this->rows('SELECT option_id FROM `'.$prefix.'options` WHERE ('.$where.') AND option_name<>? LIMIT 1', array_merge(array_map($pattern,$optionPrefixes),array('orderguard_owned_install_status'))); if ($options) {throw new OG_Privacy_Case_Refusal('migration_required');} $metaTables=array($prefix.'postmeta'); $hpos=$this->rows('SELECT TABLE_NAME FROM information_schema.TABLES WHERE TABLE_SCHEMA=? AND TABLE_NAME=?',array($this->profile['database'],$prefix.'wc_orders_meta')); if ($hpos) {$metaTables[]=$prefix.'wc_orders_meta';} $metaPrefixes=array('orderguard_','_orderguard_','og_','_og_'); $where=implode(' OR ',array_fill(0,count($metaPrefixes),"meta_key LIKE ? ESCAPE '='")); foreach($metaTables as $table) { if ($this->rows('SELECT meta_key FROM `'.$table.'` WHERE '.$where.' LIMIT 1',array_map($pattern,$metaPrefixes))) { throw new OG_Privacy_Case_Refusal('migration_required'); } } $userMeta=$this->rows('SELECT umeta_id FROM `'.$basePrefix.'usermeta` WHERE meta_key IN (?,?,?,?) LIMIT 1', array('orderguard_admin_lang','orderguard_pro_install_notice_dismissed','og_admin_lang','og_pro_install_notice_dismissed')); if ($userMeta) {throw new OG_Privacy_Case_Refusal('migration_required');} $actions=$prefix.'actionscheduler_actions';$groups=$prefix.'actionscheduler_groups'; $actionTables=$this->rows('SELECT TABLE_NAME FROM information_schema.TABLES WHERE TABLE_SCHEMA=? AND TABLE_NAME IN (?,?)', array($this->profile['database'],$actions,$groups)); if (count($actionTables)===1) {throw new OG_Privacy_Case_Refusal('legacy_inventory_unavailable');} if (count($actionTables)===2) { $queued=$this->rows('SELECT a.action_id FROM `'.$actions.'` a LEFT JOIN `'.$groups."` g ON g.group_id=a.group_id WHERE a.status IN ('pending','in-progress') AND (g.slug IN (?,?) OR a.hook LIKE ? ESCAPE '=' OR a.hook LIKE ? ESCAPE '=') LIMIT 1", array('orderguard','orderguard-privacy-review',$pattern('orderguard_'),$pattern('og_'))); if ($queued) {throw new OG_Privacy_Case_Refusal('migration_required');} } $cron=$this->rows('SELECT option_value FROM `'.$prefix.'options` WHERE option_name=? LIMIT 1',array('cron')); if ($cron) { if (!is_string($cron[0]['option_value']??null) || strlen($cron[0]['option_value'])>1048576) { throw new OG_Privacy_Case_Refusal('legacy_inventory_unavailable'); } $schedule=@unserialize($cron[0]['option_value'],array('allowed_classes'=>false)); if (!is_array($schedule)) {throw new OG_Privacy_Case_Refusal('legacy_inventory_unavailable');} foreach($schedule as $time=>$hooks){ if($time==='version'){continue;} if(!is_array($hooks)){throw new OG_Privacy_Case_Refusal('legacy_inventory_unavailable');} foreach(array_keys($hooks) as $hook){ if(!is_string($hook)){throw new OG_Privacy_Case_Refusal('legacy_inventory_unavailable');} if(strpos($hook,'orderguard_')===0 || strpos($hook,'og_')===0){throw new OG_Privacy_Case_Refusal('migration_required');} } } } } public function installCaseSchema(): array { try { $this->connect(1); $existing=$this->rows('SELECT TABLE_NAME FROM information_schema.TABLES WHERE TABLE_SCHEMA = ? AND TABLE_NAME IN (?,?,?,?)', array($this->profile['database'],$this->prefix.'privacy_cases',$this->prefix.'privacy_case_links',$this->prefix.'privacy_case_keys',$this->prefix.'privacy_namespaces')); if ($existing) { throw new OG_Privacy_Case_Refusal('existing_case_schema_refused'); } foreach ($this->caseDefinitions() as $name=>$definition) { $this->sql('CREATE TABLE '.$this->table($name).' '.$definition); } $this->validateCases(); return array('status'=>'installed'); } catch (Throwable $e) { return $this->failure($e,'case_install'); } finally { $this->finish(); } } private function caseDefinitions(): array { $end=' ENGINE=InnoDB DEFAULT CHARSET=ascii COLLATE=ascii_bin'; return array( 'privacy_cases'=>'(case_id CHAR(32) NOT NULL, source_generation CHAR(32) NOT NULL, operation_id CHAR(32) NOT NULL, state VARCHAR(32) NOT NULL, owner VARCHAR(24) NOT NULL, created_at BIGINT UNSIGNED NOT NULL, link_count SMALLINT UNSIGNED NOT NULL, namespaces VARCHAR(264) NOT NULL, commitment CHAR(64) NOT NULL, PRIMARY KEY(case_id), UNIQUE KEY operation_case(source_generation,operation_id))'.$end, 'privacy_case_links'=>'(case_id CHAR(32) NOT NULL, namespace CHAR(32) NOT NULL, subject_hash CHAR(64) NOT NULL, PRIMARY KEY(case_id,namespace,subject_hash), KEY subject_cases(namespace,subject_hash,case_id))'.$end, 'privacy_case_keys'=>'(case_id CHAR(32) NOT NULL, key_material CHAR(64) NOT NULL, PRIMARY KEY(case_id))'.$end, 'privacy_namespaces'=>'(namespace CHAR(32) NOT NULL, format_version SMALLINT UNSIGNED NOT NULL, PRIMARY KEY(namespace))'.$end, ); } private function validateCases(array $extra=array()): void { // Verify complete columns, binary collation and indexes against fixed DDL. $specs=array( 'privacy_cases'=>array(array('case_id'=>'char(32)','source_generation'=>'char(32)','operation_id'=>'char(32)','state'=>'varchar(32)','owner'=>'varchar(24)','created_at'=>'bigint unsigned','link_count'=>'smallint unsigned','namespaces'=>'varchar(264)','commitment'=>'char(64)'),array('PRIMARY'=>array(0,array('case_id')),'operation_case'=>array(0,array('source_generation','operation_id')))), 'privacy_case_links'=>array(array('case_id'=>'char(32)','namespace'=>'char(32)','subject_hash'=>'char(64)'),array('PRIMARY'=>array(0,array('case_id','namespace','subject_hash')),'subject_cases'=>array(1,array('namespace','subject_hash','case_id')))), 'privacy_case_keys'=>array(array('case_id'=>'char(32)','key_material'=>'char(64)'),array('PRIMARY'=>array(0,array('case_id')))), 'privacy_namespaces'=>array(array('namespace'=>'char(32)','format_version'=>'smallint unsigned'),array('PRIMARY'=>array(0,array('namespace')))), ); $this->validateTableSpecs(array_merge($specs,$extra)); } private function validateTableSpecs(array $specs): void { foreach ($specs as $name=>$spec) { $engine=$this->rows('SELECT ENGINE FROM information_schema.TABLES WHERE TABLE_SCHEMA=? AND TABLE_NAME=?',array($this->profile['database'],$this->prefix.$name)); if (count($engine)!==1 || $engine[0]['ENGINE']!=='InnoDB') { throw new OG_Privacy_Case_Refusal('case_schema_unavailable'); } $columns=$this->rows('SHOW FULL COLUMNS FROM '.$this->table($name)); if (array_column($columns,'Field')!==array_keys($spec[0])) { throw new OG_Privacy_Case_Refusal('case_schema_mismatch'); } foreach ($columns as $column) { $type=preg_replace('/\b(tinyint|smallint|bigint)\([0-9]+\)/','$1',strtolower($column['Type'])); $nullable=in_array($column['Field'],$spec[2]??array(),true)?'YES':'NO'; if ($type!==$spec[0][$column['Field']] || $column['Null']!==$nullable || $column['Extra']!=='' || (strpos($type,'char')!==false && $column['Collation']!=='ascii_bin')) { throw new OG_Privacy_Case_Refusal('case_schema_mismatch'); } } $actual=array(); foreach ($this->rows('SHOW INDEX FROM '.$this->table($name)) as $index) { if ($index['Sub_part']!==null) { throw new OG_Privacy_Case_Refusal('case_index_mismatch'); } $k=$index['Key_name']; if (!isset($actual[$k])) { $actual[$k]=array((int)$index['Non_unique'],array()); } $actual[$k][1][(int)$index['Seq_in_index']]=$index['Column_name']; } foreach ($actual as &$entry) { ksort($entry[1]); $entry[1]=array_values($entry[1]); } unset($entry); ksort($actual);$expected=$spec[1];ksort($expected); if ($actual!==$expected) { throw new OG_Privacy_Case_Refusal('case_index_mismatch'); } } } private function caseRead(string $case): array { $headers=$this->rows('SELECT * FROM '.$this->table('privacy_cases').' WHERE case_id=?',array($case)); if (count($headers)!==1) { throw new OG_Privacy_Case_Refusal('case_missing'); } $h=$headers[0]; $keys=$this->rows('SELECT key_material FROM '.$this->table('privacy_case_keys').' WHERE case_id=?',array($case)); if (count($keys)!==1) { throw new OG_Privacy_Case_Refusal('case_key_unavailable'); } $rows=$this->rows('SELECT namespace,subject_hash FROM '.$this->table('privacy_case_links').' WHERE case_id=? ORDER BY namespace,subject_hash LIMIT 9',array($case)); $links=array();$namespaces=array(); foreach ($rows as $row) { $links[]=array('namespace'=>$row['namespace'],'hash'=>$row['subject_hash']);$namespaces[$row['namespace']]=true; } foreach (array_keys($namespaces) as $namespace) { $catalogue=$this->rows('SELECT format_version FROM '.$this->table('privacy_namespaces').' WHERE namespace=?',array($namespace)); if (count($catalogue)!==1 || OG_Transport_Database_Row::unsigned($catalogue[0]['format_version'])!==1) { throw new OG_Privacy_Case_Refusal('case_namespace_unavailable'); } } if (OG_Transport_Database_Row::unsigned($h['link_count'])!==count($links) || OG_Transport_Database_Row::unsigned($h['created_at'])===null || $h['namespaces']!==implode(',',array_keys($namespaces)) || !in_array($h['state'],array('attention_unknown','delivered_despite_suppression'),true) || $h['owner']!=='orderguard_ops' || !hash_equals($h['commitment'],OG_Privacy_Case_Integrity::commitment($keys[0]['key_material'],$case,$h['source_generation'],$h['operation_id'],$links))) { throw new OG_Privacy_Case_Refusal('case_integrity_mismatch'); } return array('header'=>$h,'links'=>$links); } /** Internal trusted integration API ONLY; no WordPress/HTTP registration. * Records supplied uncertainty. Does not verify source outbox authority, * minimise/delete it, dispatch, close cases, or grant erasure completion. */ public function recordCase(string $install,string $generation,string $operation,string $case,string $state,array $links): array { $phase='case_validate'; try { foreach (array($install,$generation,$operation,$case) as $id) { self::token($id); } if (!in_array($state,array('attention_unknown','delivered_despite_suppression'),true)) { throw new OG_Privacy_Case_Refusal('case_state_invalid'); } $canonical=OG_Privacy_Case_Integrity::canonical($links); $phase='connect';$this->connect(4);$this->validateCases(); $phase='admit';$control=$this->enter($install,$generation,true); if ($control['state']!=='open') { throw new OG_Privacy_Case_Refusal('closed'); } $this->storeCase($generation,$operation,$case,$state,$links,$canonical); $phase='commit';$this->commit(); return array('status'=>'recorded','case_id'=>$case,'owner'=>'orderguard_ops','merchant_status'=>'Sharing needs attention','automatic_deletion'=>false,'erasure_complete'=>false); } catch (Throwable $e) { return $this->failure($e,$phase); } finally { $this->finish(); } } // Caller already owns the open generation's exclusive gate and transaction. private function storeCase(string $generation,string $operation,string $case,string $state,array $links,string $canonical): void { $existing=$this->rows('SELECT case_id FROM '.$this->table('privacy_cases').' WHERE source_generation=? AND operation_id=?',array($generation,$operation)); if ($existing) { $saved=$this->caseRead($existing[0]['case_id']); if ($existing[0]['case_id']!==$case || $saved['header']['state']!==$state || OG_Privacy_Case_Integrity::canonical($saved['links'])!==$canonical) { throw new OG_Privacy_Case_Refusal('case_conflict'); } } else { $clock=$this->rows('SELECT UNIX_TIMESTAMP() AS now_seconds'); $now=OG_Transport_Database_Row::unsigned($clock[0]['now_seconds']??null); if ($now===null || $now===0) { throw new OG_Privacy_Case_Refusal('case_clock_unavailable'); } // Policy: case links, their key row and referenced catalogue entries // are immutable. Minimise only source operation links after verified // atomic transfer. No key rotation/catalogue retirement while retained. $namespaces=array();foreach ($links as $link) {$namespaces[$link['namespace']]=true;}ksort($namespaces, SORT_STRING); foreach (array_keys($namespaces) as $namespace) { try { $this->sql('INSERT INTO '.$this->table('privacy_namespaces').' (namespace,format_version) VALUES (?,1)',array($namespace)); } catch (mysqli_sql_exception $e) { if ($e->getCode()!==1062) {throw $e;} } } $key=bin2hex(random_bytes(32)); $commitment=OG_Privacy_Case_Integrity::commitment($key,$case,$generation,$operation,$links); $this->sql('INSERT INTO '.$this->table('privacy_cases').' (case_id,source_generation,operation_id,state,owner,created_at,link_count,namespaces,commitment) VALUES (?,?,?,?,?,?,?,?,?)',array($case,$generation,$operation,$state,'orderguard_ops',$now,(string)count($links),implode(',',array_keys($namespaces)),$commitment)); $this->sql('INSERT INTO '.$this->table('privacy_case_keys').' (case_id,key_material) VALUES (?,?)',array($case,$key)); foreach ($links as $link) { $this->sql('INSERT INTO '.$this->table('privacy_case_links').' (case_id,namespace,subject_hash) VALUES (?,?,?)',array($case,$link['namespace'],$link['hash'])); } $this->caseRead($case); } } private function outboxSpecs(): array { return array( 'transport_operations'=>array(array('generation'=>'char(32)','operation_id'=>'char(32)','kind'=>'varchar(24)','natural_key'=>'char(64)','source_revision'=>'bigint unsigned','body_hmac'=>'char(64)','payload'=>'varbinary(4096)','state'=>'varchar(32)','revision'=>'bigint unsigned','claim_id'=>'char(32)','claim_until'=>'bigint unsigned','attempts'=>'smallint unsigned','max_attempts'=>'smallint unsigned','expires_at'=>'bigint unsigned','retain_until'=>'bigint unsigned','privacy_case_id'=>'char(32)','subject_count'=>'smallint unsigned','subject_commitment'=>'char(64)'),array('PRIMARY'=>array(0,array('generation','operation_id')),'natural_revision'=>array(0,array('generation','kind','natural_key','source_revision')),'due'=>array(1,array('generation','state','expires_at','operation_id')),'retention'=>array(1,array('retain_until','generation','operation_id')),'privacy_case'=>array(1,array('privacy_case_id','generation','operation_id'))),array('natural_key','body_hmac','payload','claim_id','privacy_case_id')), 'transport_subjects'=>array(array('suppression_namespace'=>'char(32)','subject_hash'=>'char(64)','generation'=>'char(32)','operation_id'=>'char(32)'),array('PRIMARY'=>array(0,array('suppression_namespace','subject_hash','generation','operation_id')),'operation_subjects'=>array(1,array('generation','operation_id','suppression_namespace','subject_hash')))), ); } /** Disposable installer only. Non-atomic DDL, never activation/upgrade. */ public function installOutboxSchema(): array { try { $this->connect(1); $existing=$this->rows('SELECT TABLE_NAME FROM information_schema.TABLES WHERE TABLE_SCHEMA=? AND TABLE_NAME IN (?,?)',array($this->profile['database'],$this->prefix.'transport_operations',$this->prefix.'transport_subjects')); if ($existing) {throw new OG_Privacy_Case_Refusal('existing_outbox_schema_refused');} $ddl=file_get_contents(__DIR__.'/outbox-schema.sql'); if ($ddl===false) {throw new OG_Privacy_Case_Refusal('outbox_schema_unavailable');} $ddl=preg_replace('/^--.*$/m','',$ddl); foreach (explode(';',str_replace('{{prefix}}',$this->prefix,$ddl)) as $statement) { if (trim($statement)!=='') {$this->sql(trim($statement));} } $this->validateCases($this->outboxSpecs()); return array('status'=>'installed'); } catch (Throwable $e) {return $this->failure($e,'outbox_install');} finally {$this->finish();} } /** Local scope integrity, not proof against an actor holding database keys. */ public static function outboxScope(string $secret,string $generation,string $operation,array $links): string { self::token($generation);self::token($operation); if (!preg_match('/\A[a-f0-9]{64}\z/D',$secret)) {throw new OG_Privacy_Case_Refusal('scope_key_invalid');} return hash_hmac('sha256',"og-outbox-subjects-v1\n".$generation."\n".$operation."\n".OG_Privacy_Case_Integrity::canonical($links),hex2bin($secret)); } /** Trusted internal command adapter, unregistered. Caller must establish local * flag authorisation and sharing consent; this is NOT a merchant entry point. * caller_asserts_sharing_authorised is an unverified trusted-caller assertion. * Staff exclusion also depends on honest, complete captured staff hashes. * Fresh subject eligibility is checked here, never accepted from preflight. */ public function saveSignalIntent(array $context,array $identity,string $operation,string $flagHash,int $flagRevision,string $level,int $expires,int $retain): array { $phase='intent_validate'; try { foreach(array('install','generation','suppression_namespace') as $k){self::token($context[$k]??'');} self::token($operation); if (($context['caller_asserts_sharing_authorised']??null)!==true || !preg_match('/\A[a-f0-9]{64}\z/D',$flagHash) || $flagRevision<1 || !in_array($level,array('hard','cod'),true) || $expires<1 || $retain<$expires) {throw new OG_Privacy_Case_Refusal('intent_command_invalid');} $phase='connect';$this->connect(4);$this->validateCases($this->outboxSpecs()); $phase='admit';$control=$this->enter($context['install'],$context['generation'],true); if ($control['state']!=='open') {throw new OG_Privacy_Case_Refusal('closed');} $phase='subject_admission'; $authority=OG_Checkout_Subject_Reader::lookup(new OG_Checkout_Native_Read_Bridge($this->db),$this->prefix,$context,$identity); if ($authority['suppression']==='unknown') {throw new OG_Privacy_Case_Refusal('intent_suppression_unknown');} if ($authority['suppression']!=='clear') {throw new OG_Privacy_Case_Refusal('intent_suppression_refused');} if ($authority['staff']==='unknown') {throw new OG_Privacy_Case_Refusal('intent_staff_unknown');} if ($authority['staff']!=='not_allowed') {throw new OG_Privacy_Case_Refusal('intent_staff_refused');} if (!in_array($flagHash,$identity['block_hashes'],true)) {throw new OG_Privacy_Case_Refusal('intent_identity_mismatch');} $clock=$this->rows('SELECT UNIX_TIMESTAMP() AS now_seconds');$now=OG_Transport_Database_Row::unsigned($clock[0]['now_seconds']??null); if ($now===null || $now>=$expires) {throw new OG_Privacy_Case_Refusal('intent_expired');} $generation=$control['generation'];$secret=$control['secret']; $links=array();foreach($identity['block_hashes'] as $hash){$links[]=array('namespace'=>$context['suppression_namespace'],'hash'=>$hash);} $canonical=OG_Privacy_Case_Integrity::canonical($links); $natural=hash_hmac('sha256',"og-flag-natural-v1\n".$flagHash,hex2bin($secret)); $staff=$identity['staff_hashes'];sort($staff,SORT_STRING); $phase='intent_lookup'; $existing=$this->rows('SELECT * FROM '.$this->table('transport_operations').' WHERE generation=? AND (operation_id=? OR (kind=? AND natural_key=? AND source_revision=?)) LIMIT 2',array($generation,$operation,'flag_signal_set',$natural,(string)$flagRevision)); if (count($existing)>1) {throw new OG_Privacy_Case_Refusal('intent_conflict');} // Replay preserves the original snapshot; it never refreshes authority. if ($existing) { try {$savedBody=json_decode($existing[0]['payload']??'',true,8,JSON_THROW_ON_ERROR);} catch(Throwable $e){throw new OG_Privacy_Case_Refusal('intent_payload_invalid');} if (!is_array($savedBody)) {throw new OG_Privacy_Case_Refusal('intent_payload_invalid');} if (!array_key_exists('block_revision',$savedBody)) {throw new OG_Privacy_Case_Refusal('intent_payload_version_unsupported');} $blockRevision=$savedBody['block_revision']; if (!is_string($blockRevision) || !preg_match('/\A[a-f0-9]{32}\z/D',$blockRevision)) {throw new OG_Privacy_Case_Refusal('intent_payload_invalid');} } else { $blockState=$this->rows('SELECT revision FROM '.$this->table('block_revision').' WHERE singleton = 1'); $blockRevision=count($blockState)===1?$blockState[0]['revision']:''; } self::token($blockRevision); $payload=json_encode(array('flag_hash'=>$flagHash,'source_revision'=>$flagRevision,'level'=>$level,'staff_hashes'=>$staff,'block_revision'=>$blockRevision),JSON_THROW_ON_ERROR); $digest=hash_hmac('sha256',"og-flag-intent-v2\n".$payload."\n".$expires."\n".$retain."\n".$canonical,hex2bin($secret)); if ($existing) { $row=$existing[0];$decoded=OG_Transport_Database_Row::decode($row); if ($decoded['status']!=='decoded' || $row['kind']!=='flag_signal_set' || $row['natural_key']!==$natural || $decoded['row']['source_revision']!==$flagRevision || $decoded['row']['expires_at']!==$expires || $decoded['row']['retain_until']!==$retain || !in_array($row['state'],array('intent','claimed','dispatch_unknown','delivered','refused','cancelled_unsent','expired_unsent','attention_unknown','delivered_despite_suppression'),true) || $row['payload']!==$payload || !is_string($row['body_hmac']) || !hash_equals($row['body_hmac'],$digest)) {throw new OG_Privacy_Case_Refusal('intent_conflict');} $winning=$row['operation_id'];self::token($winning); $stored=$this->rows('SELECT suppression_namespace,subject_hash FROM '.$this->table('transport_subjects').' WHERE generation=? AND operation_id=? ORDER BY suppression_namespace,subject_hash LIMIT 9',array($generation,$winning)); $saved=array();foreach($stored as $link){$saved[]=array('namespace'=>$link['suppression_namespace'],'hash'=>$link['subject_hash']);} if (OG_Transport_Database_Row::unsigned($row['subject_count'])!==count($links) || OG_Privacy_Case_Integrity::canonical($saved)!==$canonical || !hash_equals($row['subject_commitment'],self::outboxScope($secret,$generation,$winning,$links))) {throw new OG_Privacy_Case_Refusal('intent_scope_mismatch');} $replayed=true; } else { $phase='intent_write';$winning=$operation; $scope=self::outboxScope($secret,$generation,$winning,$links); $this->sql('INSERT INTO '.$this->table('transport_operations').' (generation,operation_id,kind,natural_key,source_revision,body_hmac,payload,state,revision,claim_id,claim_until,attempts,max_attempts,expires_at,retain_until,privacy_case_id,subject_count,subject_commitment) VALUES (?,?,?,?,?,?,?,\'intent\',1,NULL,0,0,1,?,?,NULL,?,?)',array($generation,$winning,'flag_signal_set',$natural,(string)$flagRevision,$digest,$payload,(string)$expires,(string)$retain,(string)count($links),$scope)); foreach($links as $link){$this->sql('INSERT INTO '.$this->table('transport_subjects').' (suppression_namespace,subject_hash,generation,operation_id) VALUES (?,?,?,?)',array($link['namespace'],$link['hash'],$generation,$winning));} $replayed=false; } $phase='commit';$this->commit(); return array('status'=>'intent_saved','operation_id'=>$winning,'replayed'=>$replayed,'state'=>$replayed?$row['state']:'intent','shared'=>false,'dispatch_enabled'=>false); } catch(Throwable $e) {return $this->failure($e,$phase);} finally {$this->finish();} } /** Unregistered state-machine adapter. No network call or receipt acceptance. * Fresh subject checks use the committed identity, not caller-supplied hashes. * Sharing/source flag authorisation still require the trusted production caller. */ public function workerStep(array $context,string $operation,int $expectedRevision,string $event,?string $claim=null): array { $phase='worker_validate'; try { foreach(array('install','generation','suppression_namespace') as $k){self::token($context[$k]??'');} self::token($operation);if ($claim!==null){self::token($claim);} if ($expectedRevision<1 || !in_array($event,array('claim','mark_dispatch','unresolved','fence','expire'),true)) {throw new OG_Privacy_Case_Refusal('worker_event_refused');} if (in_array($event,array('claim','mark_dispatch'),true) && ($context['caller_asserts_sharing_authorised']??null)!==true) {throw new OG_Privacy_Case_Refusal('worker_sharing_unverified');} $phase='connect';$this->connect(4);$this->validateCases($this->outboxSpecs()); $phase='admit';$control=$this->enter($context['install'],$context['generation'],true); if ($control['state']!=='open') {throw new OG_Privacy_Case_Refusal('closed');} $phase='worker_source'; $raw=$this->rows('SELECT * FROM '.$this->table('transport_operations').' WHERE generation=? AND operation_id=?',array($control['generation'],$operation)); if (count($raw)!==1){throw new OG_Privacy_Case_Refusal('worker_missing');} $decoded=OG_Transport_Database_Row::decode($raw[0]); if ($decoded['status']!=='decoded'){throw new OG_Privacy_Case_Refusal('worker_invalid');} $row=$decoded['row']; if ($row['revision']!==$expectedRevision || $row['privacy_case_id']!==null) {throw new OG_Privacy_Case_Refusal('worker_conflict');} $source=$this->rows('SELECT suppression_namespace,subject_hash FROM '.$this->table('transport_subjects').' WHERE generation=? AND operation_id=? ORDER BY suppression_namespace,subject_hash LIMIT 9',array($control['generation'],$operation)); $links=array();$hashes=array();foreach($source as $link){ if ($link['suppression_namespace']!==$context['suppression_namespace']) {throw new OG_Privacy_Case_Refusal('worker_namespace_mismatch');} $links[]=array('namespace'=>$link['suppression_namespace'],'hash'=>$link['subject_hash']);$hashes[]=$link['subject_hash']; } try {$canonical=OG_Privacy_Case_Integrity::canonical($links);}catch(InvalidArgumentException $e){throw new OG_Privacy_Case_Refusal('worker_scope_invalid');} if (OG_Transport_Database_Row::unsigned($row['subject_count'])!==count($links) || !hash_equals($row['subject_commitment'],self::outboxScope($control['secret'],$control['generation'],$operation,$links))) {throw new OG_Privacy_Case_Refusal('worker_scope_invalid');} if (!is_string($row['payload']) || !is_string($row['body_hmac'])) {throw new OG_Privacy_Case_Refusal('worker_body_invalid');} $digest=hash_hmac('sha256',"og-flag-intent-v2\n".$row['payload']."\n".$row['expires_at']."\n".$row['retain_until']."\n".$canonical,hex2bin($control['secret'])); if (!hash_equals($row['body_hmac'],$digest)) {throw new OG_Privacy_Case_Refusal('worker_body_signature_invalid');} try {$body=json_decode($row['payload'],true,8,JSON_THROW_ON_ERROR);}catch(Throwable $e){throw new OG_Privacy_Case_Refusal('worker_body_invalid');} if (!is_array($body) || array_keys($body)!==array('flag_hash','source_revision','level','staff_hashes','block_revision') || $body['source_revision']!==$row['source_revision'] || !in_array($body['flag_hash'],$hashes,true) || !in_array($body['level'],array('hard','cod'),true) || !is_array($body['staff_hashes'])) {throw new OG_Privacy_Case_Refusal('worker_body_invalid');} if (!is_string($body['block_revision']) || !preg_match('/\A[a-f0-9]{32}\z/D',$body['block_revision'])) {throw new OG_Privacy_Case_Refusal('worker_body_invalid');} $identity=array('status'=>'captured','identity_ready'=>true,'block_hashes'=>$hashes,'staff_hashes'=>$body['staff_hashes']); $phase='worker_authority'; if (in_array($event,array('claim','mark_dispatch'),true)) { $this->validateBlocks(); $current=$this->rows('SELECT revision FROM '.$this->table('block_revision').' WHERE singleton = 1'); $flag=$this->rows('SELECT level FROM '.$this->table('flags').' WHERE subject_hash = ?',array($body['flag_hash'])); if (count($current)!==1 || $current[0]['revision']!==$body['block_revision'] || count($flag)!==1 || $flag[0]['level']!==$body['level']) { throw new OG_Privacy_Case_Refusal('worker_flag_changed'); } } $authority=OG_Checkout_Subject_Reader::lookup(new OG_Checkout_Native_Read_Bridge($this->db),$this->prefix,$context,$identity); $clock=$this->rows('SELECT UNIX_TIMESTAMP() AS now_seconds');$now=OG_Transport_Database_Row::unsigned($clock[0]['now_seconds']??null); if ($now===null){throw new OG_Privacy_Case_Refusal('worker_clock_invalid');} $authority['generation']=$control['generation'];$authority['open']=true;$authority['claim_id']=$claim; if ($event==='claim'){$authority['new_claim_id']=bin2hex(random_bytes(16));} $proposal=OG_Transport_Operation_Policy::propose($row,$event,$authority,$now); if (!in_array($proposal['status'],array('proposed','unchanged'),true)) {throw new OG_Privacy_Case_Refusal('worker_'.($proposal['reason']??'refused'));} $next=$proposal['row']; if ($proposal['status']==='proposed') { $phase='worker_write'; $changed=$this->sql('UPDATE '.$this->table('transport_operations').' SET state=?,revision=?,claim_id=?,claim_until=?,attempts=? WHERE generation=? AND operation_id=? AND revision=? AND state=? AND claim_id <=> ? AND privacy_case_id IS NULL',array($next['state'],(string)$next['revision'],$next['claim_id'],(string)$next['claim_until'],(string)$next['attempts'],$control['generation'],$operation,(string)$expectedRevision,$row['state'],$row['claim_id'])); if ($changed!==1){throw new OG_Privacy_Case_Refusal('worker_conflict');} } $phase='commit';$this->commit(); return array('status'=>$proposal['status']==='unchanged'?'worker_state_unchanged':'worker_state_saved','state'=>$next['state'],'revision'=>$next['revision'],'claim_id'=>$next['claim_id'],'claim_until'=>$next['claim_until'],'attempts'=>$next['attempts'],'shared'=>false,'dispatch_enabled'=>false); }catch(Throwable $e){return array_merge($this->failure($e,$phase),array('shared'=>false,'dispatch_enabled'=>false));} finally{$this->finish();} } /** Shared bounded verification; never grants dispatch authority. */ private function retainedRows(array $control): array { $pending=$this->rows('SELECT * FROM '.$this->table('transport_operations').' WHERE generation=? AND privacy_case_id IS NULL ORDER BY operation_id LIMIT 21',array($control['generation'])); if (count($pending)>20) {throw new OG_Privacy_Case_Refusal('retained_close_capacity');} foreach($pending as $raw) { $decoded=OG_Transport_Database_Row::decode($raw); if ($decoded['status']!=='decoded') {throw new OG_Privacy_Case_Refusal('retained_close_invalid');} $row=$decoded['row']; if ($row['kind']!=='flag_signal_set' || $row['attempts']!==0 || $row['max_attempts']!==1 || !in_array($row['state'],array('intent','claimed','cancelled_unsent','expired_unsent'),true)) { throw new OG_Privacy_Case_Refusal('retained_close_not_unsent'); } self::token($row['operation_id']); if (($row['state']==='intent' && $row['claim_id']!==null) || ($row['state']==='claimed' && $row['claim_id']===null)) {throw new OG_Privacy_Case_Refusal('retained_close_invalid');} if ($row['claim_id']!==null) {self::token($row['claim_id']);} $source=$this->rows('SELECT suppression_namespace,subject_hash FROM '.$this->table('transport_subjects').' WHERE generation=? AND operation_id=? ORDER BY suppression_namespace,subject_hash LIMIT 9',array($control['generation'],$row['operation_id'])); $links=array();$hashes=array();foreach($source as $link){$links[]=array('namespace'=>$link['suppression_namespace'],'hash'=>$link['subject_hash']);$hashes[]=$link['subject_hash'];} try {$canonical=OG_Privacy_Case_Integrity::canonical($links);}catch(Throwable $e){throw new OG_Privacy_Case_Refusal('retained_close_scope_invalid');} if (OG_Transport_Database_Row::unsigned($row['subject_count'])!==count($links) || !is_string($row['subject_commitment']) || !hash_equals($row['subject_commitment'],self::outboxScope($control['secret'],$control['generation'],$row['operation_id'],$links))) {throw new OG_Privacy_Case_Refusal('retained_close_scope_invalid');} if (!is_string($row['payload']) || !is_string($row['body_hmac'])) {throw new OG_Privacy_Case_Refusal('retained_close_body_invalid');} $digest=hash_hmac('sha256',"og-flag-intent-v2\n".$row['payload']."\n".$row['expires_at']."\n".$row['retain_until']."\n".$canonical,hex2bin($control['secret'])); if (!hash_equals($row['body_hmac'],$digest)) {throw new OG_Privacy_Case_Refusal('retained_close_body_invalid');} try {$body=json_decode($row['payload'],true,8,JSON_THROW_ON_ERROR);}catch(Throwable $e){throw new OG_Privacy_Case_Refusal('retained_close_body_invalid');} if (!is_array($body) || array_keys($body)!==array('flag_hash','source_revision','level','staff_hashes','block_revision') || $body['source_revision']!==$row['source_revision'] || !in_array($body['flag_hash'],$hashes,true) || !in_array($body['level'],array('hard','cod'),true) || !is_array($body['staff_hashes']) || !is_string($body['block_revision']) || !preg_match('/\A[a-f0-9]{32}\z/D',$body['block_revision'])) {throw new OG_Privacy_Case_Refusal('retained_close_body_invalid');} } return $pending; } /** Explicit close-only path: retain rows AND current verification key. * Bounded to 20 unbound rows. No paging, rotation, deletion or completion claim. * Caller must independently authorise lifecycle closure. */ public function closeRetainingUnsent(string $install,string $generation): array { $phase='retained_close_validate'; try { self::token($install);self::token($generation); $phase='connect';$this->connect(4);$this->validateCases($this->outboxSpecs()); $phase='admit';$control=$this->enter($install,$generation,true); if ($control['state']!=='open') {throw new OG_Privacy_Case_Refusal('closed');} $phase='retained_close_verify'; $pending=$this->retainedRows($control); $phase='retained_close_write'; if ($this->sql('UPDATE '.$this->table('control').' SET state=? WHERE singleton=1 AND generation=? AND state=?',array('closed',$control['generation'],'open'))!==1) {throw new OG_Privacy_Case_Refusal('retained_close_conflict');} $phase='commit';$this->commit(); return array('status'=>'closed_retained','retained_unbound'=>count($pending),'rotation_allowed'=>false,'erasure_complete'=>false,'shared'=>false,'dispatch_enabled'=>false); }catch(Throwable $e){return $this->failure($e,$phase);} finally{$this->finish();} } private function historySpecs(): array { return array_merge($this->outboxSpecs(),array('transport_generations'=>array( array('generation'=>'char(32)','install_id'=>'char(32)','secret'=>'char(64)','created_at'=>'bigint unsigned','row_count'=>'smallint unsigned','commitment'=>'char(64)'), array('PRIMARY'=>array(0,array('generation')))), 'transport_disposals'=>array(array('generation'=>'char(32)','install_id'=>'char(32)','format_version'=>'smallint unsigned','disposed_at'=>'bigint unsigned'),array('PRIMARY'=>array(0,array('generation')))))); } /** Fresh disposable schema only; not a production migration. */ public function installHistorySchema(): array { try { $this->connect(1); $existing=$this->rows('SELECT TABLE_NAME FROM information_schema.TABLES WHERE TABLE_SCHEMA=? AND TABLE_NAME IN (?,?)',array($this->profile['database'],$this->prefix.'transport_generations',$this->prefix.'transport_disposals')); if ($existing) {throw new OG_Privacy_Case_Refusal('existing_history_schema_refused');} $this->sql('CREATE TABLE '.$this->table('transport_generations').' (generation CHAR(32) NOT NULL,install_id CHAR(32) NOT NULL,secret CHAR(64) NOT NULL,created_at BIGINT UNSIGNED NOT NULL,row_count SMALLINT UNSIGNED NOT NULL,commitment CHAR(64) NOT NULL,PRIMARY KEY(generation)) ENGINE=InnoDB DEFAULT CHARSET=ascii COLLATE=ascii_bin'); $this->sql('CREATE TABLE '.$this->table('transport_disposals').' (generation CHAR(32) NOT NULL,install_id CHAR(32) NOT NULL,format_version SMALLINT UNSIGNED NOT NULL,disposed_at BIGINT UNSIGNED NOT NULL,PRIMARY KEY(generation)) ENGINE=InnoDB DEFAULT CHARSET=ascii COLLATE=ascii_bin'); $this->validateCases($this->historySpecs());return array('status'=>'installed'); }catch(Throwable $e){return $this->failure($e,'history_install');} finally{$this->finish();} } private static function historyCommitment(string $install,string $generation,string $secret,int $created,array $rows): string { if (!preg_match('/\A[a-f0-9]{64}\z/D',$secret)) {throw new OG_Privacy_Case_Refusal('history_key_invalid');} return hash_hmac('sha256',"og-retained-generation-v1\n".$install."\n".$generation."\n".$created."\n".json_encode($rows,JSON_THROW_ON_ERROR),hex2bin($secret)); } /** Archive the frozen snapshot/key before publishing a new generation. * Caller must independently authorise reopening. No old work is adopted. */ public function reopenRetainingHistory(string $install,string $generation): array { $phase='history_validate'; try { self::token($install);self::token($generation); $phase='connect';$this->connect(4);$this->validateCases($this->historySpecs()); $phase='admit';$control=$this->enter($install,$generation,true); if ($control['state']!=='closed') {throw new OG_Privacy_Case_Refusal('history_requires_closed');} $phase='history_verify';$pending=$this->retainedRows($control); $clock=$this->rows('SELECT UNIX_TIMESTAMP() AS now_seconds');$now=OG_Transport_Database_Row::unsigned($clock[0]['now_seconds']??null); if ($now===null) {throw new OG_Privacy_Case_Refusal('history_clock_invalid');} $commitment=self::historyCommitment($install,$generation,$control['secret'],$now,$pending); $phase='history_archive'; $this->sql('INSERT INTO '.$this->table('transport_generations').' (generation,install_id,secret,created_at,row_count,commitment) VALUES (?,?,?,?,?,?)',array($generation,$install,$control['secret'],(string)$now,(string)count($pending),$commitment)); $next=bin2hex(random_bytes(16));$secret=bin2hex(random_bytes(32)); $phase='history_rotate'; if ($this->sql('UPDATE '.$this->table('control').' SET state=?,generation=?,secret=? WHERE singleton=1 AND generation=? AND state=?',array('open',$next,$secret,$generation,'closed'))!==1) {throw new OG_Privacy_Case_Refusal('history_rotation_conflict');} $phase='commit';$this->commit(); return array('status'=>'reopened_retained','generation'=>$next,'retained_generation'=>$generation,'retained_unbound'=>count($pending),'old_work_adopted'=>false,'erasure_complete'=>false,'dispatch_enabled'=>false); }catch(Throwable $e){return $this->failure($e,$phase);} finally{$this->finish();} } /** Metadata-only history; never an execution or completion receipt. */ public function retainedHistory(string $install,string $generation,string $sourceGeneration): array { $phase='history_validate'; try { self::token($install);self::token($generation);self::token($sourceGeneration); $phase='connect';$this->connect(1);$this->validateCases($this->historySpecs()); $phase='admit';$control=$this->enter($install,$generation,false); $phase='history_read';$archive=null; if ($sourceGeneration!==$control['generation'] && $this->disposedHistory($install,$sourceGeneration)) { $phase='commit';$this->commit(); return array('status'=>'history_disposed','source_generation'=>$sourceGeneration,'items'=>array(),'erasure_complete'=>false,'dispatch_enabled'=>false); } if ($sourceGeneration===$control['generation']) { if ($control['state']!=='closed') {throw new OG_Privacy_Case_Refusal('history_current_open');} $authority=$control; } else { $saved=$this->rows('SELECT * FROM '.$this->table('transport_generations').' WHERE generation=?',array($sourceGeneration)); if (count($saved)!==1 || $saved[0]['install_id']!==$install) {throw new OG_Privacy_Case_Refusal('history_authority_missing');} $archive=$saved[0];$authority=array('generation'=>$sourceGeneration,'secret'=>$archive['secret']); } $pending=$this->retainedRows($authority); if ($archive!==null) { $created=OG_Transport_Database_Row::unsigned($archive['created_at']);$count=OG_Transport_Database_Row::unsigned($archive['row_count']); if ($created===null || $count!==count($pending) || !hash_equals($archive['commitment'],self::historyCommitment($install,$sourceGeneration,$archive['secret'],$created,$pending))) {throw new OG_Privacy_Case_Refusal('history_integrity_mismatch');} } $items=array();foreach($pending as $row){$items[]=array('operation_id'=>$row['operation_id'],'state'=>$row['state'],'expires_at'=>OG_Transport_Database_Row::unsigned($row['expires_at']),'read_only'=>true);} $phase='commit';$this->commit(); return array('status'=>'retained_history','source_generation'=>$sourceGeneration,'items'=>$items,'archived'=>$archive!==null,'erasure_complete'=>false,'dispatch_enabled'=>false); }catch(Throwable $e){return $this->failure($e,$phase);} finally{$this->finish();} } /** A marker is not an erasure receipt. Any surviving source material refuses. */ private function disposedHistory(string $install,string $source): bool { $markers=$this->rows('SELECT * FROM '.$this->table('transport_disposals').' WHERE generation=?',array($source)); if (!$markers) {return false;} $m=$markers[0]; if ($m['install_id']!==$install || OG_Transport_Database_Row::unsigned($m['format_version'])!==1 || OG_Transport_Database_Row::unsigned($m['disposed_at'])===null) {throw new OG_Privacy_Case_Refusal('history_disposal_invalid');} foreach(array('transport_generations','transport_operations','transport_subjects') as $table){ if ($this->rows('SELECT generation FROM '.$this->table($table).' WHERE generation=? LIMIT 1',array($source))) {throw new OG_Privacy_Case_Refusal('history_disposal_incomplete');} } return true; } /** Explicit component primitive ONLY. Caller assertion is not verified consent. * No scheduler, retention policy, WordPress entry or automatic invocation. * Whole archived unsent generation only; refuse bound cases and orphan links. */ public function disposeRetainedHistory(string $install,string $generation,string $source,array $callerAssertions): array { $phase='disposal_validate'; try { self::token($install);self::token($generation);self::token($source); if (($callerAssertions['caller_asserts_disposal_authorised']??null)!==true) {throw new OG_Privacy_Case_Refusal('disposal_caller_assertion_missing');} $phase='connect';$this->connect(4);$this->validateCases($this->historySpecs()); $phase='admit';$control=$this->enter($install,$generation,true); if ($control['state']!=='open' || $source===$control['generation']) {throw new OG_Privacy_Case_Refusal('disposal_source_refused');} if ($this->disposedHistory($install,$source)) {$this->commit();return array('status'=>'history_disposed','replayed'=>true,'erasure_complete'=>false,'dispatch_enabled'=>false);} $archive=$this->rows('SELECT * FROM '.$this->table('transport_generations').' WHERE generation=?',array($source)); if (count($archive)!==1 || $archive[0]['install_id']!==$install) {throw new OG_Privacy_Case_Refusal('history_authority_missing');} $a=$archive[0];$rows=$this->retainedRows(array('generation'=>$source,'secret'=>$a['secret'])); $created=OG_Transport_Database_Row::unsigned($a['created_at']); if ($created===null || OG_Transport_Database_Row::unsigned($a['row_count'])!==count($rows) || !hash_equals($a['commitment'],self::historyCommitment($install,$source,$a['secret'],$created,$rows))) {throw new OG_Privacy_Case_Refusal('disposal_integrity_mismatch');} $all=$this->rows('SELECT operation_id FROM '.$this->table('transport_operations').' WHERE generation=? LIMIT 21',array($source)); $links=$this->rows('SELECT operation_id FROM '.$this->table('transport_subjects').' WHERE generation=? LIMIT 161',array($source)); $expectedLinks=0;foreach($rows as $row){$expectedLinks+=(int)$row['subject_count'];} if (count($all)!==count($rows) || count($links)!==$expectedLinks) {throw new OG_Privacy_Case_Refusal('disposal_scope_incomplete');} $clock=$this->rows('SELECT UNIX_TIMESTAMP() AS now_seconds');$now=OG_Transport_Database_Row::unsigned($clock[0]['now_seconds']??null); if ($now===null) {throw new OG_Privacy_Case_Refusal('history_clock_invalid');} $phase='disposal_marker'; $this->sql('INSERT INTO '.$this->table('transport_disposals').' (generation,install_id,format_version,disposed_at) VALUES (?,?,?,?)',array($source,$install,'1',(string)$now)); $phase='disposal_delete'; if ($this->sql('DELETE FROM '.$this->table('transport_subjects').' WHERE generation=?',array($source))!==$expectedLinks || $this->sql('DELETE FROM '.$this->table('transport_operations').' WHERE generation=?',array($source))!==count($rows) || $this->sql('DELETE FROM '.$this->table('transport_generations').' WHERE generation=?',array($source))!==1) {throw new OG_Privacy_Case_Refusal('disposal_conflict');} $phase='commit';$this->commit(); return array('status'=>'history_disposed','replayed'=>false,'erasure_complete'=>false,'dispatch_enabled'=>false); }catch(Throwable $e){return $this->failure($e,$phase);} finally{$this->finish();} } /** Link a persisted uncertainty to a case; NEVER send, retry, or minimise. */ public function bindOutboxCase(string $install,string $generation,string $operation,int $expectedRevision,string $case): array { $phase='handover_validate'; try { foreach(array($install,$generation,$operation,$case) as $id){self::token($id);} if ($expectedRevision<1 || $expectedRevision===PHP_INT_MAX) {throw new OG_Privacy_Case_Refusal('outbox_revision_invalid');} $phase='connect';$this->connect(4);$this->validateCases($this->outboxSpecs()); $phase='admit';$control=$this->enter($install,$generation,true); if ($control['state']!=='open') {throw new OG_Privacy_Case_Refusal('closed');} $phase='source_read'; $raw=$this->rows('SELECT * FROM '.$this->table('transport_operations').' WHERE generation=? AND operation_id=?',array($generation,$operation)); if (count($raw)!==1) {throw new OG_Privacy_Case_Refusal('outbox_missing');} $decoded=OG_Transport_Database_Row::decode($raw[0]); if ($decoded['status']!=='decoded') {throw new OG_Privacy_Case_Refusal('outbox_invalid');} $row=$decoded['row']; if ($row['kind']!=='flag_signal_set' || !in_array($row['state'],array('attention_unknown','delivered_despite_suppression'),true) || $row['attempts']!==1 || !is_string($row['claim_id']) || !preg_match('/\A[a-f0-9]{32}\z/D',$row['claim_id'])) {throw new OG_Privacy_Case_Refusal('outbox_not_uncertain');} $linked=$row['privacy_case_id']!==null; if (($linked && ($row['privacy_case_id']!==$case || $row['revision']!==$expectedRevision+1)) || (!$linked && $row['revision']!==$expectedRevision)) {throw new OG_Privacy_Case_Refusal('outbox_conflict');} $source=$this->rows('SELECT suppression_namespace,subject_hash FROM '.$this->table('transport_subjects').' WHERE generation=? AND operation_id=? ORDER BY suppression_namespace,subject_hash LIMIT 9',array($generation,$operation)); $links=array();foreach($source as $link){$links[]=array('namespace'=>$link['suppression_namespace'],'hash'=>$link['subject_hash']);} $phase='scope_verify'; if (count($links)>8) {throw new OG_Privacy_Case_Refusal('outbox_scope_overflow');} try {$canonical=OG_Privacy_Case_Integrity::canonical($links);} catch (InvalidArgumentException $e) {throw new OG_Privacy_Case_Refusal('outbox_scope_invalid');} if (OG_Transport_Database_Row::unsigned($row['subject_count'])!==count($links) || !hash_equals($row['subject_commitment'],self::outboxScope($control['secret'],$generation,$operation,$links))) {throw new OG_Privacy_Case_Refusal('outbox_scope_mismatch');} if ($linked) { $phase='case_verify'; // Never reconstruct a disappeared case from its surviving pointer. $saved=$this->caseRead($case); if ($saved['header']['source_generation']!==$generation || $saved['header']['operation_id']!==$operation || $saved['header']['state']!==$row['state'] || OG_Privacy_Case_Integrity::canonical($saved['links'])!==$canonical) {throw new OG_Privacy_Case_Refusal('case_conflict');} } else { $phase='case_bind'; $this->storeCase($generation,$operation,$case,$row['state'],$links,$canonical); $changed=$this->sql('UPDATE '.$this->table('transport_operations').' SET privacy_case_id=?,revision=? WHERE generation=? AND operation_id=? AND revision=? AND privacy_case_id IS NULL AND state=?',array($case,(string)($expectedRevision+1),$generation,$operation,(string)$expectedRevision,$row['state'])); if ($changed!==1) {throw new OG_Privacy_Case_Refusal('outbox_conflict');} } $phase='commit';$this->commit(); return array('status'=>'case_bound','case_id'=>$case,'revision'=>$expectedRevision+1,'replayed'=>$linked,'erasure_complete'=>false,'notification_delivered'=>false,'source_minimised'=>false); } catch (Throwable $e) { $result=$this->failure($e,$phase); if ($e instanceof OG_Privacy_Case_Refusal && in_array($e->getMessage(),array('outbox_scope_overflow','outbox_scope_invalid','outbox_scope_mismatch'),true)) { $result['disposition']='attention';$result['retryable']=false; } return $result; } finally {$this->finish();} } /** Unregistered minimisation primitive; assertion is NOT verified permission. * Keeps the verified case unresolved. Never disposes case authority or sends. */ public function minimiseBoundOutbox(string $install,string $generation,string $operation,int $expectedRevision,string $case,array $callerAssertions): array { $phase='minimise_validate'; try { foreach(array($install,$generation,$operation,$case) as $id){self::token($id);} if (($callerAssertions['caller_asserts_minimisation_authorised']??null)!==true || $expectedRevision<1 || $expectedRevision===PHP_INT_MAX) {throw new OG_Privacy_Case_Refusal('minimise_command_invalid');} $phase='connect';$this->connect(4);$this->validateCases($this->outboxSpecs()); $phase='admit';$control=$this->enter($install,$generation,true); if ($control['state']!=='open') {throw new OG_Privacy_Case_Refusal('closed');} $raw=$this->rows('SELECT * FROM '.$this->table('transport_operations').' WHERE generation=? AND operation_id=?',array($generation,$operation)); if (count($raw)!==1) {throw new OG_Privacy_Case_Refusal('outbox_missing');} $decoded=OG_Transport_Database_Row::decode($raw[0]); if ($decoded['status']!=='decoded') {throw new OG_Privacy_Case_Refusal('outbox_invalid');} $row=$decoded['row'];$replay=$row['state']==='case_minimised'; if ($row['privacy_case_id']!==$case || $row['kind']!=='flag_signal_set' || $row['revision']!==$expectedRevision+($replay?1:0)) {throw new OG_Privacy_Case_Refusal('minimise_conflict');} $phase='minimise_case_verify';$saved=$this->caseRead($case); if ($saved['header']['source_generation']!==$generation || $saved['header']['operation_id']!==$operation || !in_array($saved['header']['state'],array('attention_unknown','delivered_despite_suppression'),true)) {throw new OG_Privacy_Case_Refusal('case_conflict');} $source=$this->rows('SELECT suppression_namespace,subject_hash FROM '.$this->table('transport_subjects').' WHERE generation=? AND operation_id=? ORDER BY suppression_namespace,subject_hash LIMIT 9',array($generation,$operation)); if ($replay) { if ($source || $row['payload']!==null || $row['body_hmac']!==null || $row['claim_id']!==null || $row['claim_until']!==0 || $row['attempts']!==0 || OG_Transport_Database_Row::unsigned($row['subject_count'])!==0 || $row['subject_commitment']!==str_repeat('0',64)) {throw new OG_Privacy_Case_Refusal('minimise_tombstone_invalid');} } else { if ($row['state']!==$saved['header']['state'] || $row['attempts']!==1 || !is_string($row['claim_id']) || !preg_match('/\A[a-f0-9]{32}\z/D',$row['claim_id'])) {throw new OG_Privacy_Case_Refusal('outbox_not_uncertain');} $links=array();foreach($source as $link){$links[]=array('namespace'=>$link['suppression_namespace'],'hash'=>$link['subject_hash']);} try {$canonical=OG_Privacy_Case_Integrity::canonical($links);}catch(Throwable $e){throw new OG_Privacy_Case_Refusal('outbox_scope_invalid');} if (OG_Transport_Database_Row::unsigned($row['subject_count'])!==count($links) || !hash_equals($row['subject_commitment'],self::outboxScope($control['secret'],$generation,$operation,$links)) || $canonical!==OG_Privacy_Case_Integrity::canonical($saved['links'])) {throw new OG_Privacy_Case_Refusal('minimise_scope_mismatch');} $phase='minimise_write'; $changed=$this->sql('UPDATE '.$this->table('transport_operations').' SET state=?,revision=?,payload=NULL,body_hmac=NULL,claim_id=NULL,claim_until=0,attempts=0,subject_count=0,subject_commitment=? WHERE generation=? AND operation_id=? AND revision=? AND privacy_case_id=? AND state=?',array('case_minimised',(string)($expectedRevision+1),str_repeat('0',64),$generation,$operation,(string)$expectedRevision,$case,$row['state'])); if ($changed!==1) {throw new OG_Privacy_Case_Refusal('minimise_conflict');} $phase='minimise_links'; if ($this->sql('DELETE FROM '.$this->table('transport_subjects').' WHERE generation=? AND operation_id=?',array($generation,$operation))!==count($links)) {throw new OG_Privacy_Case_Refusal('minimise_conflict');} } $phase='commit';$this->commit(); return array('status'=>'source_minimised','case_id'=>$case,'case_state'=>$saved['header']['state'],'revision'=>$expectedRevision+1,'replayed'=>$replay,'erasure_complete'=>false,'notification_delivered'=>false,'dispatch_enabled'=>false); }catch(Throwable $e){return $this->failure($e,$phase);} finally{$this->finish();} } /** Bounded historical listing: not a subject search or whole-store integrity proof. */ public function casePage(string $install,string $generation,string $after=''): array { $phase='case_read'; try { self::token($install);self::token($generation);if ($after!=='') {self::token($after);} $this->connect(1);$this->validateCases();$control=$this->enter($install,$generation,false); if ($control['state']!=='open') {throw new OG_Privacy_Case_Refusal('closed');} $ids=$this->rows('SELECT case_id FROM '.$this->table('privacy_cases').' WHERE case_id>? ORDER BY case_id LIMIT 21',array($after)); $more=count($ids)>20;$items=array(); foreach (array_slice($ids,0,20) as $id) { $saved=$this->caseRead($id['case_id']); $items[]=array('case_id'=>$id['case_id'],'state'=>$saved['header']['state'],'owner'=>'orderguard_ops','created_at'=>$saved['header']['created_at']); } $this->commit(); return array('status'=>'page','items'=>$items,'next'=>$more?$items[count($items)-1]['case_id']:null,'erasure_complete'=>false,'notification_delivered'=>false); } catch (Throwable $e) {return $this->failure($e,$phase);} finally {$this->finish();} } private function finish(): void { if ($this->db instanceof mysqli) { if ($this->transaction && !$this->committing) { try { $this->transactionControl('rollback'); } catch (Throwable $ignored) {} } try { $this->db->close(); } catch (Throwable $ignored) {} } $this->db = null; $this->transaction = false; $this->committing = false; $this->lockGranted = false; $this->existingOperation = null; $this->existingReceipt = null; } private function failure(Throwable $e, string $phase): array { $code = (int)$e->getCode(); $status = $this->committing ? 'unknown' : (in_array($code,array(1205,1213,3572),true) ? 'busy' : 'unavailable'); return array('status'=>$status, 'reason'=>$e instanceof OG_Privacy_Case_Refusal ? $e->getMessage() : 'database_operation', 'phase'=>$phase); } }