From 12ead1d09b8fc3687369a2a0c79385fcd72605db Mon Sep 17 00:00:00 2001 From: Valithor Obsidion Date: Sun, 11 Oct 2026 20:43:00 -0400 Subject: [PATCH 01/22] Add disabled safehouse request client and offline contract checks --- docs/safehouse-integration.md | 64 +++++++++++++++++ src/Civ13/Safehouse/Client.php | 106 +++++++++++++++++++++++++++++ src/Civ13/Safehouse/Controller.php | 39 +++++++++++ src/Civ13/Safehouse/Transport.php | 10 +++ tests/safehouse/run.php | 52 ++++++++++++++ 5 files changed, 271 insertions(+) create mode 100644 docs/safehouse-integration.md create mode 100644 src/Civ13/Safehouse/Client.php create mode 100644 src/Civ13/Safehouse/Controller.php create mode 100644 src/Civ13/Safehouse/Transport.php create mode 100644 tests/safehouse/run.php diff --git a/docs/safehouse-integration.md b/docs/safehouse-integration.md new file mode 100644 index 00000000..5638dfc9 --- /dev/null +++ b/docs/safehouse-integration.md @@ -0,0 +1,64 @@ +# Discord safehouse integration (provisional, disabled) + +This patch provides a pure controller, command schema and durable request client. +It does not register commands, start a bot, read game saves or provide a live +transport. Compatibility is unaccepted until the game handler executes this +contract fixture. Existing verifier cache entries provide no trade authority. + +## Version 1 request + +```json +{"protocol":"civ13.safehouse.v1","request_id":"discord-interaction-id","discord_id":"123","action":"trade.confirm","expected_account_revision":4,"payload":{"confirmation_token":"opaque-game-token"},"request_digest":"sha256-of-canonical-envelope-without-digest"} +``` + +Objects sort keys recursively; arrays retain order. Encode UTF-8 JSON without +whitespace using PHP JSON default escaping. The digest binds the immutable +request; it is not authentication. Responses echo protocol, request_id, +discord_id, action and request_digest with status pending, committed or rejected. +Only committed establishes durable game success. A transport timeout remains +pending even when the game may already have committed. Replay the exact request. + +Proposed payloads: link.challenge {challenge_id}; link.complete {challenge_id, +proof}; stash.inspect/room.inspect {page}; room.manage {confirmation_token}; +trade.quote {item_ids, sale_kind}; trade.confirm {confirmation_token}; +trade.status {transaction_id}; market.list {confirmation_token}; market.buy +{confirmation_token}; market.cancel {confirmation_token}. This is a proposal, +not an implemented game schema. No client price or account identity is accepted. +Quotes bind actor, item graph IDs, revision, approved game price, action and +expiry. Listing and room mutations also require game-issued confirmation. + +Link challenges originate from an authenticated in-game account and are bounded, +short-lived and single-use; the trusted service adapter binds the Discord actor. +Discord alone cannot establish account ownership. Busy/in-raid/unauthorized, +expired or stale requests must be rejected by the game. Nested IDs, FIR and +condition remain game values. The bot never reconstructs inventories. + +## Proposed local spool transport + +Inject a transport using a private, configured IPC directory, not a save path. +Use one immutable request file per request ID, written to a private temporary +file then atomically renamed. A separate authenticated adapter signs the exact +request bytes using a service credential; the game validates that credential +independently of discord_id. No configured endpoint or credential is invented +by this patch. Replies must bind the authenticated request digest and identity. +Bound request size, queue count, expiry, polling and timeout before enabling. +The game owns one bounded queue and idempotency ledger; concurrent/replayed +requests cannot settle twice. Multi-party settlement requires one durable shared +transaction receipt and recovery, not separate account-save writes. + +## Local operation and limits + +Run `C:/php/php.exe tests/safehouse/run.php`. It bypasses the ordinary test +bootstrap, which reads `.env`. Tests use injected game transport and temporary +journals with no Discord/network/bootstrap effects. Constructor `enabled` is +false by default. Hosting code must use trusted Discord actor/interaction IDs +and ephemeral responses; this patch exposes no bot config or listener yet. + +The journal serializes local requests with an OS file lock, persists before +transport delivery and caches terminal replies. It bounds records at 1000 and +fails closed when full; archive only under an operator-approved game receipt +retention policy. The transport must enforce a finite timeout. Blocking transport +must not run on the shared Discord event loop. No live spool, game-side checks, +wallet/pricing/escrow correctness, cross-process crash durability or live command +registration has been verified. Activation depends on game handler compatibility, +authenticated async adapter, wallet/escrow and identity reconciliation. diff --git a/src/Civ13/Safehouse/Client.php b/src/Civ13/Safehouse/Client.php new file mode 100644 index 00000000..ad6bb6b3 --- /dev/null +++ b/src/Civ13/Safehouse/Client.php @@ -0,0 +1,106 @@ +enabled) { + throw new \LogicException('Safehouse integration is disabled'); + } + if (!preg_match('/^[A-Za-z0-9_-]{1,96}$/D', $requestId) || !preg_match('/^[0-9]{1,22}$/D', $discordId) || !in_array($action, self::ACTIONS, true) || ($expectedAccountRevision !== null && $expectedAccountRevision < 0)) { + throw new \InvalidArgumentException('Invalid request identity/action/revision'); + } + $request = ['protocol' => self::PROTOCOL, 'request_id' => $requestId, 'discord_id' => $discordId, 'action' => $action, 'expected_account_revision' => $expectedAccountRevision, 'payload' => self::canonical($payload)]; + $digest = hash('sha256', json_encode($request, JSON_THROW_ON_ERROR)); + $request['request_digest'] = $digest; + // Serialize local delivery and persist before external effects. A timeout retains + // the exact request for replay; only the game can deduplicate settlement. + $lock = fopen($this->journal.'.lock', 'c+'); + if ($lock === false || !flock($lock, LOCK_EX)) { + throw new \RuntimeException('Cannot acquire safehouse journal lock'); + } + try { + $records = is_file($this->journal) ? json_decode(file_get_contents($this->journal), true, 64, JSON_THROW_ON_ERROR) : []; + if (!is_array($records)) { + throw new \RuntimeException('Invalid safehouse journal'); + } + if (isset($records[$requestId])) { + if ($records[$requestId]['request'] !== $request) { + throw new \LogicException('Request ID already bound to another request'); + } + if (isset($records[$requestId]['response']) && $records[$requestId]['response']['status'] !== 'pending') { + return $records[$requestId]['response']; + } + } else { + if (count($records) >= $this->capacity) { + throw new \RuntimeException('Safehouse journal capacity reached; archival required'); + } + $records[$requestId] = ['request' => $request]; + $this->persist($records); + } + try { + $response = $this->transport->exchange($request); + } catch (\Throwable $error) { + return ['protocol' => self::PROTOCOL, 'request_id' => $requestId, 'status' => 'pending', 'reason' => 'transport_unresolved']; + } + foreach (['protocol', 'request_id', 'discord_id', 'action', 'request_digest'] as $key) { + if (($response[$key] ?? null) !== $request[$key]) { + throw new \UnexpectedValueException('Game response binding mismatch: '.$key); + } + } + if (!in_array($response['status'] ?? null, ['pending', 'committed', 'rejected'], true)) { + throw new \UnexpectedValueException('Unknown game outcome'); + } + $records[$requestId]['response'] = $response; + $this->persist($records); + return $response; + } finally { + flock($lock, LOCK_UN); + fclose($lock); + } + } + + private function persist(array $records): void + { + $temporary = $this->journal.'.'.bin2hex(random_bytes(8)).'.tmp'; + $stream = fopen($temporary, 'xb'); + if ($stream === false) { + throw new \RuntimeException('Cannot create journal snapshot'); + } + try { + $bytes = json_encode($records, JSON_THROW_ON_ERROR); + if (fwrite($stream, $bytes) !== strlen($bytes) || !fflush($stream) || !fsync($stream)) { + throw new \RuntimeException('Cannot persist journal snapshot'); + } + } finally { + fclose($stream); + } + if (!rename($temporary, $this->journal)) { + @unlink($temporary); + throw new \RuntimeException('Cannot replace journal snapshot'); + } + } + + private static function canonical(array $value): array + { + if (!array_is_list($value)) { + ksort($value, SORT_STRING); + } + foreach ($value as &$entry) { + if (is_array($entry)) { + $entry = self::canonical($entry); + } elseif (!is_scalar($entry) && $entry !== null) { + throw new \InvalidArgumentException('Payload must contain JSON values'); + } + } + return $value; + } +} diff --git a/src/Civ13/Safehouse/Controller.php b/src/Civ13/Safehouse/Controller.php new file mode 100644 index 00000000..ab92f032 --- /dev/null +++ b/src/Civ13/Safehouse/Controller.php @@ -0,0 +1,39 @@ + 'safehouse', 'description' => 'Manage your game safehouse', 'type' => 1, 'contexts' => [0], 'integration_types' => [0], 'options' => [ + ['name' => 'action', 'description' => 'Game-authoritative action', 'type' => 3, 'required' => true, 'choices' => array_map(fn ($action) => ['name' => $action, 'value' => $action], Client::ACTIONS)], + ['name' => 'payload', 'description' => 'Action JSON (confirmation token for irreversible actions)', 'type' => 3, 'required' => true], + ['name' => 'revision', 'description' => 'Expected game account revision', 'type' => 4, 'required' => false], + ]]; + } + + public function handle(string $interactionId, string $actorDiscordId, string $action, string $json, ?int $revision): array + { + if (strlen($json) > 4096) { + throw new \InvalidArgumentException('Payload too large'); + } + $payload = json_decode($json, true, 32, JSON_THROW_ON_ERROR); + if (!is_array($payload) || !str_starts_with(ltrim($json), '{')) { + throw new \InvalidArgumentException('Payload must be a JSON object'); + } + foreach (['discord_id', 'ckey', 'account_id', 'price', 'currency', 'items'] as $authority) { + if (array_key_exists($authority, $payload)) { + throw new \InvalidArgumentException('Game authority cannot be supplied: '.$authority); + } + } + if (in_array($action, ['trade.confirm', 'market.list', 'market.buy', 'market.cancel', 'room.manage'], true) && (!is_string($payload['confirmation_token'] ?? null) || $payload['confirmation_token'] === '' || $revision === null)) { + throw new \InvalidArgumentException('Game confirmation token and revision required'); + } + return ['ephemeral' => true, 'outcome' => $this->client->request($interactionId, $actorDiscordId, $action, $payload, $revision)]; + } +} diff --git a/src/Civ13/Safehouse/Transport.php b/src/Civ13/Safehouse/Transport.php new file mode 100644 index 00000000..02203188 --- /dev/null +++ b/src/Civ13/Safehouse/Transport.php @@ -0,0 +1,10 @@ + Throws on timeout; never invents a settlement. */ + public function exchange(array $request): array; +} diff --git a/tests/safehouse/run.php b/tests/safehouse/run.php new file mode 100644 index 00000000..39887e44 --- /dev/null +++ b/tests/safehouse/run.php @@ -0,0 +1,52 @@ +calls[] = $request; + if ($this->timeout) { throw new RuntimeException('timeout after possible commit'); } + return array_merge($request, ['status' => 'committed', 'request_digest' => $this->mismatch ? 'wrong' : $request['request_digest'], 'data' => ['item_id' => 'nested-1', 'fir' => true, 'condition' => 0.75]]); + } +}; +try { + rejects(fn () => (new Client($transport, $directory.'/disabled'))->request('i1', '123', 'stash.inspect', [], null)); + check(count($transport->calls) === 0, 'Disabled client sent request'); + $client = new Client($transport, $directory.'/journal', true); + $transport->timeout = true; + check($client->request('i1', '123', 'trade.confirm', ['confirmation_token' => 'game-token'], 4)['status'] === 'pending', 'Timeout claimed success'); + $original = $transport->calls[0]; + $transport->timeout = false; + $restarted = new Client($transport, $directory.'/journal', true); + $response = $restarted->request('i1', '123', 'trade.confirm', ['confirmation_token' => 'game-token'], 4); + check($transport->calls[1] === $original, 'Restart replay changed immutable request'); + check($response['data']['item_id'] === 'nested-1' && $response['data']['fir'] === true, 'Game metadata changed'); + $restarted->request('i1', '123', 'trade.confirm', ['confirmation_token' => 'game-token'], 4); + check(count($transport->calls) === 2, 'Terminal duplicate resent'); + rejects(fn () => $client->request('i1', '999', 'trade.confirm', ['confirmation_token' => 'game-token'], 4)); + rejects(fn () => $client->request('i1', '123', 'trade.confirm', ['confirmation_token' => 'changed'], 4)); + $transport->mismatch = true; + rejects(fn () => $client->request('i2', '123', 'stash.inspect', [], null)); + $transport->mismatch = false; + $controller = new Controller($client); + rejects(fn () => $controller->handle('i3', '123', 'trade.confirm', '{"confirmation_token":"token"}', null)); + rejects(fn () => $controller->handle('i3', '123', 'trade.quote', '{"price":1}', 4)); + check($controller->handle('i3', '123', 'stash.inspect', '{"page":1}', null)['ephemeral'], 'Private response not ephemeral'); + check(count(Controller::schema()['options'][0]['choices']) === count(Client::ACTIONS), 'Schema/router mismatch'); + echo "PASS disabled default, restart replay, terminal deduplication, actor/payload binding, response negative control, confirmation gates, private schema\n"; +} finally { + foreach (glob($directory.'/*') as $file) { unlink($file); } + rmdir($directory); +} From 856b8f56917e907d4004c63c3d87c2985bb45d35 Mon Sep 17 00:00:00 2001 From: Valithor Obsidion Date: Sun, 11 Oct 2026 20:45:07 -0400 Subject: [PATCH 02/22] Exercise cross-process replay and bound local request handling --- docs/safehouse-integration.md | 16 +++++++++++++- src/Civ13/Safehouse/Client.php | 19 +++++++++++++++-- tests/safehouse/run.php | 38 +++++++++++++++++++++++++++++++++- tests/safehouse/worker.php | 30 +++++++++++++++++++++++++++ 4 files changed, 99 insertions(+), 4 deletions(-) create mode 100644 tests/safehouse/worker.php diff --git a/docs/safehouse-integration.md b/docs/safehouse-integration.md index 5638dfc9..424e65bc 100644 --- a/docs/safehouse-integration.md +++ b/docs/safehouse-integration.md @@ -59,6 +59,20 @@ transport delivery and caches terminal replies. It bounds records at 1000 and fails closed when full; archive only under an operator-approved game receipt retention policy. The transport must enforce a finite timeout. Blocking transport must not run on the shared Discord event loop. No live spool, game-side checks, -wallet/pricing/escrow correctness, cross-process crash durability or live command +wallet/pricing/escrow correctness, production crash durability or live command registration has been verified. Activation depends on game handler compatibility, authenticated async adapter, wallet/escrow and identity reconciliation. + +Native cross-process tests additionally prove local same-ID delivery serialization +and exact replay after a worker exits after mock game commit. The mock game's +durable receipt establishes one synthetic settlement; this does not prove the +production game's transaction durability. Local lock acquisition is bounded at +two seconds, requests at 8 KiB and replies at 64 KiB. A busy lock returns pending. + +For an asynchronous adapter, split durable enqueue and reply handling: hold flock +only while inserting immutable serialized envelope bytes and checking known +receipts, atomically publish a signed IPC file, then poll bounded reply paths with +the React event-loop timer. Resolve timeout as pending, retaining the same request +bytes. Validate authenticated reply binding before terminal journal storage. Do +not call this synchronous client from a Discord interaction callback; the current +implementation belongs in an isolated worker until this split adapter is built. diff --git a/src/Civ13/Safehouse/Client.php b/src/Civ13/Safehouse/Client.php index ad6bb6b3..8938772a 100644 --- a/src/Civ13/Safehouse/Client.php +++ b/src/Civ13/Safehouse/Client.php @@ -19,14 +19,26 @@ public function request(string $requestId, string $discordId, string $action, ar throw new \InvalidArgumentException('Invalid request identity/action/revision'); } $request = ['protocol' => self::PROTOCOL, 'request_id' => $requestId, 'discord_id' => $discordId, 'action' => $action, 'expected_account_revision' => $expectedAccountRevision, 'payload' => self::canonical($payload)]; - $digest = hash('sha256', json_encode($request, JSON_THROW_ON_ERROR)); + $encoded = json_encode($request, JSON_THROW_ON_ERROR); + if (strlen($encoded) > 8192) { + throw new \InvalidArgumentException('Request exceeds safehouse size bound'); + } + $digest = hash('sha256', $encoded); $request['request_digest'] = $digest; // Serialize local delivery and persist before external effects. A timeout retains // the exact request for replay; only the game can deduplicate settlement. $lock = fopen($this->journal.'.lock', 'c+'); - if ($lock === false || !flock($lock, LOCK_EX)) { + if ($lock === false) { throw new \RuntimeException('Cannot acquire safehouse journal lock'); } + $deadline = microtime(true) + 2; + while (!flock($lock, LOCK_EX | LOCK_NB)) { + if (microtime(true) >= $deadline) { + fclose($lock); + return ['protocol' => self::PROTOCOL, 'request_id' => $requestId, 'status' => 'pending', 'reason' => 'local_delivery_busy']; + } + usleep(10000); + } try { $records = is_file($this->journal) ? json_decode(file_get_contents($this->journal), true, 64, JSON_THROW_ON_ERROR) : []; if (!is_array($records)) { @@ -59,6 +71,9 @@ public function request(string $requestId, string $discordId, string $action, ar if (!in_array($response['status'] ?? null, ['pending', 'committed', 'rejected'], true)) { throw new \UnexpectedValueException('Unknown game outcome'); } + if (strlen(json_encode($response, JSON_THROW_ON_ERROR)) > 65536) { + throw new \UnexpectedValueException('Game reply exceeds size bound'); + } $records[$requestId]['response'] = $response; $this->persist($records); return $response; diff --git a/tests/safehouse/run.php b/tests/safehouse/run.php index 39887e44..e9ff1e96 100644 --- a/tests/safehouse/run.php +++ b/tests/safehouse/run.php @@ -41,12 +41,48 @@ public function exchange(array $request): array { rejects(fn () => $client->request('i2', '123', 'stash.inspect', [], null)); $transport->mismatch = false; $controller = new Controller($client); + check($controller->handle('empty', '123', 'stash.inspect', '{}', null)['outcome']['status'] === 'committed', 'Empty object rejected'); rejects(fn () => $controller->handle('i3', '123', 'trade.confirm', '{"confirmation_token":"token"}', null)); rejects(fn () => $controller->handle('i3', '123', 'trade.quote', '{"price":1}', 4)); check($controller->handle('i3', '123', 'stash.inspect', '{"page":1}', null)['ephemeral'], 'Private response not ephemeral'); check(count(Controller::schema()['options'][0]['choices']) === count(Client::ACTIONS), 'Schema/router mismatch'); + $canonical = $client->request('ordered', '123', 'trade.quote', ['z' => 2, 'a' => ['q' => 2, 'b' => 1]], 4); + check($client->request('ordered', '123', 'trade.quote', ['a' => ['b' => 1, 'q' => 2], 'z' => 2], 4) === $canonical, 'Object order changed identity'); + rejects(fn () => $client->request('large', '123', 'stash.inspect', ['value' => str_repeat('x', 9000)], null)); + // Separate native PHP processes exercise the OS lock and restart after a + // committed mock-game effect followed by process death before reply storage. + $spawn = function (string $path, string $mode): array { + $pipes = []; + $process = proc_open([PHP_BINARY, __DIR__.'/worker.php', $path, $mode], [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes); + check(is_resource($process), 'Worker did not start'); + fclose($pipes[0]); + return [$process, $pipes]; + }; + $finish = function (array $worker, int $expected): void { + [$process, $pipes] = $worker; + $output = stream_get_contents($pipes[1]); + $error = stream_get_contents($pipes[2]); + fclose($pipes[1]); fclose($pipes[2]); + check(proc_close($process) === $expected && $error === '', 'Worker failed: '.$error.$output); + }; + foreach (['parallel', 'crash'] as $case) { mkdir($directory.'/'.$case, 0700); } + $one = $spawn($directory.'/parallel', 'normal'); + $two = $spawn($directory.'/parallel', 'normal'); + $finish($one, 0); $finish($two, 0); + check(count(file($directory.'/parallel/deliveries')) === 1, 'Concurrent terminal duplicate reached transport'); + $finish($spawn($directory.'/crash', 'crash'), 23); + $finish($spawn($directory.'/crash', 'normal'), 0); + check(count(file($directory.'/crash/deliveries')) === 2, 'Crash did not replay'); + check(count(file($directory.'/crash/settlements')) === 1, 'Mock game settled twice'); + check(file($directory.'/crash/deliveries')[0] === file($directory.'/crash/deliveries')[1], 'Crash replay changed bytes'); echo "PASS disabled default, restart replay, terminal deduplication, actor/payload binding, response negative control, confirmation gates, private schema\n"; + echo "PASS native cross-process lock, crash after game commit, exact replay and mock game single settlement\n"; } finally { - foreach (glob($directory.'/*') as $file) { unlink($file); } + foreach (glob($directory.'/*') as $file) { + if (is_dir($file)) { + foreach (glob($file.'/*') as $child) { unlink($child); } + rmdir($file); + } else { unlink($file); } + } rmdir($directory); } diff --git a/tests/safehouse/worker.php b/tests/safehouse/worker.php new file mode 100644 index 00000000..d869ccd8 --- /dev/null +++ b/tests/safehouse/worker.php @@ -0,0 +1,30 @@ +directory.'/deliveries', json_encode($request)."\n", FILE_APPEND); + $receipt = $this->directory.'/receipt'; + if (!is_file($receipt)) { + file_put_contents($receipt, json_encode($request)); + file_put_contents($this->directory.'/settlements', "settled\n", FILE_APPEND); + } elseif (json_decode(file_get_contents($receipt), true) !== $request) { + throw new RuntimeException('Mock game identity mismatch'); + } + if ($this->mode === 'crash') { + exit(23); // Game committed; bot never records the reply. + } + usleep(150000); + return array_merge($request, ['status' => 'committed']); + } +}; +$response = (new Client($transport, $directory.'/journal', true))->request('concurrent', '123', 'trade.confirm', ['confirmation_token' => 'game-token'], 4); +if ($response['status'] !== 'committed') { exit(2); } +echo "committed\n"; From ea69fb08e730fb69d8c02dc0b72964b698fafea6 Mon Sep 17 00:00:00 2001 From: Valithor Obsidion Date: Sun, 11 Oct 2026 20:45:29 -0400 Subject: [PATCH 03/22] Run isolated safehouse contracts on Windows and Linux --- .github/workflows/safehouse.yml | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 .github/workflows/safehouse.yml diff --git a/.github/workflows/safehouse.yml b/.github/workflows/safehouse.yml new file mode 100644 index 00000000..c852114a --- /dev/null +++ b/.github/workflows/safehouse.yml @@ -0,0 +1,29 @@ +name: Offline safehouse contract +on: + pull_request: + paths: + - 'src/Civ13/Safehouse/**' + - 'tests/safehouse/**' + - '.github/workflows/safehouse.yml' + push: + paths: + - 'src/Civ13/Safehouse/**' + - 'tests/safehouse/**' + - '.github/workflows/safehouse.yml' +permissions: + contents: read +jobs: + safehouse: + strategy: + matrix: + os: [ubuntu-latest, windows-latest] + php: ['8.3', '8.4'] + runs-on: ${{ matrix.os }} + timeout-minutes: 3 + steps: + - uses: actions/checkout@v4 + - uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + coverage: none + - run: php tests/safehouse/run.php From 7888ca1f221f55dc4c5e753a0912f6a648393429 Mon Sep 17 00:00:00 2001 From: Valithor Obsidion Date: Sun, 11 Oct 2026 20:48:29 -0400 Subject: [PATCH 04/22] Add bounded authenticated async spool primitives and signing fixtures --- docs/safehouse-integration.md | 31 +++++++- src/Civ13/Safehouse/AsyncSpool.php | 108 +++++++++++++++++++++++++++ tests/safehouse/run.php | 45 ++++++++++- tests/safehouse/signing-fixture.json | 12 +++ 4 files changed, 194 insertions(+), 2 deletions(-) create mode 100644 src/Civ13/Safehouse/AsyncSpool.php create mode 100644 tests/safehouse/signing-fixture.json diff --git a/docs/safehouse-integration.md b/docs/safehouse-integration.md index 424e65bc..8cc10d49 100644 --- a/docs/safehouse-integration.md +++ b/docs/safehouse-integration.md @@ -1,6 +1,7 @@ # Discord safehouse integration (provisional, disabled) -This patch provides a pure controller, command schema and durable request client. +This patch provides a pure controller, command schema, durable request client and +disabled enqueue/poll authenticated spool primitives. It does not register commands, start a bot, read game saves or provide a live transport. Compatibility is unaccepted until the game handler executes this contract fixture. Existing verifier cache entries provide no trade authority. @@ -46,6 +47,34 @@ The game owns one bounded queue and idempotency ledger; concurrent/replayed requests cannot settle twice. Multi-party settlement requires one durable shared transaction receipt and recovery, not separate account-save writes. +`AsyncSpool` publishes and polls without sleeping or launching a process. The +host must schedule polling with React timers and stop at its own bounded timeout. +Files are `SHA256(request_id).request` and `.reply`. The private spool directory +must exist. The game must retain immutable request files; archival is a separate +operator policy, never deletion during consumption. + +The authenticated envelope is `{protocol, issued_at, expires_at, body, mac}`. +Protocol is `civ13.safehouse.ipc.v1`; body is base64 of the exact UTF-8 JSON +request/reply bytes. MAC is lowercase hex HMAC-SHA256 of these exact bytes: + +```text +civ13.safehouse.ipc.v1\n\n\n\n +``` + +Each `\n` denotes one literal LF byte; there is no trailing LF. Direction is +`request` or `reply` and prevents reflection. Validity is at most 120 seconds; +future issuance skew is at most five seconds. Replies with expired envelopes +remain pending. The game must reject expired/unlinked/stale requests, enforce +bounded one-use challenges and validate service authentication before processing +actor claims. PHP uses built-in `hash_hmac`; game verification requires its pinned +crypto adapter and must not spawn a process per request. Digest is preserved from +the trusted bot envelope and is never authentication. + +Independent Python-generated signing bytes are pinned in +`tests/safehouse/signing-fixture.json` with a synthetic public fixture key. No +real key appears in code, fixtures, arguments or environment examples. This +fixture is the proposed game compatibility input, not game acceptance evidence. + ## Local operation and limits Run `C:/php/php.exe tests/safehouse/run.php`. It bypasses the ordinary test diff --git a/src/Civ13/Safehouse/AsyncSpool.php b/src/Civ13/Safehouse/AsyncSpool.php new file mode 100644 index 00000000..fd688c5d --- /dev/null +++ b/src/Civ13/Safehouse/AsyncSpool.php @@ -0,0 +1,108 @@ +clock = $clock ?? static fn (): int => time(); + if (strlen($key) < 32) { throw new \InvalidArgumentException('Service key must have at least 32 bytes'); } + if (!is_dir($directory) || is_link($directory)) { throw new \InvalidArgumentException('Private existing spool directory required'); } + $parent = $directory; + while (dirname($parent) !== $parent) { + if (is_link($parent)) { throw new \InvalidArgumentException('Symlink spool ancestor rejected'); } + $parent = dirname($parent); + } + $this->directory = realpath($directory); + } + + /** Returns the opaque file key. Repeated ID must contain identical bytes. */ + public function enqueue(array $request): string + { + if (!$this->enabled) { throw new \LogicException('Safehouse spool disabled'); } + foreach (['protocol', 'request_id', 'discord_id', 'action', 'request_digest'] as $field) { + if (!is_string($request[$field] ?? null)) { throw new \InvalidArgumentException('Invalid request envelope'); } + } + if ($request['protocol'] !== Client::PROTOCOL || !in_array($request['action'], Client::ACTIONS, true)) { throw new \InvalidArgumentException('Unsupported request'); } + $bytes = json_encode($request, JSON_THROW_ON_ERROR); + if (strlen($bytes) > 8192) { throw new \InvalidArgumentException('Spool request too large'); } + $id = hash('sha256', $request['request_id']); + $path = $this->path($id, 'request'); + $lockPath = $this->directory.'/queue.lock'; + if (is_link($lockPath)) { throw new \RuntimeException('Symlink queue lock rejected'); } + $lock = fopen($lockPath, 'c+'); + if ($lock === false || !flock($lock, LOCK_EX | LOCK_NB)) { + if (is_resource($lock)) { fclose($lock); } + throw new \RuntimeException('Queue busy; retry unchanged request'); + } + try { + if (is_file($path)) { + $existing = $this->read($path, 'request'); + if (base64_decode($existing['body'], true) !== $bytes) { throw new \LogicException('Request ID collision'); } + return $id; + } + if (count(glob($this->directory.'/*.request')) >= $this->capacity) { throw new \RuntimeException('Queue capacity reached'); } + $now = ($this->clock)(); + $envelope = self::seal($bytes, 'request', $now, $now + 120, $this->key); + $temporary = $this->directory.'/'.bin2hex(random_bytes(16)).'.tmp'; + $stream = fopen($temporary, 'xb'); + if ($stream === false) { throw new \RuntimeException('Cannot create spool temporary'); } + try { + $encoded = json_encode($envelope, JSON_THROW_ON_ERROR); + if (fwrite($stream, $encoded) !== strlen($encoded) || !fflush($stream) || !fsync($stream)) { throw new \RuntimeException('Cannot persist spool request'); } + } finally { fclose($stream); } + if (!rename($temporary, $path)) { @unlink($temporary); throw new \RuntimeException('Cannot publish spool request'); } + return $id; + } finally { flock($lock, LOCK_UN); fclose($lock); } + } + + /** Missing/expired replies remain pending. Authentication failures throw. */ + public function poll(string $id): array + { + if (!$this->enabled) { throw new \LogicException('Safehouse spool disabled'); } + $requestEnvelope = $this->read($this->path($id, 'request'), 'request'); + $request = json_decode(base64_decode($requestEnvelope['body'], true), true, 32, JSON_THROW_ON_ERROR); + $replyPath = $this->path($id, 'reply'); + if (!is_file($replyPath)) { return ['status' => 'pending', 'request_id' => $request['request_id']]; } + $replyEnvelope = $this->read($replyPath, 'reply'); + if ($replyEnvelope['expires_at'] < ($this->clock)()) { return ['status' => 'pending', 'request_id' => $request['request_id'], 'reason' => 'expired_reply']; } + $reply = json_decode(base64_decode($replyEnvelope['body'], true), true, 32, JSON_THROW_ON_ERROR); + foreach (['protocol', 'request_id', 'discord_id', 'action', 'request_digest'] as $field) { + if (($reply[$field] ?? null) !== $request[$field]) { throw new \UnexpectedValueException('Reply identity mismatch'); } + } + if (!in_array($reply['status'] ?? null, ['pending', 'committed', 'rejected'], true)) { throw new \UnexpectedValueException('Unknown reply status'); } + return $reply; + } + + /** Public for independent fixture adapters; signing never authorizes a trade. */ + public static function seal(string $bytes, string $direction, int $issued, int $expires, string $key): array + { + $body = base64_encode($bytes); + $signed = self::IPC_PROTOCOL."\n".$direction."\n".$issued."\n".$expires."\n".$body; + return ['protocol' => self::IPC_PROTOCOL, 'issued_at' => $issued, 'expires_at' => $expires, 'body' => $body, 'mac' => hash_hmac('sha256', $signed, $key)]; + } + + private function path(string $id, string $suffix): string + { + if (!preg_match('/^[a-f0-9]{64}$/D', $id)) { throw new \InvalidArgumentException('Invalid spool file key'); } + $path = $this->directory.'/'.$id.'.'.$suffix; + if (is_link($path)) { throw new \RuntimeException('Symlink spool entry rejected'); } + return $path; + } + + private function read(string $path, string $direction): array + { + if (!is_file($path) || filesize($path) > 100000) { throw new \RuntimeException('Missing or oversized spool entry'); } + $envelope = json_decode(file_get_contents($path), true, 32, JSON_THROW_ON_ERROR); + if (($envelope['protocol'] ?? null) !== self::IPC_PROTOCOL || !is_int($envelope['issued_at'] ?? null) || !is_int($envelope['expires_at'] ?? null) || !is_string($envelope['body'] ?? null) || !is_string($envelope['mac'] ?? null) || base64_decode($envelope['body'], true) === false) { throw new \UnexpectedValueException('Invalid authenticated envelope'); } + if ($envelope['expires_at'] < $envelope['issued_at'] || $envelope['expires_at'] - $envelope['issued_at'] > 120 || $envelope['issued_at'] > ($this->clock)() + 5) { throw new \UnexpectedValueException('Invalid envelope time bound'); } + $expected = self::seal(base64_decode($envelope['body'], true), $direction, $envelope['issued_at'], $envelope['expires_at'], $this->key); + if (!hash_equals($expected['mac'], $envelope['mac'])) { throw new \UnexpectedValueException('Invalid service authentication'); } + return $envelope; + } +} diff --git a/tests/safehouse/run.php b/tests/safehouse/run.php index e9ff1e96..522e1fd5 100644 --- a/tests/safehouse/run.php +++ b/tests/safehouse/run.php @@ -1,12 +1,13 @@ (new Client($transport, $directory.'/disabled'))->request('i1', '123', 'stash.inspect', [], null)); check(count($transport->calls) === 0, 'Disabled client sent request'); $client = new Client($transport, $directory.'/journal', true); @@ -75,6 +78,46 @@ public function exchange(array $request): array { check(count(file($directory.'/crash/deliveries')) === 2, 'Crash did not replay'); check(count(file($directory.'/crash/settlements')) === 1, 'Mock game settled twice'); check(file($directory.'/crash/deliveries')[0] === file($directory.'/crash/deliveries')[1], 'Crash replay changed bytes'); + mkdir($directory.'/spool', 0700); + $key = str_repeat('synthetic-test-key-', 3); + $now = 1000; + $spool = new AsyncSpool($directory.'/spool', $key, true, 1, fn () => $now); + $request = $original; + $fileKey = $spool->enqueue($request); + $published = file_get_contents($directory.'/spool/'.$fileKey.'.request'); + check($spool->enqueue($request) === $fileKey && file_get_contents($directory.'/spool/'.$fileKey.'.request') === $published, 'Spool replay changed bytes/signature'); + check($spool->poll($fileKey)['status'] === 'pending', 'Missing reply claimed success'); + rejects(fn () => $spool->enqueue(array_merge($request, ['discord_id' => '999']))); + try { $spool->enqueue(array_merge($request, ['request_id' => 'queue-full'])); throw new LogicException('Queue bound missing'); } catch (RuntimeException $e) { check($e->getMessage() === 'Queue capacity reached', 'Unexpected queue exception'); } + $reply = array_merge($request, ['status' => 'committed']); + $replyPath = $directory.'/spool/'.$fileKey.'.reply'; + $sealed = AsyncSpool::seal(json_encode($reply), 'reply', 1000, 1120, $key); + file_put_contents($replyPath, json_encode($sealed)); + check($spool->poll($fileKey)['status'] === 'committed', 'Authenticated reply not accepted'); + $tampered = $sealed; $tampered['mac'] = str_repeat('0', 64); + file_put_contents($replyPath, json_encode($tampered)); + rejects(fn () => $spool->poll($fileKey)); + $tampered = $sealed; $tampered['body'] = base64_encode(json_encode(array_merge($reply, ['discord_id' => '999']))); + file_put_contents($replyPath, json_encode($tampered)); + rejects(fn () => $spool->poll($fileKey)); + file_put_contents($replyPath, json_encode(AsyncSpool::seal(json_encode(array_merge($reply, ['discord_id' => '999'])), 'reply', 1000, 1120, $key))); + rejects(fn () => $spool->poll($fileKey)); + file_put_contents($replyPath, json_encode(AsyncSpool::seal(json_encode($reply), 'reply', 800, 920, $key))); + check($spool->poll($fileKey)['status'] === 'pending', 'Expired reply claimed success'); + rejects(fn () => $spool->poll('../escape')); + // Linux CI can create symlinks without Windows Developer Mode privileges. + if (PHP_OS_FAMILY !== 'Windows') { + unlink($replyPath); + $linkPath = $replyPath; + check(symlink($directory.'/journal', $linkPath), 'Symlink fixture unavailable'); + try { $spool->poll($fileKey); throw new LogicException('Symlink accepted'); } catch (RuntimeException $e) { check(str_contains($e->getMessage(), 'Symlink'), 'Wrong reply symlink rejection'); } + unlink($linkPath); + $requestLink = $directory.'/spool/'.str_repeat('b', 64).'.request'; + check(symlink($directory.'/journal', $requestLink), 'Request symlink fixture unavailable'); + try { $spool->poll(str_repeat('b', 64)); throw new LogicException('Request symlink accepted'); } catch (RuntimeException $e) { check(str_contains($e->getMessage(), 'Symlink'), 'Wrong symlink rejection'); } + unlink($requestLink); + } + echo "PASS bounded authenticated spool, byte-identical publication/replay, actor/body/MAC negative controls and reply expiry\n"; echo "PASS disabled default, restart replay, terminal deduplication, actor/payload binding, response negative control, confirmation gates, private schema\n"; echo "PASS native cross-process lock, crash after game commit, exact replay and mock game single settlement\n"; } finally { diff --git a/tests/safehouse/signing-fixture.json b/tests/safehouse/signing-fixture.json new file mode 100644 index 00000000..99b1fc44 --- /dev/null +++ b/tests/safehouse/signing-fixture.json @@ -0,0 +1,12 @@ +{ + "synthetic_key": "0123456789abcdef0123456789abcdef", + "direction": "request", + "exact_body": "{\"protocol\":\"civ13.safehouse.v1\",\"request_id\":\"fixture-1\",\"discord_id\":\"123\",\"action\":\"stash.inspect\",\"expected_account_revision\":null,\"payload\":{},\"request_digest\":\"synthetic-fixture-digest\"}", + "envelope": { + "protocol": "civ13.safehouse.ipc.v1", + "issued_at": 1000, + "expires_at": 1120, + "body": "eyJwcm90b2NvbCI6ImNpdjEzLnNhZmVob3VzZS52MSIsInJlcXVlc3RfaWQiOiJmaXh0dXJlLTEiLCJkaXNjb3JkX2lkIjoiMTIzIiwiYWN0aW9uIjoic3Rhc2guaW5zcGVjdCIsImV4cGVjdGVkX2FjY291bnRfcmV2aXNpb24iOm51bGwsInBheWxvYWQiOnt9LCJyZXF1ZXN0X2RpZ2VzdCI6InN5bnRoZXRpYy1maXh0dXJlLWRpZ2VzdCJ9", + "mac": "fddb71611d2a4732372e767fc8692ab8e756af399b63261f72e84b6c9f7bccfb" + } +} From 13e51f6cc601d1bb1c8940ea19e08e89ee28df8d Mon Sep 17 00:00:00 2001 From: Valithor Obsidion Date: Sun, 11 Oct 2026 20:49:06 -0400 Subject: [PATCH 05/22] Recover expired operations through fresh read-only status requests --- docs/safehouse-integration.md | 10 ++++++++++ src/Civ13/Safehouse/AsyncSpool.php | 14 ++++++++++++++ tests/safehouse/run.php | 11 ++++++++++- 3 files changed, 34 insertions(+), 1 deletion(-) diff --git a/docs/safehouse-integration.md b/docs/safehouse-integration.md index 8cc10d49..8060f086 100644 --- a/docs/safehouse-integration.md +++ b/docs/safehouse-integration.md @@ -75,6 +75,16 @@ Independent Python-generated signing bytes are pinned in real key appears in code, fixtures, arguments or environment examples. This fixture is the proposed game compatibility input, not game acceptance evidence. +Expired envelopes do not imply economic failure. `recover(file_key, +new_status_request_id)` enqueues a newly signed read-only `trade.status` request +whose payload is `{operation_request_id, operation_request_digest}` and whose +actor matches the original operation. Its fresh envelope has an independent +request ID/digest, while the economic operation and original file remain +immutable. The game authenticates it and checks ownership of the original +receipt before returning its outcome. Do not reissue an economic operation with +a fresh ID after a timeout or envelope expiry. Status queries consume bounded +queue capacity and do not authorize deleting uncertain operation files. + ## Local operation and limits Run `C:/php/php.exe tests/safehouse/run.php`. It bypasses the ordinary test diff --git a/src/Civ13/Safehouse/AsyncSpool.php b/src/Civ13/Safehouse/AsyncSpool.php index fd688c5d..aa24de8b 100644 --- a/src/Civ13/Safehouse/AsyncSpool.php +++ b/src/Civ13/Safehouse/AsyncSpool.php @@ -79,6 +79,20 @@ public function poll(string $id): array return $reply; } + /** Fresh authenticated read-only status request for an expired/uncertain + * operation. Never re-signs or reissues that economic operation. The game + * checks the actor against the original durable receipt before disclosure. */ + public function recover(string $id, string $statusRequestId): string + { + if (!$this->enabled) { throw new \LogicException('Safehouse spool disabled'); } + $originalEnvelope = $this->read($this->path($id, 'request'), 'request'); + $original = json_decode(base64_decode($originalEnvelope['body'], true), true, 32, JSON_THROW_ON_ERROR); + if ($statusRequestId === $original['request_id']) { throw new \InvalidArgumentException('Recovery needs a new status request ID'); } + $request = ['protocol' => Client::PROTOCOL, 'request_id' => $statusRequestId, 'discord_id' => $original['discord_id'], 'action' => 'trade.status', 'expected_account_revision' => null, 'payload' => ['operation_request_id' => $original['request_id'], 'operation_request_digest' => $original['request_digest']]]; + $request['request_digest'] = hash('sha256', json_encode($request, JSON_THROW_ON_ERROR)); + return $this->enqueue($request); + } + /** Public for independent fixture adapters; signing never authorizes a trade. */ public static function seal(string $bytes, string $direction, int $issued, int $expires, string $key): array { diff --git a/tests/safehouse/run.php b/tests/safehouse/run.php index 522e1fd5..19e57e98 100644 --- a/tests/safehouse/run.php +++ b/tests/safehouse/run.php @@ -81,7 +81,8 @@ public function exchange(array $request): array { mkdir($directory.'/spool', 0700); $key = str_repeat('synthetic-test-key-', 3); $now = 1000; - $spool = new AsyncSpool($directory.'/spool', $key, true, 1, fn () => $now); + $clock = function () use (&$now): int { return $now; }; + $spool = new AsyncSpool($directory.'/spool', $key, true, 1, $clock); $request = $original; $fileKey = $spool->enqueue($request); $published = file_get_contents($directory.'/spool/'.$fileKey.'.request'); @@ -105,6 +106,14 @@ public function exchange(array $request): array { file_put_contents($replyPath, json_encode(AsyncSpool::seal(json_encode($reply), 'reply', 800, 920, $key))); check($spool->poll($fileKey)['status'] === 'pending', 'Expired reply claimed success'); rejects(fn () => $spool->poll('../escape')); + $now = 1300; + $recoverySpool = new AsyncSpool($directory.'/spool', $key, true, 2, $clock); + $recoveryId = $recoverySpool->recover($fileKey, 'status-after-expiry'); + $recoveryEnvelope = json_decode(file_get_contents($directory.'/spool/'.$recoveryId.'.request'), true); + $recoveryRequest = json_decode(base64_decode($recoveryEnvelope['body']), true); + check($recoveryEnvelope['issued_at'] === 1300 && $recoveryRequest['action'] === 'trade.status' && $recoveryRequest['payload']['operation_request_id'] === $request['request_id'] && $recoveryRequest['payload']['operation_request_digest'] === $request['request_digest'], 'Recovery reissued economic operation'); + check(file_get_contents($directory.'/spool/'.$fileKey.'.request') === $published, 'Recovery overwrote immutable operation'); + rejects(fn () => $recoverySpool->recover($fileKey, $request['request_id'])); // Linux CI can create symlinks without Windows Developer Mode privileges. if (PHP_OS_FAMILY !== 'Windows') { unlink($replyPath); From 790d47f791f6db60a574ab589e936b1cbad9cfbd Mon Sep 17 00:00:00 2001 From: Valithor Obsidion Date: Sun, 11 Oct 2026 20:59:44 -0400 Subject: [PATCH 06/22] Integrate disabled Discord safehouse routing with bounded async timers --- .github/workflows/safehouse.yml | 17 ++++ config/safehouse.example.php | 8 ++ docs/safehouse-integration.md | 46 +++++++-- src/Civ13/Civ13.php | 6 ++ src/Civ13/Safehouse/AsyncDriver.php | 54 +++++++++++ src/Civ13/Safehouse/AsyncGateway.php | 8 ++ src/Civ13/Safehouse/Client.php | 39 +++++--- src/Civ13/Safehouse/Controller.php | 9 +- src/Civ13/Safehouse/DiscordRoute.php | 58 +++++++++++ src/Civ13/Safehouse/ReactTimer.php | 14 +++ src/Civ13/Slash.php | 17 ++++ tests/safehouse/discord.php | 139 +++++++++++++++++++++++++++ tests/safehouse/schema.php | 5 + 13 files changed, 399 insertions(+), 21 deletions(-) create mode 100644 config/safehouse.example.php create mode 100644 src/Civ13/Safehouse/AsyncDriver.php create mode 100644 src/Civ13/Safehouse/AsyncGateway.php create mode 100644 src/Civ13/Safehouse/DiscordRoute.php create mode 100644 src/Civ13/Safehouse/ReactTimer.php create mode 100644 tests/safehouse/discord.php create mode 100644 tests/safehouse/schema.php diff --git a/.github/workflows/safehouse.yml b/.github/workflows/safehouse.yml index c852114a..db59150c 100644 --- a/.github/workflows/safehouse.yml +++ b/.github/workflows/safehouse.yml @@ -3,11 +3,15 @@ on: pull_request: paths: - 'src/Civ13/Safehouse/**' + - 'src/Civ13/Slash.php' + - 'src/Civ13/Civ13.php' - 'tests/safehouse/**' - '.github/workflows/safehouse.yml' push: paths: - 'src/Civ13/Safehouse/**' + - 'src/Civ13/Slash.php' + - 'src/Civ13/Civ13.php' - 'tests/safehouse/**' - '.github/workflows/safehouse.yml' permissions: @@ -27,3 +31,16 @@ jobs: php-version: ${{ matrix.php }} coverage: none - run: php tests/safehouse/run.php + discord-routing: + runs-on: ubuntu-latest + timeout-minutes: 8 + steps: + - uses: actions/checkout@v4 + - uses: shivammathur/setup-php@v2 + with: + php-version: '8.4' + coverage: none + tools: composer:v2 + - run: composer install --no-dev --prefer-dist --no-interaction --no-progress + - run: php tests/safehouse/discord.php + - run: php tests/safehouse/schema.php > /tmp/safehouse-desired.json diff --git a/config/safehouse.example.php b/config/safehouse.example.php new file mode 100644 index 00000000..cb08fa8f --- /dev/null +++ b/config/safehouse.example.php @@ -0,0 +1,8 @@ + false, + 'safehouse_gateway' => null, +]; diff --git a/docs/safehouse-integration.md b/docs/safehouse-integration.md index 8060f086..294c5a21 100644 --- a/docs/safehouse-integration.md +++ b/docs/safehouse-integration.md @@ -1,9 +1,9 @@ # Discord safehouse integration (provisional, disabled) -This patch provides a pure controller, command schema, durable request client and -disabled enqueue/poll authenticated spool primitives. -It does not register commands, start a bot, read game saves or provide a live -transport. Compatibility is unaccepted until the game handler executes this +This patch provides a controller, command schema, durable request client, +disabled enqueue/poll authenticated spool and actual DiscordPHP routing hooks. +No commands have been registered, bot started or game saves read. Compatibility +is unaccepted until the game handler executes this contract fixture. Existing verifier cache entries provide no trade authority. ## Version 1 request @@ -91,7 +91,7 @@ Run `C:/php/php.exe tests/safehouse/run.php`. It bypasses the ordinary test bootstrap, which reads `.env`. Tests use injected game transport and temporary journals with no Discord/network/bootstrap effects. Constructor `enabled` is false by default. Hosting code must use trusted Discord actor/interaction IDs -and ephemeral responses; this patch exposes no bot config or listener yet. +and ephemeral responses; the disabled routing configuration is documented below. The journal serializes local requests with an OS file lock, persists before transport delivery and caches terminal replies. It bounds records at 1000 and @@ -114,4 +114,38 @@ receipts, atomically publish a signed IPC file, then poll bounded reply paths wi the React event-loop timer. Resolve timeout as pending, retaining the same request bytes. Validate authenticated reply binding before terminal journal storage. Do not call this synchronous client from a Discord interaction callback; the current -implementation belongs in an isolated worker until this split adapter is built. +implementation belongs in an isolated worker; Discord uses AsyncDriver and the spool primitives instead. + +## Disabled Discord routing activation package + +`config/safehouse.example.php` declares `safehouse_enabled=false` and +`safehouse_gateway=null`. Civ13's actual options resolver validates these values. +When explicitly enabled with an injected `AsyncGateway` and configured +`civ13_guild_id`, Slash binds only the `safehouse` callback and reconciles only +that named global command, preserving other commands. It never bulk overwrites +the command repository. Disabled mode neither binds nor saves a safehouse command. +Registered stale commands are not silently deleted when disabled. + +The actual route rejects wrong-guild/absent-member requests, derives actor from +Discord membership and request ID from the interaction, and requires both a game +confirmation token/revision and explicit `confirm=true` for irreversible actions. +It defers ephemerally using DiscordPHP `acknowledgeWithResponse(true)` before +enqueueing; a failed acknowledgement sends no request. All later updates retain +that private response and disable mentions. Pending and transport errors report +uncertainty; rejected outcomes remain game rejections. JSON views are bounded and +page requests are game-owned; rendering does not edit metadata or inventories. + +Construct `AsyncDriver(spool, ReactTimer::repeat(loop))` only after crypto/game +compatibility acceptance. It deduplicates active identical requests, bounds 64 +active requests and 40 quarter-second polls by default, cancels terminal/timeout +timers and resolves timeouts as pending. No blocking synchronous client runs in +this route. It is an injected timer driver, not automatic production activation. + +Run `php tests/safehouse/discord.php ` for offline +actual installed-library signature and handler tests. The runner loads Composer +autoload only; it bypasses bot constructors and `.env`, creates inert reflection +fixtures for Slash/Discord and mocks interaction calls/game gateway. Run +`php tests/safehouse/schema.php` to export the exact desired schema without a bot. +CI tests Discord routing against resolved Composer dependencies separately from +the dependency-free Windows/Linux spool regressions. No live registration or +Discord send was performed. diff --git a/src/Civ13/Civ13.php b/src/Civ13/Civ13.php index 3ab41229..1ee1dd87 100644 --- a/src/Civ13/Civ13.php +++ b/src/Civ13/Civ13.php @@ -461,6 +461,8 @@ private function resolveOptions(array &$options = []): array $resolver ->setDefined([ + 'safehouse_enabled', + 'safehouse_gateway', 'welcome_message', 'logger', 'onFulfilledDefault', @@ -500,6 +502,8 @@ private function resolveOptions(array &$options = []): array 'webserver_url', ]) ->setDefaults([ + 'safehouse_enabled' => false, + 'safehouse_gateway' => null, 'welcome_message' => '', 'logger' => null, 'onFulfilledDefault' => null, @@ -539,6 +543,8 @@ private function resolveOptions(array &$options = []): array 'webserver_url' => 'www.valzargaming.com', ]) ->setAllowedTypes('welcome_message', 'string') + ->setAllowedTypes('safehouse_enabled', 'bool') + ->setAllowedTypes('safehouse_gateway', ['null', \Civ13\Safehouse\AsyncGateway::class]) ->setAllowedTypes('logger', ['null', Logger::class]) ->setAllowedTypes('onFulfilledDefault', ['null', 'callable']) ->setAllowedTypes('onRejectedDefault', ['null', 'callable']) diff --git a/src/Civ13/Safehouse/AsyncDriver.php b/src/Civ13/Safehouse/AsyncDriver.php new file mode 100644 index 00000000..fa399ace --- /dev/null +++ b/src/Civ13/Safehouse/AsyncDriver.php @@ -0,0 +1,54 @@ + cancellation callable; ReactTimer supplies + * the production implementation. Finite polling never blocks Discord's loop. */ +final class AsyncDriver implements AsyncGateway +{ + private array $active = []; + public function __construct(private AsyncSpool $spool, private \Closure $repeat, private int $maxPolls = 40, private int $capacity = 64) + { + if ($maxPolls < 1 || $maxPolls > 240 || $capacity < 1 || $capacity > 1000) { throw new \InvalidArgumentException('Invalid async polling bounds'); } + } + + public function submit(array $request): PromiseInterface + { + $identity = $request['request_id']; + $bytes = json_encode($request, JSON_THROW_ON_ERROR); + if (isset($this->active[$identity])) { + if ($this->active[$identity]['bytes'] !== $bytes) { throw new \LogicException('Active request collision'); } + return $this->active[$identity]['promise']; + } + if (count($this->active) >= $this->capacity) { throw new \RuntimeException('Async request capacity reached'); } + $id = $this->spool->enqueue($request); + $deferred = new Deferred(); + $promise = $deferred->promise(); + $this->active[$identity] = ['bytes' => $bytes, 'promise' => $promise]; + $polls = 0; + $cancel = null; + $tick = function () use ($id, $identity, $deferred, &$polls, &$cancel): void { + try { + $outcome = $this->spool->poll($id); + if ($outcome['status'] === 'pending' && ++$polls < $this->maxPolls) { return; } + if ($cancel !== null) { $cancel(); } + unset($this->active[$identity]); + $deferred->resolve($outcome); + } catch (\Throwable $error) { + if ($cancel !== null) { $cancel(); } + unset($this->active[$identity]); + $deferred->reject($error); + } + }; + try { + $cancel = ($this->repeat)(0.25, $tick); + if (!is_callable($cancel)) { throw new \LogicException('Timer cancellation callable required'); } + } catch (\Throwable $error) { + unset($this->active[$identity]); + $deferred->reject($error); + } + return $promise; + } +} diff --git a/src/Civ13/Safehouse/AsyncGateway.php b/src/Civ13/Safehouse/AsyncGateway.php new file mode 100644 index 00000000..f7fe4c78 --- /dev/null +++ b/src/Civ13/Safehouse/AsyncGateway.php @@ -0,0 +1,8 @@ +enabled) { throw new \LogicException('Safehouse integration is disabled'); } - if (!preg_match('/^[A-Za-z0-9_-]{1,96}$/D', $requestId) || !preg_match('/^[0-9]{1,22}$/D', $discordId) || !in_array($action, self::ACTIONS, true) || ($expectedAccountRevision !== null && $expectedAccountRevision < 0)) { - throw new \InvalidArgumentException('Invalid request identity/action/revision'); - } - $request = ['protocol' => self::PROTOCOL, 'request_id' => $requestId, 'discord_id' => $discordId, 'action' => $action, 'expected_account_revision' => $expectedAccountRevision, 'payload' => self::canonical($payload)]; - $encoded = json_encode($request, JSON_THROW_ON_ERROR); - if (strlen($encoded) > 8192) { - throw new \InvalidArgumentException('Request exceeds safehouse size bound'); - } - $digest = hash('sha256', $encoded); - $request['request_digest'] = $digest; + $request = self::envelope($requestId, $discordId, $action, $payload, $expectedAccountRevision); // Serialize local delivery and persist before external effects. A timeout retains // the exact request for replay; only the game can deduplicate settlement. $lock = fopen($this->journal.'.lock', 'c+'); @@ -40,6 +31,30 @@ public function request(string $requestId, string $discordId, string $action, ar usleep(10000); } try { + return $this->deliver($request, $requestId); + } finally { + flock($lock, LOCK_UN); + fclose($lock); + } + } + + public static function envelope(string $requestId, string $discordId, string $action, array $payload, ?int $expectedAccountRevision): array + { + if (!preg_match('/^[A-Za-z0-9_-]{1,96}$/D', $requestId) || !preg_match('/^[0-9]{1,22}$/D', $discordId) || !in_array($action, self::ACTIONS, true) || ($expectedAccountRevision !== null && $expectedAccountRevision < 0)) { + throw new \InvalidArgumentException('Invalid request identity/action/revision'); + } + $request = ['protocol' => self::PROTOCOL, 'request_id' => $requestId, 'discord_id' => $discordId, 'action' => $action, 'expected_account_revision' => $expectedAccountRevision, 'payload' => self::canonical($payload)]; + $encoded = json_encode($request, JSON_THROW_ON_ERROR); + if (strlen($encoded) > 8192) { + throw new \InvalidArgumentException('Request exceeds safehouse size bound'); + } + $digest = hash('sha256', $encoded); + $request['request_digest'] = $digest; + return $request; + } + + private function deliver(array $request, string $requestId): array + { $records = is_file($this->journal) ? json_decode(file_get_contents($this->journal), true, 64, JSON_THROW_ON_ERROR) : []; if (!is_array($records)) { throw new \RuntimeException('Invalid safehouse journal'); @@ -77,10 +92,6 @@ public function request(string $requestId, string $discordId, string $action, ar $records[$requestId]['response'] = $response; $this->persist($records); return $response; - } finally { - flock($lock, LOCK_UN); - fclose($lock); - } } private function persist(array $records): void diff --git a/src/Civ13/Safehouse/Controller.php b/src/Civ13/Safehouse/Controller.php index ab92f032..12a50b27 100644 --- a/src/Civ13/Safehouse/Controller.php +++ b/src/Civ13/Safehouse/Controller.php @@ -14,10 +14,17 @@ public static function schema(): array ['name' => 'action', 'description' => 'Game-authoritative action', 'type' => 3, 'required' => true, 'choices' => array_map(fn ($action) => ['name' => $action, 'value' => $action], Client::ACTIONS)], ['name' => 'payload', 'description' => 'Action JSON (confirmation token for irreversible actions)', 'type' => 3, 'required' => true], ['name' => 'revision', 'description' => 'Expected game account revision', 'type' => 4, 'required' => false], + ['name' => 'confirm', 'description' => 'Confirm the reviewed game quote for an irreversible action', 'type' => 5, 'required' => false], ]]; } public function handle(string $interactionId, string $actorDiscordId, string $action, string $json, ?int $revision): array + { + $request = self::prepare($interactionId, $actorDiscordId, $action, $json, $revision); + return ['ephemeral' => true, 'outcome' => $this->client->request($interactionId, $actorDiscordId, $action, $request['payload'], $revision)]; + } + + public static function prepare(string $interactionId, string $actorDiscordId, string $action, string $json, ?int $revision): array { if (strlen($json) > 4096) { throw new \InvalidArgumentException('Payload too large'); @@ -34,6 +41,6 @@ public function handle(string $interactionId, string $actorDiscordId, string $ac if (in_array($action, ['trade.confirm', 'market.list', 'market.buy', 'market.cancel', 'room.manage'], true) && (!is_string($payload['confirmation_token'] ?? null) || $payload['confirmation_token'] === '' || $revision === null)) { throw new \InvalidArgumentException('Game confirmation token and revision required'); } - return ['ephemeral' => true, 'outcome' => $this->client->request($interactionId, $actorDiscordId, $action, $payload, $revision)]; + return Client::envelope($interactionId, $actorDiscordId, $action, $payload, $revision); } } diff --git a/src/Civ13/Safehouse/DiscordRoute.php b/src/Civ13/Safehouse/DiscordRoute.php new file mode 100644 index 00000000..d1a39e62 --- /dev/null +++ b/src/Civ13/Safehouse/DiscordRoute.php @@ -0,0 +1,58 @@ +enabled) { throw new \LogicException('Safehouse integration is disabled.'); } + if ($this->guildId === '' || (string) $interaction->guild_id !== $this->guildId || !$interaction->member || !$interaction->member->id) { throw new \LogicException('Use safehouse in its configured server.'); } + $options = $interaction->data->options; + $action = $options['action']->value ?? ''; + $payload = $options['payload']->value ?? '{}'; + $revision = $options['revision']->value ?? null; + if (!is_string($action) || !is_string($payload) || ($revision !== null && !is_int($revision))) { throw new \InvalidArgumentException('Invalid safehouse options.'); } + if (in_array($action, ['trade.confirm', 'market.list', 'market.buy', 'market.cancel', 'room.manage'], true) && ($options['confirm']->value ?? false) !== true) { throw new \LogicException('Review the game quote and set confirm to true before this irreversible action.'); } + $request = Controller::prepare((string) $interaction->id, (string) $interaction->member->id, $action, $payload, $revision); + } catch (\Throwable $error) { + return $interaction->respondWithMessage(self::message($error->getMessage()), true); + } + // Defer ephemerally before enqueuing. Failed acknowledgement must produce + // no economic request; failed edit leaves the durable request recoverable. + return $interaction->acknowledgeWithResponse(true)->then(function () use ($interaction, $request): PromiseInterface { + try { + $promise = $this->gateway->submit($request); + } catch (\Throwable $error) { + return $interaction->updateOriginalResponse(self::message('Request remains unresolved. Inspect its status before retrying an economic action.')); + } + return $promise->then( + fn (array $outcome): PromiseInterface => $interaction->updateOriginalResponse(self::message(self::render($outcome))), + fn (\Throwable $error): PromiseInterface => $interaction->updateOriginalResponse(self::message('Request remains unresolved. Inspect its status before retrying an economic action.')) + ); + }); + } + + private static function message(string $content): MessageBuilder + { + return MessageBuilder::new()->setContent(substr($content, 0, 1900))->setAllowedMentions(['parse' => []]); + } + + private static function render(array $outcome): string + { + $id = (string) ($outcome['request_id'] ?? 'unknown'); + $status = $outcome['status'] ?? 'pending'; + if ($status === 'pending') { return 'Request '.$id.' is pending. No completed trade is confirmed. Use status recovery before another economic action.'; } + if ($status === 'rejected') { return 'Game rejected request '.$id.'. '.json_encode($outcome['data'] ?? $outcome['reason'] ?? [], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR); } + if ($status !== 'committed') { return 'Request '.$id.' remains unresolved.'; } + return 'Game confirmed request '.$id.'. '.json_encode($outcome['data'] ?? [], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR); + } +} diff --git a/src/Civ13/Safehouse/ReactTimer.php b/src/Civ13/Safehouse/ReactTimer.php new file mode 100644 index 00000000..2de46e5e --- /dev/null +++ b/src/Civ13/Safehouse/ReactTimer.php @@ -0,0 +1,14 @@ +addPeriodicTimer($interval, $callback); + return static fn () => $loop->cancelTimer($timer); + }; + } +} diff --git a/src/Civ13/Slash.php b/src/Civ13/Slash.php index 0840c90b..081fca4b 100644 --- a/src/Civ13/Slash.php +++ b/src/Civ13/Slash.php @@ -39,6 +39,7 @@ class Slash public Discord $discord; public Logger $logger; private bool $setup = false; + private ?\Civ13\Safehouse\DiscordRoute $safehouseRoute = null; /** @param Civ13 $civ13 The bot instance; its `discord` and `logger` are bound by reference. */ public function __construct(Civ13 &$civ13) @@ -54,6 +55,14 @@ public function __construct(Civ13 &$civ13) */ protected function afterConstruct(): void { + if (($this->civ13->options['safehouse_enabled'] ?? false) === true) { + $gateway = $this->civ13->options['safehouse_gateway'] ?? null; + if (!$gateway instanceof \Civ13\Safehouse\AsyncGateway || $this->civ13->civ13_guild_id === '') { + throw new \LogicException('Enabled safehouse requires an injected async gateway and configured guild'); + } + $this->safehouseRoute = new \Civ13\Safehouse\DiscordRoute($gateway, $this->civ13->civ13_guild_id, true); + $this->discord->listenCommand('safehouse', fn (Interaction $interaction): PromiseInterface => $this->safehouseRoute->handle($interaction)); + } $this->__declareListeners(); $fn = function () { $this->logger->info('Setting up Interaction commands...'); @@ -132,6 +141,14 @@ public function sendFollowUpMessage(Interaction $interaction, MessageBuilder|str private function __updateCommands(): void { $this->discord->application->commands->freshen()->then(function (GlobalCommandRepository $commands): void { + if ($this->safehouseRoute !== null) { + $schema = \Civ13\Safehouse\Controller::schema(); + if ($existing = $commands->get('name', 'safehouse')) { + $schema['id'] = $existing->id; + } + // Reconcile only this named command; never bulk overwrite. + $this->save($commands, new Command($this->discord, $schema)); + } if ($names = array_map(fn (Command $command) => $command->name, iterator_to_array($commands))) { $this->logger->debug(sprintf('[GLOBAL APPLICATION COMMAND LIST] `%s`', implode('`, `', $names))); } diff --git a/tests/safehouse/discord.php b/tests/safehouse/discord.php new file mode 100644 index 00000000..c97818d2 --- /dev/null +++ b/tests/safehouse/discord.php @@ -0,0 +1,139 @@ +newInstanceWithoutConstructor(); + $fixture->member = (object) ['id' => '123']; + $fixture->data = (object) ['options' => ['action' => (object) ['value' => $action], 'payload' => (object) ['value' => $payload], 'confirm' => (object) ['value' => $confirm]]]; + if ($revision !== null) { $fixture->data->options['revision'] = (object) ['value' => $revision]; } + return $fixture; + } + public function respondWithMessage(MessageBuilder|string $builder, bool $ephemeral = false): PromiseInterface { + $this->messages[] = ['initial', $ephemeral, $builder instanceof MessageBuilder ? $builder->jsonSerialize() : $builder]; + return resolve(null); + } + public function acknowledgeWithResponse(bool $ephemeral = false): PromiseInterface { + $this->messages[] = ['defer', $ephemeral]; + return $this->failAck ? reject(new RuntimeException('Expired interaction')) : resolve(null); + } + public function updateOriginalResponse(MessageBuilder $builder): PromiseInterface { + $this->messages[] = ['edit', true, $builder->jsonSerialize()]; + return resolve(null); + } +} +$gateway = new class implements AsyncGateway { + public array $requests = []; + public array $deferreds = []; + public function submit(array $request): PromiseInterface { + $this->requests[] = $request; + $deferred = new Deferred(); $this->deferreds[] = $deferred; + return $deferred->promise(); + } +}; +$disabled = MockInteraction::fixture(); +(new DiscordRoute($gateway, '456'))->handle($disabled); +check(count($gateway->requests) === 0 && $disabled->messages[0][1], 'Disabled route sent request or leaked response'); +$route = new DiscordRoute($gateway, '456', true); +$wrongGuild = MockInteraction::fixture(); $wrongGuild->guild_id = '999'; +$route->handle($wrongGuild); +check(count($gateway->requests) === 0, 'Wrong guild reached game'); +$unconfirmed = MockInteraction::fixture('trade.confirm', '{"confirmation_token":"game-token"}', 4); +$route->handle($unconfirmed); +check(count($gateway->requests) === 0 && str_contains($unconfirmed->messages[0][2]['content'], 'confirm'), 'Mutation omitted explicit confirmation'); +$forged = MockInteraction::fixture('trade.quote', '{"discord_id":"999"}', 4); +$route->handle($forged); +check(count($gateway->requests) === 0, 'Actor option reached game'); +$expired = MockInteraction::fixture(); $expired->failAck = true; +$route->handle($expired)->then(null, static fn () => null); +check(count($gateway->requests) === 0, 'Failed Discord acknowledgement reached game'); +$inspect = MockInteraction::fixture(); +$route->handle($inspect); +check($inspect->messages === [['defer', true]], 'Inspection did not defer ephemerally'); +check($gateway->requests[0]['discord_id'] === '123', 'Actor not bound to Discord member'); +$gateway->deferreds[0]->resolve(['status' => 'pending', 'request_id' => '123456']); +check(str_contains($inspect->messages[1][2]['content'], 'pending') && $inspect->messages[1][2]['allowed_mentions'] === ['parse' => []], 'Pending claimed success or allowed mentions'); +$confirmed = MockInteraction::fixture('trade.confirm', '{"confirmation_token":"game-token"}', 4, true); $confirmed->id = '123457'; +$route->handle($confirmed); +check($gateway->requests[1]['payload'] === ['confirmation_token' => 'game-token'], 'Confirmation changed opaque game token'); +$gateway->deferreds[1]->resolve(['status' => 'committed', 'request_id' => '123457', 'data' => ['nested_id' => 'child', 'fir' => true, 'condition' => 0.75]]); +check(str_contains($confirmed->messages[1][2]['content'], 'child'), 'Inspection metadata omitted'); + +// Construct only the Slash adapter over a reflection-created inert Civ13 object. +// No Civ13/Discord constructor, token, init event or remote command save runs. +require __DIR__.'/../../src/Civ13/Civ13.php'; +require __DIR__.'/../../src/Civ13/Slash.php'; +class MockDiscord extends \Discord\Discord +{ + public array $bound = []; + public function listenCommand($names, ?callable $callback = null, ?callable $autocomplete_callback = null): \Discord\Helpers\RegisteredCommand { + $name = is_array($names) ? $names[0] : $names; + $this->bound[$name] = $callback; + return new \Discord\Helpers\RegisteredCommand($this, $name, $callback, $autocomplete_callback); + } +} +$bareBot = function (bool $enabled) use ($gateway): \Civ13\Civ13 { + $bot = (new ReflectionClass(\Civ13\Civ13::class))->newInstanceWithoutConstructor(); + $bot->discord = (new ReflectionClass(MockDiscord::class))->newInstanceWithoutConstructor(); + $bot->logger = new \Monolog\Logger('offline-safehouse'); + $bot->ready = false; + $bot->civ13_guild_id = '456'; + $bot->options = ['safehouse_enabled' => $enabled, 'safehouse_gateway' => $gateway]; + return $bot; +}; +$disabledBot = $bareBot(false); new \Civ13\Slash($disabledBot); +check(!isset($disabledBot->discord->bound['safehouse']) && isset($disabledBot->discord->bound['ping']), 'Disabled Slash route changed existing command'); +$enabledBot = $bareBot(true); new \Civ13\Slash($enabledBot); +check(isset($enabledBot->discord->bound['safehouse']) && isset($enabledBot->discord->bound['ping']), 'Enabled Slash route missing or replaced existing command'); +$boundInteraction = MockInteraction::fixture(); $boundInteraction->id = '654321'; +($enabledBot->discord->bound['safehouse'])($boundInteraction); +check(end($gateway->requests)['request_id'] === '654321', 'Slash callback did not use actual safehouse route'); +$gateway->deferreds[2]->resolve(['status' => 'rejected', 'request_id' => '654321', 'reason' => 'unlinked']); +check(str_contains($boundInteraction->messages[1][2]['content'], 'unlinked'), 'Authoritative unlinked rejection omitted'); + +// Fake timer driver checks finite asynchronous polling and cancellation. +$directory = sys_get_temp_dir().'/civ13-discord-safehouse-'.bin2hex(random_bytes(8)); +mkdir($directory, 0700); +$ticks = []; $cancelled = []; +$repeat = function (float $interval, callable $tick) use (&$ticks, &$cancelled): Closure { + $index = count($ticks); $ticks[$index] = $tick; + return function () use ($index, &$cancelled): void { $cancelled[$index] = true; }; +}; +try { + $spool = new AsyncSpool($directory, str_repeat('synthetic-', 4), true); + $driver = new AsyncDriver($spool, $repeat, 2, 1); + $request = Client::envelope('timer-1', '123', 'stash.inspect', [], null); + $outcome = null; + $promise = $driver->submit($request); + check($driver->submit($request) === $promise && count($ticks) === 1, 'Duplicate created second timer'); + try { $driver->submit(array_merge($request, ['discord_id' => '999'])); throw new RuntimeException('Active collision accepted'); } catch (LogicException $e) {} + try { $driver->submit(Client::envelope('capacity', '123', 'stash.inspect', [], null)); throw new LogicException('Active capacity accepted'); } catch (RuntimeException $e) { check($e->getMessage() === 'Async request capacity reached', 'Wrong capacity rejection'); } + $promise->then(function ($value) use (&$outcome): void { $outcome = $value; }); + $ticks[0](); check($outcome === null, 'Missing reply settled early'); + $ticks[0](); check($outcome['status'] === 'pending' && $cancelled[0], 'Timeout did not cancel/preserve pending'); + $request2 = Client::envelope('timer-2', '123', 'stash.inspect', [], null); + $driver->submit($request2)->then(function ($value) use (&$outcome): void { $outcome = $value; }); + $response = array_merge($request2, ['status' => 'rejected', 'reason' => 'in_raid']); + $id = hash('sha256', 'timer-2'); + file_put_contents($directory.'/'.$id.'.reply', json_encode(AsyncSpool::seal(json_encode($response), 'reply', time(), time() + 120, str_repeat('synthetic-', 4)))); + $ticks[1](); check($outcome['status'] === 'rejected' && $cancelled[1], 'Game rejection not terminal'); + echo "PASS actual DiscordPHP interaction signatures, disabled/guild/actor/confirmation/acknowledgement gates, ephemeral no-mention outcomes, bounded async timers\n"; +} finally { foreach (glob($directory.'/*') as $file) { unlink($file); } rmdir($directory); } diff --git a/tests/safehouse/schema.php b/tests/safehouse/schema.php new file mode 100644 index 00000000..6d003680 --- /dev/null +++ b/tests/safehouse/schema.php @@ -0,0 +1,5 @@ + Date: Sun, 11 Oct 2026 21:00:59 -0400 Subject: [PATCH 07/22] Preserve Unicode in bounded private safehouse responses --- src/Civ13/Safehouse/DiscordRoute.php | 5 ++++- tests/safehouse/discord.php | 6 +++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/src/Civ13/Safehouse/DiscordRoute.php b/src/Civ13/Safehouse/DiscordRoute.php index d1a39e62..bafe31c1 100644 --- a/src/Civ13/Safehouse/DiscordRoute.php +++ b/src/Civ13/Safehouse/DiscordRoute.php @@ -43,7 +43,10 @@ public function handle(Interaction $interaction): PromiseInterface private static function message(string $content): MessageBuilder { - return MessageBuilder::new()->setContent(substr($content, 0, 1900))->setAllowedMentions(['parse' => []]); + // Bound Unicode codepoints without splitting UTF-8. 900 supplementary + // codepoints are at most 1800 UTF-16 units for Discord's 2000 limit. + if (!preg_match('/^.{0,900}/us', $content, $match)) { $match = ['Request remains unresolved.']; } + return MessageBuilder::new()->setContent($match[0])->setAllowedMentions(['parse' => []]); } private static function render(array $outcome): string diff --git a/tests/safehouse/discord.php b/tests/safehouse/discord.php index c97818d2..7c00f766 100644 --- a/tests/safehouse/discord.php +++ b/tests/safehouse/discord.php @@ -76,6 +76,10 @@ public function submit(array $request): PromiseInterface { check($gateway->requests[1]['payload'] === ['confirmation_token' => 'game-token'], 'Confirmation changed opaque game token'); $gateway->deferreds[1]->resolve(['status' => 'committed', 'request_id' => '123457', 'data' => ['nested_id' => 'child', 'fir' => true, 'condition' => 0.75]]); check(str_contains($confirmed->messages[1][2]['content'], 'child'), 'Inspection metadata omitted'); +$unicode = MockInteraction::fixture(); $unicode->id = 'unicode'; +$route->handle($unicode); +$gateway->deferreds[2]->resolve(['status' => 'committed', 'request_id' => 'unicode', 'data' => ['name' => str_repeat('🌍', 2000)]]); +check(preg_match('//u', $unicode->messages[1][2]['content']) === 1 && strlen($unicode->messages[1][2]['content']) <= 3600, 'Bounded render split Unicode'); // Construct only the Slash adapter over a reflection-created inert Civ13 object. // No Civ13/Discord constructor, token, init event or remote command save runs. @@ -106,7 +110,7 @@ public function listenCommand($names, ?callable $callback = null, ?callable $aut $boundInteraction = MockInteraction::fixture(); $boundInteraction->id = '654321'; ($enabledBot->discord->bound['safehouse'])($boundInteraction); check(end($gateway->requests)['request_id'] === '654321', 'Slash callback did not use actual safehouse route'); -$gateway->deferreds[2]->resolve(['status' => 'rejected', 'request_id' => '654321', 'reason' => 'unlinked']); +$gateway->deferreds[3]->resolve(['status' => 'rejected', 'request_id' => '654321', 'reason' => 'unlinked']); check(str_contains($boundInteraction->messages[1][2]['content'], 'unlinked'), 'Authoritative unlinked rejection omitted'); // Fake timer driver checks finite asynchronous polling and cancellation. From 30da0e8a8772d980ae31ed31bc33cbef931f4538 Mon Sep 17 00:00:00 2001 From: Valithor Obsidion Date: Sun, 11 Oct 2026 21:02:06 -0400 Subject: [PATCH 08/22] Verify actual isolated React timer cancellation contract --- tests/safehouse/discord.php | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tests/safehouse/discord.php b/tests/safehouse/discord.php index 7c00f766..0a29fee0 100644 --- a/tests/safehouse/discord.php +++ b/tests/safehouse/discord.php @@ -121,6 +121,15 @@ public function listenCommand($names, ?callable $callback = null, ?callable $aut $index = count($ticks); $ticks[$index] = $tick; return function () use ($index, &$cancelled): void { $cancelled[$index] = true; }; }; +// Exercise the installed React loop's real addPeriodicTimer/cancelTimer contract +// in this isolated test process, with no shared bot loop or external effects. +$loop = new \React\EventLoop\StreamSelectLoop(); +$realTicks = 0; $cancelRealTimer = null; +$cancelRealTimer = \Civ13\Safehouse\ReactTimer::repeat($loop)(0.001, function () use (&$realTicks, &$cancelRealTimer): void { + if (++$realTicks === 3) { $cancelRealTimer(); } +}); +$loop->run(); +check($realTicks === 3, 'Actual React timer cancellation contract failed'); try { $spool = new AsyncSpool($directory, str_repeat('synthetic-', 4), true); $driver = new AsyncDriver($spool, $repeat, 2, 1); From 4e62448d4b9a1dd0718f52485a02d798bfb197e1 Mon Sep 17 00:00:00 2001 From: Valithor Obsidion Date: Mon, 12 Oct 2026 03:01:05 -0400 Subject: [PATCH 09/22] Pin proposed native safehouse quote and atomic market contract --- docs/safehouse-economic-contract.md | 111 +++++++++++++++++++++++++ docs/safehouse-integration.md | 2 + tests/safehouse/economic-contract.json | 28 +++++++ 3 files changed, 141 insertions(+) create mode 100644 docs/safehouse-economic-contract.md create mode 100644 tests/safehouse/economic-contract.json diff --git a/docs/safehouse-economic-contract.md b/docs/safehouse-economic-contract.md new file mode 100644 index 00000000..c0be8ad5 --- /dev/null +++ b/docs/safehouse-economic-contract.md @@ -0,0 +1,111 @@ +# Safehouse economics contract proposal v1 + +This pins the bot-side proposal for the native handler. It is not native handler +acceptance and does not enable trades. It extends the immutable request and +authenticated exact-byte envelope in `safehouse-integration.md`; neither envelope +version changes. Existing game link/read/signing acceptance is reported by the +game owner separately. Price-table IDs, wallet units/bounds and native escrow +implementation remain game-owned dependencies, not values invented by the bot. + +## Quote and confirmation actions + +All quotes require the linked actor's nonnegative `expected_account_revision`. +The request actor is the authenticated Discord actor, never a payload ckey. +`trade.quote` has exactly one of the following payloads: + +| sale_kind | Other payload field | Confirmation action | +| --- | --- | --- | +| server_buyback | item_ids: array of owned top-level graph root IDs | trade.confirm | +| player_listing | item_ids: array of owned top-level graph root IDs | market.list | +| player_purchase | listing_id: opaque game listing ID | market.buy | +| listing_cancel | listing_id: opaque game listing ID owned by actor | market.cancel | + +No client price, currency, quantity, seller, buyer, item metadata or graph body +is accepted. IDs select existing game records. Listing prices and buyback prices +come exclusively from approved game tables. Missing/unsupported pricing rejects +the quote; no fallback price, fee, exchange rate or substitute item is minted. + +A successful quote's response `data` contains: + +```text +quote_id: opaque string +confirmation_token: opaque game-issued string +confirm_action: one of the confirmation actions above +expected_account_revision: actor revision captured by quote +expires_at: integer UTC epoch seconds +price: {currency_id: game identifier, amount: bounded integer game units} +item_ids: exact top-level graph roots covered +listing_id, listing_revision: present for purchase/cancellation +``` + +Cancellation has no economic price effect; its quote may omit `price`. Quote +issuance does not transfer/reserve items or currency, publish a listing, or change +the account's economic revision. It may durably store the token/quote record. +The token expires at most 120 seconds after issuance, independently of its IPC +envelope TTL. Native tables and wallet rules define acceptable integer bounds. + +Every confirmation payload is exactly `{confirmation_token}`. The outer action, +actor and `expected_account_revision` must match the quote. The Discord route +also requires `confirm=true`; this local UI flag is not game authority and is not +part of the economic payload. Tokens bind the action, actor/account, revisions, +full graph identity including descendants/FIR/condition, immutable approved game +price and expiry. Purchase additionally binds listing/seller/counterparty identity +and listing revision; the handler observes and checks both accounts' revisions. +The token itself conveys no client authority to replace those values. + +## Atomic game effects + +- `trade.confirm`: remove the quoted graph from the seller and credit the + approved server buyback amount once in one durable native transaction. +- `market.list`: reserve the entire graph into game-owned escrow, then publish + the listing with the captured price. Listing visibility cannot precede durable + graph reservation. Failure must not leave a public unbacked listing. +- `market.buy`: atomically debit the buyer, credit the seller, transfer that + exact escrow graph to the buyer and close the listing in one shared durable + receipt. Never approximate this with two independent stash-save writes. +- `market.cancel`: only the seller may return the escrow graph and close the + unfilled listing. Competing buy/cancel can have only one winning receipt. + +Recheck linked ownership, busy/in-raid states of affected accounts, exact account +and listing revisions, graph availability/eligibility, token expiry, approved +pricing, funds, integer overflow and destination capacity at confirmation. +Reject self-purchase. Reject arbitrary descendants, duplicate graph roots and +overlapping parent/child selections. Never duplicate nested IDs, relabel FIR, +repair condition or reconstruct equivalent items. A graph transfers as the game +serialized it. Account revisions advance only as native atomic transactions +require; the response reports resulting authoritative revisions and receipt IDs. + +## Idempotency, expiry and recovery + +The game stores one durable result for the immutable request identity, actor and +digest. Duplicate delivery with identical binding returns that original result +even after the token was consumed or expired. A conflicting actor/body/digest +using the same request ID rejects before effects. A consumed token under a new +request ID rejects, never creates another settlement. Persist business rejections +as terminal receipts as well. A token is consumed as part of the durable winning +transaction, not before an uncommitted write that would strand recoverable work. + +On a crash/uncertain transaction, preserve pending and reconcile its shared +receipt before allowing conflicting effects. A fresh signed read-only +`trade.status` request has payload `{operation_request_id, +operation_request_digest}`. Verify the original receipt's actor ownership before +disclosing it. The outer status refers to completion of the status query; +`data.operation_status` and `data.operation_receipt` refer to the original +economic operation. Never interpret a successful status query as a completed +trade. Not-found is a query result, not authorization to repeat an economic +operation. Expired IPC requests remain immutable and recover via this fresh +status envelope; no uncertain operation file is overwritten or silently purged. + +## Rejections and pending outcomes + +Proposed stable business `data.code` values: `invalid_payload`, `unlinked`, +`busy`, `in_raid`, `stale_revision`, `item_not_owned`, `item_ineligible`, +`invalid_graph`, `pricing_unavailable`, `quote_expired`, `token_invalid`, +`token_used`, `action_mismatch`, `listing_unavailable`, `self_purchase`, +`insufficient_funds`, `capacity_exceeded`, `request_conflict`. These mean outer +`status=rejected` with no new economic effects. Return game-safe explanatory +text and any safe current revision, never a replacement price or confirmation. +Authentication/body/signature failures are transport failures, not accepted +economic requests. `recovery_required` means pending with no success claim until +the durable transaction resolves. The native owner must approve these names and +DTOs before compatibility is claimed. diff --git a/docs/safehouse-integration.md b/docs/safehouse-integration.md index 294c5a21..2cc0ac84 100644 --- a/docs/safehouse-integration.md +++ b/docs/safehouse-integration.md @@ -25,6 +25,8 @@ trade.quote {item_ids, sale_kind}; trade.confirm {confirmation_token}; trade.status {transaction_id}; market.list {confirmation_token}; market.buy {confirmation_token}; market.cancel {confirmation_token}. This is a proposal, not an implemented game schema. No client price or account identity is accepted. +The refined economic proposal is in `safehouse-economic-contract.md` and +`tests/safehouse/economic-contract.json`; native acceptance remains separate. Quotes bind actor, item graph IDs, revision, approved game price, action and expiry. Listing and room mutations also require game-issued confirmation. diff --git a/tests/safehouse/economic-contract.json b/tests/safehouse/economic-contract.json new file mode 100644 index 00000000..00e1744e --- /dev/null +++ b/tests/safehouse/economic-contract.json @@ -0,0 +1,28 @@ +{ + "contract": "civ13.safehouse.economics.v1", + "acceptance": "proposed-native-handler-not-yet-accepted", + "request_protocol": "civ13.safehouse.v1", + "ipc_protocol": "civ13.safehouse.ipc.v1", + "quote_action": "trade.quote", + "quote_variants": [ + {"sale_kind": "server_buyback", "selector": "item_ids", "confirm_action": "trade.confirm"}, + {"sale_kind": "player_listing", "selector": "item_ids", "confirm_action": "market.list"}, + {"sale_kind": "player_purchase", "selector": "listing_id", "confirm_action": "market.buy"}, + {"sale_kind": "listing_cancel", "selector": "listing_id", "confirm_action": "market.cancel"} + ], + "quote_required_fields": ["quote_id", "confirmation_token", "confirm_action", "expected_account_revision", "expires_at", "item_ids"], + "price_fields": ["currency_id", "amount"], + "price_authority": "approved-game-tables-only-no-client-price", + "confirmation_payload_fields": ["confirmation_token"], + "confirmation_account_revision": "required-exact-quoted-actor-revision", + "confirmation_ui_option": "confirm=true", + "quote_max_age_seconds": 120, + "listing_confirmation_fields": ["listing_id", "listing_revision"], + "immutable_operation_identity": ["request_id", "discord_id", "request_digest", "exact_authenticated_request_bytes"], + "status_action": "trade.status", + "status_payload_fields": ["operation_request_id", "operation_request_digest"], + "status_result_fields": ["operation_status", "operation_receipt"], + "business_rejection_codes": ["invalid_payload", "unlinked", "busy", "in_raid", "stale_revision", "item_not_owned", "item_ineligible", "invalid_graph", "pricing_unavailable", "quote_expired", "token_invalid", "token_used", "action_mismatch", "listing_unavailable", "self_purchase", "insufficient_funds", "capacity_exceeded", "request_conflict"], + "pending_code": "recovery_required", + "game_dependencies": ["approved-price-table-identifiers", "wallet-unit-and-safe-integer-bounds", "native-atomic-escrow-and-shared-receipt", "quote-and-listing-revision-rules", "native-handler-DTO-acceptance"] +} From fb91473e412f4f2c107f28bac3bee247aad4b059 Mon Sep 17 00:00:00 2001 From: Valithor Obsidion Date: Mon, 12 Oct 2026 03:02:08 -0400 Subject: [PATCH 10/22] Align status recovery vocabulary across proposed economic contracts --- docs/safehouse-economic-contract.md | 3 ++- docs/safehouse-integration.md | 4 ++-- tests/safehouse/economic-contract.json | 1 + 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/safehouse-economic-contract.md b/docs/safehouse-economic-contract.md index c0be8ad5..c229a681 100644 --- a/docs/safehouse-economic-contract.md +++ b/docs/safehouse-economic-contract.md @@ -92,7 +92,8 @@ operation_request_digest}`. Verify the original receipt's actor ownership before disclosing it. The outer status refers to completion of the status query; `data.operation_status` and `data.operation_receipt` refer to the original economic operation. Never interpret a successful status query as a completed -trade. Not-found is a query result, not authorization to repeat an economic +trade. `operation_status` is pending, committed, rejected or not_found. Not-found +is a query result, not authorization to repeat an economic operation. Expired IPC requests remain immutable and recover via this fresh status envelope; no uncertain operation file is overwritten or silently purged. diff --git a/docs/safehouse-integration.md b/docs/safehouse-integration.md index 2cc0ac84..1883db32 100644 --- a/docs/safehouse-integration.md +++ b/docs/safehouse-integration.md @@ -21,8 +21,8 @@ pending even when the game may already have committed. Replay the exact request. Proposed payloads: link.challenge {challenge_id}; link.complete {challenge_id, proof}; stash.inspect/room.inspect {page}; room.manage {confirmation_token}; -trade.quote {item_ids, sale_kind}; trade.confirm {confirmation_token}; -trade.status {transaction_id}; market.list {confirmation_token}; market.buy +trade.quote {sale_kind, item_ids or listing_id}; trade.confirm {confirmation_token}; +trade.status {operation_request_id, operation_request_digest}; market.list {confirmation_token}; market.buy {confirmation_token}; market.cancel {confirmation_token}. This is a proposal, not an implemented game schema. No client price or account identity is accepted. The refined economic proposal is in `safehouse-economic-contract.md` and diff --git a/tests/safehouse/economic-contract.json b/tests/safehouse/economic-contract.json index 00e1744e..0a5d255c 100644 --- a/tests/safehouse/economic-contract.json +++ b/tests/safehouse/economic-contract.json @@ -22,6 +22,7 @@ "status_action": "trade.status", "status_payload_fields": ["operation_request_id", "operation_request_digest"], "status_result_fields": ["operation_status", "operation_receipt"], + "operation_status_values": ["pending", "committed", "rejected", "not_found"], "business_rejection_codes": ["invalid_payload", "unlinked", "busy", "in_raid", "stale_revision", "item_not_owned", "item_ineligible", "invalid_graph", "pricing_unavailable", "quote_expired", "token_invalid", "token_used", "action_mismatch", "listing_unavailable", "self_purchase", "insufficient_funds", "capacity_exceeded", "request_conflict"], "pending_code": "recovery_required", "game_dependencies": ["approved-price-table-identifiers", "wallet-unit-and-safe-integer-bounds", "native-atomic-escrow-and-shared-receipt", "quote-and-listing-revision-rules", "native-handler-DTO-acceptance"] From 7c4c4ca92932edd03a2719fbc7bbadc145d50bdb Mon Sep 17 00:00:00 2001 From: Valithor Obsidion Date: Mon, 12 Oct 2026 03:32:01 -0400 Subject: [PATCH 11/22] Guard native reply publication and pin agreed economics DTO --- docs/safehouse-economic-contract.md | 19 +++-- docs/safehouse-integration.md | 13 +++ src/Civ13/Safehouse/AsyncSpool.php | 10 +++ tests/safehouse/economic-contract.json | 109 +++++++++++++++++++++---- tests/safehouse/reply-worker.php | 13 +++ tests/safehouse/run.php | 32 ++++++++ 6 files changed, 175 insertions(+), 21 deletions(-) create mode 100644 tests/safehouse/reply-worker.php diff --git a/docs/safehouse-economic-contract.md b/docs/safehouse-economic-contract.md index c229a681..e49d7891 100644 --- a/docs/safehouse-economic-contract.md +++ b/docs/safehouse-economic-contract.md @@ -1,4 +1,4 @@ -# Safehouse economics contract proposal v1 +# Safehouse economics contract v1 This pins the bot-side proposal for the native handler. It is not native handler acceptance and does not enable trades. It extends the immutable request and @@ -9,7 +9,7 @@ implementation remain game-owned dependencies, not values invented by the bot. ## Quote and confirmation actions -All quotes require the linked actor's nonnegative `expected_account_revision`. +All quotes require the linked actor's integer `expected_account_revision` in 0..1000000. The request actor is the authenticated Discord actor, never a payload ckey. `trade.quote` has exactly one of the following payloads: @@ -32,8 +32,8 @@ quote_id: opaque string confirmation_token: opaque game-issued string confirm_action: one of the confirmation actions above expected_account_revision: actor revision captured by quote -expires_at: integer UTC epoch seconds -price: {currency_id: game identifier, amount: bounded integer game units} +expires_at: canonical decimal Unix-seconds STRING +price: {currency_id: civ13.game_credits.v1, amount: bounded integer game units} item_ids: exact top-level graph roots covered listing_id, listing_revision: present for purchase/cancellation ``` @@ -99,7 +99,7 @@ status envelope; no uncertain operation file is overwritten or silently purged. ## Rejections and pending outcomes -Proposed stable business `data.code` values: `invalid_payload`, `unlinked`, +Agreed stable business `data.code` values: `invalid_payload`, `unlinked`, `busy`, `in_raid`, `stale_revision`, `item_not_owned`, `item_ineligible`, `invalid_graph`, `pricing_unavailable`, `quote_expired`, `token_invalid`, `token_used`, `action_mismatch`, `listing_unavailable`, `self_purchase`, @@ -110,3 +110,12 @@ Authentication/body/signature failures are transport failures, not accepted economic requests. `recovery_required` means pending with no success claim until the durable transaction resolves. The native owner must approve these names and DTOs before compatibility is claimed. + +The agreed native prototype price table is modern_safehouse_prices_v1. These are +prototype game credits and approved allowlisted prices, never Discord currency. +Quote expiry uses decimal text because native DM cannot exactly represent current +Unix epoch numbers. IPC timestamp tokens retain the existing wire format. +Duplicate token replay still requires valid fresh IPC bounds; an expired immutable +economic envelope must recover with fresh read-only status. For not_found the +operation_receipt is absent or null; other statuses carry the original actor and +request-digest-bound durable response when available. diff --git a/docs/safehouse-integration.md b/docs/safehouse-integration.md index 1883db32..b3190891 100644 --- a/docs/safehouse-integration.md +++ b/docs/safehouse-integration.md @@ -151,3 +151,16 @@ fixtures for Slash/Discord and mocks interaction calls/game gateway. Run CI tests Discord routing against resolved Composer dependencies separately from the dependency-free Windows/Linux spool regressions. No live registration or Discord send was performed. +# Native reply publication barrier + +For a native writer without atomic rename, create `.reply.pending` before +writing any `.reply` bytes. The bot checks marker presence before and after +authenticated reply parsing with refreshed filesystem metadata. Any marker, +including empty or malformed content, keeps the request pending; symlinks reject. +Missing, partial, invalid, expired or incorrectly bound replies never mean success. +The bot never clears a marker. One game writer must serialize publication per key, +never overwrite a completed reply, validate its signed completed receipt before +removing the marker, and on restart compare the original durable operation receipt +before clearing a crash marker. Expired replies recover through fresh status. +Native power-loss flush ordering remains a separate acceptance requirement; +presence barriers do not themselves prove durable storage ordering. diff --git a/src/Civ13/Safehouse/AsyncSpool.php b/src/Civ13/Safehouse/AsyncSpool.php index aa24de8b..3b38d921 100644 --- a/src/Civ13/Safehouse/AsyncSpool.php +++ b/src/Civ13/Safehouse/AsyncSpool.php @@ -67,6 +67,7 @@ public function poll(string $id): array if (!$this->enabled) { throw new \LogicException('Safehouse spool disabled'); } $requestEnvelope = $this->read($this->path($id, 'request'), 'request'); $request = json_decode(base64_decode($requestEnvelope['body'], true), true, 32, JSON_THROW_ON_ERROR); + if ($this->replyPending($id)) { return ['status' => 'pending', 'request_id' => $request['request_id'], 'reason' => 'reply_write_pending']; } $replyPath = $this->path($id, 'reply'); if (!is_file($replyPath)) { return ['status' => 'pending', 'request_id' => $request['request_id']]; } $replyEnvelope = $this->read($replyPath, 'reply'); @@ -76,9 +77,18 @@ public function poll(string $id): array if (($reply[$field] ?? null) !== $request[$field]) { throw new \UnexpectedValueException('Reply identity mismatch'); } } if (!in_array($reply['status'] ?? null, ['pending', 'committed', 'rejected'], true)) { throw new \UnexpectedValueException('Unknown reply status'); } + if ($this->replyPending($id)) { return ['status' => 'pending', 'request_id' => $request['request_id'], 'reason' => 'reply_write_pending']; } return $reply; } + /** Presence only: malformed/empty markers also block success. Only the game + * writer may clear its barrier after validating the durable signed receipt. */ + private function replyPending(string $id): bool + { + clearstatcache(); + return @lstat($this->path($id, 'reply.pending')) !== false; + } + /** Fresh authenticated read-only status request for an expired/uncertain * operation. Never re-signs or reissues that economic operation. The game * checks the actor against the original durable receipt before disclosure. */ diff --git a/tests/safehouse/economic-contract.json b/tests/safehouse/economic-contract.json index 0a5d255c..27d02cd2 100644 --- a/tests/safehouse/economic-contract.json +++ b/tests/safehouse/economic-contract.json @@ -1,29 +1,106 @@ { "contract": "civ13.safehouse.economics.v1", - "acceptance": "proposed-native-handler-not-yet-accepted", + "acceptance": "dto-agreed-native-integration-unverified", "request_protocol": "civ13.safehouse.v1", "ipc_protocol": "civ13.safehouse.ipc.v1", "quote_action": "trade.quote", "quote_variants": [ - {"sale_kind": "server_buyback", "selector": "item_ids", "confirm_action": "trade.confirm"}, - {"sale_kind": "player_listing", "selector": "item_ids", "confirm_action": "market.list"}, - {"sale_kind": "player_purchase", "selector": "listing_id", "confirm_action": "market.buy"}, - {"sale_kind": "listing_cancel", "selector": "listing_id", "confirm_action": "market.cancel"} + { + "sale_kind": "server_buyback", + "selector": "item_ids", + "confirm_action": "trade.confirm" + }, + { + "sale_kind": "player_listing", + "selector": "item_ids", + "confirm_action": "market.list" + }, + { + "sale_kind": "player_purchase", + "selector": "listing_id", + "confirm_action": "market.buy" + }, + { + "sale_kind": "listing_cancel", + "selector": "listing_id", + "confirm_action": "market.cancel" + } + ], + "quote_required_fields": [ + "quote_id", + "confirmation_token", + "confirm_action", + "expected_account_revision", + "expires_at", + "item_ids" + ], + "price_fields": [ + "currency_id", + "amount" ], - "quote_required_fields": ["quote_id", "confirmation_token", "confirm_action", "expected_account_revision", "expires_at", "item_ids"], - "price_fields": ["currency_id", "amount"], "price_authority": "approved-game-tables-only-no-client-price", - "confirmation_payload_fields": ["confirmation_token"], + "confirmation_payload_fields": [ + "confirmation_token" + ], "confirmation_account_revision": "required-exact-quoted-actor-revision", "confirmation_ui_option": "confirm=true", "quote_max_age_seconds": 120, - "listing_confirmation_fields": ["listing_id", "listing_revision"], - "immutable_operation_identity": ["request_id", "discord_id", "request_digest", "exact_authenticated_request_bytes"], + "listing_confirmation_fields": [ + "listing_id", + "listing_revision" + ], + "immutable_operation_identity": [ + "request_id", + "discord_id", + "request_digest", + "exact_authenticated_request_bytes" + ], "status_action": "trade.status", - "status_payload_fields": ["operation_request_id", "operation_request_digest"], - "status_result_fields": ["operation_status", "operation_receipt"], - "operation_status_values": ["pending", "committed", "rejected", "not_found"], - "business_rejection_codes": ["invalid_payload", "unlinked", "busy", "in_raid", "stale_revision", "item_not_owned", "item_ineligible", "invalid_graph", "pricing_unavailable", "quote_expired", "token_invalid", "token_used", "action_mismatch", "listing_unavailable", "self_purchase", "insufficient_funds", "capacity_exceeded", "request_conflict"], + "status_payload_fields": [ + "operation_request_id", + "operation_request_digest" + ], + "status_result_fields": [ + "operation_status", + "operation_receipt" + ], + "operation_status_values": [ + "pending", + "committed", + "rejected", + "not_found" + ], + "business_rejection_codes": [ + "invalid_payload", + "unlinked", + "busy", + "in_raid", + "stale_revision", + "item_not_owned", + "item_ineligible", + "invalid_graph", + "pricing_unavailable", + "quote_expired", + "token_invalid", + "token_used", + "action_mismatch", + "listing_unavailable", + "self_purchase", + "insufficient_funds", + "capacity_exceeded", + "request_conflict" + ], "pending_code": "recovery_required", - "game_dependencies": ["approved-price-table-identifiers", "wallet-unit-and-safe-integer-bounds", "native-atomic-escrow-and-shared-receipt", "quote-and-listing-revision-rules", "native-handler-DTO-acceptance"] -} + "game_dependencies": [ + "approved-price-table-identifiers", + "wallet-unit-and-safe-integer-bounds", + "native-atomic-escrow-and-shared-receipt", + "quote-and-listing-revision-rules", + "native-handler-DTO-acceptance" + ], + "currency_id": "civ13.game_credits.v1", + "table_id": "modern_safehouse_prices_v1", + "quote_expires_at_type": "canonical-decimal-unix-seconds-string", + "account_revision_max": 1000000, + "not_found_receipt": "null-or-absent" +} \ No newline at end of file diff --git a/tests/safehouse/reply-worker.php b/tests/safehouse/reply-worker.php new file mode 100644 index 00000000..33f1a77c --- /dev/null +++ b/tests/safehouse/reply-worker.php @@ -0,0 +1,13 @@ +poll($fileKey)['reason'] === 'reply_write_pending', 'Empty marker allowed success'); + file_put_contents($replyPath, '{partial'); + check($spool->poll($fileKey)['status'] === 'pending', 'Partial marked reply claimed success'); + file_put_contents($markerPath, '{invalid-marker'); + check($spool->poll($fileKey)['status'] === 'pending', 'Invalid marker allowed success'); + unlink($markerPath); + try { $spool->poll($fileKey); throw new LogicException('Unmarked partial reply accepted'); } catch (JsonException $e) {} + file_put_contents($replyPath, json_encode($sealed)); + file_put_contents($markerPath, 'crash-after-complete-write'); + check($spool->poll($fileKey)['status'] === 'pending', 'Complete reply bypassed crash marker'); + check($spool->enqueue($request) === $fileKey, 'Marked reply changed immutable replay'); + check(is_file($markerPath), 'Bot removed native recovery marker'); + unlink($markerPath); check($spool->poll($fileKey)['status'] === 'committed', 'Authenticated reply not accepted'); + $writerPipes = []; + $writer = proc_open([PHP_BINARY, __DIR__.'/reply-worker.php', $replyPath, base64_encode(json_encode($sealed))], [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $writerPipes); + check(is_resource($writer), 'Reply writer unavailable'); + check(trim(fgets($writerPipes[1])) === 'partial', 'Writer partial stage missing'); + check($spool->poll($fileKey)['status'] === 'pending', 'Concurrent partial publication accepted'); + fwrite($writerPipes[0], "continue\n"); fflush($writerPipes[0]); + check(trim(fgets($writerPipes[1])) === 'complete-marked', 'Writer complete stage missing'); + check($spool->poll($fileKey)['status'] === 'pending', 'Concurrent complete marked publication accepted'); + fwrite($writerPipes[0], "continue\n"); fflush($writerPipes[0]); + check(trim(fgets($writerPipes[1])) === 'released', 'Writer release missing'); + check($spool->poll($fileKey)['status'] === 'committed', 'Released authenticated reply unavailable'); + fclose($writerPipes[0]); fclose($writerPipes[1]); + check(stream_get_contents($writerPipes[2]) === '', 'Reply worker diagnostics'); fclose($writerPipes[2]); + check(proc_close($writer) === 0, 'Reply worker failed'); $tampered = $sealed; $tampered['mac'] = str_repeat('0', 64); file_put_contents($replyPath, json_encode($tampered)); rejects(fn () => $spool->poll($fileKey)); @@ -116,6 +145,9 @@ public function exchange(array $request): array { rejects(fn () => $recoverySpool->recover($fileKey, $request['request_id'])); // Linux CI can create symlinks without Windows Developer Mode privileges. if (PHP_OS_FAMILY !== 'Windows') { + check(symlink($directory.'/journal', $markerPath), 'Marker symlink fixture unavailable'); + try { $spool->poll($fileKey); throw new LogicException('Marker symlink accepted'); } catch (RuntimeException $e) { check(str_contains($e->getMessage(), 'Symlink'), 'Wrong marker symlink rejection'); } + unlink($markerPath); unlink($replyPath); $linkPath = $replyPath; check(symlink($directory.'/journal', $linkPath), 'Symlink fixture unavailable'); From e867fb8b33264ac4ff4661359d4f8688b8b176a4 Mon Sep 17 00:00:00 2001 From: Valithor Obsidion Date: Mon, 12 Oct 2026 03:32:42 -0400 Subject: [PATCH 12/22] Clarify agreed DTO versus unverified native integration --- docs/safehouse-economic-contract.md | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/docs/safehouse-economic-contract.md b/docs/safehouse-economic-contract.md index e49d7891..d1746b03 100644 --- a/docs/safehouse-economic-contract.md +++ b/docs/safehouse-economic-contract.md @@ -1,11 +1,12 @@ # Safehouse economics contract v1 -This pins the bot-side proposal for the native handler. It is not native handler -acceptance and does not enable trades. It extends the immutable request and +This pins the DTO agreed with the native owner through the coordinator. Native +end-to-end bot compatibility remains unverified; this does not enable trades. +It extends the immutable request and authenticated exact-byte envelope in `safehouse-integration.md`; neither envelope version changes. Existing game link/read/signing acceptance is reported by the -game owner separately. Price-table IDs, wallet units/bounds and native escrow -implementation remain game-owned dependencies, not values invented by the bot. +game owner separately. Prices, wallet bounds and native escrow implementation +remain game-owned dependencies, not values invented by the bot. ## Quote and confirmation actions @@ -108,8 +109,8 @@ Agreed stable business `data.code` values: `invalid_payload`, `unlinked`, text and any safe current revision, never a replacement price or confirmation. Authentication/body/signature failures are transport failures, not accepted economic requests. `recovery_required` means pending with no success claim until -the durable transaction resolves. The native owner must approve these names and -DTOs before compatibility is claimed. +the durable transaction resolves. Native integration tests must match these +agreed names and DTOs before compatibility is claimed. The agreed native prototype price table is modern_safehouse_prices_v1. These are prototype game credits and approved allowlisted prices, never Discord currency. From cdf94c94ff8838b4d12757e64c5f56a1b62138b8 Mon Sep 17 00:00:00 2001 From: Valithor Obsidion Date: Mon, 12 Oct 2026 03:42:42 -0400 Subject: [PATCH 13/22] Verify isolated native signed reply compatibility --- docs/safehouse-integration.md | 11 ++++++ tests/safehouse/native-compatibility.php | 46 ++++++++++++++++++++++++ 2 files changed, 57 insertions(+) create mode 100644 tests/safehouse/native-compatibility.php diff --git a/docs/safehouse-integration.md b/docs/safehouse-integration.md index b3190891..1f508660 100644 --- a/docs/safehouse-integration.md +++ b/docs/safehouse-integration.md @@ -164,3 +164,14 @@ removing the marker, and on restart compare the original durable operation recei before clearing a crash marker. Expired replies recover through fresh status. Native power-loss flush ordering remains a separate acceptance requirement; presence barriers do not themselves prove durable storage ordering. + +The isolated native fixture at game commit +`6ae539e5940a6a84c22163b1ca57cc662cbaf60f` was read by the bot adapter with +`php tests/safehouse/native-compatibility.php `. +Both native inspection and link receipts passed marker/restart recovery, reply +expiry, immutable original bytes/timewindow, wrong-MAC and signed wrong-actor +negative controls. The runner validates synthetic manifest metadata and copies +only the two named request/reply sets to temporary storage. This proves supplied +native artifacts work with PHP polling; it does not prove economic command +round trips, host writer exclusion or power-loss durability. The manifest has no +source-head field, so the generating checkout head must be verified separately. diff --git a/tests/safehouse/native-compatibility.php b/tests/safehouse/native-compatibility.php new file mode 100644 index 00000000..4f4309c9 --- /dev/null +++ b/tests/safehouse/native-compatibility.php @@ -0,0 +1,46 @@ + $fileKey) { + verify(hash('sha256', $requestId) === $fileKey, 'Native filename digest mismatch'); + $original = file_get_contents($source.'/before-restart/'.$fileKey.'.request'); + verify($original === file_get_contents($source.'/after-restart/'.$fileKey.'.request'), 'Recovery changed immutable request'); + $requestEnvelope = json_decode($original, true, 32, JSON_THROW_ON_ERROR); + verify($requestEnvelope['issued_at'] === 1000 && $requestEnvelope['expires_at'] === 1120, 'Recovery re-signed original timewindow'); + foreach (['request', 'reply', 'reply.pending'] as $suffix) { copy($source.'/before-restart/'.$fileKey.'.'.$suffix, $temporary.'/'.$fileKey.'.'.$suffix); } + verify($spool->poll($fileKey)['status'] === 'pending', 'Unpublished native reply accepted'); + copy($source.'/after-restart/'.$fileKey.'.reply', $temporary.'/'.$fileKey.'.reply'); + verify($spool->poll($fileKey)['status'] === 'pending', 'Complete native reply bypassed marker'); + unlink($temporary.'/'.$fileKey.'.reply.pending'); + $committed = $spool->poll($fileKey); + verify($committed['status'] === 'committed' && $committed['request_id'] === $requestId, 'Native recovery receipt incompatible'); + $replyPath = $temporary.'/'.$fileKey.'.reply'; + $replyBytes = file_get_contents($replyPath); + $replyEnvelope = json_decode($replyBytes, true, 32, JSON_THROW_ON_ERROR); + $altered = $replyEnvelope; $altered['mac'] = str_repeat('0', 64); file_put_contents($replyPath, json_encode($altered)); + rejected(fn () => $spool->poll($fileKey)); + $alteredBody = json_decode(base64_decode($replyEnvelope['body'], true), true, 32, JSON_THROW_ON_ERROR); + $alteredBody['discord_id'] = '999'; + file_put_contents($replyPath, json_encode(AsyncSpool::seal(json_encode($alteredBody), 'reply', 1000, 1120, $manifest['key']))); + rejected(fn () => $spool->poll($fileKey)); + file_put_contents($replyPath, $replyBytes); + $now = 2000; verify($spool->poll($fileKey)['status'] === 'pending', 'Expired native receipt accepted'); $now = 1000; + verify(file_get_contents($temporary.'/'.$fileKey.'.request') === $original, 'Bot changed native operation'); + echo "PASS native $requestId marker/recovery/expiry/immutable-request/MAC/actor-binding\n"; + } +} finally { + foreach (glob($temporary.'/*') as $path) { unlink($path); } rmdir($temporary); +} From 76410ac67fd08464e5267317b09c79cd383715e1 Mon Sep 17 00:00:00 2001 From: Valithor Obsidion Date: Mon, 12 Oct 2026 03:56:18 -0400 Subject: [PATCH 14/22] Add isolated quote-bound native economics driver --- .github/workflows/safehouse.yml | 1 + docs/safehouse-economic-contract.md | 24 +++++++++++ tests/safehouse/economics-driver-test.php | 44 ++++++++++++++++++++ tests/safehouse/economics-driver.php | 49 +++++++++++++++++++++++ 4 files changed, 118 insertions(+) create mode 100644 tests/safehouse/economics-driver-test.php create mode 100644 tests/safehouse/economics-driver.php diff --git a/.github/workflows/safehouse.yml b/.github/workflows/safehouse.yml index db59150c..6c5556d8 100644 --- a/.github/workflows/safehouse.yml +++ b/.github/workflows/safehouse.yml @@ -31,6 +31,7 @@ jobs: php-version: ${{ matrix.php }} coverage: none - run: php tests/safehouse/run.php + - run: php tests/safehouse/economics-driver-test.php discord-routing: runs-on: ubuntu-latest timeout-minutes: 8 diff --git a/docs/safehouse-economic-contract.md b/docs/safehouse-economic-contract.md index d1746b03..5e23ec5d 100644 --- a/docs/safehouse-economic-contract.md +++ b/docs/safehouse-economic-contract.md @@ -120,3 +120,27 @@ Duplicate token replay still requires valid fresh IPC bounds; an expired immutab economic envelope must recover with fresh read-only status. For not_found the operation_receipt is absent or null; other statuses carry the original actor and request-digest-bound durable response when available. + +## Offline native fixture driver + +`tests/safehouse/economics-driver.php` accepts a private temporary spool and a +manifest with `synthetic:true`, the public signing fixture key, and integer +`clock`. It never loads bot/bootstrap or production credentials. Invocation: + +```text +php tests/safehouse/economics-driver.php SPOOL MANIFEST inspect ID ACTOR +php tests/safehouse/economics-driver.php SPOOL MANIFEST quote ID INSPECT_KEY SALE_KIND SELECTOR_JSON +php tests/safehouse/economics-driver.php SPOOL MANIFEST confirm ID QUOTE_KEY +php tests/safehouse/economics-driver.php SPOOL MANIFEST poll FILE_KEY +php tests/safehouse/economics-driver.php SPOOL MANIFEST recover NEW_STATUS_ID ORIGINAL_KEY +``` + +Every enqueue prints its SHA256 file key. Quote revision comes from authenticated +committed inspection `data.revision`. Confirmation action/token/revision/expiry +come exclusively from the authenticated bound quote. Selectors are JSON root-ID +arrays for buyback/list and JSON listing-ID strings for purchase/cancel. The +driver preserves immutable requests through `AsyncSpool`, refuses pending or +expired quotes, and recovers uncertainty only with read-only status. Change the +synthetic clock via the manifest to test expiry; original request bytes remain +unchanged. `economics-driver-test.php` exercises all four actions and meaningful +negative controls in subprocesses; native economic execution remains separate. diff --git a/tests/safehouse/economics-driver-test.php b/tests/safehouse/economics-driver-test.php new file mode 100644 index 00000000..a0fed9ca --- /dev/null +++ b/tests/safehouse/economics-driver-test.php @@ -0,0 +1,44 @@ + true, 'key' => $key, 'clock' => 1000])); +function invoke(array $arguments, bool $success = true): string { + global $directory, $manifest; + $pipes = []; $process = proc_open(array_merge([PHP_BINARY, __DIR__.'/economics-driver.php', $directory, $manifest], $arguments), [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes); + fclose($pipes[0]); $output = stream_get_contents($pipes[1]); $error = stream_get_contents($pipes[2]); fclose($pipes[1]); fclose($pipes[2]); + $exit = proc_close($process); if (($exit === 0) !== $success) { throw new RuntimeException('Driver result mismatch '.$error); } return trim($output); +} +function reply(string $id, array $data): void { + global $directory, $key; + $envelope = json_decode(file_get_contents($directory.'/'.$id.'.request'), true); + $body = json_decode(base64_decode($envelope['body']), true); + $body['status'] = 'committed'; $body['data'] = $data; + file_put_contents($directory.'/'.$id.'.reply', json_encode(AsyncSpool::seal(json_encode($body), 'reply', 1000, 1120, $key))); +} +try { + $inspect = invoke(['inspect', 'inspect-seller', '123']); reply($inspect, ['revision' => 7]); + foreach (['server_buyback' => 'trade.confirm', 'player_listing' => 'market.list', 'player_purchase' => 'market.buy', 'listing_cancel' => 'market.cancel'] as $kind => $action) { + $selection = in_array($kind, ['server_buyback', 'player_listing'], true) ? '["native-root"]' : '"native-listing"'; + $quote = invoke(['quote', 'quote-'.$kind, $inspect, $kind, $selection]); + $data = ['confirm_action' => $action, 'confirmation_token' => 'native-token-'.$kind, 'expected_account_revision' => 7, 'expires_at' => '1120']; reply($quote, $data); + $confirmation = invoke(['confirm', 'confirm-'.$kind, $quote]); + $bytes = file_get_contents($directory.'/'.$confirmation.'.request'); + if (invoke(['confirm', 'confirm-'.$kind, $quote]) !== $confirmation || file_get_contents($directory.'/'.$confirmation.'.request') !== $bytes) { throw new RuntimeException('Immutable confirmation changed'); } + $body = json_decode(base64_decode(json_decode($bytes, true)['body']), true); + if ($body['action'] !== $action || $body['expected_account_revision'] !== 7 || $body['payload'] !== ['confirmation_token' => $data['confirmation_token']]) { throw new RuntimeException('Quote authority replaced'); } + $data['confirmation_token'] = 'changed'; reply($quote, $data); invoke(['confirm', 'confirm-'.$kind, $quote], false); + $data['expires_at'] = '999'; reply($quote, $data); invoke(['confirm', 'expired-'.$kind, $quote], false); + $data['expires_at'] = 1120; reply($quote, $data); invoke(['confirm', 'numeric-'.$kind, $quote], false); + $data['expires_at'] = '1120'; reply($quote, $data); + file_put_contents($directory.'/'.$quote.'.reply.pending', 'incomplete'); invoke(['confirm', 'pending-'.$kind, $quote], false); unlink($directory.'/'.$quote.'.reply.pending'); + $status = invoke(['recover', 'status-'.$kind, $confirmation]); + $body = json_decode(base64_decode(json_decode(file_get_contents($directory.'/'.$status.'.request'), true)['body']), true); + if ($body['action'] !== 'trade.status' || $body['payload']['operation_request_id'] !== 'confirm-'.$kind) { throw new RuntimeException('Recovery reissued trade'); } + } + echo "PASS synthetic economics driver quote-derived actions/tokens/revisions, immutable replay, token-conflict/expiry/type/pending negatives and status recovery\n"; +} finally { foreach (glob($directory.'/*') as $path) { unlink($path); } rmdir($directory); } diff --git a/tests/safehouse/economics-driver.php b/tests/safehouse/economics-driver.php new file mode 100644 index 00000000..3142bffe --- /dev/null +++ b/tests/safehouse/economics-driver.php @@ -0,0 +1,49 @@ += 0, 'Synthetic integer clock required'); +$spool = new AsyncSpool($directory, $manifest['key'], true, 1000, static fn (): int => $now); +$boundReply = static function (string $key) use ($spool): array { + $reply = $spool->poll($key); + required(($reply['status'] ?? null) === 'committed', 'Authenticated committed source reply required'); + return $reply; +}; +switch ($command) { + case 'poll': + echo json_encode($spool->poll($requestId), JSON_THROW_ON_ERROR)."\n"; exit; + case 'recover': + echo $spool->recover($argv[5] ?? '', $requestId)."\n"; exit; + case 'inspect': + $request = Client::envelope($requestId, $argv[5] ?? '', 'stash.inspect', [], null); break; + case 'quote': + $source = $boundReply($argv[5] ?? ''); + required(($source['action'] ?? null) === 'stash.inspect', 'Quote revision must come from inspection'); + $revision = $source['data']['revision'] ?? null; + required(is_int($revision) && $revision >= 0 && $revision <= 1000000, 'Invalid authoritative revision'); + $kind = $argv[6] ?? ''; + $selectors = ['server_buyback' => 'item_ids', 'player_listing' => 'item_ids', 'player_purchase' => 'listing_id', 'listing_cancel' => 'listing_id']; + required(isset($selectors[$kind]), 'Unsupported sale kind'); + $selection = json_decode($argv[7] ?? '', true, 32, JSON_THROW_ON_ERROR); + required($selectors[$kind] === 'item_ids' ? is_array($selection) && array_is_list($selection) && $selection !== [] : is_string($selection) && $selection !== '', 'Invalid selector'); + $request = Client::envelope($requestId, $source['discord_id'], 'trade.quote', ['sale_kind' => $kind, $selectors[$kind] => $selection], $revision); break; + case 'confirm': + $quote = $boundReply($argv[5] ?? ''); + required(($quote['action'] ?? null) === 'trade.quote', 'Confirmation requires bound quote reply'); + $data = $quote['data'] ?? []; + required(in_array($data['confirm_action'] ?? null, ['trade.confirm', 'market.list', 'market.buy', 'market.cancel'], true), 'Invalid quote action'); + required(is_string($data['confirmation_token'] ?? null) && $data['confirmation_token'] !== '', 'Missing authoritative token'); + required(is_int($data['expected_account_revision'] ?? null) && $data['expected_account_revision'] >= 0 && $data['expected_account_revision'] <= 1000000, 'Invalid quoted revision'); + required(is_string($data['expires_at'] ?? null) && preg_match('/^(0|[1-9][0-9]{0,10})$/D', $data['expires_at']) === 1 && (int)$data['expires_at'] >= $now, 'Quote expired or noncanonical expiry'); + $request = Client::envelope($requestId, $quote['discord_id'], $data['confirm_action'], ['confirmation_token' => $data['confirmation_token']], $data['expected_account_revision']); break; + default: throw new InvalidArgumentException('Unknown synthetic command'); +} +// enqueue retains exact immutable bytes, rejects collisions and never overwrites. +echo $spool->enqueue($request)."\n"; From 470fdfb6c3a407cc272f0c63a494de84e3a638d8 Mon Sep 17 00:00:00 2001 From: Valithor Obsidion Date: Mon, 12 Oct 2026 04:11:09 -0400 Subject: [PATCH 15/22] Match native inspection page schema in driver and Discord route --- docs/safehouse-integration.md | 4 ++++ src/Civ13/Safehouse/Controller.php | 3 +++ tests/safehouse/discord.php | 6 +++++- tests/safehouse/economics-driver-test.php | 5 ++++- tests/safehouse/economics-driver.php | 2 +- tests/safehouse/run.php | 5 ++++- 6 files changed, 21 insertions(+), 4 deletions(-) diff --git a/docs/safehouse-integration.md b/docs/safehouse-integration.md index 1f508660..34446985 100644 --- a/docs/safehouse-integration.md +++ b/docs/safehouse-integration.md @@ -175,3 +175,7 @@ only the two named request/reply sets to temporary storage. This proves supplied native artifacts work with PHP polling; it does not prove economic command round trips, host writer exclusion or power-loss durability. The manifest has no source-head field, so the generating checkout head must be verified separately. + +Native stash.inspect and room.inspect require exactly {page: integer 0..7}. The +Discord controller rejects omitted, extra, noninteger or out-of-range fields +before transport. The synthetic economics driver emits page 0 for inspection. diff --git a/src/Civ13/Safehouse/Controller.php b/src/Civ13/Safehouse/Controller.php index 12a50b27..5e3a6ace 100644 --- a/src/Civ13/Safehouse/Controller.php +++ b/src/Civ13/Safehouse/Controller.php @@ -38,6 +38,9 @@ public static function prepare(string $interactionId, string $actorDiscordId, st throw new \InvalidArgumentException('Game authority cannot be supplied: '.$authority); } } + if (in_array($action, ['stash.inspect', 'room.inspect'], true) && (array_keys($payload) !== ['page'] || !is_int($payload['page']) || $payload['page'] < 0 || $payload['page'] > 7)) { + throw new \InvalidArgumentException('Inspection requires exactly integer page in 0..7'); + } if (in_array($action, ['trade.confirm', 'market.list', 'market.buy', 'market.cancel', 'room.manage'], true) && (!is_string($payload['confirmation_token'] ?? null) || $payload['confirmation_token'] === '' || $revision === null)) { throw new \InvalidArgumentException('Game confirmation token and revision required'); } diff --git a/tests/safehouse/discord.php b/tests/safehouse/discord.php index 0a29fee0..f4633534 100644 --- a/tests/safehouse/discord.php +++ b/tests/safehouse/discord.php @@ -20,7 +20,7 @@ class MockInteraction extends Interaction public $data; public array $messages = []; public bool $failAck = false; - public static function fixture(string $action = 'stash.inspect', string $payload = '{}', ?int $revision = null, bool $confirm = false): self { + public static function fixture(string $action = 'stash.inspect', string $payload = '{"page":0}', ?int $revision = null, bool $confirm = false): self { $fixture = (new ReflectionClass(self::class))->newInstanceWithoutConstructor(); $fixture->member = (object) ['id' => '123']; $fixture->data = (object) ['options' => ['action' => (object) ['value' => $action], 'payload' => (object) ['value' => $payload], 'confirm' => (object) ['value' => $confirm]]]; @@ -53,6 +53,9 @@ public function submit(array $request): PromiseInterface { (new DiscordRoute($gateway, '456'))->handle($disabled); check(count($gateway->requests) === 0 && $disabled->messages[0][1], 'Disabled route sent request or leaked response'); $route = new DiscordRoute($gateway, '456', true); +$missingPage = MockInteraction::fixture('stash.inspect', '{}'); +$route->handle($missingPage); +check(count($gateway->requests) === 0 && $missingPage->messages[0][0] === 'initial', 'Missing inspection page reached transport'); $wrongGuild = MockInteraction::fixture(); $wrongGuild->guild_id = '999'; $route->handle($wrongGuild); check(count($gateway->requests) === 0, 'Wrong guild reached game'); @@ -69,6 +72,7 @@ public function submit(array $request): PromiseInterface { $route->handle($inspect); check($inspect->messages === [['defer', true]], 'Inspection did not defer ephemerally'); check($gateway->requests[0]['discord_id'] === '123', 'Actor not bound to Discord member'); +check($gateway->requests[0]['payload'] === ['page' => 0], 'Discord inspection incompatible with native page schema'); $gateway->deferreds[0]->resolve(['status' => 'pending', 'request_id' => '123456']); check(str_contains($inspect->messages[1][2]['content'], 'pending') && $inspect->messages[1][2]['allowed_mentions'] === ['parse' => []], 'Pending claimed success or allowed mentions'); $confirmed = MockInteraction::fixture('trade.confirm', '{"confirmation_token":"game-token"}', 4, true); $confirmed->id = '123457'; diff --git a/tests/safehouse/economics-driver-test.php b/tests/safehouse/economics-driver-test.php index a0fed9ca..969c9809 100644 --- a/tests/safehouse/economics-driver-test.php +++ b/tests/safehouse/economics-driver-test.php @@ -21,7 +21,10 @@ function reply(string $id, array $data): void { file_put_contents($directory.'/'.$id.'.reply', json_encode(AsyncSpool::seal(json_encode($body), 'reply', 1000, 1120, $key))); } try { - $inspect = invoke(['inspect', 'inspect-seller', '123']); reply($inspect, ['revision' => 7]); + $inspect = invoke(['inspect', 'inspect-seller', '123']); + $inspectBody = json_decode(base64_decode(json_decode(file_get_contents($directory.'/'.$inspect.'.request'), true)['body']), true); + if ($inspectBody['payload'] !== ['page' => 0]) { throw new RuntimeException('Native inspection page missing'); } + reply($inspect, ['revision' => 7]); foreach (['server_buyback' => 'trade.confirm', 'player_listing' => 'market.list', 'player_purchase' => 'market.buy', 'listing_cancel' => 'market.cancel'] as $kind => $action) { $selection = in_array($kind, ['server_buyback', 'player_listing'], true) ? '["native-root"]' : '"native-listing"'; $quote = invoke(['quote', 'quote-'.$kind, $inspect, $kind, $selection]); diff --git a/tests/safehouse/economics-driver.php b/tests/safehouse/economics-driver.php index 3142bffe..5e2a067e 100644 --- a/tests/safehouse/economics-driver.php +++ b/tests/safehouse/economics-driver.php @@ -22,7 +22,7 @@ function required(bool $ok, string $reason): void { if (!$ok) { throw new Unexpe case 'recover': echo $spool->recover($argv[5] ?? '', $requestId)."\n"; exit; case 'inspect': - $request = Client::envelope($requestId, $argv[5] ?? '', 'stash.inspect', [], null); break; + $request = Client::envelope($requestId, $argv[5] ?? '', 'stash.inspect', ['page' => 0], null); break; case 'quote': $source = $boundReply($argv[5] ?? ''); required(($source['action'] ?? null) === 'stash.inspect', 'Quote revision must come from inspection'); diff --git a/tests/safehouse/run.php b/tests/safehouse/run.php index c196d918..2269678a 100644 --- a/tests/safehouse/run.php +++ b/tests/safehouse/run.php @@ -44,7 +44,10 @@ public function exchange(array $request): array { rejects(fn () => $client->request('i2', '123', 'stash.inspect', [], null)); $transport->mismatch = false; $controller = new Controller($client); - check($controller->handle('empty', '123', 'stash.inspect', '{}', null)['outcome']['status'] === 'committed', 'Empty object rejected'); + foreach (['stash.inspect', 'room.inspect'] as $inspection) { + foreach (['{}', '{"page":8}', '{"page":-1}', '{"page":"0"}', '{"page":0,"extra":1}'] as $invalidPage) { rejects(fn () => Controller::prepare('invalid-page', '123', $inspection, $invalidPage, null)); } + check(Controller::prepare('valid-page', '123', $inspection, '{"page":0}', null)['payload'] === ['page' => 0], 'Inspection page changed'); + } rejects(fn () => $controller->handle('i3', '123', 'trade.confirm', '{"confirmation_token":"token"}', null)); rejects(fn () => $controller->handle('i3', '123', 'trade.quote', '{"price":1}', 4)); check($controller->handle('i3', '123', 'stash.inspect', '{"page":1}', null)['ephemeral'], 'Private response not ephemeral'); From ceb3a2a56d70afdc7c0b1a928b5c157464df1612 Mon Sep 17 00:00:00 2001 From: Valithor Obsidion Date: Mon, 12 Oct 2026 04:56:39 -0400 Subject: [PATCH 16/22] Clarify original trade status and enforce native command shapes --- docs/safehouse-acceptance.md | 48 ++++++++++++++++++++++++++++ docs/safehouse-integration.md | 14 ++++---- src/Civ13/Safehouse/Client.php | 2 +- src/Civ13/Safehouse/Controller.php | 24 ++++++++++++++ src/Civ13/Safehouse/DiscordRoute.php | 18 ++++++++--- tests/safehouse/discord.php | 16 +++++++++- tests/safehouse/run.php | 4 +++ 7 files changed, 112 insertions(+), 14 deletions(-) create mode 100644 docs/safehouse-acceptance.md diff --git a/docs/safehouse-acceptance.md b/docs/safehouse-acceptance.md new file mode 100644 index 00000000..99c2896a --- /dev/null +++ b/docs/safehouse-acceptance.md @@ -0,0 +1,48 @@ +# Disabled safehouse acceptance and host gates + +The bot remains disabled. Local mocks, native fixture artifacts and exact-head CI +are separate from command registration, live Discord delivery and host activation. +Ordinary tests/bootstrap.php loads .env; safehouse runners deliberately bypass it. + +## Evidence and remaining scope + +| Scope | Evidence | Remaining gate | +| --- | --- | --- | +| Linking | Actor derived from Discord member; strict challenge/proof payload; signed bound replies; native link artifact accepted | Native owns in-game generation/expiry/one-use proof and one-to-one identity. No live linking claim | +| Identity/condition/FIR | Bot passes native JSON without reconstructing inventory; native economic fixture preserves graph | Native inspect response currently exposes root ID/secure only. Nested condition/FIR inspection needs an authoritative DTO | +| Access/busy/in-raid | Bot never supplies account identity or ownership; native rejects access/revision failures; mocked rejection remains ephemeral | No connected active-raid or multiweek recovery acceptance | +| Economics | Native 19-phase roundtrip manifest pins game1644e49dc46a82e430e16c0cfbe47cb911b8cfd4 and bot470fdfb6c3a407cc272f0c63a494de84e3a638d8: list/purchase/cancel/buyback/replay/interruption status | Pricing is approved native prototype allowlist/table, not production market acceptance | +| Confirmation/pending | Exact token-only payload and explicit confirm=true; strict selectors/revision bounds; original status distinguished from query status; bound recovery JSON displayed | Human Discord usability/registration scope has not been exercised | +| Replay/crash/concurrency | Exact bytes retained; queue/active bounds; process lock/replay/crash mocks; native markers and signed recovery artifacts | Host exclusion of concurrent native writers and actual power-loss ordering | +| Room management/market discovery | Current allowed actions preserve game authority | Four sale kinds do not define room quote issuance; no listing browse/catalog action. Native/product contract required | +| Audit/retention | Immutable request/reply and native transaction receipts retained; no automatic uncertain deletion | Host audit export/access/retention/ack policy and bounded archival before capacity fills | + +The native economic manifest was inspected as supplied evidence, not rerun by +this bot audit. Graph preservation during settlement does not establish detailed +inspection UI, multiweek persistence or active-raid/corpse-insurance gameplay. + +## Explicit host activation requirements + +Keep safehouse_enabled=false until the concrete host package is reviewed. Supply +an injected AsyncGateway backed by AsyncSpool plus AsyncDriver/ReactTimer on the +existing event loop, the intended civ13_guild_id, and independently authenticated +service key provisioned outside tracked files. The synchronous Client is worker +only. Protect the private spool and all ancestors against untrusted replacement, +symlinks and access; do not point it at a save directory. + +Run exactly one native writer per spool with host-enforced exclusion across +processes/restarts. Marker-before-reply and validated-receipt-before-clear are +required, but do not prove disk flush or power-loss ordering. Never overwrite a +completed reply, clear a forged/uncertain marker, re-sign an economic operation, +or mint another quote/token to resolve uncertainty. Expired operations use fresh +read-only actor/digest-bound status. Agree storage flush/recovery guarantees and +receipt-backed archival/ack policy before enabling; queue saturation fails closed. + +Pin native crypto/handler and PHP dependencies, align clocks to the 120-second +TTL/five-second skew bounds, establish production wallet/treasury/pricing policy, +and retain configured audit receipts. Review the exported command schema against +the intended application's global command snapshot; registration saves only the +named safehouse command and does not bulk overwrite unrelated commands. The +handler additionally restricts use to the configured guild. Perform a separately +authorized bounded registration/restart/live ephemeral interaction check before +claiming live acceptance. No activation, live currency or deployment occurred. diff --git a/docs/safehouse-integration.md b/docs/safehouse-integration.md index 34446985..ec5ffc7a 100644 --- a/docs/safehouse-integration.md +++ b/docs/safehouse-integration.md @@ -2,9 +2,9 @@ This patch provides a controller, command schema, durable request client, disabled enqueue/poll authenticated spool and actual DiscordPHP routing hooks. -No commands have been registered, bot started or game saves read. Compatibility -is unaccepted until the game handler executes this -contract fixture. Existing verifier cache entries provide no trade authority. +No commands have been registered, bot started or game saves read by the bot. +Native fixture compatibility is recorded in `safehouse-acceptance.md`; live and +host acceptance remain separate. Existing verifier cache entries provide no trade authority. ## Version 1 request @@ -23,8 +23,8 @@ Proposed payloads: link.challenge {challenge_id}; link.complete {challenge_id, proof}; stash.inspect/room.inspect {page}; room.manage {confirmation_token}; trade.quote {sale_kind, item_ids or listing_id}; trade.confirm {confirmation_token}; trade.status {operation_request_id, operation_request_digest}; market.list {confirmation_token}; market.buy -{confirmation_token}; market.cancel {confirmation_token}. This is a proposal, -not an implemented game schema. No client price or account identity is accepted. +{confirmation_token}; market.cancel {confirmation_token}. The native handler +accepts this versioned schema. No client price or account identity is accepted. The refined economic proposal is in `safehouse-economic-contract.md` and `tests/safehouse/economic-contract.json`; native acceptance remains separate. Quotes bind actor, item graph IDs, revision, approved game price, action and @@ -100,8 +100,8 @@ transport delivery and caches terminal replies. It bounds records at 1000 and fails closed when full; archive only under an operator-approved game receipt retention policy. The transport must enforce a finite timeout. Blocking transport must not run on the shared Discord event loop. No live spool, game-side checks, -wallet/pricing/escrow correctness, production crash durability or live command -registration has been verified. Activation depends on game handler compatibility, +production crash durability or live command registration has been verified. +Isolated native wallet/escrow acceptance is recorded separately. Activation depends on host compatibility, authenticated async adapter, wallet/escrow and identity reconciliation. Native cross-process tests additionally prove local same-ID delivery serialization diff --git a/src/Civ13/Safehouse/Client.php b/src/Civ13/Safehouse/Client.php index 15567f95..c81dc27c 100644 --- a/src/Civ13/Safehouse/Client.php +++ b/src/Civ13/Safehouse/Client.php @@ -40,7 +40,7 @@ public function request(string $requestId, string $discordId, string $action, ar public static function envelope(string $requestId, string $discordId, string $action, array $payload, ?int $expectedAccountRevision): array { - if (!preg_match('/^[A-Za-z0-9_-]{1,96}$/D', $requestId) || !preg_match('/^[0-9]{1,22}$/D', $discordId) || !in_array($action, self::ACTIONS, true) || ($expectedAccountRevision !== null && $expectedAccountRevision < 0)) { + if (!preg_match('/^[A-Za-z0-9_-]{1,96}$/D', $requestId) || !preg_match('/^[0-9]{1,22}$/D', $discordId) || !in_array($action, self::ACTIONS, true) || ($expectedAccountRevision !== null && ($expectedAccountRevision < 0 || $expectedAccountRevision > 1000000))) { throw new \InvalidArgumentException('Invalid request identity/action/revision'); } $request = ['protocol' => self::PROTOCOL, 'request_id' => $requestId, 'discord_id' => $discordId, 'action' => $action, 'expected_account_revision' => $expectedAccountRevision, 'payload' => self::canonical($payload)]; diff --git a/src/Civ13/Safehouse/Controller.php b/src/Civ13/Safehouse/Controller.php index 5e3a6ace..cf6ec0d3 100644 --- a/src/Civ13/Safehouse/Controller.php +++ b/src/Civ13/Safehouse/Controller.php @@ -41,6 +41,30 @@ public static function prepare(string $interactionId, string $actorDiscordId, st if (in_array($action, ['stash.inspect', 'room.inspect'], true) && (array_keys($payload) !== ['page'] || !is_int($payload['page']) || $payload['page'] < 0 || $payload['page'] > 7)) { throw new \InvalidArgumentException('Inspection requires exactly integer page in 0..7'); } + $fields = match ($action) { + 'link.challenge' => ['challenge_id'], + 'link.complete' => ['challenge_id', 'proof'], + 'stash.inspect', 'room.inspect' => ['page'], + 'room.manage', 'trade.confirm', 'market.list', 'market.buy', 'market.cancel' => ['confirmation_token'], + 'trade.status' => ['operation_request_digest', 'operation_request_id'], + 'trade.quote' => match ($payload['sale_kind'] ?? null) { + 'server_buyback', 'player_listing' => ['item_ids', 'sale_kind'], + 'player_purchase', 'listing_cancel' => ['listing_id', 'sale_kind'], + default => throw new \InvalidArgumentException('Unknown sale kind'), + }, + default => throw new \InvalidArgumentException('Unknown action'), + }; + $keys = array_keys($payload); sort($keys); sort($fields); + if ($keys !== $fields) { throw new \InvalidArgumentException('Unexpected or missing action payload fields'); } + foreach ($payload as $field => $value) { + if ($field === 'page') { continue; } + if ($field === 'item_ids') { + if (!is_array($value) || !array_is_list($value) || count($value) < 1 || count($value) > 16 || count(array_unique($value, SORT_REGULAR)) !== count($value)) { throw new \InvalidArgumentException('Select 1..16 distinct root IDs'); } + foreach ($value as $id) { if (!is_string($id) || !preg_match('/^[A-Za-z0-9_-]{1,80}$/D', $id)) { throw new \InvalidArgumentException('Invalid graph root ID'); } } + } elseif (!is_string($value) || !preg_match('/^[A-Za-z0-9_-]{1,128}$/D', $value)) { throw new \InvalidArgumentException('Invalid game-issued identifier'); } + } + if ($action === 'trade.status' && !preg_match('/^[a-f0-9]{64}$/D', $payload['operation_request_digest'])) { throw new \InvalidArgumentException('Invalid original request digest'); } + if ($action === 'trade.quote' && $revision === null) { throw new \InvalidArgumentException('Quote requires expected account revision'); } if (in_array($action, ['trade.confirm', 'market.list', 'market.buy', 'market.cancel', 'room.manage'], true) && (!is_string($payload['confirmation_token'] ?? null) || $payload['confirmation_token'] === '' || $revision === null)) { throw new \InvalidArgumentException('Game confirmation token and revision required'); } diff --git a/src/Civ13/Safehouse/DiscordRoute.php b/src/Civ13/Safehouse/DiscordRoute.php index bafe31c1..9fc80056 100644 --- a/src/Civ13/Safehouse/DiscordRoute.php +++ b/src/Civ13/Safehouse/DiscordRoute.php @@ -32,11 +32,11 @@ public function handle(Interaction $interaction): PromiseInterface try { $promise = $this->gateway->submit($request); } catch (\Throwable $error) { - return $interaction->updateOriginalResponse(self::message('Request remains unresolved. Inspect its status before retrying an economic action.')); + return $interaction->updateOriginalResponse(self::message(self::render(['status' => 'pending', 'request_id' => $request['request_id']], $request))); } return $promise->then( - fn (array $outcome): PromiseInterface => $interaction->updateOriginalResponse(self::message(self::render($outcome))), - fn (\Throwable $error): PromiseInterface => $interaction->updateOriginalResponse(self::message('Request remains unresolved. Inspect its status before retrying an economic action.')) + fn (array $outcome): PromiseInterface => $interaction->updateOriginalResponse(self::message(self::render($outcome, $request))), + fn (\Throwable $error): PromiseInterface => $interaction->updateOriginalResponse(self::message(self::render(['status' => 'pending', 'request_id' => $request['request_id']], $request))) ); }); } @@ -49,13 +49,21 @@ private static function message(string $content): MessageBuilder return MessageBuilder::new()->setContent($match[0])->setAllowedMentions(['parse' => []]); } - private static function render(array $outcome): string + private static function render(array $outcome, array $request): string { $id = (string) ($outcome['request_id'] ?? 'unknown'); $status = $outcome['status'] ?? 'pending'; - if ($status === 'pending') { return 'Request '.$id.' is pending. No completed trade is confirmed. Use status recovery before another economic action.'; } + if ($status === 'pending') { return 'Request '.$id.' is pending. No completed trade is confirmed. Do not submit another economic operation. Use trade.status with payload '.json_encode(['operation_request_id' => $request['request_id'], 'operation_request_digest' => $request['request_digest']], JSON_THROW_ON_ERROR); } if ($status === 'rejected') { return 'Game rejected request '.$id.'. '.json_encode($outcome['data'] ?? $outcome['reason'] ?? [], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR); } if ($status !== 'committed') { return 'Request '.$id.' remains unresolved.'; } + if ($request['action'] === 'trade.status') { + $operationStatus = $outcome['data']['operation_status'] ?? null; + if (!in_array($operationStatus, ['pending', 'committed', 'rejected', 'not_found'], true)) { return 'Status query completed, but the original operation remains unresolved.'; } + if ($operationStatus === 'pending' || $operationStatus === 'not_found') { return 'Status query completed. Original operation is '.$operationStatus.'. No completed trade is confirmed. Do not issue another economic operation; retain its original request for recovery.'; } + $receipt = $outcome['data']['operation_receipt'] ?? null; + if (!is_array($receipt) || ($receipt['request_id'] ?? null) !== $request['payload']['operation_request_id'] || ($receipt['request_digest'] ?? null) !== $request['payload']['operation_request_digest'] || ($receipt['discord_id'] ?? null) !== $request['discord_id'] || ($receipt['status'] ?? null) !== $operationStatus) { return 'Status query completed, but its original receipt binding is unresolved.'; } + return 'Original operation '.$receipt['request_id'].' is '.$operationStatus.'. '.json_encode($receipt['data'] ?? [], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR); + } return 'Game confirmed request '.$id.'. '.json_encode($outcome['data'] ?? [], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR); } } diff --git a/tests/safehouse/discord.php b/tests/safehouse/discord.php index f4633534..70ebf021 100644 --- a/tests/safehouse/discord.php +++ b/tests/safehouse/discord.php @@ -75,6 +75,7 @@ public function submit(array $request): PromiseInterface { check($gateway->requests[0]['payload'] === ['page' => 0], 'Discord inspection incompatible with native page schema'); $gateway->deferreds[0]->resolve(['status' => 'pending', 'request_id' => '123456']); check(str_contains($inspect->messages[1][2]['content'], 'pending') && $inspect->messages[1][2]['allowed_mentions'] === ['parse' => []], 'Pending claimed success or allowed mentions'); +check(str_contains($inspect->messages[1][2]['content'], $gateway->requests[0]['request_digest']), 'Pending omitted bound recovery digest'); $confirmed = MockInteraction::fixture('trade.confirm', '{"confirmation_token":"game-token"}', 4, true); $confirmed->id = '123457'; $route->handle($confirmed); check($gateway->requests[1]['payload'] === ['confirmation_token' => 'game-token'], 'Confirmation changed opaque game token'); @@ -85,6 +86,19 @@ public function submit(array $request): PromiseInterface { $gateway->deferreds[2]->resolve(['status' => 'committed', 'request_id' => 'unicode', 'data' => ['name' => str_repeat('🌍', 2000)]]); check(preg_match('//u', $unicode->messages[1][2]['content']) === 1 && strlen($unicode->messages[1][2]['content']) <= 3600, 'Bounded render split Unicode'); +$digest = str_repeat('a', 64); +foreach (['pending', 'not_found', 'committed', 'rejected', 'wrong_actor'] as $operationStatus) { + $statusInteraction = MockInteraction::fixture('trade.status', json_encode(['operation_request_id' => 'original-operation', 'operation_request_digest' => $digest])); + $statusInteraction->id = 'status-'.$operationStatus; + $route->handle($statusInteraction); + $data = ['operation_status' => $operationStatus === 'wrong_actor' ? 'committed' : $operationStatus]; + if (in_array($operationStatus, ['committed', 'rejected', 'wrong_actor'], true)) { $data['operation_receipt'] = ['request_id' => 'original-operation', 'request_digest' => $digest, 'discord_id' => $operationStatus === 'wrong_actor' ? '999' : '123', 'status' => $data['operation_status']]; } + $gateway->deferreds[array_key_last($gateway->deferreds)]->resolve(['status' => 'committed', 'data' => $data]); + $content = $statusInteraction->messages[1][2]['content']; + check(!str_contains($content, 'Game confirmed request'), 'Status query misreported original success'); + check(str_contains($content, $operationStatus === 'wrong_actor' ? 'unresolved' : $operationStatus), 'Original operation status omitted'); +} + // Construct only the Slash adapter over a reflection-created inert Civ13 object. // No Civ13/Discord constructor, token, init event or remote command save runs. require __DIR__.'/../../src/Civ13/Civ13.php'; @@ -114,7 +128,7 @@ public function listenCommand($names, ?callable $callback = null, ?callable $aut $boundInteraction = MockInteraction::fixture(); $boundInteraction->id = '654321'; ($enabledBot->discord->bound['safehouse'])($boundInteraction); check(end($gateway->requests)['request_id'] === '654321', 'Slash callback did not use actual safehouse route'); -$gateway->deferreds[3]->resolve(['status' => 'rejected', 'request_id' => '654321', 'reason' => 'unlinked']); +$gateway->deferreds[array_key_last($gateway->deferreds)]->resolve(['status' => 'rejected', 'request_id' => '654321', 'reason' => 'unlinked']); check(str_contains($boundInteraction->messages[1][2]['content'], 'unlinked'), 'Authoritative unlinked rejection omitted'); // Fake timer driver checks finite asynchronous polling and cancellation. diff --git a/tests/safehouse/run.php b/tests/safehouse/run.php index 2269678a..39011e0a 100644 --- a/tests/safehouse/run.php +++ b/tests/safehouse/run.php @@ -48,6 +48,10 @@ public function exchange(array $request): array { foreach (['{}', '{"page":8}', '{"page":-1}', '{"page":"0"}', '{"page":0,"extra":1}'] as $invalidPage) { rejects(fn () => Controller::prepare('invalid-page', '123', $inspection, $invalidPage, null)); } check(Controller::prepare('valid-page', '123', $inspection, '{"page":0}', null)['payload'] === ['page' => 0], 'Inspection page changed'); } + foreach ([['trade.quote', '{"sale_kind":"player_purchase","item_ids":["root"]}', 1], ['trade.quote', '{"sale_kind":"server_buyback","item_ids":["root","root"]}', 1], ['trade.quote', '{"sale_kind":"server_buyback","item_ids":["root"]}', null], ['link.complete', '{"challenge_id":"id","proof":"proof","ckey":"victim"}', null], ['link.complete', '{"challenge_id":"id"}', null], ['trade.confirm', '{"confirmation_token":"token","price":1}', 1], ['trade.status', '{"operation_request_id":"id","operation_request_digest":"bad"}', null], ['stash.inspect', '{"page":0}', 1000001]] as [$action, $payload, $revision]) { + rejects(fn () => Controller::prepare('bad-contract', '123', $action, $payload, $revision)); + } + check(Controller::prepare('link-proof', '123', 'link.complete', '{"challenge_id":"game-origin","proof":"game-proof"}', null)['payload'] === ['challenge_id' => 'game-origin', 'proof' => 'game-proof'], 'Game-origin link proof altered'); rejects(fn () => $controller->handle('i3', '123', 'trade.confirm', '{"confirmation_token":"token"}', null)); rejects(fn () => $controller->handle('i3', '123', 'trade.quote', '{"price":1}', 4)); check($controller->handle('i3', '123', 'stash.inspect', '{"page":1}', null)['ephemeral'], 'Private response not ephemeral'); From 5c36356ffc67b2b7c6fd6ff820ea1eca1ae75a23 Mon Sep 17 00:00:00 2001 From: Valithor Obsidion Date: Mon, 12 Oct 2026 07:45:22 -0400 Subject: [PATCH 17/22] Add bounded native detail browse and room quote contracts --- docs/safehouse-acceptance.md | 2 +- docs/safehouse-economic-contract.md | 25 +++++++++++++++ docs/safehouse-integration.md | 5 ++- src/Civ13/Safehouse/AsyncSpool.php | 13 ++++++-- src/Civ13/Safehouse/Client.php | 4 +-- src/Civ13/Safehouse/Controller.php | 12 ++++++-- tests/safehouse/discord.php | 11 +++++++ tests/safehouse/economic-contract.json | 37 ++++++++++++++++++++++- tests/safehouse/economics-driver-test.php | 6 ++++ tests/safehouse/economics-driver.php | 20 ++++++++++-- tests/safehouse/run.php | 15 +++++++++ 11 files changed, 138 insertions(+), 12 deletions(-) diff --git a/docs/safehouse-acceptance.md b/docs/safehouse-acceptance.md index 99c2896a..a287247b 100644 --- a/docs/safehouse-acceptance.md +++ b/docs/safehouse-acceptance.md @@ -14,7 +14,7 @@ Ordinary tests/bootstrap.php loads .env; safehouse runners deliberately bypass i | Economics | Native 19-phase roundtrip manifest pins game1644e49dc46a82e430e16c0cfbe47cb911b8cfd4 and bot470fdfb6c3a407cc272f0c63a494de84e3a638d8: list/purchase/cancel/buyback/replay/interruption status | Pricing is approved native prototype allowlist/table, not production market acceptance | | Confirmation/pending | Exact token-only payload and explicit confirm=true; strict selectors/revision bounds; original status distinguished from query status; bound recovery JSON displayed | Human Discord usability/registration scope has not been exercised | | Replay/crash/concurrency | Exact bytes retained; queue/active bounds; process lock/replay/crash mocks; native markers and signed recovery artifacts | Host exclusion of concurrent native writers and actual power-loss ordering | -| Room management/market discovery | Current allowed actions preserve game authority | Four sale kinds do not define room quote issuance; no listing browse/catalog action. Native/product contract required | +| Room management/market discovery | Additive room.quote/market.browse/stash.detail DTO agreed and bot routing implemented disabled | Native implementation and exact-head integrated fixture acceptance pending; room upgrades use one account snapshot and receipt | | Audit/retention | Immutable request/reply and native transaction receipts retained; no automatic uncertain deletion | Host audit export/access/retention/ack policy and bounded archival before capacity fills | The native economic manifest was inspected as supplied evidence, not rerun by diff --git a/docs/safehouse-economic-contract.md b/docs/safehouse-economic-contract.md index 5e23ec5d..10c42446 100644 --- a/docs/safehouse-economic-contract.md +++ b/docs/safehouse-economic-contract.md @@ -1,5 +1,27 @@ # Safehouse economics contract v1 +Additive agreed actions: `stash.detail` exact `{item_id,page}` with page0..31, +`market.browse` exact `{page}` with page0..15, and `room.quote` exact +`{operation:"upgrade",item_ids}`. The first two are read-only with nullable outer +expected_account_revision; room.quote requires outer revision0..1000000. No +revision is supplied in payload. Root IDs are1..80 ASCII identifier characters; +room selectors contain1..16 distinct roots. Native eligibility and required +5/10/15 workshop parts remain game authority. room.manage remains token-only plus +quoted outer revision and Discord confirm=true. Native upgrade consumes parts, +level and original request receipt in one account snapshot, not the shared trade +journal, preserving trade graph/level conservation. + +Detail data is `{account_revision,graph_revision,item_id,total_nodes,nodes,next_page}`; +both revisions are saved account revision. Up to16 nodes/page expose only index, +id,type,parent graph index,found_in_raid,condition `{health,maxhealth}`,material_key, +amount,ammo_refs (contained graph indexes only); unavailable values are null. +Root observations retain id/secure and add node_count/root compact summary. +Browse is8 listings/page, at most128 active listings: listing_id/listing_revision, +price `{currency_id:"civ13.game_credits.v1",amount}`,item_ids,item_count,roots. +No seller identity, arbitrary saved scalar strings or insurance secret is exposed. +Quotes revalidate escrow and do not treat browse observations as purchase authority. +Native rejects `response_too_large` before publishing rather than truncating DTOs. + This pins the DTO agreed with the native owner through the coordinator. Native end-to-end bot compatibility remains unverified; this does not enable trades. It extends the immutable request and @@ -129,6 +151,9 @@ manifest with `synthetic:true`, the public signing fixture key, and integer ```text php tests/safehouse/economics-driver.php SPOOL MANIFEST inspect ID ACTOR +php tests/safehouse/economics-driver.php SPOOL MANIFEST browse ID INSPECT_KEY PAGE +php tests/safehouse/economics-driver.php SPOOL MANIFEST detail ID INSPECT_KEY ITEM_ID PAGE +php tests/safehouse/economics-driver.php SPOOL MANIFEST room-quote ID INSPECT_KEY SELECTOR_JSON php tests/safehouse/economics-driver.php SPOOL MANIFEST quote ID INSPECT_KEY SALE_KIND SELECTOR_JSON php tests/safehouse/economics-driver.php SPOOL MANIFEST confirm ID QUOTE_KEY php tests/safehouse/economics-driver.php SPOOL MANIFEST poll FILE_KEY diff --git a/docs/safehouse-integration.md b/docs/safehouse-integration.md index ec5ffc7a..51692af5 100644 --- a/docs/safehouse-integration.md +++ b/docs/safehouse-integration.md @@ -108,7 +108,10 @@ Native cross-process tests additionally prove local same-ID delivery serializati and exact replay after a worker exits after mock game commit. The mock game's durable receipt establishes one synthetic settlement; this does not prove the production game's transaction durability. Local lock acquisition is bounded at -two seconds, requests at 8 KiB and replies at 64 KiB. A busy lock returns pending. +two seconds, decoded request/reply bodies at 8192 bytes. Sealed wire is at most +12000 bytes, and the entire spool file at most12001 bytes before trimming outer +JSON whitespace. LF and historical Windows CRLF are accepted only within that +file cap; authenticated decoded body bytes are never trimmed. A busy lock returns pending. For an asynchronous adapter, split durable enqueue and reply handling: hold flock only while inserting immutable serialized envelope bytes and checking known diff --git a/src/Civ13/Safehouse/AsyncSpool.php b/src/Civ13/Safehouse/AsyncSpool.php index 3b38d921..f22e1fb8 100644 --- a/src/Civ13/Safehouse/AsyncSpool.php +++ b/src/Civ13/Safehouse/AsyncSpool.php @@ -106,6 +106,7 @@ public function recover(string $id, string $statusRequestId): string /** Public for independent fixture adapters; signing never authorizes a trade. */ public static function seal(string $bytes, string $direction, int $issued, int $expires, string $key): array { + if (strlen($bytes) > 8192) { throw new \InvalidArgumentException('Decoded IPC body exceeds 8192 bytes'); } $body = base64_encode($bytes); $signed = self::IPC_PROTOCOL."\n".$direction."\n".$issued."\n".$expires."\n".$body; return ['protocol' => self::IPC_PROTOCOL, 'issued_at' => $issued, 'expires_at' => $expires, 'body' => $body, 'mac' => hash_hmac('sha256', $signed, $key)]; @@ -121,10 +122,18 @@ private function path(string $id, string $suffix): string private function read(string $path, string $direction): array { - if (!is_file($path) || filesize($path) > 100000) { throw new \RuntimeException('Missing or oversized spool entry'); } - $envelope = json_decode(file_get_contents($path), true, 32, JSON_THROW_ON_ERROR); + clearstatcache(true, $path); + if (!is_file($path) || filesize($path) > 12001) { throw new \RuntimeException('Missing or oversized spool entry'); } + $wire = file_get_contents($path); + // Native text2file and historical Windows fixture writers may append + // CRLF. Bound the entire file first; only outer JSON whitespace is trim. + $wire = trim($wire); + if (strlen($wire) > 12000) { throw new \UnexpectedValueException('IPC wire exceeds 12000 bytes'); } + $envelope = json_decode($wire, true, 32, JSON_THROW_ON_ERROR); if (($envelope['protocol'] ?? null) !== self::IPC_PROTOCOL || !is_int($envelope['issued_at'] ?? null) || !is_int($envelope['expires_at'] ?? null) || !is_string($envelope['body'] ?? null) || !is_string($envelope['mac'] ?? null) || base64_decode($envelope['body'], true) === false) { throw new \UnexpectedValueException('Invalid authenticated envelope'); } if ($envelope['expires_at'] < $envelope['issued_at'] || $envelope['expires_at'] - $envelope['issued_at'] > 120 || $envelope['issued_at'] > ($this->clock)() + 5) { throw new \UnexpectedValueException('Invalid envelope time bound'); } + $decoded = base64_decode($envelope['body'], true); + if (strlen($decoded) > 8192 || base64_encode($decoded) !== $envelope['body']) { throw new \UnexpectedValueException('Noncanonical or oversized IPC body'); } $expected = self::seal(base64_decode($envelope['body'], true), $direction, $envelope['issued_at'], $envelope['expires_at'], $this->key); if (!hash_equals($expected['mac'], $envelope['mac'])) { throw new \UnexpectedValueException('Invalid service authentication'); } return $envelope; diff --git a/src/Civ13/Safehouse/Client.php b/src/Civ13/Safehouse/Client.php index c81dc27c..0f4bb3b0 100644 --- a/src/Civ13/Safehouse/Client.php +++ b/src/Civ13/Safehouse/Client.php @@ -6,7 +6,7 @@ final class Client { public const PROTOCOL = 'civ13.safehouse.v1'; - public const ACTIONS = ['link.challenge', 'link.complete', 'stash.inspect', 'room.inspect', 'room.manage', 'trade.quote', 'trade.confirm', 'trade.status', 'market.list', 'market.buy', 'market.cancel']; + public const ACTIONS = ['link.challenge', 'link.complete', 'stash.inspect', 'room.inspect', 'room.manage', 'trade.quote', 'trade.confirm', 'trade.status', 'market.list', 'market.buy', 'market.cancel', 'stash.detail', 'market.browse', 'room.quote']; public function __construct(private Transport $transport, private string $journal, private bool $enabled = false, private int $capacity = 1000) {} @@ -86,7 +86,7 @@ private function deliver(array $request, string $requestId): array if (!in_array($response['status'] ?? null, ['pending', 'committed', 'rejected'], true)) { throw new \UnexpectedValueException('Unknown game outcome'); } - if (strlen(json_encode($response, JSON_THROW_ON_ERROR)) > 65536) { + if (strlen(json_encode($response, JSON_THROW_ON_ERROR)) > 8192) { throw new \UnexpectedValueException('Game reply exceeds size bound'); } $records[$requestId]['response'] = $response; diff --git a/src/Civ13/Safehouse/Controller.php b/src/Civ13/Safehouse/Controller.php index cf6ec0d3..15e0369f 100644 --- a/src/Civ13/Safehouse/Controller.php +++ b/src/Civ13/Safehouse/Controller.php @@ -38,13 +38,17 @@ public static function prepare(string $interactionId, string $actorDiscordId, st throw new \InvalidArgumentException('Game authority cannot be supplied: '.$authority); } } - if (in_array($action, ['stash.inspect', 'room.inspect'], true) && (array_keys($payload) !== ['page'] || !is_int($payload['page']) || $payload['page'] < 0 || $payload['page'] > 7)) { - throw new \InvalidArgumentException('Inspection requires exactly integer page in 0..7'); + if (in_array($action, ['stash.inspect', 'room.inspect', 'stash.detail', 'market.browse'], true)) { + $pageMax = match ($action) { 'stash.detail' => 31, 'market.browse' => 15, default => 7 }; + if (!is_int($payload['page'] ?? null) || $payload['page'] < 0 || $payload['page'] > $pageMax) { throw new \InvalidArgumentException('Invalid observation page'); } } $fields = match ($action) { 'link.challenge' => ['challenge_id'], 'link.complete' => ['challenge_id', 'proof'], 'stash.inspect', 'room.inspect' => ['page'], + 'stash.detail' => ['item_id', 'page'], + 'market.browse' => ['page'], + 'room.quote' => ['item_ids', 'operation'], 'room.manage', 'trade.confirm', 'market.list', 'market.buy', 'market.cancel' => ['confirmation_token'], 'trade.status' => ['operation_request_digest', 'operation_request_id'], 'trade.quote' => match ($payload['sale_kind'] ?? null) { @@ -63,8 +67,10 @@ public static function prepare(string $interactionId, string $actorDiscordId, st foreach ($value as $id) { if (!is_string($id) || !preg_match('/^[A-Za-z0-9_-]{1,80}$/D', $id)) { throw new \InvalidArgumentException('Invalid graph root ID'); } } } elseif (!is_string($value) || !preg_match('/^[A-Za-z0-9_-]{1,128}$/D', $value)) { throw new \InvalidArgumentException('Invalid game-issued identifier'); } } + if ($action === 'stash.detail' && strlen($payload['item_id']) > 80) { throw new \InvalidArgumentException('Invalid graph root ID'); } + if ($action === 'room.quote' && $payload['operation'] !== 'upgrade') { throw new \InvalidArgumentException('Unknown room operation'); } if ($action === 'trade.status' && !preg_match('/^[a-f0-9]{64}$/D', $payload['operation_request_digest'])) { throw new \InvalidArgumentException('Invalid original request digest'); } - if ($action === 'trade.quote' && $revision === null) { throw new \InvalidArgumentException('Quote requires expected account revision'); } + if (in_array($action, ['trade.quote', 'room.quote'], true) && $revision === null) { throw new \InvalidArgumentException('Quote requires expected account revision'); } if (in_array($action, ['trade.confirm', 'market.list', 'market.buy', 'market.cancel', 'room.manage'], true) && (!is_string($payload['confirmation_token'] ?? null) || $payload['confirmation_token'] === '' || $revision === null)) { throw new \InvalidArgumentException('Game confirmation token and revision required'); } diff --git a/tests/safehouse/discord.php b/tests/safehouse/discord.php index 70ebf021..976277ed 100644 --- a/tests/safehouse/discord.php +++ b/tests/safehouse/discord.php @@ -98,6 +98,17 @@ public function submit(array $request): PromiseInterface { check(!str_contains($content, 'Game confirmed request'), 'Status query misreported original success'); check(str_contains($content, $operationStatus === 'wrong_actor' ? 'unresolved' : $operationStatus), 'Original operation status omitted'); } +foreach ([['stash.detail', '{"item_id":"native-root","page":31}', null], ['market.browse', '{"page":15}', null], ['room.quote', '{"operation":"upgrade","item_ids":["native-part"]}', 4]] as [$action, $payload, $revision]) { + $observation = MockInteraction::fixture($action, $payload, $revision); $observation->id = 'addition-'.str_replace('.', '-', $action); + $route->handle($observation); + check(end($gateway->requests)['action'] === $action && $observation->messages[0] === ['defer', true], 'Additive action not routed ephemerally'); + $data = ['nodes' => [['index' => 1, 'id' => 'native-root', 'parent' => 0, 'found_in_raid' => true, 'condition' => ['health' => 20, 'maxhealth' => 100], 'material_key' => null, 'amount' => null, 'ammo_refs' => []]], 'next_page' => null]; + $gateway->deferreds[array_key_last($gateway->deferreds)]->resolve(['status' => 'committed', 'data' => $data]); + check(str_contains($observation->messages[1][2]['content'], 'found_in_raid') && str_contains($observation->messages[1][2]['content'], 'maxhealth'), 'Authoritative node summary omitted'); +} +$unconfirmedRoom = MockInteraction::fixture('room.manage', '{"confirmation_token":"native-room-token"}', 4); +$countBefore = count($gateway->requests); $route->handle($unconfirmedRoom); +check(count($gateway->requests) === $countBefore, 'Room mutation bypassed explicit confirmation'); // Construct only the Slash adapter over a reflection-created inert Civ13 object. // No Civ13/Discord constructor, token, init event or remote command save runs. diff --git a/tests/safehouse/economic-contract.json b/tests/safehouse/economic-contract.json index 27d02cd2..0f5e79e3 100644 --- a/tests/safehouse/economic-contract.json +++ b/tests/safehouse/economic-contract.json @@ -102,5 +102,40 @@ "table_id": "modern_safehouse_prices_v1", "quote_expires_at_type": "canonical-decimal-unix-seconds-string", "account_revision_max": 1000000, - "not_found_receipt": "null-or-absent" + "not_found_receipt": "null-or-absent", + "decoded_body_max_bytes": 8192, + "wire_max_bytes": 12000, + "spool_file_max_bytes": 12001, + "additive_actions": [ + { + "payload_fields": [ + "item_id", + "page" + ], + "nodes_per_page": 16, + "action": "stash.detail", + "outer_revision": "nullable", + "page_max": 31 + }, + { + "payload_fields": [ + "page" + ], + "listings_per_page": 8, + "action": "market.browse", + "outer_revision": "nullable", + "page_max": 15 + }, + { + "payload_fields": [ + "item_ids", + "operation" + ], + "confirm_action": "room.manage", + "operation": "upgrade", + "action": "room.quote", + "atomic_receipt": "single-account-snapshot", + "outer_revision": "required0..1000000" + } + ] } \ No newline at end of file diff --git a/tests/safehouse/economics-driver-test.php b/tests/safehouse/economics-driver-test.php index 969c9809..75a94c5a 100644 --- a/tests/safehouse/economics-driver-test.php +++ b/tests/safehouse/economics-driver-test.php @@ -25,6 +25,12 @@ function reply(string $id, array $data): void { $inspectBody = json_decode(base64_decode(json_decode(file_get_contents($directory.'/'.$inspect.'.request'), true)['body']), true); if ($inspectBody['payload'] !== ['page' => 0]) { throw new RuntimeException('Native inspection page missing'); } reply($inspect, ['revision' => 7]); + invoke(['browse', 'browse-market', $inspect, '15']); invoke(['detail', 'detail-root', $inspect, 'native-root', '31']); + invoke(['browse', 'invalid-page', $inspect, '16'], false); invoke(['detail', 'invalid-detail-page', $inspect, 'native-root', '32'], false); + $roomQuote = invoke(['room-quote', 'room-quote', $inspect, '["native-part"]']); + $roomData = ['confirm_action' => 'room.manage', 'confirmation_token' => 'native-room-token', 'expected_account_revision' => 7, 'expires_at' => '1120']; reply($roomQuote, $roomData); + invoke(['confirm', 'room-manage', $roomQuote]); + $roomData['confirm_action'] = 'trade.confirm'; reply($roomQuote, $roomData); invoke(['confirm', 'wrong-room-action', $roomQuote], false); foreach (['server_buyback' => 'trade.confirm', 'player_listing' => 'market.list', 'player_purchase' => 'market.buy', 'listing_cancel' => 'market.cancel'] as $kind => $action) { $selection = in_array($kind, ['server_buyback', 'player_listing'], true) ? '["native-root"]' : '"native-listing"'; $quote = invoke(['quote', 'quote-'.$kind, $inspect, $kind, $selection]); diff --git a/tests/safehouse/economics-driver.php b/tests/safehouse/economics-driver.php index 5e2a067e..9eab992d 100644 --- a/tests/safehouse/economics-driver.php +++ b/tests/safehouse/economics-driver.php @@ -23,11 +23,27 @@ function required(bool $ok, string $reason): void { if (!$ok) { throw new Unexpe echo $spool->recover($argv[5] ?? '', $requestId)."\n"; exit; case 'inspect': $request = Client::envelope($requestId, $argv[5] ?? '', 'stash.inspect', ['page' => 0], null); break; + case 'browse': + case 'detail': + $source = $boundReply($argv[5] ?? ''); + required(($source['action'] ?? null) === 'stash.inspect', 'Observation actor must come from inspection'); + $pageText = $argv[$command === 'detail' ? 7 : 6] ?? '0'; + required(ctype_digit($pageText) && (int)$pageText <= ($command === 'detail' ? 31 : 15), 'Invalid observation page'); + $payload = ['page' => (int)$pageText]; + if ($command === 'detail') { required(preg_match('/^[A-Za-z0-9_-]{1,80}$/D', $argv[6] ?? '') === 1, 'Invalid root ID'); $payload['item_id'] = $argv[6]; } + $request = Client::envelope($requestId, $source['discord_id'], $command === 'detail' ? 'stash.detail' : 'market.browse', $payload, null); break; case 'quote': + case 'room-quote': $source = $boundReply($argv[5] ?? ''); required(($source['action'] ?? null) === 'stash.inspect', 'Quote revision must come from inspection'); $revision = $source['data']['revision'] ?? null; required(is_int($revision) && $revision >= 0 && $revision <= 1000000, 'Invalid authoritative revision'); + if ($command === 'room-quote') { + $selection = json_decode($argv[6] ?? '', true, 32, JSON_THROW_ON_ERROR); + required(is_array($selection) && array_is_list($selection) && count($selection) >= 1 && count($selection) <= 16 && count(array_unique($selection, SORT_REGULAR)) === count($selection), 'Invalid room selectors'); + foreach ($selection as $id) { required(is_string($id) && preg_match('/^[A-Za-z0-9_-]{1,80}$/D', $id) === 1, 'Invalid room root ID'); } + $request = Client::envelope($requestId, $source['discord_id'], 'room.quote', ['operation' => 'upgrade', 'item_ids' => $selection], $revision); break; + } $kind = $argv[6] ?? ''; $selectors = ['server_buyback' => 'item_ids', 'player_listing' => 'item_ids', 'player_purchase' => 'listing_id', 'listing_cancel' => 'listing_id']; required(isset($selectors[$kind]), 'Unsupported sale kind'); @@ -36,9 +52,9 @@ function required(bool $ok, string $reason): void { if (!$ok) { throw new Unexpe $request = Client::envelope($requestId, $source['discord_id'], 'trade.quote', ['sale_kind' => $kind, $selectors[$kind] => $selection], $revision); break; case 'confirm': $quote = $boundReply($argv[5] ?? ''); - required(($quote['action'] ?? null) === 'trade.quote', 'Confirmation requires bound quote reply'); + required(in_array($quote['action'] ?? null, ['trade.quote', 'room.quote'], true), 'Confirmation requires bound quote reply'); $data = $quote['data'] ?? []; - required(in_array($data['confirm_action'] ?? null, ['trade.confirm', 'market.list', 'market.buy', 'market.cancel'], true), 'Invalid quote action'); + required($quote['action'] === 'room.quote' ? ($data['confirm_action'] ?? null) === 'room.manage' : in_array($data['confirm_action'] ?? null, ['trade.confirm', 'market.list', 'market.buy', 'market.cancel'], true), 'Invalid quote action'); required(is_string($data['confirmation_token'] ?? null) && $data['confirmation_token'] !== '', 'Missing authoritative token'); required(is_int($data['expected_account_revision'] ?? null) && $data['expected_account_revision'] >= 0 && $data['expected_account_revision'] <= 1000000, 'Invalid quoted revision'); required(is_string($data['expires_at'] ?? null) && preg_match('/^(0|[1-9][0-9]{0,10})$/D', $data['expires_at']) === 1 && (int)$data['expires_at'] >= $now, 'Quote expired or noncanonical expiry'); diff --git a/tests/safehouse/run.php b/tests/safehouse/run.php index 39011e0a..e3bda03c 100644 --- a/tests/safehouse/run.php +++ b/tests/safehouse/run.php @@ -52,6 +52,10 @@ public function exchange(array $request): array { rejects(fn () => Controller::prepare('bad-contract', '123', $action, $payload, $revision)); } check(Controller::prepare('link-proof', '123', 'link.complete', '{"challenge_id":"game-origin","proof":"game-proof"}', null)['payload'] === ['challenge_id' => 'game-origin', 'proof' => 'game-proof'], 'Game-origin link proof altered'); + check(Controller::prepare('detail', '123', 'stash.detail', '{"item_id":"root","page":31}', null)['payload'] === ['item_id' => 'root', 'page' => 31], 'Detail schema changed'); + check(Controller::prepare('browse', '123', 'market.browse', '{"page":15}', null)['action'] === 'market.browse', 'Browse missing'); + check(Controller::prepare('room-quote', '123', 'room.quote', '{"operation":"upgrade","item_ids":["part"]}', 7)['payload'] === ['item_ids' => ['part'], 'operation' => 'upgrade'], 'Room quote mutated selectors'); + foreach ([['stash.detail', '{"item_id":"root","page":32}', null], ['market.browse', '{"page":16}', null], ['market.browse', '{"page":"1"}', null], ['room.quote', '{"operation":"upgrade","item_ids":["part"]}', null], ['room.quote', '{"operation":"downgrade","item_ids":["part"]}', 1], ['room.quote', '{"operation":"upgrade","item_ids":["part","part"]}', 1], ['room.quote', '{"operation":"upgrade","item_ids":["part"],"required_parts":5}', 1]] as [$action, $payload, $revision]) { rejects(fn () => Controller::prepare('bad-addition', '123', $action, $payload, $revision)); } rejects(fn () => $controller->handle('i3', '123', 'trade.confirm', '{"confirmation_token":"token"}', null)); rejects(fn () => $controller->handle('i3', '123', 'trade.quote', '{"price":1}', 4)); check($controller->handle('i3', '123', 'stash.inspect', '{"page":1}', null)['ephemeral'], 'Private response not ephemeral'); @@ -100,6 +104,17 @@ public function exchange(array $request): array { $reply = array_merge($request, ['status' => 'committed']); $replyPath = $directory.'/spool/'.$fileKey.'.reply'; $sealed = AsyncSpool::seal(json_encode($reply), 'reply', 1000, 1120, $key); + check(strlen(AsyncSpool::seal(str_repeat('x', 8192), 'reply', 1000, 1120, $key)['body']) === 10924, 'Exact byte boundary rejected'); + rejects(fn () => AsyncSpool::seal(str_repeat('x', 8193), 'reply', 1000, 1120, $key)); + rejects(fn () => AsyncSpool::seal(str_repeat('🌍', 2049), 'reply', 1000, 1120, $key)); + file_put_contents($replyPath, json_encode($sealed)."\n"); + check($spool->poll($fileKey)['status'] === 'committed', 'Native single-LF reply rejected'); + file_put_contents($replyPath, json_encode($sealed)."\r\n"); check($spool->poll($fileKey)['status'] === 'committed', 'Bounded historical CRLF rejected'); + $oversizedBody = base64_encode(str_repeat('x', 8193)); + $oversized = ['protocol' => AsyncSpool::IPC_PROTOCOL, 'issued_at' => 1000, 'expires_at' => 1120, 'body' => $oversizedBody, 'mac' => hash_hmac('sha256', AsyncSpool::IPC_PROTOCOL."\nreply\n1000\n1120\n".$oversizedBody, $key)]; + file_put_contents($replyPath, json_encode($oversized)); rejects(fn () => $spool->poll($fileKey)); + file_put_contents($replyPath, str_repeat('x', 12002)); + try { $spool->poll($fileKey); throw new LogicException('Oversized wire accepted'); } catch (RuntimeException $e) { check(str_contains($e->getMessage(), 'oversized'), 'Wrong file bound rejection'); } file_put_contents($replyPath, json_encode($sealed)); $markerPath = $replyPath.'.pending'; file_put_contents($markerPath, ''); From be8545fc0a279573343d24bfb61876d877b7f24a Mon Sep 17 00:00:00 2001 From: Valithor Obsidion Date: Mon, 12 Oct 2026 09:00:50 -0400 Subject: [PATCH 18/22] Add read-only authoritative insurance inspection --- docs/safehouse-insurance-contract.md | 26 ++++++++++++++++++++++ src/Civ13/Safehouse/Client.php | 2 +- src/Civ13/Safehouse/Controller.php | 33 ++++++++++++++++++++++++++++ src/Civ13/Safehouse/DiscordRoute.php | 5 +++++ tests/safehouse/discord.php | 14 ++++++++++++ tests/safehouse/run.php | 11 ++++++++++ 6 files changed, 90 insertions(+), 1 deletion(-) create mode 100644 docs/safehouse-insurance-contract.md diff --git a/docs/safehouse-insurance-contract.md b/docs/safehouse-insurance-contract.md new file mode 100644 index 00000000..7cc0769e --- /dev/null +++ b/docs/safehouse-insurance-contract.md @@ -0,0 +1,26 @@ +# Read-only insurance projection + +`insurance.inspect` has exact payload `{ "cursor": null }` initially or an opaque +printable ASCII cursor of 1..256 bytes. Empty cursors are rejected. The existing +outer `expected_account_revision` is nullable. There is no Discord claim action. + +The authoritative response has account_revision (0..1000000), mail_revision and +completed_generation (0..16777215), expiration exactly `to be implemented`, +entries (at most eight), and next_cursor (null or the same cursor syntax). +Each entry has item_id (ASCII identifier 1..80), native catalog type/name +(nullable UTF-8 strings at most 160/128 bytes), condition with health/maxhealth +(finite numbers or null), found_in_raid (boolean or null), returned_generation +and eligible_generation (0..16777215), and state (`pending` or `eligible`). +The decoded signed response remains bounded to 8192 bytes. Proofs, tokens and +arbitrary saved scalar strings are absent. Native stale cursors reject with +`stale_cursor`; the bot never constructs or interprets their HMAC bindings. + +Returns are recorded at the loss round closure and remain locked through one +additional full successful round closure. Restart does not advance generation. +The bot displays native state without deriving eligibility from time or cached +rounds. Logical storage is unlimited; cursor paging and native sharding bound +individual reads. Counter limits are not an entry limit. Expiration has no +active TTL and is explicitly pending a balance decision. + +Mocked projection/routing tests do not prove native sharding, full round closure, +or insurance recovery. Native compatibility is pending a pinned fixture. diff --git a/src/Civ13/Safehouse/Client.php b/src/Civ13/Safehouse/Client.php index 0f4bb3b0..fb887118 100644 --- a/src/Civ13/Safehouse/Client.php +++ b/src/Civ13/Safehouse/Client.php @@ -6,7 +6,7 @@ final class Client { public const PROTOCOL = 'civ13.safehouse.v1'; - public const ACTIONS = ['link.challenge', 'link.complete', 'stash.inspect', 'room.inspect', 'room.manage', 'trade.quote', 'trade.confirm', 'trade.status', 'market.list', 'market.buy', 'market.cancel', 'stash.detail', 'market.browse', 'room.quote']; + public const ACTIONS = ['link.challenge', 'link.complete', 'stash.inspect', 'room.inspect', 'room.manage', 'trade.quote', 'trade.confirm', 'trade.status', 'market.list', 'market.buy', 'market.cancel', 'stash.detail', 'market.browse', 'room.quote', 'insurance.inspect']; public function __construct(private Transport $transport, private string $journal, private bool $enabled = false, private int $capacity = 1000) {} diff --git a/src/Civ13/Safehouse/Controller.php b/src/Civ13/Safehouse/Controller.php index 15e0369f..0e824764 100644 --- a/src/Civ13/Safehouse/Controller.php +++ b/src/Civ13/Safehouse/Controller.php @@ -43,6 +43,7 @@ public static function prepare(string $interactionId, string $actorDiscordId, st if (!is_int($payload['page'] ?? null) || $payload['page'] < 0 || $payload['page'] > $pageMax) { throw new \InvalidArgumentException('Invalid observation page'); } } $fields = match ($action) { + 'insurance.inspect' => ['cursor'], 'link.challenge' => ['challenge_id'], 'link.complete' => ['challenge_id', 'proof'], 'stash.inspect', 'room.inspect' => ['page'], @@ -61,6 +62,7 @@ public static function prepare(string $interactionId, string $actorDiscordId, st $keys = array_keys($payload); sort($keys); sort($fields); if ($keys !== $fields) { throw new \InvalidArgumentException('Unexpected or missing action payload fields'); } foreach ($payload as $field => $value) { + if ($action === 'insurance.inspect' && $field === 'cursor') { self::cursor($value); continue; } if ($field === 'page') { continue; } if ($field === 'item_ids') { if (!is_array($value) || !array_is_list($value) || count($value) < 1 || count($value) > 16 || count(array_unique($value, SORT_REGULAR)) !== count($value)) { throw new \InvalidArgumentException('Select 1..16 distinct root IDs'); } @@ -76,4 +78,35 @@ public static function prepare(string $interactionId, string $actorDiscordId, st } return Client::envelope($interactionId, $actorDiscordId, $action, $payload, $revision); } + + private static function cursor(mixed $value): void + { + if ($value !== null && (!is_string($value) || !preg_match('/^[!-~]{1,256}$/D', $value))) { throw new \InvalidArgumentException('Invalid insurance cursor'); } + } + + /** Validate the native projection without deriving eligibility from local clocks. */ + public static function insuranceProjection(array $data): array + { + $exact = static function (array $object, array $fields): void { $keys = array_keys($object); sort($keys); sort($fields); if ($keys !== $fields) { throw new \UnexpectedValueException('Invalid insurance projection fields'); } }; + $integer = static function (mixed $value, int $max): void { if (!is_int($value) || $value < 0 || $value > $max) { throw new \UnexpectedValueException('Invalid insurance counter'); } }; + $exact($data, ['account_revision', 'mail_revision', 'completed_generation', 'expiration', 'entries', 'next_cursor']); + $integer($data['account_revision'], 1000000); + foreach (['mail_revision', 'completed_generation'] as $field) { $integer($data[$field], 16777215); } + self::cursor($data['next_cursor']); + if ($data['expiration'] !== 'to be implemented' || !is_array($data['entries']) || !array_is_list($data['entries']) || count($data['entries']) > 8) { throw new \UnexpectedValueException('Invalid insurance projection bounds'); } + foreach ($data['entries'] as $entry) { + if (!is_array($entry)) { throw new \UnexpectedValueException('Invalid insurance entry'); } + $exact($entry, ['item_id', 'type', 'name', 'condition', 'found_in_raid', 'returned_generation', 'eligible_generation', 'state']); + if (!is_string($entry['item_id']) || !preg_match('/^[A-Za-z0-9_-]{1,80}$/D', $entry['item_id'])) { throw new \UnexpectedValueException('Invalid insurance item identity'); } + foreach (['type' => 160, 'name' => 128] as $field => $max) { $value = $entry[$field]; if ($value !== null && (!is_string($value) || strlen($value) > $max || !preg_match('//u', $value))) { throw new \UnexpectedValueException('Invalid insurance catalog string'); } } + if (!is_array($entry['condition'])) { throw new \UnexpectedValueException('Invalid insurance condition'); } + $exact($entry['condition'], ['health', 'maxhealth']); + foreach ($entry['condition'] as $value) { if ($value !== null && ((!is_int($value) && !is_float($value)) || !is_finite((float) $value))) { throw new \UnexpectedValueException('Invalid insurance condition'); } } + if ($entry['found_in_raid'] !== null && !is_bool($entry['found_in_raid'])) { throw new \UnexpectedValueException('Invalid insurance provenance'); } + foreach (['returned_generation', 'eligible_generation'] as $field) { $integer($entry[$field], 16777215); } + if (!in_array($entry['state'], ['pending', 'eligible'], true)) { throw new \UnexpectedValueException('Invalid insurance state'); } + } + if (strlen(json_encode($data, JSON_THROW_ON_ERROR)) > 8192) { throw new \UnexpectedValueException('Invalid insurance projection size'); } + return $data; + } } diff --git a/src/Civ13/Safehouse/DiscordRoute.php b/src/Civ13/Safehouse/DiscordRoute.php index 9fc80056..feb8c5f9 100644 --- a/src/Civ13/Safehouse/DiscordRoute.php +++ b/src/Civ13/Safehouse/DiscordRoute.php @@ -56,6 +56,11 @@ private static function render(array $outcome, array $request): string if ($status === 'pending') { return 'Request '.$id.' is pending. No completed trade is confirmed. Do not submit another economic operation. Use trade.status with payload '.json_encode(['operation_request_id' => $request['request_id'], 'operation_request_digest' => $request['request_digest']], JSON_THROW_ON_ERROR); } if ($status === 'rejected') { return 'Game rejected request '.$id.'. '.json_encode($outcome['data'] ?? $outcome['reason'] ?? [], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR); } if ($status !== 'committed') { return 'Request '.$id.' remains unresolved.'; } + if ($request['action'] === 'insurance.inspect') { + try { $data = Controller::insuranceProjection($outcome['data'] ?? []); } + catch (\Throwable $error) { return 'Insurance inspection completed, but its projection is invalid. No eligibility or claim is confirmed.'; } + return 'Insurance returns: game-reported pending or eligible state. Pending returns remain locked through one additional full round closure. No Discord claim action is available. Expiration: to be implemented. '.json_encode($data, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR); + } if ($request['action'] === 'trade.status') { $operationStatus = $outcome['data']['operation_status'] ?? null; if (!in_array($operationStatus, ['pending', 'committed', 'rejected', 'not_found'], true)) { return 'Status query completed, but the original operation remains unresolved.'; } diff --git a/tests/safehouse/discord.php b/tests/safehouse/discord.php index 976277ed..3829c18f 100644 --- a/tests/safehouse/discord.php +++ b/tests/safehouse/discord.php @@ -110,6 +110,20 @@ public function submit(array $request): PromiseInterface { $countBefore = count($gateway->requests); $route->handle($unconfirmedRoom); check(count($gateway->requests) === $countBefore, 'Room mutation bypassed explicit confirmation'); +$insuranceData = ['account_revision' => 4, 'mail_revision' => 9, 'completed_generation' => 5, 'expiration' => 'to be implemented', 'entries' => [['item_id' => 'return_1', 'type' => null, 'name' => null, 'condition' => ['health' => null, 'maxhealth' => null], 'found_in_raid' => null, 'returned_generation' => 5, 'eligible_generation' => 6, 'state' => 'pending']], 'next_cursor' => 'opaque.next']; +foreach (['pending', 'eligible', 'invalid', 'stale_cursor'] as $case) { + $inspection = MockInteraction::fixture('insurance.inspect', '{"cursor":null}'); $inspection->id = 'insurance-'.$case; + $route->handle($inspection); + $data = $insuranceData; + if ($case === 'eligible') { $data['entries'][0]['state'] = 'eligible'; } + if ($case === 'invalid') { $data['entries'][0]['eligible_generation'] = 16777216; } + $gateway->deferreds[array_key_last($gateway->deferreds)]->resolve(['status' => $case === 'stale_cursor' ? 'rejected' : 'committed', 'data' => $case === 'stale_cursor' ? ['code' => 'stale_cursor'] : $data]); + $content = $inspection->messages[1][2]['content']; + check(str_contains($content, $case === 'invalid' ? 'invalid' : $case), 'Insurance state/rejection omitted'); + if ($case === 'pending' || $case === 'eligible') { check(str_contains($content, 'to be implemented') && str_contains($content, 'No Discord claim'), 'Insurance expiration or claim boundary misleading'); } + check($inspection->messages[0] === ['defer', true], 'Insurance inspection not ephemeral'); +} + // Construct only the Slash adapter over a reflection-created inert Civ13 object. // No Civ13/Discord constructor, token, init event or remote command save runs. require __DIR__.'/../../src/Civ13/Civ13.php'; diff --git a/tests/safehouse/run.php b/tests/safehouse/run.php index e3bda03c..1e9a9875 100644 --- a/tests/safehouse/run.php +++ b/tests/safehouse/run.php @@ -23,6 +23,17 @@ public function exchange(array $request): array { } }; try { + $insurance = ['account_revision' => 4, 'mail_revision' => 9, 'completed_generation' => 5, 'expiration' => 'to be implemented', 'entries' => [['item_id' => 'return_1', 'type' => '/obj/item', 'name' => null, 'condition' => ['health' => 2, 'maxhealth' => null], 'found_in_raid' => true, 'returned_generation' => 5, 'eligible_generation' => 6, 'state' => 'pending']], 'next_cursor' => 'opaque.cursor']; + check(Controller::insuranceProjection($insurance) === $insurance, 'Pending eligibility was inferred locally'); + check(Controller::prepare('insurance1', '123', 'insurance.inspect', '{"cursor":null}', null)['payload'] === ['cursor' => null], 'Initial insurance cursor changed'); + check(Controller::prepare('insurance2', '123', 'insurance.inspect', '{"cursor":"opaque.cursor"}', 4)['payload']['cursor'] === 'opaque.cursor', 'Opaque cursor changed'); + foreach (['{"cursor":""}', '{"cursor":"é"}', '{"cursor":0}', '{"cursor":null,"page":0}'] as $invalid) { rejects(fn () => Controller::prepare('insurance3', '123', 'insurance.inspect', $invalid, null)); } + rejects(fn () => Controller::prepare('insurance4', '123', 'insurance.claim', '{"item_id":"return_1"}', 4)); + foreach (['mail_revision' => 16777216, 'account_revision' => 1000001, 'expiration' => 'tomorrow', 'next_cursor' => ''] as $field => $value) { $bad = $insurance; $bad[$field] = $value; rejects(fn () => Controller::insuranceProjection($bad)); } + $bad = $insurance; $bad['entries'] = array_fill(0, 9, $insurance['entries'][0]); rejects(fn () => Controller::insuranceProjection($bad)); + foreach (['state' => 'claimable', 'found_in_raid' => 1, 'name' => str_repeat('😀', 33), 'eligible_generation' => -1, 'proof' => 'secret'] as $field => $value) { $bad = $insurance; $bad['entries'][0][$field] = $value; rejects(fn () => Controller::insuranceProjection($bad)); } + $bad = $insurance; $bad['entries'][0]['condition']['health'] = INF; rejects(fn () => Controller::insuranceProjection($bad)); + echo "PASS insurance pending projection, opaque paging, premature claim rejection and counter/catalog/condition limits\n"; $signingFixture = json_decode(file_get_contents(__DIR__.'/signing-fixture.json'), true, 32, JSON_THROW_ON_ERROR); check(AsyncSpool::seal($signingFixture['exact_body'], 'request', 1000, 1120, $signingFixture['synthetic_key']) === $signingFixture['envelope'], 'Independent Python HMAC fixture mismatch'); rejects(fn () => (new Client($transport, $directory.'/disabled'))->request('i1', '123', 'stash.inspect', [], null)); From 091aed2a0d6ff9f6e54a84cb65e3f295efca9bf1 Mon Sep 17 00:00:00 2001 From: Valithor Obsidion Date: Tue, 13 Oct 2026 03:07:58 -0400 Subject: [PATCH 19/22] Validate required safehouse receipt fields and interaction boundaries --- src/Civ13/Safehouse/AsyncSpool.php | 5 +--- src/Civ13/Safehouse/Client.php | 32 +++++++++++++++-------- tests/safehouse/discord.php | 29 +++++++++++++++++--- tests/safehouse/economics-driver-test.php | 2 +- tests/safehouse/run.php | 15 +++++++++-- tests/safehouse/worker.php | 2 +- 6 files changed, 62 insertions(+), 23 deletions(-) diff --git a/src/Civ13/Safehouse/AsyncSpool.php b/src/Civ13/Safehouse/AsyncSpool.php index f22e1fb8..ed8ed709 100644 --- a/src/Civ13/Safehouse/AsyncSpool.php +++ b/src/Civ13/Safehouse/AsyncSpool.php @@ -73,10 +73,7 @@ public function poll(string $id): array $replyEnvelope = $this->read($replyPath, 'reply'); if ($replyEnvelope['expires_at'] < ($this->clock)()) { return ['status' => 'pending', 'request_id' => $request['request_id'], 'reason' => 'expired_reply']; } $reply = json_decode(base64_decode($replyEnvelope['body'], true), true, 32, JSON_THROW_ON_ERROR); - foreach (['protocol', 'request_id', 'discord_id', 'action', 'request_digest'] as $field) { - if (($reply[$field] ?? null) !== $request[$field]) { throw new \UnexpectedValueException('Reply identity mismatch'); } - } - if (!in_array($reply['status'] ?? null, ['pending', 'committed', 'rejected'], true)) { throw new \UnexpectedValueException('Unknown reply status'); } + Client::validateReply($reply, $request); if ($this->replyPending($id)) { return ['status' => 'pending', 'request_id' => $request['request_id'], 'reason' => 'reply_write_pending']; } return $reply; } diff --git a/src/Civ13/Safehouse/Client.php b/src/Civ13/Safehouse/Client.php index fb887118..f4ddad9e 100644 --- a/src/Civ13/Safehouse/Client.php +++ b/src/Civ13/Safehouse/Client.php @@ -64,6 +64,7 @@ private function deliver(array $request, string $requestId): array throw new \LogicException('Request ID already bound to another request'); } if (isset($records[$requestId]['response']) && $records[$requestId]['response']['status'] !== 'pending') { + self::validateReply($records[$requestId]['response'], $request); return $records[$requestId]['response']; } } else { @@ -78,22 +79,31 @@ private function deliver(array $request, string $requestId): array } catch (\Throwable $error) { return ['protocol' => self::PROTOCOL, 'request_id' => $requestId, 'status' => 'pending', 'reason' => 'transport_unresolved']; } - foreach (['protocol', 'request_id', 'discord_id', 'action', 'request_digest'] as $key) { - if (($response[$key] ?? null) !== $request[$key]) { - throw new \UnexpectedValueException('Game response binding mismatch: '.$key); - } - } - if (!in_array($response['status'] ?? null, ['pending', 'committed', 'rejected'], true)) { - throw new \UnexpectedValueException('Unknown game outcome'); - } - if (strlen(json_encode($response, JSON_THROW_ON_ERROR)) > 8192) { - throw new \UnexpectedValueException('Game reply exceeds size bound'); - } + self::validateReply($response, $request); $records[$requestId]['response'] = $response; $this->persist($records); return $response; } + /** Validates game receipts, including cached receipts; local pending notices are separate. */ + public static function validateReply(array $response, array $request): void + { + foreach (['protocol', 'request_id', 'discord_id', 'action', 'request_digest'] as $key) { + if (($response[$key] ?? null) !== $request[$key]) { + throw new \UnexpectedValueException('Game response binding mismatch: '.$key); + } + } + if (!in_array($response['status'] ?? null, ['pending', 'committed', 'rejected'], true)) { + throw new \UnexpectedValueException('Unknown game response status'); + } + if (!array_key_exists('reason', $response) || !is_string($response['reason']) || !array_key_exists('data', $response) || !is_array($response['data'])) { + throw new \UnexpectedValueException('Invalid game response reason/data'); + } + if (strlen(json_encode($response, JSON_THROW_ON_ERROR)) > 8192) { + throw new \UnexpectedValueException('Game response exceeds safehouse size bound'); + } + } + private function persist(array $records): void { $temporary = $this->journal.'.'.bin2hex(random_bytes(8)).'.tmp'; diff --git a/tests/safehouse/discord.php b/tests/safehouse/discord.php index 3829c18f..29d66906 100644 --- a/tests/safehouse/discord.php +++ b/tests/safehouse/discord.php @@ -81,9 +81,30 @@ public function submit(array $request): PromiseInterface { check($gateway->requests[1]['payload'] === ['confirmation_token' => 'game-token'], 'Confirmation changed opaque game token'); $gateway->deferreds[1]->resolve(['status' => 'committed', 'request_id' => '123457', 'data' => ['nested_id' => 'child', 'fir' => true, 'condition' => 0.75]]); check(str_contains($confirmed->messages[1][2]['content'], 'child'), 'Inspection metadata omitted'); +foreach (['trade.confirm','market.list','market.buy','market.cancel','room.manage'] as $action) { + foreach ([false, 1, 'true'] as $confirmation) { + $candidate=MockInteraction::fixture($action,'{"confirmation_token":"game-token"}',4,true); + $candidate->data->options['confirm']->value=$confirmation; + $before=count($gateway->requests);$route->handle($candidate); + check(count($gateway->requests)===$before && $candidate->messages[0][0]==='initial','Nonboolean/omitted confirmation reached game '.$action); + } +} +foreach (['unlinked_actor','account_busy','account_in_raid','item_not_owned','stale_revision','quote_expired','quote_actor_mismatch','quote_action_mismatch','listing_changed','account_recovery_required'] as $code) { + $denied=MockInteraction::fixture('trade.confirm','{"confirmation_token":"game-token"}',4,true);$denied->id='negative-'.$code; + $route->handle($denied);$before=count($gateway->requests); + $gateway->deferreds[array_key_last($gateway->deferreds)]->resolve(['status'=>'rejected','request_id'=>$denied->id,'reason'=>$code,'data'=>['code'=>$code]]); + check(count($gateway->requests)===$before,'Rejected operation retried '.$code); + $content=$denied->messages[1][2]['content']; + check(str_contains($content,'rejected') && str_contains($content,$code) && !str_contains($content,'Game confirmed'),'Rejection falsely acknowledged '.$code); +} +$nested=MockInteraction::fixture('stash.detail','{"item_id":"root-1","page":0}');$nested->id='nested-preservation';$route->handle($nested); +$nodes=[['index'=>1,'id'=>'root-1','parent'=>0,'found_in_raid'=>false,'condition'=>['health'=>0,'maxhealth'=>100]],['index'=>2,'id'=>'child-2','parent'=>1,'found_in_raid'=>true,'condition'=>['health'=>17,'maxhealth'=>80]]]; +$gateway->deferreds[array_key_last($gateway->deferreds)]->resolve(['status'=>'committed','request_id'=>$nested->id,'reason'=>'saved_graph','data'=>['item_id'=>'root-1','nodes'=>$nodes,'next_page'=>null]]); +$content=$nested->messages[1][2]['content']; +check(str_contains($content,json_encode($nodes,JSON_UNESCAPED_UNICODE|JSON_THROW_ON_ERROR)),'Distinct nested identity/FIR/zero condition changed'); $unicode = MockInteraction::fixture(); $unicode->id = 'unicode'; $route->handle($unicode); -$gateway->deferreds[2]->resolve(['status' => 'committed', 'request_id' => 'unicode', 'data' => ['name' => str_repeat('🌍', 2000)]]); +$gateway->deferreds[array_key_last($gateway->deferreds)]->resolve(['status' => 'committed', 'request_id' => 'unicode', 'data' => ['name' => str_repeat('🌍', 2000)]]); check(preg_match('//u', $unicode->messages[1][2]['content']) === 1 && strlen($unicode->messages[1][2]['content']) <= 3600, 'Bounded render split Unicode'); $digest = str_repeat('a', 64); @@ -93,7 +114,7 @@ public function submit(array $request): PromiseInterface { $route->handle($statusInteraction); $data = ['operation_status' => $operationStatus === 'wrong_actor' ? 'committed' : $operationStatus]; if (in_array($operationStatus, ['committed', 'rejected', 'wrong_actor'], true)) { $data['operation_receipt'] = ['request_id' => 'original-operation', 'request_digest' => $digest, 'discord_id' => $operationStatus === 'wrong_actor' ? '999' : '123', 'status' => $data['operation_status']]; } - $gateway->deferreds[array_key_last($gateway->deferreds)]->resolve(['status' => 'committed', 'data' => $data]); + $gateway->deferreds[array_key_last($gateway->deferreds)]->resolve(['status' => 'committed', 'reason' => 'committed', 'data' => $data]); $content = $statusInteraction->messages[1][2]['content']; check(!str_contains($content, 'Game confirmed request'), 'Status query misreported original success'); check(str_contains($content, $operationStatus === 'wrong_actor' ? 'unresolved' : $operationStatus), 'Original operation status omitted'); @@ -103,7 +124,7 @@ public function submit(array $request): PromiseInterface { $route->handle($observation); check(end($gateway->requests)['action'] === $action && $observation->messages[0] === ['defer', true], 'Additive action not routed ephemerally'); $data = ['nodes' => [['index' => 1, 'id' => 'native-root', 'parent' => 0, 'found_in_raid' => true, 'condition' => ['health' => 20, 'maxhealth' => 100], 'material_key' => null, 'amount' => null, 'ammo_refs' => []]], 'next_page' => null]; - $gateway->deferreds[array_key_last($gateway->deferreds)]->resolve(['status' => 'committed', 'data' => $data]); + $gateway->deferreds[array_key_last($gateway->deferreds)]->resolve(['status' => 'committed', 'reason' => 'committed', 'data' => $data]); check(str_contains($observation->messages[1][2]['content'], 'found_in_raid') && str_contains($observation->messages[1][2]['content'], 'maxhealth'), 'Authoritative node summary omitted'); } $unconfirmedRoom = MockInteraction::fixture('room.manage', '{"confirmation_token":"native-room-token"}', 4); @@ -187,7 +208,7 @@ public function listenCommand($names, ?callable $callback = null, ?callable $aut $ticks[0](); check($outcome['status'] === 'pending' && $cancelled[0], 'Timeout did not cancel/preserve pending'); $request2 = Client::envelope('timer-2', '123', 'stash.inspect', [], null); $driver->submit($request2)->then(function ($value) use (&$outcome): void { $outcome = $value; }); - $response = array_merge($request2, ['status' => 'rejected', 'reason' => 'in_raid']); + $response = array_merge($request2, ['status' => 'rejected', 'reason' => 'in_raid', 'data' => ['code' => 'in_raid']]); $id = hash('sha256', 'timer-2'); file_put_contents($directory.'/'.$id.'.reply', json_encode(AsyncSpool::seal(json_encode($response), 'reply', time(), time() + 120, str_repeat('synthetic-', 4)))); $ticks[1](); check($outcome['status'] === 'rejected' && $cancelled[1], 'Game rejection not terminal'); diff --git a/tests/safehouse/economics-driver-test.php b/tests/safehouse/economics-driver-test.php index 75a94c5a..59716564 100644 --- a/tests/safehouse/economics-driver-test.php +++ b/tests/safehouse/economics-driver-test.php @@ -17,7 +17,7 @@ function reply(string $id, array $data): void { global $directory, $key; $envelope = json_decode(file_get_contents($directory.'/'.$id.'.request'), true); $body = json_decode(base64_decode($envelope['body']), true); - $body['status'] = 'committed'; $body['data'] = $data; + $body['status'] = 'committed'; $body['reason'] = 'committed'; $body['data'] = $data; file_put_contents($directory.'/'.$id.'.reply', json_encode(AsyncSpool::seal(json_encode($body), 'reply', 1000, 1120, $key))); } try { diff --git a/tests/safehouse/run.php b/tests/safehouse/run.php index 1e9a9875..c24fc8ab 100644 --- a/tests/safehouse/run.php +++ b/tests/safehouse/run.php @@ -19,7 +19,7 @@ function rejects(callable $fn): void { try { $fn(); } catch (LogicException|Unex public function exchange(array $request): array { $this->calls[] = $request; if ($this->timeout) { throw new RuntimeException('timeout after possible commit'); } - return array_merge($request, ['status' => 'committed', 'request_digest' => $this->mismatch ? 'wrong' : $request['request_digest'], 'data' => ['item_id' => 'nested-1', 'fir' => true, 'condition' => 0.75]]); + return array_merge($request, ['status' => 'committed', 'reason' => 'committed', 'request_digest' => $this->mismatch ? 'wrong' : $request['request_digest'], 'data' => ['item_id' => 'nested-1', 'fir' => true, 'condition' => 0.75]]); } }; try { @@ -112,12 +112,23 @@ public function exchange(array $request): array { check($spool->poll($fileKey)['status'] === 'pending', 'Missing reply claimed success'); rejects(fn () => $spool->enqueue(array_merge($request, ['discord_id' => '999']))); try { $spool->enqueue(array_merge($request, ['request_id' => 'queue-full'])); throw new LogicException('Queue bound missing'); } catch (RuntimeException $e) { check($e->getMessage() === 'Queue capacity reached', 'Unexpected queue exception'); } - $reply = array_merge($request, ['status' => 'committed']); + $reply = array_merge($request, ['status' => 'committed', 'reason' => 'committed', 'data' => []]); $replyPath = $directory.'/spool/'.$fileKey.'.reply'; $sealed = AsyncSpool::seal(json_encode($reply), 'reply', 1000, 1120, $key); check(strlen(AsyncSpool::seal(str_repeat('x', 8192), 'reply', 1000, 1120, $key)['body']) === 10924, 'Exact byte boundary rejected'); rejects(fn () => AsyncSpool::seal(str_repeat('x', 8193), 'reply', 1000, 1120, $key)); rejects(fn () => AsyncSpool::seal(str_repeat('🌍', 2049), 'reply', 1000, 1120, $key)); + foreach (['reason', 'data'] as $field) { + foreach (['missing', null, 1, true] as $invalid) { + $bad = $reply; + if ($invalid === 'missing') { unset($bad[$field]); } else { $bad[$field] = $invalid; } + rejects(fn () => Client::validateReply($bad, $request)); + file_put_contents($replyPath, json_encode(AsyncSpool::seal(json_encode($bad), 'reply', 1000, 1120, $key))); + rejects(fn () => $spool->poll($fileKey)); + } + } + $bad = $reply; $bad['reason'] = []; rejects(fn () => Client::validateReply($bad, $request)); + $bad = $reply; $bad['data'] = 'scalar'; rejects(fn () => Client::validateReply($bad, $request)); file_put_contents($replyPath, json_encode($sealed)."\n"); check($spool->poll($fileKey)['status'] === 'committed', 'Native single-LF reply rejected'); file_put_contents($replyPath, json_encode($sealed)."\r\n"); check($spool->poll($fileKey)['status'] === 'committed', 'Bounded historical CRLF rejected'); diff --git a/tests/safehouse/worker.php b/tests/safehouse/worker.php index d869ccd8..34b35984 100644 --- a/tests/safehouse/worker.php +++ b/tests/safehouse/worker.php @@ -22,7 +22,7 @@ public function exchange(array $request): array { exit(23); // Game committed; bot never records the reply. } usleep(150000); - return array_merge($request, ['status' => 'committed']); + return array_merge($request, ['status' => 'committed', 'reason' => 'committed', 'data' => []]); } }; $response = (new Client($transport, $directory.'/journal', true))->request('concurrent', '123', 'trade.confirm', ['confirmation_token' => 'game-token'], 4); From eb1867c7682702877bfc9168df3c964c9788d8a7 Mon Sep 17 00:00:00 2001 From: Valithor Obsidion Date: Tue, 13 Oct 2026 04:22:54 -0400 Subject: [PATCH 20/22] Reject incomplete original recovery receipts --- src/Civ13/Safehouse/DiscordRoute.php | 4 +++- tests/safehouse/discord.php | 34 +++++++++++++++++++++++++++- 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/src/Civ13/Safehouse/DiscordRoute.php b/src/Civ13/Safehouse/DiscordRoute.php index feb8c5f9..87a242a2 100644 --- a/src/Civ13/Safehouse/DiscordRoute.php +++ b/src/Civ13/Safehouse/DiscordRoute.php @@ -64,10 +64,12 @@ private static function render(array $outcome, array $request): string if ($request['action'] === 'trade.status') { $operationStatus = $outcome['data']['operation_status'] ?? null; if (!in_array($operationStatus, ['pending', 'committed', 'rejected', 'not_found'], true)) { return 'Status query completed, but the original operation remains unresolved.'; } + if (($operationStatus === 'pending' || $operationStatus === 'not_found') && ($outcome['data']['operation_receipt'] ?? null) !== null) { return 'Status query completed, but its original receipt binding is unresolved.'; } if ($operationStatus === 'pending' || $operationStatus === 'not_found') { return 'Status query completed. Original operation is '.$operationStatus.'. No completed trade is confirmed. Do not issue another economic operation; retain its original request for recovery.'; } $receipt = $outcome['data']['operation_receipt'] ?? null; if (!is_array($receipt) || ($receipt['request_id'] ?? null) !== $request['payload']['operation_request_id'] || ($receipt['request_digest'] ?? null) !== $request['payload']['operation_request_digest'] || ($receipt['discord_id'] ?? null) !== $request['discord_id'] || ($receipt['status'] ?? null) !== $operationStatus) { return 'Status query completed, but its original receipt binding is unresolved.'; } - return 'Original operation '.$receipt['request_id'].' is '.$operationStatus.'. '.json_encode($receipt['data'] ?? [], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR); + if (($receipt['protocol'] ?? null) !== Client::PROTOCOL || !in_array($receipt['action'] ?? null, Client::ACTIONS, true) || !is_string($receipt['reason'] ?? null) || strlen($receipt['reason']) < 1 || strlen($receipt['reason']) > 128 || !preg_match('//u', $receipt['reason']) || preg_match('/[\x{0000}-\x{001f}\x{007f}-\x{009f}]/u', $receipt['reason']) || !is_array($receipt['data'] ?? null)) { return 'Status query completed, but its original receipt schema is unresolved.'; } + return 'Original operation '.$receipt['request_id'].' is '.$operationStatus.'. '.json_encode($receipt['data'], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR); } return 'Game confirmed request '.$id.'. '.json_encode($outcome['data'] ?? [], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR); } diff --git a/tests/safehouse/discord.php b/tests/safehouse/discord.php index 29d66906..9185920f 100644 --- a/tests/safehouse/discord.php +++ b/tests/safehouse/discord.php @@ -113,12 +113,44 @@ public function submit(array $request): PromiseInterface { $statusInteraction->id = 'status-'.$operationStatus; $route->handle($statusInteraction); $data = ['operation_status' => $operationStatus === 'wrong_actor' ? 'committed' : $operationStatus]; - if (in_array($operationStatus, ['committed', 'rejected', 'wrong_actor'], true)) { $data['operation_receipt'] = ['request_id' => 'original-operation', 'request_digest' => $digest, 'discord_id' => $operationStatus === 'wrong_actor' ? '999' : '123', 'status' => $data['operation_status']]; } + if (in_array($operationStatus, ['committed', 'rejected', 'wrong_actor'], true)) { $data['operation_receipt'] = ['protocol' => Client::PROTOCOL, 'action' => 'stash.detail', 'reason' => 'committed', 'data' => [], 'request_id' => 'original-operation', 'request_digest' => $digest, 'discord_id' => $operationStatus === 'wrong_actor' ? '999' : '123', 'status' => $data['operation_status']]; } $gateway->deferreds[array_key_last($gateway->deferreds)]->resolve(['status' => 'committed', 'reason' => 'committed', 'data' => $data]); $content = $statusInteraction->messages[1][2]['content']; check(!str_contains($content, 'Game confirmed request'), 'Status query misreported original success'); check(str_contains($content, $operationStatus === 'wrong_actor' ? 'unresolved' : $operationStatus), 'Original operation status omitted'); } +$validReceipt = ['protocol'=>Client::PROTOCOL,'action'=>'link.complete','request_id'=>'original-operation','request_digest'=>$digest,'discord_id'=>'123','status'=>'committed','reason'=>'linked','data'=>[]]; +foreach (Client::ACTIONS as $originalAction) { + $receipt=$validReceipt; $receipt['action']=$originalAction; + $interaction=MockInteraction::fixture('trade.status',json_encode(['operation_request_id'=>'original-operation','operation_request_digest'=>$digest])); $route->handle($interaction); + $gateway->deferreds[array_key_last($gateway->deferreds)]->resolve(['status'=>'committed','data'=>['operation_status'=>'committed','operation_receipt'=>$receipt]]); + check(str_contains($interaction->messages[1][2]['content'],'Original operation original-operation is committed'),'Supported non-economic original receipt rejected '.$originalAction); +} +foreach (['protocol'=>null,'action'=>'unknown.action','reason'=>null,'data'=>null,'status'=>'rejected','request_digest'=>str_repeat('b',64),'request_id'=>'other','discord_id'=>'999'] as $field=>$invalid) { + foreach (['missing','invalid'] as $kind) { + $receipt=$validReceipt; if ($kind==='missing') { unset($receipt[$field]); } else { $receipt[$field]=$invalid; } + $interaction=MockInteraction::fixture('trade.status',json_encode(['operation_request_id'=>'original-operation','operation_request_digest'=>$digest])); $route->handle($interaction); + $gateway->deferreds[array_key_last($gateway->deferreds)]->resolve(['status'=>'committed','data'=>['operation_status'=>'committed','operation_receipt'=>$receipt]]); + check(str_contains($interaction->messages[1][2]['content'],'unresolved'),'Malformed original receipt claimed completion '.$field.'/'.$kind); + } +} +foreach (['',str_repeat('x',129),"bad\nreason","bad\u{0085}reason","bad\u{009f}reason",str_repeat("é",65),[],1,true] as $reason) { + $receipt=$validReceipt; $receipt['reason']=$reason; + $interaction=MockInteraction::fixture('trade.status',json_encode(['operation_request_id'=>'original-operation','operation_request_digest'=>$digest])); $route->handle($interaction); + $gateway->deferreds[array_key_last($gateway->deferreds)]->resolve(['status'=>'committed','data'=>['operation_status'=>'committed','operation_receipt'=>$receipt]]); + check(str_contains($interaction->messages[1][2]['content'],'unresolved'),'Invalid reason accepted'); +} +foreach ([str_repeat('x',128),str_repeat("é",64)] as $reason) { + $receipt=$validReceipt; $receipt['reason']=$reason; + $interaction=MockInteraction::fixture('trade.status',json_encode(['operation_request_id'=>'original-operation','operation_request_digest'=>$digest])); $route->handle($interaction); + $gateway->deferreds[array_key_last($gateway->deferreds)]->resolve(['status'=>'committed','data'=>['operation_status'=>'committed','operation_receipt'=>$receipt]]); + check(str_contains($interaction->messages[1][2]['content'],'Original operation original-operation is committed'),'Exact reason byte bound rejected'); +} +foreach (['pending','not_found'] as $state) { + $interaction=MockInteraction::fixture('trade.status',json_encode(['operation_request_id'=>'original-operation','operation_request_digest'=>$digest])); $route->handle($interaction); + $gateway->deferreds[array_key_last($gateway->deferreds)]->resolve(['status'=>'committed','data'=>['operation_status'=>$state,'operation_receipt'=>$validReceipt]]); + check(str_contains($interaction->messages[1][2]['content'],'unresolved'),'Nonterminal status accepted attached receipt'); +} foreach ([['stash.detail', '{"item_id":"native-root","page":31}', null], ['market.browse', '{"page":15}', null], ['room.quote', '{"operation":"upgrade","item_ids":["native-part"]}', 4]] as [$action, $payload, $revision]) { $observation = MockInteraction::fixture($action, $payload, $revision); $observation->id = 'addition-'.str_replace('.', '-', $action); $route->handle($observation); From f7d7db5a087618b1e5022014e125c0752c34ec17 Mon Sep 17 00:00:00 2001 From: Valithor Obsidion Date: Thu, 22 Oct 2026 13:28:42 -0400 Subject: [PATCH 21/22] Keep production DNS discovery out of portable startup --- bot.php | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/bot.php b/bot.php index 07317a3d..57e2ea88 100644 --- a/bot.php +++ b/bot.php @@ -163,15 +163,14 @@ function loadEnv(string $filePath, bool $explicit = false): array $filesystem = FilesystemFactory::create(); include $baseDir.'/variable_functions.php'; -$civ13_ip = gethostbyname('www.civ13.com'); -$vzg_ip = gethostbyname('www.valzargaming.com'); -$val_ip = gethostbyname('www.valgorithms.com'); - -$http_whitelist = $portable !== null ? ['127.0.0.1'] : [ - $civ13_ip, - $vzg_ip, - $val_ip -]; +if ($portable === null) { + $civ13_ip = gethostbyname('www.civ13.com'); + $vzg_ip = gethostbyname('www.valzargaming.com'); + $val_ip = gethostbyname('www.valgorithms.com'); + $http_whitelist = [$civ13_ip, $vzg_ip, $val_ip]; +} else { + $http_whitelist = ['127.0.0.1']; +} $webapi = null; $socket = null; From b06ca166db3a4b9c3a9c7f0f8a31173b96d72979 Mon Sep 17 00:00:00 2001 From: Valithor Obsidion Date: Thu, 22 Oct 2026 14:32:34 -0400 Subject: [PATCH 22/22] Separate disabled-source review from safehouse activation evidence --- .github/workflows/safehouse.yml | 2 ++ docs/safehouse-acceptance.md | 49 +++++++++++++++++++++++++++++++-- 2 files changed, 48 insertions(+), 3 deletions(-) diff --git a/.github/workflows/safehouse.yml b/.github/workflows/safehouse.yml index bc64644c..59639eef 100644 --- a/.github/workflows/safehouse.yml +++ b/.github/workflows/safehouse.yml @@ -8,6 +8,7 @@ on: - 'src/Civ13/Slash.php' - 'src/Civ13/Civ13.php' - 'tests/safehouse/**' + - 'docs/safehouse-acceptance.md' - '.github/workflows/safehouse.yml' push: paths: @@ -17,6 +18,7 @@ on: - 'src/Civ13/Slash.php' - 'src/Civ13/Civ13.php' - 'tests/safehouse/**' + - 'docs/safehouse-acceptance.md' - '.github/workflows/safehouse.yml' permissions: contents: read diff --git a/docs/safehouse-acceptance.md b/docs/safehouse-acceptance.md index 31bc86f7..ca5010d2 100644 --- a/docs/safehouse-acceptance.md +++ b/docs/safehouse-acceptance.md @@ -4,6 +4,16 @@ The safehouse integration remains disabled by default. Local mocks, native fixtu are separate from command registration, live Discord delivery and host activation. Ordinary tests/bootstrap.php loads .env; safehouse runners deliberately bypass it. +## Source review versus activation + +This PR supplies an optional, disabled source package. Source acceptance requires +reviewed defaults and migration behavior, the strict DTO/registration boundaries, +and passing exact-head offline CI. Existing installations need no configuration +change to retain disabled behavior. Final submitted PR review is a merge gate. +The native compatibility, host, Discord usability and gameplay evidence below +remains required for the corresponding compatibility or activation claim; it is +not a requirement to activate a host before reviewing or merging disabled code. + ## Evidence and remaining scope | Scope | Evidence | Remaining gate | @@ -42,12 +52,45 @@ To close the remaining native integration evidence gate, supply a synthetic artifact that names the exact native and bot commits, the DTO/crypto contract, request/reply digests, and observed room quote/manage, market browse and stash detail results. Include revision/actor rejection and replay/recovery controls. -Review that artifact through the existing offline compatibility runners before -claiming compatibility; an older paired receipt cannot silently cover newer -contracts. Connected raid/long-term recovery and host activation gates below +The existing `native-compatibility.php` checker accepts only the fixed +`inspect-1`/`link-1` fixture, synthetic clock 1000 and its before/after restart +layout. It cannot certify arbitrary room/browse/detail artifacts. Those newer +contracts need a reviewed native producer and an appropriate offline checker; +the synthetic PHP economics-driver test is not native evidence. Record the +producer's exact commit separately because the older fixture manifest has no +source-head field. An older paired receipt cannot silently cover newer contracts. +Connected raid/long-term recovery and host activation gates below remain separate. This bot-only review does not authorize generating a new game fixture, accessing game saves, enabling integration or running a live operation. +## Bounded evidence plan + +- Repeat the existing public CI commands at the reviewed bot head: + `php tests/safehouse/run.php` and + `php tests/safehouse/economics-driver-test.php` in the three-minute job; + `php tests/safehouse/discord.php` and `php tests/safehouse/schema.php` in + the eight-minute dependency job. Effects are temporary files, synthetic PHP + workers and injected Discord transports; no registration or game starts. +- Given a reviewed synthetic inspect/link artifact, run + `php tests/safehouse/native-compatibility.php ` in an + isolated job capped at three minutes. It copies only the two named fixture + sets into temporary storage and removes that copy. Retain the native/bot SHAs, + artifact digest, command and result. This consumes evidence without a game. +- Later native DTO acceptance must first name the producer's actual command and + fixture. The bot-side `economics-driver.php SPOOL MANIFEST` operations are + `inspect`, `browse`, `detail`, `room-quote`, `quote`, `confirm`, `poll` and + `recover`; their detailed arguments are in the + [offline fixture driver contract](safehouse-economic-contract.md#offline-native-fixture-driver). + Bound an approved run to + one synthetic actor, the existing public synthetic key, a new temporary spool, + at most 32 requests and five minutes. Advance only the synthetic clock for + expiry controls. Retain request/reply digests and reject/replay/recovery + observations. No native producer command or execution is authorized here. +- Connected gameplay and live registration require a separate concrete fixture, + ownership/resource window and authorization. Keep them pending until their + observed receipts exist; neither a source merge nor offline artifacts supply + them. + ## Explicit host activation requirements Keep safehouse_enabled=false until the concrete host package is reviewed. Supply