Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 28 additions & 1 deletion src/Effect/EffectProfile.php
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
*
Expand All @@ -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 '
Expand Down Expand Up @@ -100,6 +123,7 @@ public static function readOnly(): self
Externality::None,
Reversibility::Guaranteed,
Authority::Read,
subject: Subject::None,
rollbackContract: 'nothing-to-roll-back',
);
}
Expand All @@ -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;
}

/**
Expand All @@ -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.
Expand Down Expand Up @@ -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(),
Expand Down
79 changes: 79 additions & 0 deletions src/Effect/Subject.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
<?php

/**
* This file is part of Milpa Command — the Command-as-atom core of the Milpa PHP framework.
*
* (c) Rodrigo Vicente - TeamX Agency — https://teamx.agency <hola@teamx.agency>
*
* @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,
};
}
}
16 changes: 15 additions & 1 deletion tests/Effect/EffectProfileTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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(
Expand All @@ -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']);
}
Expand Down Expand Up @@ -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());
Expand Down
114 changes: 114 additions & 0 deletions tests/Effect/SubjectTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
<?php

declare(strict_types=1);

namespace Milpa\Command\Tests\Effect;

use Milpa\Command\Effect\Authority;
use Milpa\Command\Effect\EffectProfile;
use Milpa\Command\Effect\Externality;
use Milpa\Command\Effect\Mutation;
use Milpa\Command\Effect\Reversibility;
use Milpa\Command\Effect\Subject;
use PHPUnit\Framework\TestCase;

/**
* The fifth dimension: what the mutation is made OF.
*
* The other four answer «how much» — how durable, how far it reaches, whether it comes back, what
* power it spends. None of them answers «of what», and that turned out to be the axis this house
* actually gates on: eight operations were measured identical on all four while half demanded a
* signature and half did not (greenhouse decisions/0017, 0018, 0019).
*/
final class SubjectTest extends TestCase
{
/**
* Unknown outranks everything KNOWN, exactly like the other four dimensions.
*
* «I do not know what this changes» is a worse position than «I know it replaces code», because
* the second can be reasoned about and the first cannot.
*/
public function testUnknownOutweighsEvenChangingTheExecutable(): void
{
self::assertGreaterThan(Subject::None->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());
}
}
Loading