From ef1a0a902aa87960c3ef77570a3991b4d4cf192d Mon Sep 17 00:00:00 2001 From: Rod Vince Date: Sun, 11 Oct 2026 09:50:26 -0600 Subject: [PATCH 1/3] feat: a standing grant, and the judgment of one call under it (greenhouse decisions/0615) The only way to let a seat repeat an act was to hand it the scope whole: no ceiling, no condition, nothing to take it back when the evidence changes. This is the contract of something narrower, with no I/O in it. - Operation::$actsOn declares what content a call acts on, as a digest derived from the bytes; actsOnDigest() reads it, and instrumentDigest() pins the operation that measures (its declaration, its handler, what it says it acts on). - StandingGrant is the document a person signs: seats, named calls, an effect ceiling, conditions each measured by an operation, how an act is undone, a watched falsifier. It refuses, at construction, a ceiling with an unclassified axis or above compensatable, covering any standing.* operation, a grant with no condition, a falsifier nothing watches, and any key it does not read. - ConditionReceipt is the fact a measurement leaves: which operation, over which content, said what, as which instrument. - StandingJudge::judge() asks seven things in order and only refuses or lets pass: named, signature, in force, watched, subject, ceiling, conditions. A grant that does not name the call says so and nothing else. A measurement and an act are joined by content, never by clock. Money is not expressible: there is no effect axis for it, and the README says so. --- README.md | 60 ++++ src/Operation.php | 109 +++++- src/Standing/Canonical.php | 97 ++++++ src/Standing/ConditionReceipt.php | 94 ++++++ src/Standing/Falsifier.php | 71 ++++ src/Standing/GrantCondition.php | 58 ++++ src/Standing/GrantStanding.php | 44 +++ src/Standing/GrantState.php | 37 +++ src/Standing/NamedCall.php | 112 +++++++ src/Standing/StandingCall.php | 48 +++ src/Standing/StandingCheck.php | 47 +++ src/Standing/StandingGrant.php | 310 ++++++++++++++++++ src/Standing/StandingJudge.php | 183 +++++++++++ src/Standing/StandingVerdict.php | 67 ++++ tests/AnOperationSaysWhatItActsOnTest.php | 108 ++++++ ...onReceiptBindsContentAndInstrumentTest.php | 53 +++ .../AStandingGrantNamesWhatItCoversTest.php | 115 +++++++ ...StandingGrantNeverCrossesItsFloorsTest.php | 146 +++++++++ tests/Standing/Grants.php | 109 ++++++ .../TheStandingJudgeAsksSevenThingsTest.php | 249 ++++++++++++++ 20 files changed, 2111 insertions(+), 6 deletions(-) create mode 100644 src/Standing/Canonical.php create mode 100644 src/Standing/ConditionReceipt.php create mode 100644 src/Standing/Falsifier.php create mode 100644 src/Standing/GrantCondition.php create mode 100644 src/Standing/GrantStanding.php create mode 100644 src/Standing/GrantState.php create mode 100644 src/Standing/NamedCall.php create mode 100644 src/Standing/StandingCall.php create mode 100644 src/Standing/StandingCheck.php create mode 100644 src/Standing/StandingGrant.php create mode 100644 src/Standing/StandingJudge.php create mode 100644 src/Standing/StandingVerdict.php create mode 100644 tests/AnOperationSaysWhatItActsOnTest.php create mode 100644 tests/Standing/AConditionReceiptBindsContentAndInstrumentTest.php create mode 100644 tests/Standing/AStandingGrantNamesWhatItCoversTest.php create mode 100644 tests/Standing/AStandingGrantNeverCrossesItsFloorsTest.php create mode 100644 tests/Standing/Grants.php create mode 100644 tests/Standing/TheStandingJudgeAsksSevenThingsTest.php diff --git a/README.md b/README.md index 8f02424..08563e7 100644 --- a/README.md +++ b/README.md @@ -166,6 +166,66 @@ authorisation at every link of the chain: ask "remove the old plugin" and a samp with no fact saying why. With the contract declared, an unnamed target becomes a question instead. The doubt belongs to the operation that discovers a concrete operation cannot exist yet. +## A standing grant, and the judgment of one call under it + +Some acts repeat, and a person should not have to sign each one. The only way to let a seat repeat +an act used to be handing it the scope whole: no ceiling, no condition, nothing to take it back when +the evidence changes. A **standing grant** is narrower than that on every count. A person signs it +once; it names the seats, the operations, the widest effect a call may have, what must have been +**measured on the very content the act touches**, and what would show the grant was wrong to give. + +An operation says what content a call acts on, as a digest derived from the bytes: + +```php +new Operation( + name: 'release:publish', + // … + actsOn: static fn (array $arguments): ?string => is_file($arguments['archive']) + ? 'sha256:' . hash_file('sha256', $arguments['archive']) + : null, +); +``` + +The operation that measures a condition declares the same thing, and a host joins the two **by +content, never by clock**: a measurement taken on another candidate, or after the act, is not this +one. `StandingJudge::judge()` then asks seven things, in order, and the first that fails is the +verdict: + +| # | Check | Refuses when | +|---|-------|--------------| +| 1 | `Named` | the grant does not name this operation, these arguments and this seat — the call is then judged as it was before any grant existed | +| 2 | `Signature` | the grant's signature does not hold *now* | +| 3 | `InForce` | it is suspended, superseded, revoked, or past its own date | +| 4 | `Watched` | the operation watching its falsifier gave no sign of life within its window | +| 5 | `Subject` | the operation cannot say what content this call acts on | +| 6 | `Ceiling` | the effect of this call is wider than the grant's ceiling (`EffectProfile::isNoWiderThan`, the one comparator) | +| 7 | `Conditions` | a condition has no green receipt on this content, from the instrument as it is today | + +```php +$verdict = StandingJudge::judge($grant, $standing, $call); + +$verdict->named(); // false: not this grant's call +$verdict->passes(); // true: every check held; $verdict->cited are the receipts it ran on +$verdict->failed; // the StandingCheck that refused, with $verdict->sentence for the reader +``` + +The judgment is pure: it reads a `StandingGrant`, the facts a host gathered about it +(`GrantStanding`) and the call as facts (`StandingCall`). It never grants, widens or restores — +growing back is a person's new signature, and it happens in the host. + +Some documents cannot be written at all. `StandingGrant` refuses, at construction: a ceiling with an +unclassified axis, a ceiling above `compensatable`, covering any `standing.*` operation, a grant +with no condition, a falsifier nothing watches, and **any key it does not read** — a line the judge +would ignore is a limit nobody keeps. + +**What it cannot say: money.** There is no effect axis for it, so no ceiling bounds what a call +spends, and nothing in an operation's declaration says that it spends. "Do not grant over an +operation that spends" is a rule for whoever signs, not something this package can hold. + +And one limit of the receipt: it pins the measuring operation by `Operation::instrumentDigest()` — +its declaration and the body of its handler. A handler that delegates changes behaviour without +changing that digest. + ## Requirements - PHP **≥ 8.3** diff --git a/src/Operation.php b/src/Operation.php index cf18fcd..7c2fad8 100644 --- a/src/Operation.php +++ b/src/Operation.php @@ -197,6 +197,27 @@ public function __construct( * cannot both create its target and amend one that exists. Both are refused at declaration. */ public bool $amendsNamedTarget = false, + /** + * WHAT a call of this operation acts on, as the digest of its content, derived from the call's + * arguments — or `null` when this operation does not say (greenhouse decisions/0615). + * + * A condition measured on one candidate says nothing about another, and «the same candidate» cannot + * be a name, a path or a moment: a file is rewritten under its name, and a measurement taken after + * the act legalizes nothing. So the operation that measures and the operation that acts each declare + * how their arguments lead to the bytes they touch, and a host joins the two by that digest — by + * content, never by clock. The value is derived FROM THE BYTES (the sha256 of the candidate's + * archive, the id of a commit); it never arrives as an argument, because a digest the caller hands + * in is a statement, not a reading. + * + * Declaring it changes nothing on its own. A host that judges a standing grant reads it through + * {@see self::actsOnDigest()}; an operation that declares none answers `null` there, and not being + * able to look is not having looked: no grant applies to a call that cannot say what it touches. + * + * LAST on purpose, like every field added to this constructor: positional callers keep working. + * + * @var (\Closure(array): ?string)|null + */ + public ?\Closure $actsOn = null, ) { if ($this->amendsNamedTarget && $this->namedTarget === null) { throw new \InvalidArgumentException( @@ -298,16 +319,92 @@ public function handlerDigest(): ?string return null; } - $reflejo = new \ReflectionFunction(\Closure::fromCallable($this->handler)); - $archivo = $reflejo->getFileName(); - $lineas = $archivo === false ? false : file($archivo); - if ($lineas === false) { + return self::bodyDigest(\Closure::fromCallable($this->handler)); + } + + /** + * The digest of the content this call acts on, or `null` when this operation cannot say. + * + * Asked of the SAME declaration for the call that measures and for the call that acts, so the two + * meet only when both answers agree. `null` covers every way of not knowing — no declaration, a + * declaration that answered nothing for these arguments, one that threw because the candidate is + * not there — and all of them mean the same to a judge: nothing can be bound to this call. + * + * @param array $arguments + */ + public function actsOnDigest(array $arguments): ?string + { + if ($this->actsOn === null) { + return null; + } + + try { + $digest = ($this->actsOn)($arguments); + } catch (\Throwable) { + return null; + } + + return \is_string($digest) && trim($digest) !== '' ? trim($digest) : null; + } + + /** + * What makes this operation the INSTRUMENT it is today: its identity, its inputs, its declared + * effects, the body of its handler and the body of what says what it acts on — in one digest + * (greenhouse decisions/0615). + * + * A condition receipt pins it. A guard that did not see, hardened afterwards, is another + * instrument: what the old one measured green says nothing about what the new one would find, so a + * receipt taken by a different digest does not count. It carries the limit {@see self::handlerDigest()} + * declares — a handler that delegates changes behaviour without changing this — and says so rather + * than hide it. + * + * `null` when either body cannot be read, or when the operation does not say what it acts on: an + * instrument that cannot be pinned, or that measures nothing in particular, yields no receipt worth + * keeping. + */ + public function instrumentDigest(): ?string + { + $handler = $this->handlerDigest(); + $subject = $this->actsOn === null ? null : self::bodyDigest($this->actsOn); + if ($handler === null || $subject === null) { + return null; + } + + $effects = $this->effectCeiling(); + $scopes = $this->scopes; + sort($scopes); + + return 'sha256:' . hash('sha256', Standing\Canonical::json([ + 'acts_on' => $subject, + 'effects' => [ + 'authority' => $effects->authority->value, + 'externality' => $effects->externality->value, + 'mutation' => $effects->mutation->value, + 'reversibility' => $effects->reversibility->value, + 'subject' => $effects->subject->value, + ], + 'handler' => $handler, + 'inputs' => $this->inputSchema, + 'mutating' => $this->mutating, + 'operation' => (new Consent\OperationId($this->name))->canonical, + 'permission' => $this->permission, + 'scopes' => $scopes, + ])); + } + + /** The digest of a closure's source lines, or `null` when they cannot be read. */ + private static function bodyDigest(\Closure $closure): ?string + { + $reflection = new \ReflectionFunction($closure); + $file = $reflection->getFileName(); + $lines = $file === false ? false : file($file); + if ($lines === false) { return null; } - $cuerpo = \array_slice($lineas, $reflejo->getStartLine() - 1, $reflejo->getEndLine() - $reflejo->getStartLine() + 1); + $body = \array_slice($lines, $reflection->getStartLine() - 1, $reflection->getEndLine() - $reflection->getStartLine() + 1); - return 'sha256:' . hash('sha256', implode('', $cuerpo)); + return 'sha256:' . hash('sha256', implode('', $body)); } /** diff --git a/src/Standing/Canonical.php b/src/Standing/Canonical.php new file mode 100644 index 0000000..70966ad --- /dev/null +++ b/src/Standing/Canonical.php @@ -0,0 +1,97 @@ + + * + * @license Apache-2.0 + * + * @link https://github.com/getmilpa/command + */ + +declare(strict_types=1); + +namespace Milpa\Command\Standing; + +/** + * One spelling for a structure that gets signed or digested (greenhouse decisions/0615). + * + * JSON has many spellings of the same object and a signature is over bytes, not over meaning: a + * grant a person signed as `{"id":…,"to":…}` and a host rebuilt as `{"to":…,"id":…}` would be two + * documents. So every map is sorted at every depth and every list is left alone — in a map, key + * order carries nothing; in a list it is the meaning. + * + * Part of the contract, not a convenience: a host that keeps a record of grants and receipts digests + * its lines with the same spelling, so that a judge written anywhere reads the same bytes. + */ +final class Canonical +{ + /** + * The canonical bytes of a structure. + * + * @param array $value + */ + public static function json(array $value): string + { + return json_encode(self::sorted($value), \JSON_THROW_ON_ERROR | \JSON_UNESCAPED_SLASHES | \JSON_UNESCAPED_UNICODE); + } + + /** + * Refuse a key this contract does not read. + * + * A document a person signs must not carry a line the judge ignores: someone who writes + * `"budget": 50` believes they bounded something, and a reader that skipped it in silence would + * let them keep believing it. + * + * @param array $data + * @param list $known + */ + public static function only(array $data, array $known, string $where): void + { + foreach (array_keys($data) as $key) { + if (!\in_array($key, $known, true)) { + throw new \InvalidArgumentException(\sprintf( + '%s carries «%s», which nothing reads (it reads: %s). A line the judge ignores is a limit nobody keeps — remove it.', + $where, + (string) $key, + implode(', ', $known), + )); + } + } + } + + /** + * A required, non-blank string of a document, trimmed. + * + * @param array $data + */ + public static function text(array $data, string $key, string $where): string + { + $value = $data[$key] ?? null; + if (!\is_string($value) || trim($value) === '') { + throw new \InvalidArgumentException(\sprintf('%s needs «%s», in words', $where, $key)); + } + + return trim($value); + } + + /** + * @param array $value + * + * @return array + */ + private static function sorted(array $value): array + { + if (!array_is_list($value)) { + ksort($value); + } + foreach ($value as $key => $item) { + if (\is_array($item)) { + $value[$key] = self::sorted($item); + } + } + + return $value; + } +} diff --git a/src/Standing/ConditionReceipt.php b/src/Standing/ConditionReceipt.php new file mode 100644 index 0000000..d8b8bd4 --- /dev/null +++ b/src/Standing/ConditionReceipt.php @@ -0,0 +1,94 @@ + + * + * @license Apache-2.0 + * + * @link https://github.com/getmilpa/command + */ + +declare(strict_types=1); + +namespace Milpa\Command\Standing; + +use Milpa\Command\Consent\OperationId; + +/** + * THE FACT: this operation ran over this content and said this (greenhouse decisions/0615). + * + * It is what joins a measurement to an act, and every field is load-bearing: + * + * · `by` + `arguments` which operation measured, called how — what a condition's `by` is matched against. + * · `subject` the digest of the content it measured, computed by the host from the bytes. The + * same digest tomorrow is still measured; one byte apart is another candidate. + * · `ok` what the run itself answered. Never a field someone filled in: a verdict that + * arrives as an argument is a saying. + * · `instrument` the digest of the operation as it was when it measured + * ({@see \Milpa\Command\Operation::instrumentDigest()}). What an older instrument + * found does not hold for the one that replaced it. + * · `who`, `at` for the reader of the record. The judge joins by content, not by clock. + * + * WHAT IT IS NOT: proof that the host wrote it. This is a value; whether a receipt is the house's own + * is a question about the ledger it was read from, and it is asked there. + */ +final readonly class ConditionReceipt +{ + /** + * @param array $arguments the arguments the measuring call carried + */ + public function __construct( + public OperationId $by, + public array $arguments, + public string $subject, + public bool $ok, + public string $instrument, + public ?string $who, + public \DateTimeImmutable $at, + ) { + if (trim($subject) === '' || trim($instrument) === '') { + throw new \InvalidArgumentException('a condition receipt is bound to the digest of what was measured and to the digest of what measured it; one without either binds nothing'); + } + } + + /** + * The receipt as data. + * + * @return array{by: string, arguments: array, subject: string, ok: bool, instrument: string, who: ?string, at: string} + */ + public function toArray(): array + { + return [ + 'by' => $this->by->canonical, + 'arguments' => $this->arguments, + 'subject' => $this->subject, + 'ok' => $this->ok, + 'instrument' => $this->instrument, + 'who' => $this->who, + 'at' => $this->at->format(\DATE_ATOM), + ]; + } + + /** + * A receipt back from what a ledger stored, or `null` when the line is not one. + * + * `null` and not an exception: a ledger is read whole, and one line that is not a receipt must not + * make the rest unreadable. What cannot be read simply measures nothing. + * + * @param array $data + */ + public static function fromArray(array $data): ?self + { + $at = \is_string($data['at'] ?? null) ? date_create_immutable($data['at']) : false; + if (!\is_string($data['by'] ?? null) || !\is_array($data['arguments'] ?? null) || !\is_string($data['subject'] ?? null) + || !\is_bool($data['ok'] ?? null) || !\is_string($data['instrument'] ?? null) || $at === false + || trim($data['subject']) === '' || trim($data['instrument']) === '') { + return null; + } + $who = $data['who'] ?? null; + + return new self(new OperationId($data['by']), $data['arguments'], $data['subject'], $data['ok'], $data['instrument'], \is_string($who) ? $who : null, $at); + } +} diff --git a/src/Standing/Falsifier.php b/src/Standing/Falsifier.php new file mode 100644 index 0000000..c4ca52f --- /dev/null +++ b/src/Standing/Falsifier.php @@ -0,0 +1,71 @@ + + * + * @license Apache-2.0 + * + * @link https://github.com/getmilpa/command + */ + +declare(strict_types=1); + +namespace Milpa\Command\Standing; + +/** + * What would show a standing grant was wrong to give, and what watches for it (greenhouse decisions/0615). + * + * A position whose falsifier nothing watches is not granted (greenhouse decisions/0613 §1): a flag + * that is gone looks exactly like a flag that is obeyed. So the falsifier names `watcher` — the + * operation of the house that looks — and `withinSeconds`, how recently it must have said it looked + * and found nothing. A watcher that went quiet is not green: past that window the grant lends nothing. + */ +final readonly class Falsifier +{ + public function __construct( + public string $name, + public string $statement, + public NamedCall $watcher, + public int $withinSeconds, + ) { + if (trim($name) === '' || trim($statement) === '') { + throw new \InvalidArgumentException('a falsifier names itself and says what would show the grant wrong'); + } + if ($withinSeconds < 1) { + throw new \InvalidArgumentException("falsifier «{$name}» needs «within_seconds»: how recently its watcher must have given a sign of life, as a whole number of seconds above zero"); + } + } + + /** + * The falsifier as data. + * + * @return array{name: string, statement: string, watcher: array, within_seconds: int} + */ + public function toArray(): array + { + return ['name' => $this->name, 'statement' => $this->statement, 'watcher' => $this->watcher->toArray(), 'within_seconds' => $this->withinSeconds]; + } + + /** The inverse of {@see self::toArray()}. */ + public static function fromArray(mixed $data): self + { + if (!\is_array($data)) { + throw new \InvalidArgumentException( + 'a standing grant needs a falsifier that something watches: {"name", "statement", "watcher": {"operation": "…"}, "within_seconds"}. ' + . 'A position nobody watches is not granted.', + ); + } + Canonical::only($data, ['name', 'statement', 'watcher', 'within_seconds'], 'the falsifier'); + $name = Canonical::text($data, 'name', 'the falsifier'); + $within = $data['within_seconds'] ?? null; + + return new self( + $name, + Canonical::text($data, 'statement', "falsifier «{$name}»"), + NamedCall::fromArray($data['watcher'] ?? null, "falsifier «{$name}», in «watcher»,"), + \is_int($within) ? $within : 0, + ); + } +} diff --git a/src/Standing/GrantCondition.php b/src/Standing/GrantCondition.php new file mode 100644 index 0000000..389ff74 --- /dev/null +++ b/src/Standing/GrantCondition.php @@ -0,0 +1,58 @@ + + * + * @license Apache-2.0 + * + * @link https://github.com/getmilpa/command + */ + +declare(strict_types=1); + +namespace Milpa\Command\Standing; + +/** + * One condition of a standing grant, and the operation whose result measures it (greenhouse decisions/0615). + * + * A condition nobody can measure does not fit in a grant: it is a sentence, and a sentence depends + * on someone obeying it. So each one names `by` — the operation of the house whose run, over the very + * content the act touches, says whether it holds. The house writes that reading down itself; nothing + * lets a caller hand one in. + */ +final readonly class GrantCondition +{ + public function __construct( + public string $name, + public string $statement, + public NamedCall $by, + ) { + if (trim($name) === '' || trim($statement) === '') { + throw new \InvalidArgumentException('a condition names itself and says what must hold — one without a name or a statement is a claim nobody can check'); + } + } + + /** + * The condition as data. + * + * @return array{name: string, statement: string, by: array} + */ + public function toArray(): array + { + return ['name' => $this->name, 'statement' => $this->statement, 'by' => $this->by->toArray()]; + } + + /** The inverse of {@see self::toArray()}. */ + public static function fromArray(mixed $data): self + { + if (!\is_array($data)) { + throw new \InvalidArgumentException('a condition is {"name", "statement", "by": {"operation": "…"}}'); + } + Canonical::only($data, ['name', 'statement', 'by'], 'a condition'); + $name = Canonical::text($data, 'name', 'a condition'); + + return new self($name, Canonical::text($data, 'statement', "condition «{$name}»"), NamedCall::fromArray($data['by'] ?? null, "condition «{$name}», in «by»,")); + } +} diff --git a/src/Standing/GrantStanding.php b/src/Standing/GrantStanding.php new file mode 100644 index 0000000..20bc1e6 --- /dev/null +++ b/src/Standing/GrantStanding.php @@ -0,0 +1,44 @@ + + * + * @license Apache-2.0 + * + * @link https://github.com/getmilpa/command + */ + +declare(strict_types=1); + +namespace Milpa\Command\Standing; + +/** + * What a host knows about one standing grant at the moment of a call (greenhouse decisions/0615). + * + * FACTS, gathered by the host from its own ledger and its own catalogue, and handed to + * {@see StandingJudge} — which decides nothing about how they were obtained. Read at the moment of + * the act: a state remembered from the start of a session is the defect this exists to close, and a + * stored «valid» would be a grade that survives its evidence (greenhouse evidence/0254). + */ +final readonly class GrantStanding +{ + /** + * @param GrantState $state what the ledger derives for the grant now + * @param string|null $because for a state other than in force: what put it there and who may lift it, in words + * @param string|null $signatureFault why the grant's signature does not hold NOW — or `null`: it was re-verified and it holds + * @param \DateTimeImmutable|null $watcherSignalAt the watcher's last sign of life since the grant was given or last reinstated, or `null`: none + * @param list $receipts every receipt the host holds, in ledger order — the last one about a content says what is known of it now + * @param array $instruments operation (canonical id) => its instrument digest today + */ + public function __construct( + public GrantState $state = GrantState::InForce, + public ?string $because = null, + public ?string $signatureFault = null, + public ?\DateTimeImmutable $watcherSignalAt = null, + public array $receipts = [], + public array $instruments = [], + ) { + } +} diff --git a/src/Standing/GrantState.php b/src/Standing/GrantState.php new file mode 100644 index 0000000..2eba13a --- /dev/null +++ b/src/Standing/GrantState.php @@ -0,0 +1,37 @@ + + * + * @license Apache-2.0 + * + * @link https://github.com/getmilpa/command + */ + +declare(strict_types=1); + +namespace Milpa\Command\Standing; + +/** + * What a host's ledger says of a standing grant right now (greenhouse decisions/0615). + * + * Never a field of the grant: what a person signed is not edited. A host DERIVES this from what was + * appended after the grant — and «expired» is not here because it needs no ledger: the grant carries + * its own date and the judge reads the clock. + */ +enum GrantState: string +{ + /** Nothing after it took it back. */ + case InForce = 'in_force'; + + /** Its falsifier fired, its subject moved under an act, or someone asked for it to stop. Only a person lifts it. */ + case Suspended = 'suspended'; + + /** A later grant, signed by a person, replaced it. */ + case Superseded = 'superseded'; + + /** A person withdrew it. */ + case Revoked = 'revoked'; +} diff --git a/src/Standing/NamedCall.php b/src/Standing/NamedCall.php new file mode 100644 index 0000000..6d15ab9 --- /dev/null +++ b/src/Standing/NamedCall.php @@ -0,0 +1,112 @@ + + * + * @license Apache-2.0 + * + * @link https://github.com/getmilpa/command + */ + +declare(strict_types=1); + +namespace Milpa\Command\Standing; + +use Milpa\Command\Consent\OperationId; + +/** + * An operation, and the arguments a document fixes for it (greenhouse decisions/0615). + * + * A standing grant never says «anything like this»: it names each operation whole, by its identity, + * and the values a call must carry to be the act that was meant. The same shape names the act a + * grant covers, the operation that measures one of its conditions, and the one that watches its + * falsifier — three places, one way of naming a call. + * + * ITS LIMIT IS PART OF THE CONTRACT. A call matches when it is that operation and carries EACH fixed + * argument with exactly that value; arguments the document does not fix are free. That is what lets + * one grant cover a class — the candidate changes on every call — and it is also why an operation + * whose own argument can weaken it must have that argument fixed here, by whoever writes the grant. + */ +final readonly class NamedCall +{ + public OperationId $operation; + + /** @var array */ + public array $arguments; + + /** + * @param array $arguments the values a call must carry, by argument name + */ + public function __construct(OperationId|string $operation, array $arguments = []) + { + $id = $operation instanceof OperationId ? $operation : new OperationId($operation); + if (preg_match('/^[a-z0-9]+(?:-[a-z0-9]+)*(?:\.[a-z0-9]+(?:-[a-z0-9]+)*)*$/', $id->canonical) !== 1) { + throw new \InvalidArgumentException(\sprintf( + '«%s» does not name one operation. A standing grant names each operation whole — no pattern, no wildcard: what is not named is not covered.', + $operation instanceof OperationId ? $operation->canonical : $operation, + )); + } + + $fixed = []; + foreach ($arguments as $name => $value) { + if (!\is_string($name) || trim($name) === '' || !(\is_string($value) || \is_int($value) || \is_bool($value))) { + throw new \InvalidArgumentException(\sprintf( + 'an argument fixed for «%s» is a name and one plain value — text, a whole number, or true/false; got %s', + $id->canonical, + json_encode([$name => $value]) ?: get_debug_type($value), + )); + } + $fixed[$name] = $value; + } + ksort($fixed); + + $this->operation = $id; + $this->arguments = $fixed; + } + + /** + * Whether a call is the one this names: that operation, carrying every fixed argument with that value. + * + * @param array $arguments + */ + public function matches(OperationId|string $operation, array $arguments): bool + { + if (!$this->operation->is($operation)) { + return false; + } + foreach ($this->arguments as $name => $value) { + if (!\array_key_exists($name, $arguments) || $arguments[$name] !== $value) { + return false; + } + } + + return true; + } + + /** + * The name as data. `arguments` appears only when something is fixed, so one call has one spelling. + * + * @return array{operation: string, arguments?: array} + */ + public function toArray(): array + { + return ['operation' => $this->operation->canonical] + ($this->arguments === [] ? [] : ['arguments' => $this->arguments]); + } + + /** The inverse of {@see self::toArray()}; `$where` names the place in the document, for the refusal. */ + public static function fromArray(mixed $data, string $where): self + { + if (!\is_array($data) || !\is_string($data['operation'] ?? null)) { + throw new \InvalidArgumentException(\sprintf('%s names an operation: {"operation": "…"} and, when it fixes any, {"arguments": {…}}', $where)); + } + Canonical::only($data, ['operation', 'arguments'], $where); + $arguments = $data['arguments'] ?? []; + if (!\is_array($arguments)) { + throw new \InvalidArgumentException(\sprintf('%s fixes its arguments by name: {"arguments": {"name": value}}', $where)); + } + + return new self($data['operation'], $arguments); + } +} diff --git a/src/Standing/StandingCall.php b/src/Standing/StandingCall.php new file mode 100644 index 0000000..674ac81 --- /dev/null +++ b/src/Standing/StandingCall.php @@ -0,0 +1,48 @@ + + * + * @license Apache-2.0 + * + * @link https://github.com/getmilpa/command + */ + +declare(strict_types=1); + +namespace Milpa\Command\Standing; + +use Milpa\Command\Consent\OperationId; +use Milpa\Command\Effect\EffectProfile; + +/** + * The call a standing grant is asked about, as facts (greenhouse decisions/0615). + * + * Each one is the host's reading, never the caller's word: `subject` is what + * {@see \Milpa\Command\Operation::actsOnDigest()} computed from the bytes, and `effect` is what + * {@see \Milpa\Command\Operation::ceilingForCall()} composed for these arguments. + */ +final readonly class StandingCall +{ + public OperationId $operation; + + /** + * @param array $arguments the arguments of the call, as the handler will receive them + * @param string $seat who calls, by the key the grant would name it by + * @param string|null $subject the digest of the content this call acts on, or `null` when the operation cannot say + * @param EffectProfile $effect the ceiling THIS call carries + * @param \DateTimeImmutable $at the moment of the act + */ + public function __construct( + OperationId|string $operation, + public array $arguments, + public string $seat, + public ?string $subject, + public EffectProfile $effect, + public \DateTimeImmutable $at, + ) { + $this->operation = $operation instanceof OperationId ? $operation : new OperationId($operation); + } +} diff --git a/src/Standing/StandingCheck.php b/src/Standing/StandingCheck.php new file mode 100644 index 0000000..a6bf1be --- /dev/null +++ b/src/Standing/StandingCheck.php @@ -0,0 +1,47 @@ + + * + * @license Apache-2.0 + * + * @link https://github.com/getmilpa/command + */ + +declare(strict_types=1); + +namespace Milpa\Command\Standing; + +/** + * The seven things a call must pass for a standing grant to lend it anything, in the order they are + * asked (greenhouse decisions/0615 §3). + * + * They are an enum and not seven booleans because the ways of failing are not interchangeable for + * whoever reads the refusal: «suspended» is a person's to lift, «not measured» is the caller's to + * fix, «above the ceiling» is nobody's to fix under this grant. A refusal names the one that failed. + */ +enum StandingCheck: string +{ + /** Does the grant name this operation, these arguments and this seat? If not, it is not this grant's call. */ + case Named = 'named'; + + /** Does the grant's signature hold NOW — bytes unaltered, the signer still entitled to give it? */ + case Signature = 'signature'; + + /** Is it in force — not suspended, superseded, revoked, or past its own date? */ + case InForce = 'in_force'; + + /** Did its falsifier's watcher give a sign of life within its window? */ + case Watched = 'watched'; + + /** Does the operation say what content this call acts on? */ + case Subject = 'subject'; + + /** Does the effect of THIS call fit under the grant's ceiling? */ + case Ceiling = 'ceiling'; + + /** Does every condition have a green receipt, on this very content, from today's instrument? */ + case Conditions = 'conditions'; +} diff --git a/src/Standing/StandingGrant.php b/src/Standing/StandingGrant.php new file mode 100644 index 0000000..d25c600 --- /dev/null +++ b/src/Standing/StandingGrant.php @@ -0,0 +1,310 @@ + + * + * @license Apache-2.0 + * + * @link https://github.com/getmilpa/command + */ + +declare(strict_types=1); + +namespace Milpa\Command\Standing; + +use Milpa\Command\Consent\OperationId; +use Milpa\Command\Effect\EffectProfile; +use Milpa\Command\Effect\Reversibility; + +/** + * A STANDING GRANT: what a person signs once so that a seat may repeat an act without asking — under + * a ceiling, on conditions, and only while its falsifier stays quiet (greenhouse decisions/0613, 0615). + * + * ── WHAT IT REPLACES ──────────────────────────────────────────────────────────────────────────── + * + * Not the signature on each call. Measured on the published family (greenhouse decisions/0615): what + * makes an act «the person's» is the scope a seat lacks, and the only way to let a seat repeat that + * act was to hand it the scope whole — no ceiling, no condition, nothing to take it back when the + * evidence changes. A grant is narrower than that on every count, and it is the first thing in the + * family that can say «only this candidate, and only measured». + * + * ── WHAT IT CARRIES, AND WHY EACH PART ────────────────────────────────────────────────────────── + * + * · `to` the seats it lends to. Never «anyone». + * · `covers` the operations it names, with the arguments it fixes. A positive, closed list: what + * is not named is not covered, and nothing here is a pattern. + * · `ceiling` the widest effect a covered call may have, on every axis. + * · `conditions` what must have been MEASURED on the very content an act touches, each by the + * operation that measures it. + * · `undo` how an absorbed act is taken back, in words, for the record of each one. + * · `falsifier` what would show the grant wrong, and the operation that watches for it. + * · `expiresAt` optional; it only ever narrows. + * + * ── THE FLOORS NO GRANT CROSSES (Rod, 2026-10-11) ─────────────────────────────────────────────── + * + * Refused HERE, at construction, so that no such document can exist to be signed: + * + * · a ceiling with an unclassified axis — measured: an unclassified ceiling admits everything; + * · a ceiling above «compensatable» — a grant never lends what cannot at least be compensated; + * · covering any `standing.*` operation — nothing a grant lends may give, restore or withdraw a grant; + * · no condition at all — that is the scope handed over whole, with a watcher; + * · a falsifier nothing watches — {@see Falsifier}. + * + * The other half of the first floor — never an operation that gives authority by the HOST's own words — + * needs the host's catalogue and is asked there. + * + * ── WHAT IT CANNOT SAY ────────────────────────────────────────────────────────────────────────── + * + * MONEY. There is no axis for it, so no ceiling here can bound what a call spends, and nothing in an + * operation's declaration says that it spends. «Do not grant over an operation that spends» is a rule + * for whoever signs, not something this class or any judge of it can hold. + * + * ── WHAT IT IS NOT ────────────────────────────────────────────────────────────────────────────── + * + * An authority. This is the document; whether a person signed it, whether it is still in force and + * whether a given call passes are asked elsewhere ({@see StandingJudge}), with facts a host gathers. + * Its state is never a field of it: what was signed is not edited. + */ +final readonly class StandingGrant +{ + /** The highest reversibility a ceiling may carry. */ + public const Reversibility CEILING_REVERSIBILITY = Reversibility::Compensatable; + + private const ID = '/^[a-z0-9][a-z0-9._-]{0,62}$/'; + + private const KEYS = ['id', 'supersedes', 'to', 'covers', 'ceiling', 'conditions', 'undo', 'falsifier', 'expires_at']; + + /** @var list */ + public array $to; + + /** @var list */ + public array $covers; + + /** @var list */ + public array $conditions; + + /** + * @param array $to the seats it lends to, each by the key the host knows it by + * @param array $covers the calls it names + * @param array $conditions what must be measured on the content an act touches + */ + public function __construct( + public string $id, + array $to, + array $covers, + public EffectProfile $ceiling, + array $conditions, + public string $undo, + public Falsifier $falsifier, + public ?string $supersedes = null, + public ?\DateTimeImmutable $expiresAt = null, + ) { + if (preg_match(self::ID, $id) !== 1) { + throw new \InvalidArgumentException("a standing grant is known by a short id — lowercase letters, digits, dot, dash or underscore, e.g. «m-01»; got «{$id}»"); + } + if ($supersedes !== null && (preg_match(self::ID, $supersedes) !== 1 || $supersedes === $id)) { + throw new \InvalidArgumentException("standing grant «{$id}» supersedes another grant by its id, and never itself; got «{$supersedes}»"); + } + + $seats = []; + foreach ($to as $seat) { + if (!\is_string($seat) || trim($seat) === '' || preg_match('/[\s*]/', $seat) === 1) { + throw new \InvalidArgumentException("standing grant «{$id}» lends to the seats it names, each by its key — never to «anyone», and never to a pattern"); + } + $seats[$seat] = true; + } + if ($seats === []) { + throw new \InvalidArgumentException("standing grant «{$id}» lends to nobody: name at least one seat in «to»"); + } + $seats = array_map(strval(...), array_keys($seats)); + sort($seats); + + if ($covers === []) { + throw new \InvalidArgumentException("standing grant «{$id}» covers nothing: name at least one operation in «covers»"); + } + foreach ($covers as $act) { + if (!$act instanceof NamedCall) { + throw new \InvalidArgumentException("standing grant «{$id}» covers named calls, one by one"); + } + if ($act->operation->canonical === 'standing' || str_starts_with($act->operation->canonical, 'standing.')) { + throw new \InvalidArgumentException(\sprintf( + 'standing grant «%s» cannot cover «%s»: nothing a grant lends may give, restore, withdraw or stop a grant. Those stay acts of a person, or of anyone who only narrows.', + $id, + $act->operation->canonical, + )); + } + } + + if (!$ceiling->isFullyClassified()) { + throw new \InvalidArgumentException("standing grant «{$id}» leaves an axis of its ceiling unclassified, and an unclassified ceiling admits everything: classify mutation, externality, reversibility, authority and subject"); + } + if ($ceiling->reversibility->weight() > self::CEILING_REVERSIBILITY->weight()) { + throw new \InvalidArgumentException(\sprintf( + 'standing grant «%s» sets a ceiling of reversibility «%s». A standing grant never lends what cannot at least be compensated: the highest it carries is «%s». An act above it stays a person\'s, call by call.', + $id, + $ceiling->reversibility->value, + self::CEILING_REVERSIBILITY->value, + )); + } + + if ($conditions === []) { + throw new \InvalidArgumentException("standing grant «{$id}» asks nothing of what an act touches: without a condition it is the scope handed over whole. Name at least one, with the operation that measures it"); + } + $names = []; + foreach ($conditions as $condition) { + if (!$condition instanceof GrantCondition || isset($names[$condition->name])) { + throw new \InvalidArgumentException("standing grant «{$id}» lists its conditions one by one, each under its own name"); + } + $names[$condition->name] = true; + } + + if (trim($undo) === '') { + throw new \InvalidArgumentException("standing grant «{$id}» needs «undo»: how an act absorbed under it is taken back, in words"); + } + + $this->to = $seats; + $this->covers = array_values($covers); + $this->conditions = array_values($conditions); + } + + /** Whether this grant lends to that seat, by the key exactly as the grant spells it. */ + public function lendsTo(string $seat): bool + { + return \in_array($seat, $this->to, true); + } + + /** Whether this grant names that operation at all, whatever arguments it fixes for it. */ + public function namesOperation(OperationId|string $operation): bool + { + foreach ($this->covers as $act) { + if ($act->operation->is($operation)) { + return true; + } + } + + return false; + } + + /** + * The named call that covers this one, or `null` when none does. + * + * @param array $arguments + */ + public function covering(OperationId|string $operation, array $arguments): ?NamedCall + { + foreach ($this->covers as $act) { + if ($act->matches($operation, $arguments)) { + return $act; + } + } + + return null; + } + + /** + * The grant as data — the document a person reads and signs. + * + * @return array + */ + public function toArray(): array + { + return [ + 'id' => $this->id, + 'supersedes' => $this->supersedes, + 'to' => $this->to, + 'covers' => array_map(static fn (NamedCall $act): array => $act->toArray(), $this->covers), + 'ceiling' => [ + 'mutation' => $this->ceiling->mutation->value, + 'externality' => $this->ceiling->externality->value, + 'reversibility' => $this->ceiling->reversibility->value, + 'authority' => $this->ceiling->authority->value, + 'subject' => $this->ceiling->subject->value, + ] + ($this->ceiling->rollbackContract === null ? [] : ['rollback_contract' => $this->ceiling->rollbackContract]), + 'conditions' => array_map(static fn (GrantCondition $condition): array => $condition->toArray(), $this->conditions), + 'undo' => $this->undo, + 'falsifier' => $this->falsifier->toArray(), + 'expires_at' => $this->expiresAt?->setTimezone(new \DateTimeZone('UTC'))->format('Y-m-d\TH:i:s\Z'), + ]; + } + + /** The exact bytes that identify this grant — one spelling, whatever order its author wrote it in. */ + public function canonical(): string + { + return Canonical::json($this->toArray()); + } + + /** `sha256:` of {@see self::canonical()}: what a record of this grant is checked against. */ + public function digest(): string + { + return 'sha256:' . hash('sha256', $this->canonical()); + } + + /** + * A grant from the document a person wrote. Every refusal names what is missing or what is not read. + * + * @param array $data + */ + public static function fromArray(array $data): self + { + Canonical::only($data, self::KEYS, 'a standing grant'); + $id = Canonical::text($data, 'id', 'a standing grant'); + $where = "standing grant «{$id}»"; + + $ceiling = $data['ceiling'] ?? null; + if (!\is_array($ceiling)) { + throw new \InvalidArgumentException("{$where} needs «ceiling»: the widest effect a covered call may have, on all five axes"); + } + Canonical::only($ceiling, ['mutation', 'externality', 'reversibility', 'authority', 'subject', 'rollback_contract'], "the ceiling of {$where}"); + + $expires = $data['expires_at'] ?? null; + $expiresAt = \is_string($expires) ? date_create_immutable($expires) : null; + if ($expires !== null && !$expiresAt instanceof \DateTimeImmutable) { + throw new \InvalidArgumentException("{$where} gives «expires_at» as a date and time, e.g. 2026-12-31T00:00:00Z"); + } + + $supersedes = $data['supersedes'] ?? null; + if ($supersedes !== null && !\is_string($supersedes)) { + throw new \InvalidArgumentException("{$where} supersedes another grant by its id"); + } + + return new self( + id: $id, + to: self::listOf($data, 'to', $where), + covers: array_map(static fn (mixed $act): NamedCall => NamedCall::fromArray($act, "{$where}, in «covers»,"), self::listOf($data, 'covers', $where)), + ceiling: EffectProfile::fromArray($ceiling), + conditions: array_map(GrantCondition::fromArray(...), self::listOf($data, 'conditions', $where)), + undo: Canonical::text($data, 'undo', $where), + falsifier: Falsifier::fromArray($data['falsifier'] ?? null), + supersedes: $supersedes, + expiresAt: $expiresAt, + ); + } + + /** A grant from its JSON text. */ + public static function fromJson(string $json): self + { + $data = json_decode($json, true); + if (!\is_array($data) || array_is_list($data)) { + throw new \InvalidArgumentException('a standing grant is one JSON object: {"id", "to", "covers", "ceiling", "conditions", "undo", "falsifier"}'); + } + + return self::fromArray($data); + } + + /** + * @param array $data + * + * @return list + */ + private static function listOf(array $data, string $key, string $where): array + { + $value = $data[$key] ?? null; + if (!\is_array($value) || !array_is_list($value)) { + throw new \InvalidArgumentException("{$where} gives «{$key}» as a list"); + } + + return $value; + } +} diff --git a/src/Standing/StandingJudge.php b/src/Standing/StandingJudge.php new file mode 100644 index 0000000..e1af58f --- /dev/null +++ b/src/Standing/StandingJudge.php @@ -0,0 +1,183 @@ + + * + * @license Apache-2.0 + * + * @link https://github.com/getmilpa/command + */ + +declare(strict_types=1); + +namespace Milpa\Command\Standing; + +use Milpa\Command\Effect\EffectProfile; + +/** + * THE JUDGMENT: does this standing grant lend anything to this call, right now? (greenhouse decisions/0615 §3) + * + * Pure. It reads a grant, what a host knows of it at this moment and the call as facts, and answers. + * It opens no file and asks no clock but the one it is handed — so every way it can fail is a case a + * test can write down, and a host in any language can be held to the same seven questions. + * + * ── WHAT IT DOES NOT DO ───────────────────────────────────────────────────────────────────────── + * + * Grant. Widen. Restore. It refuses or lets pass, and when the grant does not name the call it says + * exactly that and nothing else: the call is then judged as it was before any grant existed. + * Growing back is a person's new signature and happens somewhere else entirely. + * + * ── WHY THE ORDER ─────────────────────────────────────────────────────────────────────────────── + * + * Whose refusal it is comes first. A grant that is suspended is the PERSON's to lift, and saying so + * matters more than telling the caller its candidate was not measured: measuring would not help. Only + * a grant that stands asks what the caller can actually fix — the subject, the ceiling, the conditions. + * + * ── THE ONE COMPARATOR ────────────────────────────────────────────────────────────────────────── + * + * The ceiling is asked with {@see EffectProfile::isNoWiderThan()} and with nothing else. A second + * ordering here would be the duplicate judge: the day the two disagree, whichever one a reader + * happens to consult decides authority. + */ +final class StandingJudge +{ + /** The seven checks, in order; the first that fails is the verdict. */ + public static function judge(StandingGrant $grant, GrantStanding $standing, StandingCall $call): StandingVerdict + { + $id = $grant->id; + $act = $grant->lendsTo($call->seat) ? $grant->covering($call->operation, $call->arguments) : null; + if ($act === null) { + return StandingVerdict::refuse(StandingCheck::Named, 'not_named', "Standing grant «{$id}» does not name this call."); + } + $operation = $call->operation->forCli(); + + if ($standing->signatureFault !== null) { + return StandingVerdict::refuse(StandingCheck::Signature, 'signature', \sprintf( + 'Standing grant «%s» names this call and is not honoured: %s. Nothing is lent under it. A person gives it again with a new signature; no seat can.', + $id, + $standing->signatureFault, + )); + } + + if ($standing->state !== GrantState::InForce) { + return StandingVerdict::refuse(StandingCheck::InForce, $standing->state->value, \sprintf( + 'Standing grant «%s» is %s%s. %s', + $id, + $standing->state->value, + $standing->because === null ? '' : ' — ' . $standing->because, + $standing->state === GrantState::Suspended + ? 'While it is, every act it covers is the person\'s again, this one included: a person lifts it with a new signature that names this suspension. No seat can, and neither can the house.' + : 'It lends nothing, and nothing brings it back: a person gives a new grant.', + )); + } + if ($grant->expiresAt !== null && $call->at >= $grant->expiresAt) { + return StandingVerdict::refuse(StandingCheck::InForce, 'expired', \sprintf( + 'Standing grant «%s» expired at %s. It lends nothing, and nothing brings it back: a person gives a new grant.', + $id, + $grant->expiresAt->format(\DATE_ATOM), + )); + } + + $watcher = $grant->falsifier->watcher->operation->forCli(); + $signal = $standing->watcherSignalAt; + if ($signal === null) { + return StandingVerdict::refuse(StandingCheck::Watched, 'never_watched', \sprintf( + 'Standing grant «%s» is not being watched: `%s`, which watches its falsifier «%s», has given no sign of life since the grant was given or last restored. No sign is not green. Run `%s`.', + $id, + $watcher, + $grant->falsifier->name, + $watcher, + )); + } + $age = $call->at->getTimestamp() - $signal->getTimestamp(); + if ($age < 0 || $age > $grant->falsifier->withinSeconds) { + return StandingVerdict::refuse(StandingCheck::Watched, 'watcher_quiet', \sprintf( + 'Standing grant «%s» is not being watched: `%s`, which watches its falsifier «%s», last gave a sign of life at %s, and the grant asks for one within %d seconds. A watcher that went quiet is not green. Run `%s`.', + $id, + $watcher, + $grant->falsifier->name, + $signal->format(\DATE_ATOM), + $grant->falsifier->withinSeconds, + $watcher, + )); + } + + if ($call->subject === null) { + return StandingVerdict::refuse(StandingCheck::Subject, 'no_subject', \sprintf( + 'Standing grant «%s» lends nothing to this call: `%s` did not say what content the call acts on, so no condition can be bound to it. Not being able to look is not having looked.', + $id, + $operation, + )); + } + + if (!$call->effect->isNoWiderThan($grant->ceiling)) { + return StandingVerdict::refuse(StandingCheck::Ceiling, 'above_ceiling', \sprintf( + 'Standing grant «%s» lends nothing to this call: its effect (%s) is wider than the grant\'s ceiling (%s). No condition measured on the candidate changes that — an act above the ceiling is a person\'s, call by call.', + $id, + self::axes($call->effect), + self::axes($grant->ceiling), + )); + } + + $cited = []; + foreach ($grant->conditions as $condition) { + $measuring = $condition->by->operation->forCli(); + $last = null; + foreach ($standing->receipts as $receipt) { + if ($receipt->subject === $call->subject && $condition->by->matches($receipt->by, $receipt->arguments)) { + $last = $receipt; + } + } + if ($last === null) { + return StandingVerdict::refuse(StandingCheck::Conditions, 'not_measured', \sprintf( + 'Standing grant «%s» lends nothing to this call: condition «%s» was never measured on this candidate (%s). Run `%s` on it first — a measurement taken on another candidate, or after the act, is not this one.', + $id, + $condition->name, + $call->subject, + $measuring, + )); + } + if (($standing->instruments[$last->by->canonical] ?? null) !== $last->instrument) { + return StandingVerdict::refuse(StandingCheck::Conditions, 'instrument_changed', \sprintf( + 'Standing grant «%s» lends nothing to this call: condition «%s» was measured on this candidate by `%s` as it was then, and it is not that instrument any more. What the old one found does not hold for the new one. Run `%s` again.', + $id, + $condition->name, + $measuring, + $measuring, + )); + } + if (!$last->ok) { + return StandingVerdict::refuse(StandingCheck::Conditions, 'measured_red', \sprintf( + 'Standing grant «%s» lends nothing to this call: condition «%s» does not hold on this candidate (%s) — `%s` measured it and said no.', + $id, + $condition->name, + $call->subject, + $measuring, + )); + } + $cited[] = $last; + } + + return StandingVerdict::pass(\sprintf( + 'Standing grant «%s» covers this call: %d condition(s) measured green on %s, its effect under the ceiling, its falsifier watched.', + $id, + \count($cited), + $call->subject, + ), $cited); + } + + /** A profile's five axes as a reader sees them — a description, never a comparison. */ + private static function axes(EffectProfile $profile): string + { + return \sprintf( + 'mutation «%s», externality «%s», reversibility «%s», authority «%s», subject «%s»', + $profile->mutation->value, + $profile->externality->value, + $profile->reversibility->value, + $profile->authority->value, + $profile->subject->value, + ); + } +} diff --git a/src/Standing/StandingVerdict.php b/src/Standing/StandingVerdict.php new file mode 100644 index 0000000..f49a417 --- /dev/null +++ b/src/Standing/StandingVerdict.php @@ -0,0 +1,67 @@ + + * + * @license Apache-2.0 + * + * @link https://github.com/getmilpa/command + */ + +declare(strict_types=1); + +namespace Milpa\Command\Standing; + +/** + * What the judge said of one call under one grant (greenhouse decisions/0615). + * + * Three answers, and they must not be confused: the grant does not name this call (it is nobody's + * refusal — the call goes on to whatever judged it before); it names the call and refuses it; it names + * the call and lets it pass. There is no fourth: a verdict never grants, widens or restores anything. + * + * `reason` is the stable word for the failure, for a host that words its own refusals or translates + * them; `sentence` is the English one, written for whoever is reading the refusal. + */ +final readonly class StandingVerdict +{ + /** + * @param list $cited the receipts a passing call ran on, one per condition + */ + private function __construct( + public ?StandingCheck $failed, + public string $reason, + public string $sentence, + public array $cited = [], + ) { + } + + /** + * The call passes, on these receipts. + * + * @param list $cited + */ + public static function pass(string $sentence, array $cited): self + { + return new self(null, 'passed', $sentence, $cited); + } + + /** The call stops at this check. */ + public static function refuse(StandingCheck $failed, string $reason, string $sentence): self + { + return new self($failed, $reason, $sentence); + } + + /** Whether every check passed. */ + public function passes(): bool + { + return $this->failed === null; + } + + /** Whether the grant names this call at all — false means it is not this grant's to judge. */ + public function named(): bool + { + return $this->failed !== StandingCheck::Named; + } +} diff --git a/tests/AnOperationSaysWhatItActsOnTest.php b/tests/AnOperationSaysWhatItActsOnTest.php new file mode 100644 index 0000000..12c90db --- /dev/null +++ b/tests/AnOperationSaysWhatItActsOnTest.php @@ -0,0 +1,108 @@ +candidate = (string) tempnam(sys_get_temp_dir(), 'candidate'); + file_put_contents($this->candidate, 'the candidate, byte for byte'); + } + + protected function tearDown(): void + { + @unlink($this->candidate); + } + + private function operation(?\Closure $actsOn, ?\Closure $handler = null, ?array $scopes = null): Operation + { + return new Operation( + name: 'lab:scan', + description: 'Lab only', + handler: $handler ?? static fn (array $input): array => ['ok' => true], + scopes: $scopes ?? ['lab:scan'], + effects: EffectProfile::readOnly(), + actsOn: $actsOn, + ); + } + + private static function sha256OfThePath(): \Closure + { + return static fn (array $arguments): ?string => 'sha256:' . hash_file('sha256', (string) $arguments['path']); + } + + public function testAnOperationThatDeclaresNothingActsOnNothingItCanName(): void + { + self::assertNull($this->operation(null)->actsOnDigest(['path' => $this->candidate])); + } + + public function testTheDigestComesFromTheBytesAndFollowsThem(): void + { + $operation = $this->operation(self::sha256OfThePath()); + $before = $operation->actsOnDigest(['path' => $this->candidate]); + + self::assertSame('sha256:' . hash('sha256', 'the candidate, byte for byte'), $before); + self::assertSame($before, $operation->actsOnDigest(['path' => $this->candidate]), 'the same bytes, the same digest'); + + file_put_contents($this->candidate, 'the candidate, byte for bytE'); + self::assertNotSame($before, $operation->actsOnDigest(['path' => $this->candidate]), 'one byte apart is another candidate'); + } + + public function testEveryWayOfNotKnowingIsNull(): void + { + $missing = $this->operation(static function (array $arguments): string { + throw new \RuntimeException('there is no such candidate'); + }); + + self::assertNull($missing->actsOnDigest(['path' => '/nowhere']), 'a declaration that could not look'); + self::assertNull($this->operation(static fn (array $arguments): ?string => null)->actsOnDigest([]), 'a declaration that answered nothing'); + self::assertNull($this->operation(static fn (array $arguments): string => ' ')->actsOnDigest([]), 'a blank digest is no digest'); + self::assertSame('sha256:abc', $this->operation(static fn (array $arguments): string => " sha256:abc\n")->actsOnDigest([])); + } + + public function testAnInstrumentIsPinnedByItsDeclarationItsHandlerAndWhatItSaysItActsOn(): void + { + $actsOn = self::sha256OfThePath(); + $instrument = $this->operation($actsOn)->instrumentDigest(); + + self::assertIsString($instrument); + self::assertStringStartsWith('sha256:', $instrument); + self::assertSame($instrument, $this->operation($actsOn)->instrumentDigest(), 'the same operation, the same instrument'); + + // A guard that did not see, hardened afterwards, is another instrument. + $hardened = $this->operation($actsOn, static fn (array $input): array => ['ok' => false, 'found' => 3]); + self::assertNotSame($instrument, $hardened->instrumentDigest()); + + // And so is one that reads other bytes, or declares another reach. + self::assertNotSame($instrument, $this->operation(static fn (array $arguments): string => 'sha256:' . hash('sha256', 'something else'))->instrumentDigest()); + self::assertNotSame($instrument, $this->operation($actsOn, scopes: ['lab:scan', 'lab:read'])->instrumentDigest()); + } + + public function testWhatCannotBePinnedIsNoInstrument(): void + { + self::assertNull($this->operation(null)->instrumentDigest(), 'it measures nothing in particular'); + + $unreadable = new Operation('lab:scan', 'Lab only', ['App\\Lab\\Scanner', 'scan'], actsOn: self::sha256OfThePath()); + self::assertNull($unreadable->instrumentDigest(), 'its handler cannot be read'); + + $internal = new Operation('lab:scan', 'Lab only', static fn (array $input): array => [], actsOn: strtoupper(...)); + self::assertNull($internal->instrumentDigest(), 'what says what it acts on cannot be read'); + } +} diff --git a/tests/Standing/AConditionReceiptBindsContentAndInstrumentTest.php b/tests/Standing/AConditionReceiptBindsContentAndInstrumentTest.php new file mode 100644 index 0000000..1d7615a --- /dev/null +++ b/tests/Standing/AConditionReceiptBindsContentAndInstrumentTest.php @@ -0,0 +1,53 @@ + 'candidate.tar', 'strict' => true]); + $again = ConditionReceipt::fromArray($receipt->toArray()); + + self::assertEquals($receipt, $again); + self::assertSame('lab.scan', $receipt->toArray()['by']); + self::assertSame(Grants::CANDIDATE, $again?->subject); + self::assertTrue($again?->ok); + } + + public function testAReceiptThatBindsNothingCannotBeWritten(): void + { + foreach ([[' ', Grants::INSTRUMENT], [Grants::CANDIDATE, '']] as [$subject, $instrument]) { + try { + new ConditionReceipt(new OperationId('lab:scan'), [], $subject, true, $instrument, null, Grants::at()); + self::fail('a receipt bound to nothing was written'); + } catch (\InvalidArgumentException $error) { + self::assertStringContainsString('binds nothing', $error->getMessage()); + } + } + } + + public function testALedgerLineThatIsNotAReceiptMeasuresNothing(): void + { + $stored = Grants::receipt()->toArray(); + self::assertNotNull(ConditionReceipt::fromArray($stored)); + self::assertNull(ConditionReceipt::fromArray(['who' => 7] + $stored)?->who, 'a who that is not text is nobody'); + + foreach ([ + ['by' => 7], ['arguments' => 'path'], ['subject' => null], ['subject' => ' '], ['ok' => 'yes'], + ['ok' => 1], ['instrument' => null], ['instrument' => ''], ['at' => 'no date'], ['at' => 7], + ] as $broken) { + self::assertNull(ConditionReceipt::fromArray($broken + $stored), (string) json_encode($broken)); + } + self::assertNull(ConditionReceipt::fromArray([])); + } +} diff --git a/tests/Standing/AStandingGrantNamesWhatItCoversTest.php b/tests/Standing/AStandingGrantNamesWhatItCoversTest.php new file mode 100644 index 0000000..9946cc4 --- /dev/null +++ b/tests/Standing/AStandingGrantNamesWhatItCoversTest.php @@ -0,0 +1,115 @@ + ['SEAT-B', Grants::SEAT, 'SEAT-B']]); + + self::assertSame(['SEAT-B', Grants::SEAT], $grant->to, 'a seat named twice is named once, and the order is not part of the grant'); + self::assertTrue($grant->lendsTo('SEAT-B')); + self::assertFalse($grant->lendsTo('seat-b')); + self::assertFalse($grant->lendsTo('SEAT-C')); + } + + public function testItNamesAnOperationHoweverASurfaceSpellsIt(): void + { + $grant = Grants::grant(); + + self::assertTrue($grant->namesOperation('lab:publish')); + self::assertTrue($grant->namesOperation('lab_publish')); + self::assertTrue($grant->namesOperation(new OperationId('lab.publish'))); + self::assertFalse($grant->namesOperation('lab:merge')); + } + + public function testACallIsCoveredWhenItCarriesEveryFixedArgumentWithThatValue(): void + { + $grant = Grants::grant(['covers' => [ + ['operation' => 'lab:publish', 'arguments' => ['channel' => 'stable', 'dry' => false, 'retries' => 2]], + ['operation' => 'lab:merge'], + ]]); + $exact = ['channel' => 'stable', 'dry' => false, 'retries' => 2, 'path' => 'candidate.tar']; + + self::assertSame('lab.publish', $grant->covering('lab_publish', $exact)?->operation->canonical); + self::assertNull($grant->covering('lab:publish', ['channel' => 'beta'] + $exact)); + self::assertNull($grant->covering('lab:publish', ['dry' => true] + $exact)); + self::assertNull($grant->covering('lab:publish', ['retries' => '2'] + $exact), 'the text «2» is not the number 2'); + self::assertNull($grant->covering('lab:publish', ['channel' => 'stable']), 'an argument the grant fixes and the call omits'); + self::assertNotNull($grant->covering('lab:merge', ['anything' => 'at all']), 'what a grant does not fix is free'); + self::assertNull($grant->covering('lab:scan', $exact)); + } + + public function testANamedCallHasOneSpelling(): void + { + self::assertSame(['operation' => 'lab.scan'], (new NamedCall('lab:scan'))->toArray()); + self::assertSame( + ['operation' => 'lab.scan', 'arguments' => ['a' => 1, 'b' => true]], + NamedCall::fromArray(['operation' => 'lab_scan', 'arguments' => ['b' => true, 'a' => 1]], 'here')->toArray(), + ); + } + + public function testItsBytesDoNotDependOnTheOrderItWasWrittenIn(): void + { + $document = Grants::document(['supersedes' => 'the-one-before', 'expires_at' => '2026-12-31T18:00:00-06:00']); + $reordered = array_reverse($document, true); + $reordered['ceiling'] = array_reverse($document['ceiling'], true); + + $grant = StandingGrant::fromArray($document); + $same = StandingGrant::fromArray($reordered); + + self::assertSame($grant->canonical(), $same->canonical()); + self::assertSame($grant->digest(), $same->digest()); + self::assertSame('sha256:' . hash('sha256', $grant->canonical()), $grant->digest()); + self::assertSame('2027-01-01T00:00:00Z', $grant->toArray()['expires_at'], 'one instant, one spelling'); + self::assertSame('the-one-before', $grant->supersedes); + } + + public function testItReadsBackWhatItWrote(): void + { + $grant = Grants::grant(['expires_at' => '2026-12-31T00:00:00Z']); + $again = StandingGrant::fromJson($grant->canonical()); + + self::assertSame($grant->digest(), $again->digest()); + self::assertEquals($grant, $again); + } + + public function testChangingAnythingAPersonSignedChangesItsDigest(): void + { + $digest = Grants::grant()->digest(); + + foreach ([ + ['to' => [Grants::SEAT, 'SEAT-B']], + ['covers' => [['operation' => 'lab:publish']]], + ['ceiling' => ['externality' => 'third_party'] + Grants::document()['ceiling']], + ['undo' => 'withdraw it'], + ['expires_at' => '2026-12-31T00:00:00Z'], + ['supersedes' => 'older'], + ] as $change) { + self::assertNotSame($digest, Grants::grant($change)->digest(), (string) json_encode($change)); + } + } + + public function testACeilingThatPromisesAnInverseKeepsTheOperationItNames(): void + { + $ceiling = ['reversibility' => 'guaranteed', 'rollback_contract' => 'lab.withdraw'] + Grants::document()['ceiling']; + $grant = Grants::grant(['ceiling' => $ceiling]); + + self::assertSame('lab.withdraw', $grant->toArray()['ceiling']['rollback_contract']); + self::assertSame($grant->digest(), StandingGrant::fromJson($grant->canonical())->digest()); + } +} diff --git a/tests/Standing/AStandingGrantNeverCrossesItsFloorsTest.php b/tests/Standing/AStandingGrantNeverCrossesItsFloorsTest.php new file mode 100644 index 0000000..c1c052d --- /dev/null +++ b/tests/Standing/AStandingGrantNeverCrossesItsFloorsTest.php @@ -0,0 +1,146 @@ +id); + self::assertSame([Grants::SEAT], $grant->to); + self::assertNull($grant->supersedes); + self::assertNull($grant->expiresAt); + } + + /** + * @param array $change + */ + #[DataProvider('documentsThatAreRefused')] + public function testItIsRefusedAndTheRefusalNamesWhy(array $change, string $says): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage($says); + + Grants::grant($change); + } + + /** @return iterable, string}> */ + public static function documentsThatAreRefused(): iterable + { + $ceiling = Grants::document()['ceiling']; + + // The two floors Rod decided on 2026-10-11, and the measured one behind the second. + yield 'a ceiling that admits the irreversible' => [['ceiling' => ['reversibility' => 'irreversible'] + $ceiling], 'the highest it carries is «compensatable»']; + yield 'a ceiling that admits what only a person can recover' => [['ceiling' => ['reversibility' => 'manual_recovery'] + $ceiling], 'never lends what cannot at least be compensated']; + yield 'a ceiling with an unclassified axis' => [['ceiling' => ['externality' => 'unknown'] + $ceiling], 'an unclassified ceiling admits everything']; + yield 'a ceiling with unclassified reversibility' => [['ceiling' => ['reversibility' => 'unknown'] + $ceiling], 'an unclassified ceiling admits everything']; + yield 'covering the act that gives a grant' => [['covers' => [['operation' => 'standing:grant']]], 'nothing a grant lends may give, restore, withdraw or stop a grant']; + yield 'covering the act that restores a grant, spelled another way' => [['covers' => [['operation' => 'standing_reinstate']]], 'cannot cover «standing.reinstate»']; + yield 'covering the whole standing namespace by its bare name' => [['covers' => [['operation' => 'standing']]], 'cannot cover «standing»']; + + // A grant that asks nothing, or that nothing watches, is not a grant. + yield 'no condition' => [['conditions' => []], 'the scope handed over whole']; + yield 'no falsifier' => [['falsifier' => null], 'A position nobody watches is not granted']; + yield 'a falsifier with no watcher' => [['falsifier' => ['name' => 'f', 'statement' => 's', 'within_seconds' => 60]], 'in «watcher», names an operation']; + yield 'a watcher with no window' => [['falsifier' => ['name' => 'f', 'statement' => 's', 'watcher' => ['operation' => 'lab:watch']]], 'needs «within_seconds»']; + yield 'a watcher with a window of nothing' => [['falsifier' => ['name' => 'f', 'statement' => 's', 'watcher' => ['operation' => 'lab:watch'], 'within_seconds' => 0]], 'needs «within_seconds»']; + yield 'a falsifier that does not say what it is' => [['falsifier' => ['name' => 'f', 'statement' => ' ', 'watcher' => ['operation' => 'lab:watch'], 'within_seconds' => 60]], 'falsifier «f» needs «statement»']; + + // A positive, closed list: never anyone, never a pattern. + yield 'lending to anyone' => [['to' => ['*']], 'never to «anyone»']; + yield 'lending to nobody' => [['to' => []], 'lends to nobody']; + yield 'lending to something that is not a key' => [['to' => [7]], 'never to «anyone»']; + yield 'seats not given as a list' => [['to' => Grants::SEAT], 'gives «to» as a list']; + yield 'covering nothing' => [['covers' => []], 'covers nothing']; + yield 'covering a pattern' => [['covers' => [['operation' => 'lab:*']]], 'no pattern, no wildcard']; + yield 'covering something that is not an operation' => [['covers' => [['arguments' => ['channel' => 'stable']]]], 'in «covers», names an operation']; + yield 'fixing an argument to something that is not one plain value' => [['covers' => [['operation' => 'lab:publish', 'arguments' => ['channels' => ['stable']]]]], 'one plain value']; + yield 'fixing arguments that are not named' => [['covers' => [['operation' => 'lab:publish', 'arguments' => 'stable']]], 'fixes its arguments by name']; + + // A line the judge would ignore is a limit nobody keeps — and money is the one a person will try. + yield 'a budget, which nothing reads' => [['budget' => 50], 'carries «budget», which nothing reads']; + yield 'a limit inside a covered call' => [['covers' => [['operation' => 'lab:publish', 'max_spend' => 50]]], 'carries «max_spend», which nothing reads']; + yield 'a limit inside the ceiling' => [['ceiling' => ['money' => 'none'] + $ceiling], 'carries «money», which nothing reads']; + yield 'a line inside a condition' => [['conditions' => [['name' => 'c', 'statement' => 's', 'by' => ['operation' => 'lab:scan'], 'optional' => true]]], 'carries «optional», which nothing reads']; + yield 'a line inside the falsifier' => [['falsifier' => ['name' => 'f', 'statement' => 's', 'watcher' => ['operation' => 'lab:watch'], 'within_seconds' => 60, 'mute' => true]], 'carries «mute», which nothing reads']; + + // The rest of the document says what it is. + yield 'no id' => [['id' => null], 'needs «id»']; + yield 'an id that is not a short name' => [['id' => 'Release Line'], 'is known by a short id']; + yield 'superseding itself' => [['supersedes' => 'release-line'], 'and never itself']; + yield 'superseding something that is not an id' => [['supersedes' => 7], 'supersedes another grant by its id']; + yield 'superseding a name that is not an id' => [['supersedes' => 'the old one'], 'supersedes another grant by its id']; + yield 'no ceiling' => [['ceiling' => null], 'needs «ceiling»']; + yield 'a ceiling missing an axis' => [['ceiling' => ['mutation' => 'persistent']], 'a stored profile needs a valid «reversibility»']; + yield 'no undo' => [['undo' => ' '], 'needs «undo»']; + yield 'a condition that is not one' => [['conditions' => ['measured']], 'a condition is {"name", "statement", "by"']; + yield 'a condition nothing measures' => [['conditions' => [['name' => 'c', 'statement' => 's']]], 'condition «c», in «by», names an operation']; + yield 'two conditions under one name' => [['conditions' => [ + ['name' => 'c', 'statement' => 's', 'by' => ['operation' => 'lab:scan']], + ['name' => 'c', 'statement' => 't', 'by' => ['operation' => 'lab:build']], + ]], 'each under its own name']; + yield 'conditions not given as a list' => [['conditions' => ['c' => ['name' => 'c']]], 'gives «conditions» as a list']; + yield 'an expiry that is not a date' => [['expires_at' => 'next week, maybe'], 'gives «expires_at» as a date and time']; + yield 'an expiry that is not text' => [['expires_at' => 1790000000], 'gives «expires_at» as a date and time']; + } + + public function testTheValuesRefuseTheSameThingsWhenBuiltByHand(): void + { + $by = new NamedCall('lab:scan'); + $ceiling = EffectProfile::fromArray(Grants::document()['ceiling']); + $falsifier = new Falsifier('f', 's', new NamedCall('lab:watch'), 60); + $condition = new GrantCondition('c', 's', $by); + $refused = static function (\Closure $build): string { + try { + $build(); + } catch (\InvalidArgumentException $error) { + return $error->getMessage(); + } + + return 'ACCEPTED'; + }; + + self::assertStringContainsString('covers named calls, one by one', $refused(static fn () => new StandingGrant('g', [Grants::SEAT], [['operation' => 'lab:publish']], $ceiling, [$condition], 'undo', $falsifier))); + self::assertStringContainsString('lists its conditions one by one', $refused(static fn () => new StandingGrant('g', [Grants::SEAT], [$by], $ceiling, ['c'], 'undo', $falsifier))); + self::assertStringContainsString('needs «undo»', $refused(static fn () => new StandingGrant('g', [Grants::SEAT], [$by], $ceiling, [$condition], ' ', $falsifier))); + self::assertStringContainsString('a claim nobody can check', $refused(static fn () => new GrantCondition(' ', 's', $by))); + self::assertStringContainsString('names itself and says what would show the grant wrong', $refused(static fn () => new Falsifier('', 's', $by, 60))); + self::assertStringContainsString('a name and one plain value', $refused(static fn () => new NamedCall('lab:scan', [' ' => 'x']))); + self::assertStringContainsString('does not name one operation', $refused(static fn () => new NamedCall(new \Milpa\Command\Consent\OperationId('lab scan')))); + self::assertSame('ACCEPTED', $refused(static fn () => new StandingGrant('g', [Grants::SEAT], [$by], $ceiling, [$condition], 'undo', $falsifier))); + } + + public function testAGrantIsOneJsonObject(): void + { + self::assertSame('release-line', StandingGrant::fromJson((string) json_encode(Grants::document()))->id); + + foreach (['not json at all', '["a", "list"]', '"a string"'] as $text) { + try { + StandingGrant::fromJson($text); + self::fail("«{$text}» was read as a grant"); + } catch (\InvalidArgumentException $error) { + self::assertStringContainsString('a standing grant is one JSON object', $error->getMessage()); + } + } + } +} diff --git a/tests/Standing/Grants.php b/tests/Standing/Grants.php new file mode 100644 index 0000000..8c44781 --- /dev/null +++ b/tests/Standing/Grants.php @@ -0,0 +1,109 @@ + $change + * + * @return array + */ + public static function document(array $change = []): array + { + $document = [ + 'id' => 'release-line', + 'to' => [self::SEAT], + 'covers' => [['operation' => 'lab:publish', 'arguments' => ['channel' => 'stable']]], + 'ceiling' => [ + 'mutation' => 'persistent', + 'externality' => 'public', + 'reversibility' => 'compensatable', + 'authority' => 'write_as_user', + 'subject' => 'executable', + ], + 'conditions' => [['name' => 'no-host-names', 'statement' => 'what is published names no machine', 'by' => ['operation' => 'lab:scan']]], + 'undo' => 'withdraw the release and publish the one before it', + 'falsifier' => ['name' => 'a-published-name', 'statement' => 'a published release names a machine', 'watcher' => ['operation' => 'lab:watch'], 'within_seconds' => 3600], + ]; + foreach ($change as $key => $value) { + if ($value === null) { + unset($document[$key]); + } else { + $document[$key] = $value; + } + } + + return $document; + } + + /** @param array $change */ + public static function grant(array $change = []): StandingGrant + { + return StandingGrant::fromArray(self::document($change)); + } + + public static function at(string $time = '2026-10-11T12:00:00Z'): \DateTimeImmutable + { + return new \DateTimeImmutable($time); + } + + /** What the lab's publishing operation declares: exactly at the grant's ceiling. */ + public static function effect(Reversibility $reversibility = Reversibility::Compensatable, Externality $externality = Externality::Public): EffectProfile + { + return new EffectProfile(Mutation::Persistent, $externality, $reversibility, Authority::WriteAsUser, subject: Subject::Executable); + } + + /** @param array $arguments */ + public static function call(?string $subject = self::CANDIDATE, array $arguments = ['channel' => 'stable', 'path' => 'candidate.tar'], string $seat = self::SEAT, ?EffectProfile $effect = null, ?string $at = null): StandingCall + { + return new StandingCall('lab:publish', $arguments, $seat, $subject, $effect ?? self::effect(), self::at($at ?? '2026-10-11T12:00:00Z')); + } + + /** @param array $arguments */ + public static function receipt(string $subject = self::CANDIDATE, bool $ok = true, string $instrument = self::INSTRUMENT, string $by = 'lab:scan', array $arguments = ['path' => 'candidate.tar']): ConditionReceipt + { + return new ConditionReceipt(new OperationId($by), $arguments, $subject, $ok, $instrument, 'key:' . self::SEAT, self::at('2026-10-11T11:50:00Z')); + } + + /** + * The house as it stands when everything is in order: in force, watched ten minutes ago, the candidate + * measured green by today's scanner. + * + * @param list|null $receipts + */ + public static function standing(?array $receipts = null, ?string $watcherSignalAt = '2026-10-11T11:50:00Z'): GrantStanding + { + return new GrantStanding( + watcherSignalAt: $watcherSignalAt === null ? null : self::at($watcherSignalAt), + receipts: $receipts ?? [self::receipt()], + instruments: ['lab.scan' => self::INSTRUMENT], + ); + } +} diff --git a/tests/Standing/TheStandingJudgeAsksSevenThingsTest.php b/tests/Standing/TheStandingJudgeAsksSevenThingsTest.php new file mode 100644 index 0000000..5046823 --- /dev/null +++ b/tests/Standing/TheStandingJudgeAsksSevenThingsTest.php @@ -0,0 +1,249 @@ +passes()); + self::assertTrue($verdict->named()); + self::assertNull($verdict->failed); + self::assertSame('passed', $verdict->reason); + self::assertSame([$receipt], $verdict->cited); + self::assertStringContainsString(Grants::CANDIDATE, $verdict->sentence); + } + + public function testAGrantThatDoesNotLendToThisSeatDoesNotNameTheCall(): void + { + $verdict = StandingJudge::judge(Grants::grant(), Grants::standing(), Grants::call(seat: 'ANOTHER-SEAT')); + + self::assertFalse($verdict->passes()); + self::assertFalse($verdict->named()); + self::assertSame(StandingCheck::Named, $verdict->failed); + self::assertSame('not_named', $verdict->reason); + } + + public function testAnArgumentTheGrantFixesAndTheCallChangesIsAnotherCall(): void + { + $verdict = StandingJudge::judge(Grants::grant(), Grants::standing(), Grants::call(arguments: ['channel' => 'beta', 'path' => 'candidate.tar'])); + + self::assertFalse($verdict->named()); + } + + public function testAnotherOperationIsNotNamed(): void + { + $call = new \Milpa\Command\Standing\StandingCall('lab:merge', ['channel' => 'stable'], Grants::SEAT, Grants::CANDIDATE, Grants::effect(), Grants::at()); + + self::assertFalse(StandingJudge::judge(Grants::grant(), Grants::standing(), $call)->named()); + } + + public function testASignatureThatDoesNotHoldNowRefusesAndSaysWhy(): void + { + $standing = new GrantStanding(signatureFault: 'the key that signed it no longer answers for this seat', watcherSignalAt: Grants::at('2026-10-11T11:50:00Z'), receipts: [Grants::receipt()], instruments: ['lab.scan' => Grants::INSTRUMENT]); + $verdict = StandingJudge::judge(Grants::grant(), $standing, Grants::call()); + + self::assertTrue($verdict->named()); + self::assertSame(StandingCheck::Signature, $verdict->failed); + self::assertSame('signature', $verdict->reason); + self::assertStringContainsString('no longer answers for this seat', $verdict->sentence); + } + + public function testASuspendedGrantRefusesItsWholeClassAndSaysOnlyAPersonLiftsIt(): void + { + $standing = new GrantStanding(GrantState::Suspended, 'its falsifier fired at 2026-10-11T11:55:00Z', watcherSignalAt: Grants::at('2026-10-11T11:50:00Z'), receipts: [Grants::receipt()], instruments: ['lab.scan' => Grants::INSTRUMENT]); + $verdict = StandingJudge::judge(Grants::grant(), $standing, Grants::call()); + + self::assertSame(StandingCheck::InForce, $verdict->failed); + self::assertSame('suspended', $verdict->reason); + self::assertStringContainsString('its falsifier fired', $verdict->sentence); + self::assertStringContainsString('a person lifts it with a new signature', $verdict->sentence); + self::assertStringContainsString('No seat can', $verdict->sentence); + } + + public function testASupersededOrRevokedGrantLendsNothingAndNothingBringsItBack(): void + { + foreach ([GrantState::Superseded, GrantState::Revoked] as $state) { + $verdict = StandingJudge::judge(Grants::grant(), new GrantStanding($state, receipts: [Grants::receipt()]), Grants::call()); + + self::assertSame(StandingCheck::InForce, $verdict->failed); + self::assertSame($state->value, $verdict->reason); + self::assertStringContainsString('a person gives a new grant', $verdict->sentence); + self::assertStringNotContainsString('lifts it', $verdict->sentence); + } + } + + public function testAGrantPastItsOwnDateRefuses(): void + { + $grant = Grants::grant(['expires_at' => '2026-10-11T12:00:00Z']); + + self::assertTrue(StandingJudge::judge($grant, Grants::standing(), Grants::call(at: '2026-10-11T11:59:59Z'))->passes()); + $verdict = StandingJudge::judge($grant, Grants::standing(), Grants::call(at: '2026-10-11T12:00:00Z')); + self::assertSame(StandingCheck::InForce, $verdict->failed); + self::assertSame('expired', $verdict->reason); + } + + public function testAWatcherThatNeverGaveASignIsNotGreen(): void + { + $verdict = StandingJudge::judge(Grants::grant(), Grants::standing(watcherSignalAt: null), Grants::call()); + + self::assertSame(StandingCheck::Watched, $verdict->failed); + self::assertSame('never_watched', $verdict->reason); + self::assertStringContainsString('Run `lab:watch`', $verdict->sentence); + } + + public function testAWatcherThatWentQuietPastItsWindowIsNotGreen(): void + { + // within_seconds is 3600: a sign exactly that old still counts; one second older does not. + self::assertTrue(StandingJudge::judge(Grants::grant(), Grants::standing(watcherSignalAt: '2026-10-11T11:00:00Z'), Grants::call())->passes()); + + $verdict = StandingJudge::judge(Grants::grant(), Grants::standing(watcherSignalAt: '2026-10-11T10:59:59Z'), Grants::call()); + self::assertSame(StandingCheck::Watched, $verdict->failed); + self::assertSame('watcher_quiet', $verdict->reason); + } + + public function testASignOfLifeFromTheFutureIsNotASignOfLife(): void + { + $verdict = StandingJudge::judge(Grants::grant(), Grants::standing(watcherSignalAt: '2026-10-11T12:00:01Z'), Grants::call()); + + self::assertSame('watcher_quiet', $verdict->reason); + } + + public function testACallThatCannotSayWhatItActsOnIsLentNothing(): void + { + $verdict = StandingJudge::judge(Grants::grant(), Grants::standing(), Grants::call(subject: null)); + + self::assertSame(StandingCheck::Subject, $verdict->failed); + self::assertSame('no_subject', $verdict->reason); + } + + public function testAnEffectAboveTheCeilingRefusesEvenWithEveryConditionGreen(): void + { + foreach ([Grants::effect(Reversibility::Irreversible), Grants::effect(externality: Externality::Unknown)] as $effect) { + $verdict = StandingJudge::judge(Grants::grant(), Grants::standing(), Grants::call(effect: $effect)); + + self::assertSame(StandingCheck::Ceiling, $verdict->failed); + self::assertSame('above_ceiling', $verdict->reason); + self::assertStringContainsString('reversibility «compensatable»', $verdict->sentence); + } + } + + public function testACandidateNobodyMeasuredIsRefused(): void + { + $verdict = StandingJudge::judge(Grants::grant(), Grants::standing([]), Grants::call()); + + self::assertSame(StandingCheck::Conditions, $verdict->failed); + self::assertSame('not_measured', $verdict->reason); + self::assertStringContainsString('Run `lab:scan`', $verdict->sentence); + } + + public function testAReceiptOnAnotherCandidateIsNotThisOne(): void + { + $verdict = StandingJudge::judge(Grants::grant(), Grants::standing([Grants::receipt('sha256:candidate-b')]), Grants::call()); + + self::assertSame('not_measured', $verdict->reason); + } + + public function testAReceiptFromAnotherOperationDoesNotMeasureTheCondition(): void + { + $verdict = StandingJudge::judge(Grants::grant(), Grants::standing([Grants::receipt(by: 'lab:lint')]), Grants::call()); + + self::assertSame('not_measured', $verdict->reason); + } + + public function testAnArgumentTheConditionFixesForItsInstrumentMustHaveBeenCarried(): void + { + $grant = Grants::grant(['conditions' => [['name' => 'no-host-names', 'statement' => 'strictly', 'by' => ['operation' => 'lab:scan', 'arguments' => ['strict' => true]]]]]); + + self::assertSame('not_measured', StandingJudge::judge($grant, Grants::standing(), Grants::call())->reason); + self::assertTrue(StandingJudge::judge($grant, Grants::standing([Grants::receipt(arguments: ['path' => 'candidate.tar', 'strict' => true])]), Grants::call())->passes()); + } + + public function testACandidateMeasuredRedIsRefused(): void + { + $verdict = StandingJudge::judge(Grants::grant(), Grants::standing([Grants::receipt(ok: false)]), Grants::call()); + + self::assertSame(StandingCheck::Conditions, $verdict->failed); + self::assertSame('measured_red', $verdict->reason); + } + + public function testTheLastReceiptAboutACandidateIsWhatIsKnownOfItNow(): void + { + // Ledger order, not the clock: green then red is red; red then green is green. + self::assertSame('measured_red', StandingJudge::judge(Grants::grant(), Grants::standing([Grants::receipt(), Grants::receipt(ok: false)]), Grants::call())->reason); + + $green = Grants::receipt(); + $verdict = StandingJudge::judge(Grants::grant(), Grants::standing([Grants::receipt(ok: false), $green]), Grants::call()); + self::assertTrue($verdict->passes()); + self::assertSame([$green], $verdict->cited); + } + + public function testWhatAnOlderInstrumentFoundDoesNotHoldForTheOneThatReplacedIt(): void + { + $verdict = StandingJudge::judge(Grants::grant(), Grants::standing([Grants::receipt(instrument: 'sha256:the-scanner-before-it-was-hardened')]), Grants::call()); + + self::assertSame(StandingCheck::Conditions, $verdict->failed); + self::assertSame('instrument_changed', $verdict->reason); + } + + public function testAnInstrumentTheHouseNoLongerHasMeasuresNothing(): void + { + $standing = new GrantStanding(watcherSignalAt: Grants::at('2026-10-11T11:50:00Z'), receipts: [Grants::receipt()], instruments: []); + + self::assertSame('instrument_changed', StandingJudge::judge(Grants::grant(), $standing, Grants::call())->reason); + } + + public function testEveryConditionNeedsItsOwnReceipt(): void + { + $grant = Grants::grant(['conditions' => [ + ['name' => 'no-host-names', 'statement' => 'names no machine', 'by' => ['operation' => 'lab:scan']], + ['name' => 'builds', 'statement' => 'the candidate builds', 'by' => ['operation' => 'lab:build']], + ]]); + $scan = Grants::receipt(); + $build = Grants::receipt(by: 'lab:build'); + $instruments = ['lab.scan' => Grants::INSTRUMENT, 'lab.build' => Grants::INSTRUMENT]; + $watched = Grants::at('2026-10-11T11:50:00Z'); + + $missing = StandingJudge::judge($grant, new GrantStanding(watcherSignalAt: $watched, receipts: [$scan], instruments: $instruments), Grants::call()); + self::assertSame('not_measured', $missing->reason); + self::assertStringContainsString('«builds»', $missing->sentence); + self::assertSame([], $missing->cited); + + $both = StandingJudge::judge($grant, new GrantStanding(watcherSignalAt: $watched, receipts: [$build, $scan], instruments: $instruments), Grants::call()); + self::assertTrue($both->passes()); + self::assertSame([$scan, $build], $both->cited); + } + + public function testWhoseRefusalItIsComesBeforeWhatTheCallerCouldFix(): void + { + // Suspended AND unmeasured AND above the ceiling: the answer is the person's one. + $standing = new GrantStanding(GrantState::Suspended, 'someone asked for it to stop'); + $verdict = StandingJudge::judge(Grants::grant(), $standing, Grants::call(subject: null, effect: Grants::effect(Reversibility::Irreversible))); + + self::assertSame(StandingCheck::InForce, $verdict->failed); + + // And a broken signature comes before the state the ledger derives from it. + $broken = new GrantStanding(GrantState::Suspended, signatureFault: 'its bytes were altered'); + self::assertSame(StandingCheck::Signature, StandingJudge::judge(Grants::grant(), $broken, Grants::call())->failed); + } +} From 6aeb594da08a0bcd1683d4cfcb504c34dc0729c2 Mon Sep 17 00:00:00 2001 From: Rod Vince Date: Sun, 11 Oct 2026 10:41:15 -0600 Subject: [PATCH 2/3] feat: among several grants, a suspended one that names the call refuses it One grant that passes used to be enough. That let a second grant walk around a suspension, which is the permissive reading and runs against the asymmetry: shrinking wins. StandingJudge::among() now says how the answers of several grants combine: - a grant that names the call and is suspended refuses it, whatever another would lend, until a person lifts that suspension or gives a grant that supersedes it by name; - expired, revoked and superseded grants stop nobody; - otherwise one grant that passes is enough, and when none does the refusal that got furthest speaks, the later grant's on a tie. It adds no criterion to the judgment of any single grant. --- README.md | 6 + src/Standing/StandingJudge.php | 70 ++++++++++ .../TheStandingJudgeAsksSevenThingsTest.php | 124 ++++++++++++++++++ 3 files changed, 200 insertions(+) diff --git a/README.md b/README.md index 08563e7..194d6ec 100644 --- a/README.md +++ b/README.md @@ -213,6 +213,12 @@ The judgment is pure: it reads a `StandingGrant`, the facts a host gathered abou (`GrantStanding`) and the call as facts (`StandingCall`). It never grants, widens or restores — growing back is a person's new signature, and it happens in the host. +A house may hold several grants that name the same call. `StandingJudge::among()` says which +answer stands, and **shrinking wins**: a grant that names the call and is suspended refuses it, +whatever another grant would lend — until a person lifts that suspension or gives a grant that +supersedes it by name. Expired, revoked and superseded grants stop nobody. Otherwise one grant that +passes is enough, and when none does, the refusal that got furthest speaks. + Some documents cannot be written at all. `StandingGrant` refuses, at construction: a ceiling with an unclassified axis, a ceiling above `compensatable`, covering any `standing.*` operation, a grant with no condition, a falsifier nothing watches, and **any key it does not read** — a line the judge diff --git a/src/Standing/StandingJudge.php b/src/Standing/StandingJudge.php index e1af58f..35bf532 100644 --- a/src/Standing/StandingJudge.php +++ b/src/Standing/StandingJudge.php @@ -35,6 +35,11 @@ * matters more than telling the caller its candidate was not measured: measuring would not help. Only * a grant that stands asks what the caller can actually fix — the subject, the ceiling, the conditions. * + * ── MORE THAN ONE GRANT ───────────────────────────────────────────────────────────────────────── + * + * {@see self::judge()} answers for one grant. A house may hold several that name the same call, and how + * their answers combine is part of the judgment too: {@see self::among()}. + * * ── THE ONE COMPARATOR ────────────────────────────────────────────────────────────────────────── * * The ceiling is asked with {@see EffectProfile::isNoWiderThan()} and with nothing else. A second @@ -168,6 +173,71 @@ public static function judge(StandingGrant $grant, GrantStanding $standing, Stan ), $cited); } + /** + * ONE CALL UNDER EVERY GRANT A HOST HOLDS: which answer stands — or `null` when none of them names the call. + * + * Each grant is judged by {@see self::judge()}, alone; this only says how their answers combine, and it adds + * no criterion to any of them: + * + * · A grant that names the call and is SUSPENDED refuses it, whatever another grant would lend. Shrinking + * wins (greenhouse decisions/0613 §4): a suspension that a second grant walks around is not a suspension. + * What takes a grant out of the way is a person's act that names it — lifting that suspension, or giving + * a grant that supersedes it — and a host then no longer reports it as suspended. Expired, revoked and + * superseded grants are not suspended: they lend nothing and stop nobody. + * · Otherwise one grant that passes is enough. + * · Otherwise the refusal that got furthest through the seven checks speaks — it is the one the caller is + * closest to — and, level with another, the later grant's. + * + * @param list $grants every grant the host holds that may + * concern the call, in the order they + * were given + * + * @return array{grant: StandingGrant, verdict: StandingVerdict}|null + */ + public static function among(array $grants, StandingCall $call): ?array + { + $suspended = null; + $passed = null; + $furthest = null; + $naming = 0; + foreach ($grants as ['grant' => $grant, 'standing' => $standing]) { + $verdict = self::judge($grant, $standing, $call); + if (!$verdict->named()) { + continue; + } + ++$naming; + $answer = ['grant' => $grant, 'verdict' => $verdict]; + if ($standing->state === GrantState::Suspended) { + $suspended = $answer; + } elseif ($verdict->passes()) { + $passed ??= $answer; + } elseif ($furthest === null || self::reached($verdict) >= self::reached($furthest['verdict'])) { + $furthest = $answer; + } + } + + if ($suspended !== null && $naming > 1) { + $verdict = $suspended['verdict']; + + return ['grant' => $suspended['grant'], 'verdict' => StandingVerdict::refuse( + $verdict->failed ?? StandingCheck::InForce, + $verdict->reason, + $verdict->sentence . \sprintf( + ' Another grant names this call too, and it lends nothing while «%1$s» is suspended: a person lifts that suspension, or gives a grant that supersedes «%1$s» by name.', + $suspended['grant']->id, + ), + )]; + } + + return $suspended ?? $passed ?? $furthest; + } + + /** How far through the seven checks a refusal got. */ + private static function reached(StandingVerdict $verdict): int + { + return (int) array_search($verdict->failed, StandingCheck::cases(), true); + } + /** A profile's five axes as a reader sees them — a description, never a comparison. */ private static function axes(EffectProfile $profile): string { diff --git a/tests/Standing/TheStandingJudgeAsksSevenThingsTest.php b/tests/Standing/TheStandingJudgeAsksSevenThingsTest.php index 5046823..9ce2120 100644 --- a/tests/Standing/TheStandingJudgeAsksSevenThingsTest.php +++ b/tests/Standing/TheStandingJudgeAsksSevenThingsTest.php @@ -246,4 +246,128 @@ public function testWhoseRefusalItIsComesBeforeWhatTheCallerCouldFix(): void $broken = new GrantStanding(GrantState::Suspended, signatureFault: 'its bytes were altered'); self::assertSame(StandingCheck::Signature, StandingJudge::judge(Grants::grant(), $broken, Grants::call())->failed); } + + /** + * @param array $change + * + * @return array{grant: \Milpa\Command\Standing\StandingGrant, standing: GrantStanding} + */ + private static function held(string $id, ?GrantStanding $standing = null, array $change = []): array + { + return ['grant' => Grants::grant(['id' => $id] + $change), 'standing' => $standing ?? Grants::standing()]; + } + + private static function suspended(): GrantStanding + { + return new GrantStanding(GrantState::Suspended, 'its falsifier fired', watcherSignalAt: Grants::at('2026-10-11T11:50:00Z'), receipts: [Grants::receipt()], instruments: ['lab.scan' => Grants::INSTRUMENT]); + } + + public function testAmongSeveralGrantsOneThatPassesIsEnough(): void + { + $unmeasured = self::held('asks-more', change: ['conditions' => [['name' => 'builds', 'statement' => 'it builds', 'by' => ['operation' => 'lab:build']]]]); + $answer = StandingJudge::among([$unmeasured, self::held('first-that-passes'), self::held('also-passes')], Grants::call()); + + self::assertNotNull($answer); + self::assertTrue($answer['verdict']->passes()); + self::assertSame('first-that-passes', $answer['grant']->id); + } + + public function testNoGrantThatNamesTheCallIsNoAnswerAtAll(): void + { + self::assertNull(StandingJudge::among([], Grants::call())); + self::assertNull(StandingJudge::among([self::held('for-another-seat'), self::held('suspended-for-another-seat', self::suspended())], Grants::call(seat: 'ANOTHER-SEAT'))); + } + + public function testASuspendedGrantThatNamesTheCallRefusesItWhateverAnotherWouldLend(): void + { + // Shrinking wins: a suspension a second grant walks around is not a suspension. Given before it or after it. + foreach ([[self::held('stopped', self::suspended()), self::held('would-lend')], [self::held('would-lend'), self::held('stopped', self::suspended())]] as $grants) { + self::assertTrue(StandingJudge::judge($grants[0]['grant'], Grants::standing(), Grants::call())->passes(), 'control: alone and in force, each of them lends'); + + $answer = StandingJudge::among($grants, Grants::call()); + + self::assertNotNull($answer); + self::assertFalse($answer['verdict']->passes()); + self::assertSame('stopped', $answer['grant']->id); + self::assertSame(StandingCheck::InForce, $answer['verdict']->failed); + self::assertSame('suspended', $answer['verdict']->reason); + self::assertStringContainsString('Standing grant «stopped» is suspended', $answer['verdict']->sentence); + self::assertStringContainsString('Another grant names this call too, and it lends nothing while «stopped» is suspended', $answer['verdict']->sentence); + self::assertStringContainsString('gives a grant that supersedes «stopped» by name', $answer['verdict']->sentence); + } + } + + public function testASuspendedGrantSpeaksEvenWhenAnotherRefusalGotFurther(): void + { + // The caller could measure its candidate, and it would change nothing: the suspension is what decides. + $answer = StandingJudge::among([self::held('stopped', self::suspended()), self::held('unmeasured', Grants::standing([]))], Grants::call()); + + self::assertSame('stopped', $answer['grant']->id ?? null); + self::assertSame('suspended', $answer['verdict']->reason ?? null); + } + + public function testASuspendedGrantAloneSaysOnlyWhatItSaidBefore(): void + { + $alone = StandingJudge::among([self::held('stopped', self::suspended())], Grants::call()); + $judged = StandingJudge::judge(Grants::grant(['id' => 'stopped']), self::suspended(), Grants::call()); + + self::assertSame($judged->sentence, $alone['verdict']->sentence ?? null); + self::assertStringNotContainsString('Another grant', $judged->sentence); + } + + public function testASuspendedGrantWhoseSignatureDoesNotHoldStillStopsTheCall(): void + { + $broken = new GrantStanding(GrantState::Suspended, 'stopped', signatureFault: 'the key that signed it no longer holds the word'); + $answer = StandingJudge::among([self::held('stopped', $broken), self::held('would-lend')], Grants::call()); + + self::assertFalse($answer['verdict']->passes() ?? true); + self::assertSame(StandingCheck::Signature, $answer['verdict']->failed ?? null); + self::assertStringContainsString('lends nothing while «stopped» is suspended', $answer['verdict']->sentence ?? ''); + } + + public function testASuspendedGrantThatDoesNotNameTheCallStopsNothing(): void + { + $elsewhere = self::held('stopped', self::suspended(), ['covers' => [['operation' => 'lab:publish', 'arguments' => ['channel' => 'beta']]]]); + $answer = StandingJudge::among([$elsewhere, self::held('would-lend')], Grants::call()); + + self::assertTrue($answer['verdict']->passes() ?? false); + self::assertSame('would-lend', $answer['grant']->id ?? null); + } + + public function testExpiredRevokedAndSupersededGrantsStopNobody(): void + { + $gone = [ + self::held('revoked', new GrantStanding(GrantState::Revoked)), + self::held('superseded', new GrantStanding(GrantState::Superseded)), + self::held('expired', change: ['expires_at' => '2026-10-11T00:00:00Z']), + ]; + + $answer = StandingJudge::among([...$gone, self::held('would-lend')], Grants::call()); + self::assertTrue($answer['verdict']->passes() ?? false); + self::assertSame('would-lend', $answer['grant']->id ?? null); + + // And with nothing left that lends, they refuse — each as what it is, the later one speaking. + self::assertSame('expired', StandingJudge::among($gone, Grants::call())['verdict']->reason ?? null); + } + + public function testWhenNoneLendsTheRefusalThatGotFurthestSpeaksWhicheverWasGivenFirst(): void + { + $unwatched = self::held('unwatched', Grants::standing(watcherSignalAt: null)); + $unmeasured = self::held('unmeasured', Grants::standing([])); + + foreach ([[$unwatched, $unmeasured], [$unmeasured, $unwatched]] as $grants) { + $answer = StandingJudge::among($grants, Grants::call()); + + self::assertSame('unmeasured', $answer['grant']->id ?? null, 'the one the caller can do something about'); + self::assertSame(StandingCheck::Conditions, $answer['verdict']->failed ?? null); + } + } + + public function testLevelWithAnotherTheLaterGrantSpeaks(): void + { + $answer = StandingJudge::among([self::held('older', new GrantStanding(GrantState::Superseded)), self::held('newer', change: ['expires_at' => '2026-10-11T00:00:00Z'])], Grants::call()); + + self::assertSame('newer', $answer['grant']->id ?? null); + self::assertSame('expired', $answer['verdict']->reason ?? null); + } } From 7b293ba392ce89478911c3a1abe2a87ac18e452c Mon Sep 17 00:00:00 2001 From: Rod Vince Date: Sun, 11 Oct 2026 11:03:35 -0600 Subject: [PATCH 3/3] =?UTF-8?q?fix:=20the=20contract=20after=20an=20indepe?= =?UTF-8?q?ndent=20review=20=E2=80=94=20the=20floor=20by=20its=20axis,=20i?= =?UTF-8?q?nstruments=20matched=20closed,=20a=20lifted=20suspension=20name?= =?UTF-8?q?d?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three things a security review found in the contract, each reproduced before it was fixed (greenhouse decisions/0615): - The floor «never an operation that itself gives authority» was left to a word the host looked for. A ceiling above write_as_user on the authority axis is now refused here: measured on the published catalogue, thirteen operations declare institutional authority and four of them ask for that word. - A condition's instrument and a falsifier's watcher matched any call that carried the fixed arguments, so a later green run with an argument that weakens it covered a red one. They are now matched closed (NamedCall::matchesExactly): besides the fixed arguments a call may carry only those listed in «free». What a grant covers stays open — it is a class of acts — and «free» is refused there, where nothing would read it. - A grant that supersedes a suspended one may now say which suspension it lifts («lifts»), so that a host can ask of superseding what it asks of reinstating. --- README.md | 23 +++- src/Standing/NamedCall.php | 72 +++++++++-- src/Standing/StandingGrant.php | 36 +++++- src/Standing/StandingJudge.php | 2 +- tests/Standing/Grants.php | 2 +- .../TheStandingJudgeAsksSevenThingsTest.php | 8 +- .../WhatAReviewFoundInTheContractTest.php | 119 ++++++++++++++++++ 7 files changed, 242 insertions(+), 20 deletions(-) create mode 100644 tests/Standing/WhatAReviewFoundInTheContractTest.php diff --git a/README.md b/README.md index 194d6ec..9fca0fc 100644 --- a/README.md +++ b/README.md @@ -220,9 +220,26 @@ supersedes it by name. Expired, revoked and superseded grants stop nobody. Other passes is enough, and when none does, the refusal that got furthest speaks. Some documents cannot be written at all. `StandingGrant` refuses, at construction: a ceiling with an -unclassified axis, a ceiling above `compensatable`, covering any `standing.*` operation, a grant -with no condition, a falsifier nothing watches, and **any key it does not read** — a line the judge -would ignore is a limit nobody keeps. +unclassified axis, a ceiling above `compensatable`, a ceiling whose **authority** is above +`write_as_user` (an act above it itself gives authority, and no grant lends one), covering any +`standing.*` operation, a grant with no condition, a falsifier nothing watches, and **any key it +does not read** — a line the judge would ignore is a limit nobody keeps. + +A grant names calls in two ways, and the difference is deliberate. What it **covers** is matched +openly: the call carries each fixed argument, and the rest is free — the candidate changes on every +call. The operation that **measures** a condition and the one that **watches** the falsifier are +matched closed (`NamedCall::matchesExactly()`): besides the fixed arguments a call may carry only +those listed in `free`, typically the one that says which candidate. An open match fails open +there — a later run with `--lenient` would speak for the condition. + +```json +"conditions": [{"name": "no-host-names", "statement": "what is published names no machine", + "by": {"operation": "release:scan", "arguments": {"strict": true}, "free": ["archive"]}}] +``` + +A grant that supersedes a **suspended** one says which suspension it lifts (`lifts`, beside +`supersedes`): taking a suspended grant out of the way is growing back, and growing back names what +it lifts. **What it cannot say: money.** There is no effect axis for it, so no ceiling bounds what a call spends, and nothing in an operation's declaration says that it spends. "Do not grant over an diff --git a/src/Standing/NamedCall.php b/src/Standing/NamedCall.php index 6d15ab9..cec5aa1 100644 --- a/src/Standing/NamedCall.php +++ b/src/Standing/NamedCall.php @@ -24,10 +24,16 @@ * grant covers, the operation that measures one of its conditions, and the one that watches its * falsifier — three places, one way of naming a call. * - * ITS LIMIT IS PART OF THE CONTRACT. A call matches when it is that operation and carries EACH fixed - * argument with exactly that value; arguments the document does not fix are free. That is what lets - * one grant cover a class — the candidate changes on every call — and it is also why an operation - * whose own argument can weaken it must have that argument fixed here, by whoever writes the grant. + * TWO WAYS OF MATCHING, AND WHICH PLACE USES WHICH. + * + * · {@see self::matches()} — OPEN. The call is that operation and carries each fixed argument with + * exactly that value; what the document does not fix is free. It is what lets one grant cover a + * CLASS of acts: the candidate changes on every call. Only `covers` is read this way. + * · {@see self::matchesExactly()} — CLOSED. Besides the fixed arguments, a call may carry only the + * ones named in `free` (the argument that says WHICH candidate). An instrument and a watcher are + * read this way, because an open match fails open there: a later run with an argument nobody + * described — `--lenient`, `--skip=…` — would speak for the condition, and a green one would + * cover a red one (greenhouse decisions/0615, «Lo que la revisión encontró», 5). */ final readonly class NamedCall { @@ -36,10 +42,15 @@ /** @var array */ public array $arguments; + /** @var list */ + public array $free; + /** * @param array $arguments the values a call must carry, by argument name + * @param array $free the arguments a call may also carry, with any value — for a closed + * match: typically the one that says which candidate */ - public function __construct(OperationId|string $operation, array $arguments = []) + public function __construct(OperationId|string $operation, array $arguments = [], array $free = []) { $id = $operation instanceof OperationId ? $operation : new OperationId($operation); if (preg_match('/^[a-z0-9]+(?:-[a-z0-9]+)*(?:\.[a-z0-9]+(?:-[a-z0-9]+)*)*$/', $id->canonical) !== 1) { @@ -62,8 +73,23 @@ public function __construct(OperationId|string $operation, array $arguments = [] } ksort($fixed); + $open = []; + foreach ($free as $name) { + if (!\is_string($name) || trim($name) === '' || \array_key_exists($name, $fixed)) { + throw new \InvalidArgumentException(\sprintf( + '«free» of «%s» lists the names of the arguments a call may carry with any value — and never one it already fixes; got %s', + $id->canonical, + json_encode($name) ?: get_debug_type($name), + )); + } + $open[$name] = true; + } + $open = array_map(strval(...), array_keys($open)); + sort($open); + $this->operation = $id; $this->arguments = $fixed; + $this->free = $open; } /** @@ -86,13 +112,35 @@ public function matches(OperationId|string $operation, array $arguments): bool } /** - * The name as data. `arguments` appears only when something is fixed, so one call has one spelling. + * Whether a call is EXACTLY the one this describes: that operation, every fixed argument with that value, and + * no argument besides them except the ones left free. * - * @return array{operation: string, arguments?: array} + * @param array $arguments + */ + public function matchesExactly(OperationId|string $operation, array $arguments): bool + { + if (!$this->matches($operation, $arguments)) { + return false; + } + foreach (array_keys($arguments) as $name) { + if (!\array_key_exists($name, $this->arguments) && !\in_array($name, $this->free, true)) { + return false; + } + } + + return true; + } + + /** + * The name as data. `arguments` and `free` appear only when they say something, so one call has one spelling. + * + * @return array{operation: string, arguments?: array, free?: list} */ public function toArray(): array { - return ['operation' => $this->operation->canonical] + ($this->arguments === [] ? [] : ['arguments' => $this->arguments]); + return ['operation' => $this->operation->canonical] + + ($this->arguments === [] ? [] : ['arguments' => $this->arguments]) + + ($this->free === [] ? [] : ['free' => $this->free]); } /** The inverse of {@see self::toArray()}; `$where` names the place in the document, for the refusal. */ @@ -101,12 +149,16 @@ public static function fromArray(mixed $data, string $where): self if (!\is_array($data) || !\is_string($data['operation'] ?? null)) { throw new \InvalidArgumentException(\sprintf('%s names an operation: {"operation": "…"} and, when it fixes any, {"arguments": {…}}', $where)); } - Canonical::only($data, ['operation', 'arguments'], $where); + Canonical::only($data, ['operation', 'arguments', 'free'], $where); $arguments = $data['arguments'] ?? []; if (!\is_array($arguments)) { throw new \InvalidArgumentException(\sprintf('%s fixes its arguments by name: {"arguments": {"name": value}}', $where)); } + $free = $data['free'] ?? []; + if (!\is_array($free) || !array_is_list($free)) { + throw new \InvalidArgumentException(\sprintf('%s gives «free» as a list of argument names', $where)); + } - return new self($data['operation'], $arguments); + return new self($data['operation'], $arguments, $free); } } diff --git a/src/Standing/StandingGrant.php b/src/Standing/StandingGrant.php index d25c600..6eebc37 100644 --- a/src/Standing/StandingGrant.php +++ b/src/Standing/StandingGrant.php @@ -15,6 +15,7 @@ namespace Milpa\Command\Standing; use Milpa\Command\Consent\OperationId; +use Milpa\Command\Effect\Authority; use Milpa\Command\Effect\EffectProfile; use Milpa\Command\Effect\Reversibility; @@ -48,6 +49,10 @@ * * · a ceiling with an unclassified axis — measured: an unclassified ceiling admits everything; * · a ceiling above «compensatable» — a grant never lends what cannot at least be compensated; + * · a ceiling whose AUTHORITY is above «write_as_user» — nothing a grant lends may itself give + * authority. The floor is the axis that says it, not a list of words: measured on the published + * catalogue, thirteen operations declare institutional authority and four of them ask for the word + * a list would have looked for (greenhouse decisions/0615, «Lo que la revisión encontró», 4); * · covering any `standing.*` operation — nothing a grant lends may give, restore or withdraw a grant; * · no condition at all — that is the scope handed over whole, with a watcher; * · a falsifier nothing watches — {@see Falsifier}. @@ -72,9 +77,12 @@ /** The highest reversibility a ceiling may carry. */ public const Reversibility CEILING_REVERSIBILITY = Reversibility::Compensatable; + /** The highest authority a ceiling may carry: acting as a user. Above it an act decides what the house believes. */ + public const Authority CEILING_AUTHORITY = Authority::WriteAsUser; + private const ID = '/^[a-z0-9][a-z0-9._-]{0,62}$/'; - private const KEYS = ['id', 'supersedes', 'to', 'covers', 'ceiling', 'conditions', 'undo', 'falsifier', 'expires_at']; + private const KEYS = ['id', 'supersedes', 'lifts', 'to', 'covers', 'ceiling', 'conditions', 'undo', 'falsifier', 'expires_at']; /** @var list */ public array $to; @@ -89,6 +97,9 @@ * @param array $to the seats it lends to, each by the key the host knows it by * @param array $covers the calls it names * @param array $conditions what must be measured on the content an act touches + * @param string|null $lifts for a grant that supersedes a SUSPENDED one: the suspension it lifts, + * by the digest the host shows for it. Taking a suspended grant out of + * the way is growing back, and growing back names what it lifts */ public function __construct( public string $id, @@ -100,6 +111,7 @@ public function __construct( public Falsifier $falsifier, public ?string $supersedes = null, public ?\DateTimeImmutable $expiresAt = null, + public ?string $lifts = null, ) { if (preg_match(self::ID, $id) !== 1) { throw new \InvalidArgumentException("a standing grant is known by a short id — lowercase letters, digits, dot, dash or underscore, e.g. «m-01»; got «{$id}»"); @@ -108,6 +120,10 @@ public function __construct( throw new \InvalidArgumentException("standing grant «{$id}» supersedes another grant by its id, and never itself; got «{$supersedes}»"); } + if ($lifts !== null && ($supersedes === null || trim($lifts) === '')) { + throw new \InvalidArgumentException("standing grant «{$id}» says «lifts» only together with «supersedes»: it names the suspension of the grant it replaces, by its digest"); + } + $seats = []; foreach ($to as $seat) { if (!\is_string($seat) || trim($seat) === '' || preg_match('/[\s*]/', $seat) === 1) { @@ -128,6 +144,9 @@ public function __construct( if (!$act instanceof NamedCall) { throw new \InvalidArgumentException("standing grant «{$id}» covers named calls, one by one"); } + if ($act->free !== []) { + throw new \InvalidArgumentException("standing grant «{$id}» gives «free» for «{$act->operation->canonical}» in «covers», where nothing reads it: a covered act already leaves free every argument it does not fix"); + } if ($act->operation->canonical === 'standing' || str_starts_with($act->operation->canonical, 'standing.')) { throw new \InvalidArgumentException(\sprintf( 'standing grant «%s» cannot cover «%s»: nothing a grant lends may give, restore, withdraw or stop a grant. Those stay acts of a person, or of anyone who only narrows.', @@ -149,6 +168,15 @@ public function __construct( )); } + if ($ceiling->authority->weight() > self::CEILING_AUTHORITY->weight()) { + throw new \InvalidArgumentException(\sprintf( + 'standing grant «%s» sets a ceiling of authority «%s». An act above «%s» itself gives authority — it decides what the house believes or does — and no grant lends one. It stays a person\'s act, signed each time.', + $id, + $ceiling->authority->value, + self::CEILING_AUTHORITY->value, + )); + } + if ($conditions === []) { throw new \InvalidArgumentException("standing grant «{$id}» asks nothing of what an act touches: without a condition it is the scope handed over whole. Name at least one, with the operation that measures it"); } @@ -213,6 +241,7 @@ public function toArray(): array return [ 'id' => $this->id, 'supersedes' => $this->supersedes, + 'lifts' => $this->lifts, 'to' => $this->to, 'covers' => array_map(static fn (NamedCall $act): array => $act->toArray(), $this->covers), 'ceiling' => [ @@ -268,6 +297,10 @@ public static function fromArray(array $data): self if ($supersedes !== null && !\is_string($supersedes)) { throw new \InvalidArgumentException("{$where} supersedes another grant by its id"); } + $lifts = $data['lifts'] ?? null; + if ($lifts !== null && !\is_string($lifts)) { + throw new \InvalidArgumentException("{$where} says what it «lifts» by the digest of that suspension"); + } return new self( id: $id, @@ -279,6 +312,7 @@ public static function fromArray(array $data): self falsifier: Falsifier::fromArray($data['falsifier'] ?? null), supersedes: $supersedes, expiresAt: $expiresAt, + lifts: $lifts, ); } diff --git a/src/Standing/StandingJudge.php b/src/Standing/StandingJudge.php index 35bf532..8dd2fb2 100644 --- a/src/Standing/StandingJudge.php +++ b/src/Standing/StandingJudge.php @@ -131,7 +131,7 @@ public static function judge(StandingGrant $grant, GrantStanding $standing, Stan $measuring = $condition->by->operation->forCli(); $last = null; foreach ($standing->receipts as $receipt) { - if ($receipt->subject === $call->subject && $condition->by->matches($receipt->by, $receipt->arguments)) { + if ($receipt->subject === $call->subject && $condition->by->matchesExactly($receipt->by, $receipt->arguments)) { $last = $receipt; } } diff --git a/tests/Standing/Grants.php b/tests/Standing/Grants.php index 8c44781..e613386 100644 --- a/tests/Standing/Grants.php +++ b/tests/Standing/Grants.php @@ -48,7 +48,7 @@ public static function document(array $change = []): array 'authority' => 'write_as_user', 'subject' => 'executable', ], - 'conditions' => [['name' => 'no-host-names', 'statement' => 'what is published names no machine', 'by' => ['operation' => 'lab:scan']]], + 'conditions' => [['name' => 'no-host-names', 'statement' => 'what is published names no machine', 'by' => ['operation' => 'lab:scan', 'free' => ['path']]]], 'undo' => 'withdraw the release and publish the one before it', 'falsifier' => ['name' => 'a-published-name', 'statement' => 'a published release names a machine', 'watcher' => ['operation' => 'lab:watch'], 'within_seconds' => 3600], ]; diff --git a/tests/Standing/TheStandingJudgeAsksSevenThingsTest.php b/tests/Standing/TheStandingJudgeAsksSevenThingsTest.php index 9ce2120..69f5948 100644 --- a/tests/Standing/TheStandingJudgeAsksSevenThingsTest.php +++ b/tests/Standing/TheStandingJudgeAsksSevenThingsTest.php @@ -173,7 +173,7 @@ public function testAReceiptFromAnotherOperationDoesNotMeasureTheCondition(): vo public function testAnArgumentTheConditionFixesForItsInstrumentMustHaveBeenCarried(): void { - $grant = Grants::grant(['conditions' => [['name' => 'no-host-names', 'statement' => 'strictly', 'by' => ['operation' => 'lab:scan', 'arguments' => ['strict' => true]]]]]); + $grant = Grants::grant(['conditions' => [['name' => 'no-host-names', 'statement' => 'strictly', 'by' => ['operation' => 'lab:scan', 'arguments' => ['strict' => true], 'free' => ['path']]]]]); self::assertSame('not_measured', StandingJudge::judge($grant, Grants::standing(), Grants::call())->reason); self::assertTrue(StandingJudge::judge($grant, Grants::standing([Grants::receipt(arguments: ['path' => 'candidate.tar', 'strict' => true])]), Grants::call())->passes()); @@ -216,8 +216,8 @@ public function testAnInstrumentTheHouseNoLongerHasMeasuresNothing(): void public function testEveryConditionNeedsItsOwnReceipt(): void { $grant = Grants::grant(['conditions' => [ - ['name' => 'no-host-names', 'statement' => 'names no machine', 'by' => ['operation' => 'lab:scan']], - ['name' => 'builds', 'statement' => 'the candidate builds', 'by' => ['operation' => 'lab:build']], + ['name' => 'no-host-names', 'statement' => 'names no machine', 'by' => ['operation' => 'lab:scan', 'free' => ['path']]], + ['name' => 'builds', 'statement' => 'the candidate builds', 'by' => ['operation' => 'lab:build', 'free' => ['path']]], ]]); $scan = Grants::receipt(); $build = Grants::receipt(by: 'lab:build'); @@ -264,7 +264,7 @@ private static function suspended(): GrantStanding public function testAmongSeveralGrantsOneThatPassesIsEnough(): void { - $unmeasured = self::held('asks-more', change: ['conditions' => [['name' => 'builds', 'statement' => 'it builds', 'by' => ['operation' => 'lab:build']]]]); + $unmeasured = self::held('asks-more', change: ['conditions' => [['name' => 'builds', 'statement' => 'it builds', 'by' => ['operation' => 'lab:build', 'free' => ['path']]]]]); $answer = StandingJudge::among([$unmeasured, self::held('first-that-passes'), self::held('also-passes')], Grants::call()); self::assertNotNull($answer); diff --git a/tests/Standing/WhatAReviewFoundInTheContractTest.php b/tests/Standing/WhatAReviewFoundInTheContractTest.php new file mode 100644 index 0000000..561827a --- /dev/null +++ b/tests/Standing/WhatAReviewFoundInTheContractTest.php @@ -0,0 +1,119 @@ +ceiling->authority->value, 'control: acting as a user fits under a ceiling'); + foreach (['privileged', 'unknown'] as $authority) { + try { + Grants::grant(['ceiling' => ['authority' => $authority] + $ceiling]); + self::fail("a ceiling with authority «{$authority}» was accepted"); + } catch (\InvalidArgumentException $refused) { + self::assertMatchesRegularExpression('/gives authority|unclassified ceiling/', $refused->getMessage()); + } + } + } + + public function testAnInstrumentMatchesOnlyTheCallItsConditionDescribes(): void + { + // The condition fixes strict=true and says which argument carries the candidate. Nothing else may ride along. + $by = NamedCall::fromArray(['operation' => 'lab:scan', 'arguments' => ['strict' => true], 'free' => ['path']], 'here'); + + self::assertTrue($by->matchesExactly('lab:scan', ['strict' => true, 'path' => 'a.tar'])); + self::assertTrue($by->matchesExactly('lab_scan', ['strict' => true]), 'an argument it leaves free may be absent'); + self::assertFalse($by->matchesExactly('lab:scan', ['strict' => true, 'path' => 'a.tar', 'skip' => 'binaries']), 'an argument nobody described'); + self::assertFalse($by->matchesExactly('lab:scan', ['strict' => false, 'path' => 'a.tar'])); + self::assertFalse($by->matchesExactly('lab:scan', ['path' => 'a.tar']), 'a fixed argument left out'); + self::assertFalse($by->matchesExactly('lab:lint', ['strict' => true, 'path' => 'a.tar'])); + self::assertSame(['operation' => 'lab.scan', 'arguments' => ['strict' => true], 'free' => ['path']], $by->toArray()); + self::assertSame(['operation' => 'lab.scan'], (new NamedCall('lab:scan'))->toArray(), 'one spelling when nothing is free'); + } + + public function testALaterGreenRunWithAnArgumentThatWeakensItDoesNotCoverARedOne(): void + { + $grant = Grants::grant(['conditions' => [['name' => 'no-host-names', 'statement' => 'names no machine', 'by' => ['operation' => 'lab:scan', 'free' => ['path']]]]]); + $red = Grants::receipt(ok: false); + $weakened = Grants::receipt(arguments: ['path' => 'candidate.tar', 'lenient' => true]); + + $verdict = StandingJudge::judge($grant, Grants::standing([$red, $weakened]), Grants::call()); + + self::assertSame('measured_red', $verdict->reason, 'the run nobody described does not speak for the condition'); + self::assertTrue(StandingJudge::judge($grant, Grants::standing([$red, Grants::receipt()]), Grants::call())->passes(), 'control: the same call, green afterwards, does'); + } + + public function testACoveredActStillLeavesItsOtherArgumentsFree(): void + { + // A grant covers a CLASS of acts: the candidate changes on every call. Closing that would cover nothing. + self::assertNotNull(Grants::grant()->covering('lab:publish', ['channel' => 'stable', 'path' => 'any.tar', 'note' => 'x'])); + + try { + Grants::grant(['covers' => [['operation' => 'lab:publish', 'free' => ['path']]]]); + self::fail('«free» was accepted where nothing reads it'); + } catch (\InvalidArgumentException $refused) { + self::assertStringContainsString('«free»', $refused->getMessage()); + } + } + + public function testWhatIsFreeIsAListOfArgumentNamesAndNeverOneThatIsFixed(): void + { + foreach ([['free' => 'path'], ['free' => [7]], ['free' => [' ']], ['arguments' => ['path' => 'a'], 'free' => ['path']]] as $broken) { + try { + NamedCall::fromArray(['operation' => 'lab:scan'] + $broken, 'here'); + self::fail('accepted: ' . json_encode($broken)); + } catch (\InvalidArgumentException $refused) { + self::assertStringContainsString('free', $refused->getMessage()); + } + } + } + + public function testAGrantThatSupersedesAnotherMaySayWhichSuspensionItLifts(): void + { + $grant = Grants::grant(['id' => 'second', 'supersedes' => 'release-line', 'lifts' => 'sha256:the-suspension']); + + self::assertSame('sha256:the-suspension', $grant->lifts); + self::assertSame('sha256:the-suspension', $grant->toArray()['lifts']); + self::assertSame($grant->digest(), StandingGrant::fromJson($grant->canonical())->digest()); + self::assertNull(Grants::grant()->lifts); + self::assertNotSame($grant->digest(), Grants::grant(['id' => 'second', 'supersedes' => 'release-line'])->digest(), 'what it lifts is part of what was signed'); + + foreach ([['lifts' => 'sha256:x'], ['supersedes' => 'older', 'lifts' => ' '], ['supersedes' => 'older', 'lifts' => 7]] as $broken) { + try { + Grants::grant($broken); + self::fail('accepted: ' . json_encode($broken)); + } catch (\InvalidArgumentException $refused) { + self::assertStringContainsString('lifts', $refused->getMessage()); + } + } + } + + public function testAWatcherIsNamedTheSameClosedWay(): void + { + $grant = Grants::grant(['falsifier' => ['name' => 'f', 'statement' => 's', 'watcher' => ['operation' => 'lab:watch', 'arguments' => ['line' => 'stable']], 'within_seconds' => 60]]); + + self::assertTrue($grant->falsifier->watcher->matchesExactly('lab:watch', ['line' => 'stable'])); + self::assertFalse($grant->falsifier->watcher->matchesExactly('lab:watch', ['line' => 'stable', 'quiet' => true])); + self::assertInstanceOf(GrantStanding::class, Grants::standing()); + } +}