diff --git a/README.md b/README.md index 8f02424..9fca0fc 100644 --- a/README.md +++ b/README.md @@ -166,6 +166,89 @@ 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. + +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`, 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 +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..cec5aa1 --- /dev/null +++ b/src/Standing/NamedCall.php @@ -0,0 +1,164 @@ + + * + * @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. + * + * 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 +{ + public OperationId $operation; + + /** @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 = [], 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) { + 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); + + $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; + } + + /** + * 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; + } + + /** + * 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. + * + * @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]) + + ($this->free === [] ? [] : ['free' => $this->free]); + } + + /** 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', '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, $free); + } +} 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..6eebc37 --- /dev/null +++ b/src/Standing/StandingGrant.php @@ -0,0 +1,344 @@ + + * + * @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\Authority; +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; + * · 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}. + * + * 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; + + /** 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', 'lifts', '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 + * @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, + array $to, + array $covers, + public EffectProfile $ceiling, + array $conditions, + public string $undo, + 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}»"); + } + 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}»"); + } + + 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) { + 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->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.', + $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 ($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"); + } + $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, + 'lifts' => $this->lifts, + '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"); + } + $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, + 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, + lifts: $lifts, + ); + } + + /** 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..8dd2fb2 --- /dev/null +++ b/src/Standing/StandingJudge.php @@ -0,0 +1,253 @@ + + * + * @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. + * + * ── 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 + * 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->matchesExactly($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); + } + + /** + * 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 + { + 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..e613386 --- /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', '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], + ]; + 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..69f5948 --- /dev/null +++ b/tests/Standing/TheStandingJudgeAsksSevenThingsTest.php @@ -0,0 +1,373 @@ +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], '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()); + } + + 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', 'free' => ['path']]], + ['name' => 'builds', 'statement' => 'the candidate builds', 'by' => ['operation' => 'lab:build', 'free' => ['path']]], + ]]); + $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); + } + + /** + * @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', 'free' => ['path']]]]]); + $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); + } +} 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()); + } +}