diff --git a/composer.json b/composer.json index 275ea775a..99977edb2 100644 --- a/composer.json +++ b/composer.json @@ -25,7 +25,7 @@ }, "scripts": { "ecs": "vendor/bin/ecs check --fix", - "stan": "vendor/bin/phpstan analyse src tests", + "stan": "vendor/bin/phpstan analyse src tests examples/business-state-machines", "feature": [ "Composer\\Config::disableProcessTimeout", "phpunit --testdox --testsuite feature" diff --git a/docs/business-state-machines.md b/docs/business-state-machines.md index 776def4ce..5c78f9ba4 100644 --- a/docs/business-state-machines.md +++ b/docs/business-state-machines.md @@ -159,7 +159,9 @@ An application commit adapter must: An immutable snapshot is not a concurrency fence. The runner checks snapshot correlation and source consistency, but the application activity must implement the transaction, operation admission and provider reconciliation. A database fence cannot recall a request already sent to a provider. -For separate application and DW stores, the application's decision record is the business commit authority. Use an explicit durable handoff and idempotent outcome lookup. No distributed transaction is implied. Shared-store and cross-store persistence race guarantees are not demonstrated by this slice's in-memory runner tests. +For separate application and DW stores, the application's decision record is the business commit authority. Use an explicit durable handoff and idempotent outcome lookup. No distributed transaction is implied. + +The [SQL business-decision example](../examples/business-state-machines/README.md) supplies an application-owned reference boundary and real database tests for cancellation/completion, authority revocation, atomic publication intent and lost acknowledgement. It does not replace application authentication, evidence validation, provider fencing or publication delivery. The in-memory runner tests alone do not establish persistence race guarantees. ## Compensation and nested-machine boundary diff --git a/examples/business-state-machines/README.md b/examples/business-state-machines/README.md new file mode 100644 index 000000000..6aa30007b --- /dev/null +++ b/examples/business-state-machines/README.md @@ -0,0 +1,31 @@ +# SQL business decisions + +This application-side example implements the `TransitionRuntime::commit()` persistence boundary using Laravel's database connection. It uses application tables in `schema.php`; it does not add DW migrations or change native workflow storage. Invoke `SqlBusinessStore` from a recorded application activity. + +The subject row is the common fence for completion, cancellation, and authority changes. The store checks the original state and revision, then writes the new state, immutable decision, and publication intent in one transaction. Retrying an identical decision after losing its acknowledgement returns the original result. Reusing its identity with another subject or content refuses. A competing transition gets a revision conflict. + +Cancellation takes that same row lock. Cancellation accepted first prevents the completion commit. Completion accepted first makes cancellation against the old revision a conflict. `cancellation_requested` means business forward completion is fenced; it does not mean external effects stopped, compensation succeeded, or the business reached a Cancelled state. + +The trusted authorization callback receives the full execution, principal, operation, and proposed transition context. It must authenticate the principal, enforce the exact pinned profile and transition, and verify the assessment against authoritative immutable facts. Returning a version for a caller-supplied `satisfied` value is unsafe. The subject row must still authorize that principal and version under lock. Revocation must update the same subject row transactionally. The callback runs outside SQL locks, and caller-owned transactions refuse. + +The example's host-policy fixture uses a closed profile and a fixed immutable assessment. Replace it with the application's own trusted registry and retained facts. A serialized scope, profile fingerprint, principal name, or snapshot ID is not authentication or evidence by itself. + +Run the executable SQL example tests from the repository root: + +```bash +vendor/bin/phpunit tests/Unit/V2/StateMachine/BusinessCommitTest.php +``` + +They exercise the actual example implementation on SQLite: lost acknowledgement, cancellation before/after completion, competing revisions, publication failure rollback, decision reuse, authorization revocation, foreign principals, transaction ownership, and profile substitution. The test does not require a provider or a running workflow worker. + +For the separate-process race tests, use the repository's configured MySQL/MariaDB or PostgreSQL test database: + +```bash +vendor/bin/phpunit tests/Feature/V2/V2BusinessCommitConcurrencyTest.php +``` + +The six cases observe the competing `SELECT ... FOR UPDATE` before releasing the winner. Both cancellation/completion orderings, both revocation/completion orderings, duplicate completion, and competing completion use separate processes and connections. SQLite and hosts without `pcntl` explicitly skip this race suite. + +The publication table stores an intent. A real application still needs an idempotent dispatcher and consumer; this transaction does not atomically publish to an external broker. Separate business and DW stores communicate through the recorded commit activity and exact decision identity, without a distributed transaction. Activity retries must retain the original request; they must not repeat provider effects while recovering a lost business commit acknowledgement. + +This example covers business persistence. Provider admission/fencing, uncertain effects, retained child compensation, the complete two-integration application workflow, policy migration, and resource budgets remain separate parts of the business state-machine work. The SQL tests do not establish those guarantees. diff --git a/examples/business-state-machines/SqlBusinessStore.php b/examples/business-state-machines/SqlBusinessStore.php new file mode 100644 index 000000000..186a003b3 --- /dev/null +++ b/examples/business-state-machines/SqlBusinessStore.php @@ -0,0 +1,204 @@ +execution(); + $authority = $this->authorize($execution, $principal, 'commit', $context); + if (! in_array($context->snapshot->phase, [SnapshotPhase::Before, SnapshotPhase::After], true) + || $context->snapshot->transitionKey !== $context->transition->key + || ! in_array($execution->sourceState, $context->transition->from, true) + || $context->snapshot->state !== $execution->sourceState + || $context->snapshot->revision !== $execution->sourceRevision + || ! $context->snapshot->target->satisfied()) { + throw new LogicException('The protected commit requires the matching satisfied assessment.'); + } + $request = [ + 'execution' => $execution->toArray(), + 'transition' => get_object_vars($context->transition), + 'snapshot' => [ + 'id' => $context->snapshot->snapshotId, + 'phase' => $context->snapshot->phase->value, + 'target' => $context->snapshot->target->toArray(), + 'permission' => $context->snapshot->permission->toArray(), + ], + ]; + $id = self::decisionId($execution, 'transition', $execution->executionId); + return $this->decide($execution, $principal, $authority, $id, $request, false); + } + + /** + * Accepted business cancellation closes forward commits; it does not verify compensation. + */ + public function requestCancellation( + ExecutionReference $execution, + string $requestId, + string $principal, + ): CommitResult { + $authority = $this->authorize($execution, $principal, 'cancel'); + if (trim($requestId) === '') { + throw new LogicException('Cancellation requires an immutable request identity.'); + } + return $this->decide( + $execution, + $principal, + $authority, + self::decisionId($execution, 'cancellation', $requestId), + [ + 'execution' => $execution->toArray(), + 'requestId' => $requestId, + ], + true, + ); + } + + public static function subjectId(ExecutionReference $execution): string + { + return hash('sha256', json_encode([$execution->scope, $execution->subject], JSON_THROW_ON_ERROR)); + } + + public static function decisionId(ExecutionReference $execution, string $kind, string $key): string + { + // A decision ID cannot be reused for another subject inside the same authorized scope. + return hash('sha256', json_encode([$execution->scope, $kind, $key], JSON_THROW_ON_ERROR)); + } + + private function authorize( + ExecutionReference $execution, + string $principal, + string $operation, + ?TransitionContext $context = null, + ): string { + if ($this->db->transactionLevel() !== 0) { + throw new LogicException('The business boundary must own its transaction.'); + } + $authority = ($this->authorize)($execution, $principal, $operation, $context); + if ($this->db->transactionLevel() !== 0 || trim($authority) === '' || strlen($authority) > 128) { + throw new LogicException('Host authorization must return a bounded version outside SQL locks.'); + } + return $authority; + } + + /** + * @param array $request + */ + private function decide( + ExecutionReference $execution, + string $principal, + string $authority, + string $id, + array $request, + bool $cancellation, + ): CommitResult { + $fingerprint = hash('sha256', json_encode($request, JSON_THROW_ON_ERROR)); + return $this->db->transaction(function () use ( + $execution, + $principal, + $authority, + $id, + $request, + $fingerprint, + $cancellation, + ): CommitResult { + // Cancellation, authority revocation and completion must all serialize on this row. + $subject = $this->db->table('example_machine_subjects') + ->where('id', self::subjectId($execution))->lockForUpdate()->first(); + if ($subject === null || $subject->scope !== $execution->scope + || $subject->subject !== $execution->subject || $subject->principal !== $principal + || $subject->authority_version !== $authority) { + throw new LogicException('The original subject authority is not current.'); + } + $existing = $this->db->table('example_machine_decisions') + ->where('id', $id) + ->first(); + if ($existing !== null) { + if ($existing->subject_id !== $subject->id || $existing->request_sha256 !== $fingerprint) { + throw new LogicException('The decision identity was reused with different content.'); + } + // Lost acknowledgement: return the original decision even after later cancellation/state changes. + return new CommitResult($execution, TransitionStatus::from($existing->status), $existing->reason); + } + if ((bool) $subject->cancellation_requested) { + $status = TransitionStatus::CancellationRequested; + $reason = 'business_cancellation_requested'; + } elseif ($subject->state !== $execution->sourceState + || (string) $subject->revision !== $execution->sourceRevision) { + $status = TransitionStatus::Conflict; + $reason = 'source_changed'; + } elseif ($cancellation) { + $this->db->table('example_machine_subjects') + ->where('id', $subject->id) + ->update([ + 'cancellation_requested' => true, + ]); + $status = TransitionStatus::CancellationRequested; + $reason = 'business_cancellation_requested'; + } else { + if ((int) $subject->revision >= PHP_INT_MAX) { + throw new LogicException('The subject revision is exhausted.'); + } + $revision = (int) $subject->revision + 1; + $this->db->table('example_machine_subjects') + ->where('id', $subject->id) + ->update([ + 'state' => $request['transition']['to'], + 'revision' => $revision, + ]); + $this->db->table('example_machine_publications') + ->insert([ + 'id' => $id, + 'subject_id' => $subject->id, + 'payload' => json_encode([ + 'execution' => $execution->toArray(), + 'transitionKey' => $request['transition']['key'], + 'state' => $request['transition']['to'], + 'revision' => (string) $revision, + ], JSON_THROW_ON_ERROR), + ]); + $status = TransitionStatus::Committed; + $reason = 'business_state_committed'; + } + $this->db->table('example_machine_decisions') + ->insert([ + 'id' => $id, + 'subject_id' => $subject->id, + 'request_sha256' => $fingerprint, + 'status' => $status->value, + 'reason' => $reason, + ]); + return new CommitResult($execution, $status, $reason); + }); + } +} diff --git a/examples/business-state-machines/schema.php b/examples/business-state-machines/schema.php new file mode 100644 index 000000000..508decb99 --- /dev/null +++ b/examples/business-state-machines/schema.php @@ -0,0 +1,36 @@ +create('example_machine_subjects', static function (Blueprint $table): void { + $table->string('id', 64) + ->primary(); + $table->string('scope'); + $table->string('subject'); + $table->string('principal'); + $table->string('authority_version', 128); + $table->string('state'); + $table->bigInteger('revision'); + $table->boolean('cancellation_requested') + ->default(false); + }); + $schema->create('example_machine_decisions', static function (Blueprint $table): void { + $table->string('id', 64) + ->primary(); + $table->string('subject_id', 64); + $table->string('request_sha256', 64); + $table->string('status'); + $table->string('reason'); + }); + $schema->create('example_machine_publications', static function (Blueprint $table): void { + $table->string('id', 64) + ->primary(); + $table->string('subject_id', 64); + $table->text('payload'); + }); +}; diff --git a/phpstan.neon b/phpstan.neon index b5575b986..d7d2afe25 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -1,4 +1,6 @@ parameters: level: 0 + scanDirectories: + - examples/business-state-machines scanFiles: - scripts/ci/check-committed-composer-locks.php diff --git a/tests/Feature/V2/V2BusinessCommitConcurrencyTest.php b/tests/Feature/V2/V2BusinessCommitConcurrencyTest.php new file mode 100644 index 000000000..a1c92d373 --- /dev/null +++ b/tests/Feature/V2/V2BusinessCommitConcurrencyTest.php @@ -0,0 +1,224 @@ +getDriverName(), ['mysql', 'mariadb', 'pgsql'], true) + || ! function_exists('pcntl_fork')) { + $this->markTestSkipped('Separate processes and SQL row-lock observation are required.'); + } + self::stopWorkers(); + Queue::fake(); + class_exists(BusinessCommitFixture::class); + $schema = require __DIR__ . '/../../../examples/business-state-machines/schema.php'; + $schema(DB::connection()->getSchemaBuilder()); + BusinessCommitFixture::seed(BusinessCommitFixture::context()->execution()); + } + + protected function tearDown(): void + { + if (isset($this->app)) { + foreach (['publications', 'decisions', 'subjects'] as $suffix) { + DB::connection()->getSchemaBuilder()->dropIfExists('example_machine_' . $suffix); + } + } + parent::tearDown(); + } + + public static function operations(): array + { + return [ + 'cancellation wins' => ['cancel', 'commit', ['cancellation_requested', 'cancellation_requested'], false], + 'completion wins' => ['commit', 'cancel', ['committed', 'conflict'], true], + 'revocation wins' => ['revoke', 'commit', ['revoked', 'refused'], false], + 'completion precedes revocation' => ['commit', 'revoke', ['committed', 'revoked'], true], + 'competing completion' => ['commit', 'competing', ['committed', 'conflict'], true], + 'duplicate completion' => ['commit', 'commit', ['committed', 'committed'], true], + ]; + } + + #[DataProvider('operations')] + public function testBusinessDecisionsShareTheAuthoritativeSubjectFence( + string $winner, + string $loser, + array $expected, + bool $committed, + ): void { + $this->assertSame($expected, $this->race($winner, $loser)); + $subject = DB::table('example_machine_subjects')->first(); + $this->assertSame($committed ? 'ready' : 'received', $subject->state); + $this->assertSame($committed ? 2 : 1, (int) $subject->revision); + $this->assertSame($committed ? 1 : 0, DB::table('example_machine_publications')->count()); + $this->assertSame( + (int) in_array('cancel', [$winner, $loser], true) && ! $committed, + (bool) $subject->cancellation_requested, + ); + if (in_array('revoke', [$winner, $loser], true)) { + $this->assertSame('grant-v2', $subject->authority_version); + } + } + + private function operate(string $operation): string + { + if ($operation === 'revoke') { + DB::transaction(static function (): void { + $subject = DB::table('example_machine_subjects')->lockForUpdate()->first(); + DB::table('example_machine_subjects')->where('id', $subject->id)->update([ + 'authority_version' => 'grant-v2', + ]); + }); + return 'revoked'; + } + $context = BusinessCommitFixture::context($operation === 'competing' ? 'transition-2' : 'transition-1'); + try { + $store = BusinessCommitFixture::store(); + $result = $operation === 'cancel' + ? $store->requestCancellation($context->execution(), 'cancel-1', 'alice') + : $store->commit($context, 'alice'); + return $result->status->value; + } catch (LogicException $error) { + if ($error->getMessage() !== 'The original subject authority is not current.') { + throw $error; + } + return 'refused'; + } + } + + private function race(string $winner, string $loser): array + { + $root = sys_get_temp_dir() . '/dw-business-commit-' . bin2hex(random_bytes(8)); + mkdir($root, 0700); + $driver = DB::connection()->getDriverName(); + DB::purge(); + $children = []; + try { + foreach ([$winner, $loser] as $index => $operation) { + $pid = pcntl_fork(); + if ($pid === -1) { + throw new RuntimeException('Cannot fork the business commit race.'); + } + if ($pid === 0) { + try { + DB::reconnect(); + $backend = $driver === 'pgsql' + ? DB::selectOne('SELECT pg_backend_pid() AS id')->id + : DB::selectOne('SELECT CONNECTION_ID() AS id')->id; + if ($driver === 'pgsql') { + DB::statement("SET lock_timeout = '15s'"); + } else { + DB::statement('SET SESSION innodb_lock_wait_timeout = 15'); + } + file_put_contents($root . '/backend-' . $index, (string) $backend); + if ($index === 0) { + $held = false; + DB::listen(static function (QueryExecuted $query) use ($root, &$held): void { + if (! $held && str_starts_with(strtolower($query->sql), 'update') + && str_contains($query->sql, 'example_machine_subjects')) { + if ($query->connection->transactionLevel() === 0) { + throw new RuntimeException('The winning mutation was not transactional.'); + } + $held = true; + touch($root . '/held'); + self::waitFile($root . '/release'); + } + }); + } else { + self::waitFile($root . '/held'); + } + file_put_contents($root . '/' . $index . '.json', json_encode([ + 'result' => $this->operate($operation), + ], JSON_THROW_ON_ERROR)); + DB::disconnect(); + exit(0); + } catch (Throwable $error) { + file_put_contents($root . '/' . $index . '.json', json_encode([ + 'error' => $error::class . ': ' . $error->getMessage(), + ], JSON_THROW_ON_ERROR)); + DB::disconnect(); + exit(1); + } + } + $children[] = $pid; + } + self::waitFile($root . '/held'); + self::waitFile($root . '/backend-1'); + $backend = (int) file_get_contents($root . '/backend-1'); + DB::reconnect(); + $deadline = microtime(true) + 5; + $waiting = false; + while (! $waiting && microtime(true) < $deadline) { + $rows = $driver === 'pgsql' + ? DB::select('SELECT pid, query, wait_event_type FROM pg_stat_activity WHERE pid = ?', [$backend]) + : DB::select('SHOW FULL PROCESSLIST'); + foreach ($rows as $row) { + $id = (int) ($driver === 'pgsql' ? $row->pid : $row->Id); + $sql = strtolower((string) ($driver === 'pgsql' ? $row->query : $row->Info)); + $waiting = $id === $backend && str_starts_with($sql, 'select') + && str_contains($sql, 'example_machine_subjects') && str_contains($sql, 'for update') + && ($driver !== 'pgsql' || $row->wait_event_type === 'Lock'); + if ($waiting) { + break; + } + } + if (! $waiting) { + usleep(10_000); + } + } + $this->assertTrue($waiting, 'The competing business subject row lock was not observed.'); + touch($root . '/release'); + $results = []; + foreach ($children as $index => $pid) { + pcntl_waitpid($pid, $status); + $payload = json_decode( + file_get_contents($root . '/' . $index . '.json'), + true, + flags: JSON_THROW_ON_ERROR + ); + $this->assertSame(0, pcntl_wexitstatus($status), $payload['error'] ?? 'Business contender failed.'); + $results[] = $payload['result']; + } + return $results; + } finally { + touch($root . '/release'); + foreach ($children as $pid) { + if (pcntl_waitpid($pid, $status, WNOHANG) === 0) { + posix_kill($pid, SIGKILL); + pcntl_waitpid($pid, $status); + } + } + foreach (glob($root . '/*') as $file) { + unlink($file); + } + rmdir($root); + DB::reconnect(); + } + } + + private static function waitFile(string $path): void + { + $deadline = microtime(true) + 10; + while (! is_file($path)) { + if (microtime(true) > $deadline) { + throw new RuntimeException('The business commit barrier timed out.'); + } + usleep(10_000); + } + } +} diff --git a/tests/Fixtures/V2/BusinessCommitFixture.php b/tests/Fixtures/V2/BusinessCommitFixture.php new file mode 100644 index 000000000..369a62c17 --- /dev/null +++ b/tests/Fixtures/V2/BusinessCommitFixture.php @@ -0,0 +1,80 @@ +key, + SnapshotPhase::After, + 'immutable-assessment-1', + 'received', + '1', + new Decision(Verdict::Satisfied, 'effects_verified', ['fact-1']), + new Decision(Verdict::Satisfied, 'work_permitted', ['grant-1']), + )); + } + + public static function seed(ExecutionReference $execution): void + { + DB::table('example_machine_subjects')->insert([ + 'id' => SqlBusinessStore::subjectId($execution), + 'scope' => $execution->scope, + 'subject' => $execution->subject, + 'principal' => 'alice', + 'authority_version' => 'grant-v1', + 'state' => 'received', + 'revision' => 1, + 'cancellation_requested' => false, + ]); + } + + public static function store(string $authorityVersion = 'grant-v1'): SqlBusinessStore + { + return new SqlBusinessStore(DB::connection(), static function ( + ExecutionReference $execution, + string $principal, + string $operation, + ?TransitionContext $context, + ) use ($authorityVersion): string { + if ($principal !== 'alice' || $execution->scope !== 'tenant-a' + || $execution->policyFingerprint !== str_repeat('a', 64) + || ! in_array($operation, ['commit', 'cancel'], true) + || DB::connection()->transactionLevel() !== 0) { + throw new LogicException('The application policy refused protected access.'); + } + if ($operation === 'commit') { + $canonical = self::context($execution->executionId, $execution->subject); + if ($context === null || get_object_vars($context->transition) !== get_object_vars( + $canonical->transition + ) + || json_encode($context->snapshot, JSON_THROW_ON_ERROR) + !== json_encode($canonical->snapshot, JSON_THROW_ON_ERROR)) { + throw new LogicException('The pinned profile or immutable assessment was substituted.'); + } + } + return $authorityVersion; + }); + } +} diff --git a/tests/Unit/V2/StateMachine/BusinessCommitTest.php b/tests/Unit/V2/StateMachine/BusinessCommitTest.php new file mode 100644 index 000000000..5168d9425 --- /dev/null +++ b/tests/Unit/V2/StateMachine/BusinessCommitTest.php @@ -0,0 +1,210 @@ +getSchemaBuilder()); + BusinessCommitFixture::seed(BusinessCommitFixture::context()->execution()); + } + + public function testLostCommitAcknowledgementReturnsTheOriginalDecisionAndOnePublication(): void + { + $context = BusinessCommitFixture::context(); + BusinessCommitFixture::store()->commit($context, 'alice'); // Simulate loss of the returned acknowledgement. + $recovered = BusinessCommitFixture::store()->commit($context, 'alice'); + $this->assertSame(TransitionStatus::Committed, $recovered->status); + $this->assertTrue($context->execution()->equals($recovered->execution)); + $this->assertSame(['ready', 2], $this->subjectState()); + $this->assertSame(1, DB::table('example_machine_decisions')->count()); + $this->assertSame(1, DB::table('example_machine_publications')->count()); + $publication = json_decode( + DB::table('example_machine_publications')->value('payload'), + true, + 512, + JSON_THROW_ON_ERROR + ); + $this->assertSame($context->execution()->toArray(), $publication['execution']); + $this->assertSame('2', $publication['revision']); + } + + public function testAcceptedCancellationFencesCompletionWithoutClaimingCompensation(): void + { + $context = BusinessCommitFixture::context(); + $store = BusinessCommitFixture::store(); + $first = $store->requestCancellation($context->execution(), 'cancel-1', 'alice'); + $duplicate = $store->requestCancellation($context->execution(), 'cancel-1', 'alice'); + $commit = $store->commit($context, 'alice'); + $this->assertSame(TransitionStatus::CancellationRequested, $first->status); + $this->assertSame($first->reason, $duplicate->reason); + $this->assertSame(TransitionStatus::CancellationRequested, $commit->status); + $this->assertSame(['received', 1], $this->subjectState()); + $this->assertSame(2, DB::table('example_machine_decisions')->count()); + $this->assertSame(0, DB::table('example_machine_publications')->count()); + } + + public function testLateCancellationCannotReplaceAnAlreadyCommittedDecision(): void + { + $context = BusinessCommitFixture::context(); + $store = BusinessCommitFixture::store(); + $store->commit($context, 'alice'); + $cancel = $store->requestCancellation($context->execution(), 'cancel-1', 'alice'); + $this->assertSame(TransitionStatus::Conflict, $cancel->status); + $this->assertSame(TransitionStatus::Committed, $store->commit($context, 'alice')->status); + $this->assertFalse((bool) DB::table('example_machine_subjects')->value('cancellation_requested')); + $this->assertSame(['ready', 2], $this->subjectState()); + $this->assertSame(1, DB::table('example_machine_publications')->count()); + } + + public function testLostAcknowledgementSurvivesAcceptedCancellationOfTheNextRevision(): void + { + $context = BusinessCommitFixture::context(); + $store = BusinessCommitFixture::store(); + $store->commit($context, 'alice'); + $next = new ExecutionReference('tenant-a', 'item-1', 'next-execution', str_repeat('a', 64), 'ready', '2'); + $this->assertSame( + TransitionStatus::CancellationRequested, + $store->requestCancellation($next, 'cancel-next-revision', 'alice') +->status, + ); + $this->assertSame(TransitionStatus::Committed, $store->commit($context, 'alice')->status); + $this->assertTrue((bool) DB::table('example_machine_subjects')->value('cancellation_requested')); + $this->assertSame(['ready', 2], $this->subjectState()); + $this->assertSame(1, DB::table('example_machine_publications')->count()); + } + + public function testPublicationFailureRollsBackStateAndDecisionTogether(): void + { + $context = BusinessCommitFixture::context(); + $id = SqlBusinessStore::decisionId($context->execution(), 'transition', $context->execution()->executionId); + DB::table('example_machine_publications')->insert([ + 'id' => $id, + 'subject_id' => 'existing-intent', + 'payload' => '{}', + ]); + try { + BusinessCommitFixture::store()->commit($context, 'alice'); + $this->fail('The conflicting publication must fail the entire commit.'); + } catch (QueryException) { + $this->assertSame(['received', 1], $this->subjectState()); + $this->assertSame(0, DB::table('example_machine_decisions')->count()); + $this->assertSame('{}', DB::table('example_machine_publications')->value('payload')); + } + } + + public function testACompetingTransitionGetsAnExplicitRevisionConflict(): void + { + $store = BusinessCommitFixture::store(); + $store->commit(BusinessCommitFixture::context(), 'alice'); + $this->assertSame( + TransitionStatus::Conflict, + $store->commit(BusinessCommitFixture::context('transition-2'), 'alice')->status + ); + $this->assertSame(['ready', 2], $this->subjectState()); + $this->assertSame(1, DB::table('example_machine_publications')->count()); + } + + public function testAReusedDecisionCannotChangeItsSubject(): void + { + $store = BusinessCommitFixture::store(); + $store->commit(BusinessCommitFixture::context(), 'alice'); + $other = BusinessCommitFixture::context(subject: 'item-2'); + BusinessCommitFixture::seed($other->execution()); + $this->expectExceptionMessage('The decision identity was reused with different content.'); + $store->commit($other, 'alice'); + } + + public function testLiveAuthorityRevocationRefusesAnOldAssessment(): void + { + DB::table('example_machine_subjects')->update([ + 'authority_version' => 'grant-v2', + ]); + $this->expectExceptionMessage('The original subject authority is not current.'); + BusinessCommitFixture::store()->commit(BusinessCommitFixture::context(), 'alice'); + } + + public function testForeignPrincipalIsRefusedBeforeAnyProtectedQuery(): void + { + $queries = []; + DB::listen(static function (QueryExecuted $query) use (&$queries): void { + $queries[] = $query->sql; + }); + try { + BusinessCommitFixture::store()->commit(BusinessCommitFixture::context(), 'mallory'); + $this->fail('A foreign principal must be refused.'); + } catch (LogicException $error) { + $this->assertSame('The application policy refused protected access.', $error->getMessage()); + $this->assertSame([], $queries); + } + } + + public function testCallerOwnedTransactionIsRefusedBeforeTheHostPolicyRuns(): void + { + $calls = 0; + $store = new SqlBusinessStore(DB::connection(), static function () use (&$calls): string { + ++$calls; + return 'grant-v1'; + }); + DB::beginTransaction(); + try { + $store->commit(BusinessCommitFixture::context(), 'alice'); + $this->fail('A caller-owned transaction must be refused.'); + } catch (LogicException $error) { + $this->assertSame('The business boundary must own its transaction.', $error->getMessage()); + $this->assertSame(0, $calls); + } finally { + DB::rollBack(); + } + } + + public function testAProfileFingerprintDoesNotAuthorizeAChangedTargetState(): void + { + $original = BusinessCommitFixture::context(); + $forged = new TransitionContext(new TransitionDefinition( + 'prepare', + 'accepted', + ['received'], + 'cancelled', + 'prepare', + '1', + ), $original->snapshot); + $this->expectExceptionMessage('The pinned profile or immutable assessment was substituted.'); + BusinessCommitFixture::store()->commit($forged, 'alice'); + } + + protected function defineEnvironment($app): void + { + $app['config']->set('database.default', 'example'); + $app['config']->set('database.connections.example', [ + 'driver' => 'sqlite', + 'database' => ':memory:', + 'prefix' => '', + ]); + } + + private function subjectState(): array + { + $row = DB::table('example_machine_subjects')->first(); + return [$row->state, (int) $row->revision]; + } +}