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
83 changes: 83 additions & 0 deletions src/Effect/Descent.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
<?php

/**
* This file is part of milpa/command — the atom: one declared Operation, projected by every surface.
*
* (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;

/**
* 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<string, mixed> $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();
}
}
34 changes: 34 additions & 0 deletions src/Effect/EffectProfile.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<Descent>
*/
public readonly array $descents = [],
) {
// A READ HAS NO SUBJECT, AND SAYING OTHERWISE IS IMPOSSIBLE RATHER THAN MERELY WRONG.
//
Expand Down Expand Up @@ -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<string, mixed> $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.
*
Expand Down
117 changes: 117 additions & 0 deletions tests/Effect/DescentTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
<?php

/**
* This file is part of milpa/command — the atom: one declared Operation, projected by every surface.
*
* (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\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,
)],
);
}
}
Loading