diff --git a/src/Effect/Descent.php b/src/Effect/Descent.php new file mode 100644 index 0000000..03b993f --- /dev/null +++ b/src/Effect/Descent.php @@ -0,0 +1,83 @@ + + * + * @license Apache-2.0 + * + * @link https://github.com/getmilpa/command + */ + +declare(strict_types=1); + +namespace Milpa\Command\Effect; + +/** + * An argument that LOWERS this operation's ceiling for one call, and the reason it may. + * + * greenhouse decisions/0029, forced by `capabilities:enable --dry-run` asking permission to do + * nothing: rule S2 judges the OPERATION, so a rehearsal of an Executable and Privileged operation + * carried the ceiling of the real thing though it wrote nothing. + * + * WHY THIS IS THE DANGEROUS DIRECTION. `escalatesOn` is safe because it can only raise: a careless + * or lying declarant only harms themself, which is what lets an adversarial enumerator be additive + * (GOV-14). Lowering inverts that — whoever declares a descent badly is not punished, they are + * EXEMPTED, and the failure is invisible: a heavy operation that quietly stops asking. + * + * So three things are true of every descent here: + * + * · it names the full RESULTING ceiling, never a delta — «a bit less» is not a place, and what a + * reader needs is exactly where this lands; + * · it carries its REASON, the same shape `rollbackContract` already has for the one reversibility + * level that buys less scrutiny; + * · a descent that cannot hold does not lower anything. Failing upwards is the only failure this + * axis can afford. + */ +final readonly class Descent +{ + /** + * @param string $argument the input key whose presence triggers this descent + * @param mixed $whenValue the value that triggers it — identity, so `--dry-run=false` is not a descent + * @param EffectProfile $to the ceiling this call actually carries, in full + * @param string $because what makes it true, and without which nothing is lowered + */ + public function __construct( + public string $argument, + public mixed $whenValue, + public EffectProfile $to, + public string $because, + ) { + } + + /** + * Does this call trigger the descent? Identity on the value, so a different one does not. + * + * @param array $arguments + */ + public function triggeredBy(array $arguments): bool + { + return \array_key_exists($this->argument, $arguments) + && $arguments[$this->argument] === $this->whenValue; + } + + /** + * Is this descent one anybody should honour? + * + * A reason is required, and the destination has to be genuinely lighter on every axis. A descent + * that raises anything is not a descent — it would be a back door for climbing without saying so. + */ + public function holds(EffectProfile $original): bool + { + if (trim($this->because) === '') { + return false; + } + + return $this->to->mutation->weight() <= $original->mutation->weight() + && $this->to->externality->weight() <= $original->externality->weight() + && $this->to->reversibility->weight() <= $original->reversibility->weight() + && $this->to->authority->weight() <= $original->authority->weight() + && $this->to->subject->weight() <= $original->subject->weight(); + } +} diff --git a/src/Effect/EffectProfile.php b/src/Effect/EffectProfile.php index ca13f0b..d42e8b5 100644 --- a/src/Effect/EffectProfile.php +++ b/src/Effect/EffectProfile.php @@ -78,6 +78,16 @@ public function __construct( * that backs it, or it is exactly the self-certification GOV-00 exists to forbid. */ public readonly ?string $rollbackContract = null, + /** + * Arguments that LOWER this ceiling for one call — the dangerous direction (decisions/0029). + * + * LAST on purpose: every existing positional construction — join() among them — keeps + * working untouched, and a new field that renumbers the old ones would break callers to + * make room for something they never asked for. + * + * @var list + */ + public readonly array $descents = [], ) { // A READ HAS NO SUBJECT, AND SAYING OTHERWISE IS IMPOSSIBLE RATHER THAN MERELY WRONG. // @@ -168,6 +178,30 @@ public function join(self $other): self ); } + /** + * The ceiling THIS CALL carries, once its arguments are known. + * + * Escalation is not resolved here and must not be: `unresolvedEscalators()` answers a different + * question — «is the ceiling still the ceiling?» — and while it returns anything the answer is + * yes. This only ever descends. + * + * A descent that does not hold is ignored in silence rather than raising, because a call that + * refuses to run because someone declared badly punishes the caller for the author's mistake. + * The one that stops is the ceiling: it simply does not come down. + * + * @param array $arguments + */ + public function forCall(array $arguments): self + { + foreach ($this->descents as $descent) { + if ($descent->triggeredBy($arguments) && $descent->holds($this)) { + return $descent->to; + } + } + + return $this; + } + /** * Which declared escalating arguments are still unresolved in this call. * diff --git a/tests/Effect/DescentTest.php b/tests/Effect/DescentTest.php new file mode 100644 index 0000000..12365ca --- /dev/null +++ b/tests/Effect/DescentTest.php @@ -0,0 +1,117 @@ + + * + * @license Apache-2.0 + * + * @link https://github.com/getmilpa/command + */ + +declare(strict_types=1); + +namespace Milpa\Command\Tests\Effect; + +use Milpa\Command\Effect\Authority; +use Milpa\Command\Effect\Descent; +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 battery greenhouse decisions/0029 froze before this class existed. + * + * The second case is the control and it is what makes the rest mean anything: WITHOUT the argument + * the ceiling must stay where it was. A descent that applies either way is not lowering on demand, + * it is a lighter ceiling declared through a longer sentence. + */ +final class DescentTest extends TestCase +{ + /** 1 · with the argument, the declared destination is what the call carries. */ + public function testTheDeclaredArgumentLowersTheCeilingForThatCall(): void + { + $techo = $this->instala(); + + self::assertSame(Subject::None, $techo->forCall(['dry_run' => true])->subject); + self::assertSame(Mutation::None, $techo->forCall(['dry_run' => true])->mutation); + } + + /** 2 · THE CONTROL: without it, nothing moves. */ + public function testWithoutTheArgumentTheCeilingStaysWhereItWas(): void + { + $techo = $this->instala(); + + self::assertSame(Subject::Executable, $techo->forCall([])->subject); + self::assertSame(Subject::Executable, $techo->forCall(['other' => true])->subject); + } + + /** 3 · a descent with no reason lowers nothing — failing upwards is the only affordable failure. */ + public function testADescentWithoutAReasonDoesNotLower(): void + { + $techo = $this->instala(razon: ' '); + + self::assertSame(Subject::Executable, $techo->forCall(['dry_run' => true])->subject); + } + + /** 4 · a «descent» to a HIGHER ceiling is not a back door for climbing quietly. */ + public function testADescentThatRaisesAnythingIsIgnored(): void + { + $suave = new EffectProfile( + mutation: Mutation::None, + externality: Externality::None, + reversibility: Reversibility::Guaranteed, + authority: Authority::Read, + subject: Subject::None, + rollbackContract: 'reads only', + descents: [new Descent('escalate', true, new EffectProfile( + mutation: Mutation::Persistent, + externality: Externality::ThirdParty, + reversibility: Reversibility::Irreversible, + authority: Authority::Privileged, + subject: Subject::Executable, + ), 'claims to lower while raising every axis')], + ); + + self::assertSame(Subject::None, $suave->forCall(['escalate' => true])->subject); + self::assertSame(Authority::Read, $suave->forCall(['escalate' => true])->authority); + } + + /** 5 · the same argument carrying another value does not trigger it. */ + public function testAnotherValueDoesNotTriggerTheDescent(): void + { + $techo = $this->instala(); + + self::assertSame(Subject::Executable, $techo->forCall(['dry_run' => false])->subject); + self::assertSame(Subject::Executable, $techo->forCall(['dry_run' => 'yes'])->subject); + } + + /** The shape of the operation that forced this: installs code, unless it is only rehearsing. */ + private function instala(string $razon = 'the handler prints the command it would run and returns before running it'): EffectProfile + { + return new EffectProfile( + mutation: Mutation::Persistent, + externality: Externality::ThirdParty, + reversibility: Reversibility::Compensatable, + authority: Authority::Privileged, + subject: Subject::Executable, + descents: [new Descent( + argument: 'dry_run', + whenValue: true, + to: new EffectProfile( + mutation: Mutation::None, + externality: Externality::None, + reversibility: Reversibility::Guaranteed, + authority: Authority::Read, + subject: Subject::None, + rollbackContract: 'nothing ran, so there is nothing to undo', + ), + because: $razon, + )], + ); + } +}