From a166210a3b0a74020e700d0ad4a7be9fe877ba7c Mon Sep 17 00:00:00 2001 From: Rod Vince Date: Wed, 12 Aug 2026 13:24:28 -0600 Subject: [PATCH 1/2] feat: an argument may lower the ceiling for one call, and it is not believed on its word MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. This is the dangerous direction and the code says so. escalatesOn is safe because it can only raise — a careless or lying declarant harms only themself, which is what lets an adversarial enumerator be additive under GOV-14. Lowering inverts that: whoever declares a descent badly is not punished but EXEMPTED, and the failure is invisible, a heavy operation that quietly stops asking. So a Descent names the full resulting ceiling rather than a delta — "a bit less" is not a place — and carries its reason, the shape rollbackContract already has for the one reversibility level that buys less scrutiny. A descent with no reason lowers nothing, and one that raises any axis is ignored rather than honoured, because it would otherwise be a back door for climbing quietly. Failing upwards is the only failure this axis can afford. The field goes LAST in the constructor on purpose: join() and every other positional construction keep working untouched, and a new field that renumbers the old ones breaks callers to make room for something they never asked for. join() itself is deliberately not touched — the descent resolves one concrete call, and a descent inside the fold would take GOV-14 down with it. Five cases, and the second is the control: without the argument the ceiling must not move. A descent that applies either way is not lowering on demand, it is a lighter ceiling declared through a longer sentence. --- src/Effect/Descent.php | 79 +++++++++++++++++++++++ src/Effect/EffectProfile.php | 34 ++++++++++ tests/Effect/DescentTest.php | 117 +++++++++++++++++++++++++++++++++++ 3 files changed, 230 insertions(+) create mode 100644 src/Effect/Descent.php create mode 100644 tests/Effect/DescentTest.php diff --git a/src/Effect/Descent.php b/src/Effect/Descent.php new file mode 100644 index 0000000..87b5a2a --- /dev/null +++ b/src/Effect/Descent.php @@ -0,0 +1,79 @@ + + * + * @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. */ + 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, + )], + ); + } +} From d4704bac28b008b6c66883ed93ffe00bee88d4a0 Mon Sep 17 00:00:00 2001 From: Rod Vince Date: Wed, 12 Aug 2026 13:40:50 -0600 Subject: [PATCH 2/2] types: the descent's argument bag declares its value type PHPStan asked and it is right to: an untyped array in a signature that decides whether a ceiling comes down is exactly where a reader should not have to guess. --- src/Effect/Descent.php | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/Effect/Descent.php b/src/Effect/Descent.php index 87b5a2a..03b993f 100644 --- a/src/Effect/Descent.php +++ b/src/Effect/Descent.php @@ -51,7 +51,11 @@ public function __construct( ) { } - /** Does this call trigger the descent? Identity on the value, so a different one does not. */ + /** + * 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)