From 222018af2ca3a881eac9e0b1e8610c706cadb124 Mon Sep 17 00:00:00 2001 From: Rod Vince Date: Sun, 9 Aug 2026 11:17:38 -0600 Subject: [PATCH] =?UTF-8?q?feat(effect):=20a=20fifth=20dimension=20?= =?UTF-8?q?=E2=80=94=20what=20the=20change=20is=20made=20OF,=20not=20how?= =?UTF-8?q?=20much=20of=20it=20there=20is?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The four existing axes all answer «how much»: how durable, how far it reaches, whether it comes back, what power it spends. Eight operations were compared where half demand a cryptographic signature and half do not, and on every one of those axes they were IDENTICAL — same durability, same authority, same recoverability. Four dimensions and none of them discriminates, because 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 each failed differently, and all three missed an operation whose package-manager command had been captured by running it. The property sits between what reading derives and what running measures, so declaring it is not a convenience, it is the only remaining option. Four levels, and each earned its place rather than being enumerated. Data is rows and tokens and indexes — what the code reads, not the code. Configuration is how the code already there behaves: a setting, a mode, a constitution, and this is the level that stopped the previous rule from overreaching, since founding an app and changing an agent's autonomy both live here and neither is what a signature is for. Executable is which code will run at all. A fifth level splitting that one into loaded and replaced was proposed and rejected by its own test: under the qualified rule it changes no consent decision, and a level that changes no decision is vocabulary rather than dimension. Unknown is the ceiling and not a gap, like everywhere else in this namespace. That inverts the burden: whoever wants to run without consent has to WRITE that their operation leaves the executable alone, and a written claim is one a reviewer can quote and refute. It does not prevent the lie, it gives it a name. A read has no subject, and saying otherwise is now 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. The durability axis already answers that nothing changes, so the two are made to agree at construction, the same treatment the guaranteed-rollback claim already gets and for the same reason: a contradiction that cannot be declared never has to be caught by a reviewer. Note for consumers: the fifth axis counts toward «fully classified», so every profile that declared four is now honestly reported as incomplete. Nothing gates on that aggregate today — its one consumer uses it for a report and filters on the individual dimensions — and the pressure is the intended effect rather than a side effect. The way out is to classify, never to add a permissive default. Verified by mutation: removing the axis from the join, and removing the construction invariant, each fail the suite. Measured in the greenhouse: decisions/0017, 0018 and 0019. --- src/Effect/EffectProfile.php | 29 +++++++- src/Effect/Subject.php | 79 ++++++++++++++++++++ tests/Effect/EffectProfileTest.php | 16 +++- tests/Effect/SubjectTest.php | 114 +++++++++++++++++++++++++++++ 4 files changed, 236 insertions(+), 2 deletions(-) create mode 100644 src/Effect/Subject.php create mode 100644 tests/Effect/SubjectTest.php 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()); + } +}