diff --git a/src/Effect/EffectProfile.php b/src/Effect/EffectProfile.php index 96f7544..ca13f0b 100644 --- a/src/Effect/EffectProfile.php +++ b/src/Effect/EffectProfile.php @@ -62,6 +62,14 @@ public function __construct( public readonly Reversibility $reversibility = Reversibility::Unknown, public readonly Authority $authority = Authority::Unknown, public readonly array $escalatesOn = [], + /** + * What the change is made OF — the only axis here that does not answer «how much». + * + * It arrives fifth because the other four were measured NOT to discriminate: eight + * operations, half of them demanding a signature and half not, came out identical on + * mutation, externality, reversibility and authority. See {@see Subject}. + */ + public readonly Subject $subject = Subject::Unknown, /** * The rollback contract, when reversibility claims to be `Guaranteed`. * @@ -71,6 +79,21 @@ public function __construct( */ public readonly ?string $rollbackContract = null, ) { + // A READ HAS NO SUBJECT, AND SAYING OTHERWISE IS IMPOSSIBLE RATHER THAN MERELY WRONG. + // + // Four blind judges classified thirty-three operations from the definition alone, and every + // disagreement they produced landed on an operation that changes nothing at all: they were + // being asked what a read is made of. `Mutation::None` already answers that nothing changes, + // so the two are made to agree by construction — the same treatment the guaranteed-rollback + // claim gets below, and for the same reason: a contradiction that cannot be declared never + // has to be caught by a reviewer. + if ($mutation === Mutation::None && $subject !== Subject::None && $subject !== Subject::Unknown) { + throw new \InvalidArgumentException( + 'an operation that changes nothing cannot declare a subject: `Mutation::None` and ' + . '«' . $subject->value . '» disagree about whether anything happens' + ); + } + if ($reversibility === Reversibility::Guaranteed && ($rollbackContract === null || trim($rollbackContract) === '')) { throw new \InvalidArgumentException( 'reversibility «guaranteed» requires a rollback contract: a claim that lowers scrutiny ' @@ -100,6 +123,7 @@ public static function readOnly(): self Externality::None, Reversibility::Guaranteed, Authority::Read, + subject: Subject::None, rollbackContract: 'nothing-to-roll-back', ); } @@ -115,7 +139,8 @@ public function isFullyClassified(): bool return $this->mutation !== Mutation::Unknown && $this->externality !== Externality::Unknown && $this->reversibility !== Reversibility::Unknown - && $this->authority !== Authority::Unknown; + && $this->authority !== Authority::Unknown + && $this->subject !== Subject::Unknown; } /** @@ -133,6 +158,7 @@ public function join(self $other): self $this->reversibility->weight() >= $other->reversibility->weight() ? $this->reversibility : $other->reversibility, $this->authority->weight() >= $other->authority->weight() ? $this->authority : $other->authority, array_values(array_unique([...$this->escalatesOn, ...$other->escalatesOn])), + $this->subject->weight() >= $other->subject->weight() ? $this->subject : $other->subject, // The joined profile keeps a rollback contract ONLY while both sides still guarantee it. // Joining a guaranteed operation with an irreversible one does not produce something // half-recoverable; it produces something irreversible, and the contract no longer applies. @@ -178,6 +204,7 @@ public function toArray(): array 'externality' => $this->externality->value, 'reversibility' => $this->reversibility->value, 'authority' => $this->authority->value, + 'subject' => $this->subject->value, 'escalates_on' => $this->escalatesOn, 'rollback_contract' => $this->rollbackContract, 'fully_classified' => $this->isFullyClassified(), diff --git a/src/Effect/Subject.php b/src/Effect/Subject.php new file mode 100644 index 0000000..aa8fbc8 --- /dev/null +++ b/src/Effect/Subject.php @@ -0,0 +1,79 @@ + + * + * @license Apache-2.0 + * + * @link https://github.com/getmilpa/command + */ + +declare(strict_types=1); + +namespace Milpa\Command\Effect; + +/** + * What the change is made OF — the only dimension here that does not answer «how much». + * + * ── WHY A FIFTH AXIS, MEASURED RATHER THAN ARGUED ─────────────────────────────────────────────── + * + * Eight operations were compared: four demanded a cryptographic signature and four did not, and on + * every declared dimension they were IDENTICAL — same durability, same authority, same + * recoverability. Four axes and none of them discriminated, because all four answer how much and + * none answers of what. The property this framework actually gates on was declared nowhere. + * + * It is also not derivable. Three independent static readers were built to infer it from what a + * handler touches — shallow, transitive, and verb-qualified — and all three failed differently, and + * all three missed an operation whose package-manager command had been printed by running it. The + * property lives between what reading derives and what running measures, so it must be declared. + * + * ── AND `Unknown` IS THE CEILING, NOT A GAP ───────────────────────────────────────────────────── + * + * Like every dimension in this namespace, an operation that never said carries the worst reading, + * not the most convenient one (GOV-05). That inverts who carries the burden: whoever wants to run + * without consent has to WRITE that their operation does not touch the executable, and a written + * claim is one a reviewer can quote and refute. It does not prevent the lie; it gives it a name. + */ +enum Subject: string +{ + /** Nothing changes. The operation reads — there is no subject to speak of. */ + case None = 'none'; + + /** Rows, tokens, entries in a store, an index. What the code reads and writes, not the code. */ + case Data = 'data'; + + /** + * How the code that is ALREADY there behaves: a setting, a mode, a constitution. + * + * The same classes keep loading; they act differently. This is the level that stopped the + * previous rule from overreaching — founding an app and changing an agent's autonomy both live + * here, and neither is the kind of act a signature is for. + */ + case Configuration = 'configuration'; + + /** + * WHICH CODE WILL RUN: installs, removes, replaces, or stops something from booting. + * + * Writing a new class into the app's own tree belongs here too. The test is not whether bytes + * reach the disk — it is whether the set of things this app will execute is different afterwards. + */ + case Executable = 'executable'; + + case Unknown = 'unknown'; + + /** How much scrutiny this level demands — higher wins when profiles are joined. */ + public function weight(): int + { + return match ($this) { + self::None => 0, + self::Data => 1, + self::Configuration => 2, + self::Executable => 3, + // ABOVE changing the executable, for the same reason it is above every other maximum in + // this namespace: not knowing what an act is made of is worse than knowing the worst. + self::Unknown => 4, + }; + } +} diff --git a/tests/Effect/EffectProfileTest.php b/tests/Effect/EffectProfileTest.php index b9d533b..2082daa 100644 --- a/tests/Effect/EffectProfileTest.php +++ b/tests/Effect/EffectProfileTest.php @@ -9,6 +9,7 @@ use Milpa\Command\Effect\Externality; use Milpa\Command\Effect\Mutation; use Milpa\Command\Effect\Reversibility; +use Milpa\Command\Effect\Subject; use Milpa\Command\Operation; use PHPUnit\Framework\TestCase; @@ -130,6 +131,7 @@ public function testItSerialisesWithWhetherItIsActuallyClassified(): void $unclassified = EffectProfile::unclassified()->toArray(); self::assertFalse($unclassified['fully_classified']); self::assertSame('unknown', $unclassified['mutation']); + self::assertSame('unknown', $unclassified['subject']); self::assertSame([], $unclassified['escalates_on']); $classified = (new EffectProfile( @@ -138,8 +140,12 @@ public function testItSerialisesWithWhetherItIsActuallyClassified(): void Reversibility::Irreversible, Authority::WriteAsUser, escalatesOn: ['path'], + subject: Subject::Executable, ))->toArray(); self::assertTrue($classified['fully_classified']); + // A dimension that does not travel in the payload is a dimension a JSON consumer cannot + // read, which is the same as not having it for everyone outside this process. + self::assertSame('executable', $classified['subject']); self::assertSame(['path'], $classified['escalates_on']); self::assertNull($classified['rollback_contract']); } @@ -200,7 +206,15 @@ public function testAMutatingOperationMayRefineWhatKindOfMutationItPerforms(): v 'x', static fn (): array => [], mutating: true, - effects: new EffectProfile(Mutation::Persistent, Externality::None, Reversibility::ManualRecovery, Authority::WriteAsUser), + // The fifth dimension is part of «fully» now: four answers about how much and none + // about of what is not a classification, it is a classification with a hole. + effects: new EffectProfile( + Mutation::Persistent, + Externality::None, + Reversibility::ManualRecovery, + Authority::WriteAsUser, + subject: Subject::Data, + ), ); self::assertTrue($op->effectCeiling()->isFullyClassified()); diff --git a/tests/Effect/SubjectTest.php b/tests/Effect/SubjectTest.php new file mode 100644 index 0000000..be85f1d --- /dev/null +++ b/tests/Effect/SubjectTest.php @@ -0,0 +1,114 @@ +weight(), Subject::Configuration->weight()); + self::assertGreaterThan(Subject::Configuration->weight(), Subject::Executable->weight()); + self::assertGreaterThan(Subject::Executable->weight(), Subject::Unknown->weight()); + } + + /** An operation that never said gets the ceiling, not the floor (GOV-05). */ + public function testTheDefaultIsUnknownAndUnknownIsNotClassified(): void + { + $profile = EffectProfile::unclassified(); + + self::assertSame(Subject::Unknown, $profile->subject); + self::assertFalse($profile->isFullyClassified()); + } + + /** Declaring the other four and forgetting this one is still not classified. */ + public function testFourOutOfFiveIsNotClassified(): void + { + $profile = new EffectProfile( + Mutation::Persistent, + Externality::None, + Reversibility::ManualRecovery, + Authority::Privileged, + ); + + self::assertFalse( + $profile->isFullyClassified(), + 'a profile missing the subject is four answers about how much and none about of what', + ); + } + + /** Joining takes the higher subject, like every other dimension — risks are not averaged. */ + public function testJoiningTakesTheHigherSubject(): void + { + $data = new EffectProfile( + Mutation::Persistent, + Externality::None, + Reversibility::ManualRecovery, + Authority::WriteAsUser, + subject: Subject::Data, + ); + $code = new EffectProfile( + Mutation::Persistent, + Externality::None, + Reversibility::ManualRecovery, + Authority::WriteAsUser, + subject: Subject::Executable, + ); + + self::assertSame(Subject::Executable, $data->join($code)->subject); + self::assertSame(Subject::Executable, $code->join($data)->subject); + } + + /** + * A read has no subject, and saying otherwise is IMPOSSIBLE rather than merely wrong. + * + * Four blind judges classified thirty-three operations with only the definition, and every + * disagreement they produced was on an operation that changes nothing at all — they were being + * asked what a read is made of, and there is no answer. The durability axis already says nothing + * changes; this invariant makes the two agree by construction instead of by review. + */ + public function testAnOperationThatChangesNothingCannotClaimASubject(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessageMatches('/changes nothing/i'); + + new EffectProfile( + Mutation::None, + Externality::None, + Reversibility::Guaranteed, + Authority::Read, + subject: Subject::Executable, + rollbackContract: 'nothing-to-roll-back', + ); + } + + /** And the read-only profile says so itself. */ + public function testTheReadOnlyProfileDeclaresNoSubject(): void + { + self::assertSame(Subject::None, EffectProfile::readOnly()->subject); + self::assertTrue(EffectProfile::readOnly()->isFullyClassified()); + } +}