diff --git a/README.md b/README.md index 9fca0fc..fa89843 100644 --- a/README.md +++ b/README.md @@ -193,13 +193,13 @@ 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* | +| 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. **`judge()` does not answer a stop beside the call**: see just below | +| 2 | `Signature` | the grant's signature does not hold *now*, or what the host keeps of the grant does not read whole — its list of receipts holding something that is not a receipt among the ways — and a host that does not say it verified the one and read the other is read as one that did neither | | 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 | +| 7 | `Conditions` | a condition has no green receipt on this content, from the instrument as it is today — or the last receipt about this content says no | ```php $verdict = StandingJudge::judge($grant, $standing, $call); @@ -209,15 +209,87 @@ $verdict->passes(); // true: every check held; $verdict->cited are the receipt $verdict->failed; // the StandingCheck that refused, with $verdict->sentence for the reader ``` +**A host asks `among()`, also when it holds one grant** — and hands it every grant that lends to the +seat and covers the operation, not only those that name the call: + +```php +$answer = StandingJudge::among([['grant' => $grant, 'standing' => $standing]], $call, $recordFault); +``` + +`judge()` answers for one grant and says whether *that grant names the call as it is spelled*. It +does not ask the one criterion `among()` adds (decided by Rod, 2026-10-12): a **stopped** grant that only +*may* be naming the call — the argument it fixes left out, or empty — is "not named" to `judge()`, +and refused, `stopped_beside`, by `among()`. A host that asks `judge()` alone and lets "not named" go +on to what the seat holds on its own has let the seat walk around a stop; one that must ask it asks +`StandingJudge::mayBeNaming()` of the same grant too. + 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. +**Nothing in `GrantStanding` says "yes" by default.** Where the grant stands (`state`) has to be +said. The signature is faulted until the host says it verified it (`signatureFault: null`), and so is +the record: `recordFault: null` says every line the host keeps of this grant was read. A host that +forgets any of the three lends nothing. + 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. +supersedes it by name. So does a grant that names the call and **whose record does not read whole** +— the host said so (`recordFault`), or its list of receipts holds something that is not a receipt: +the line that cannot be read may be the one that stopped it, and a stop that cannot be read is not +"no stop" — whatever state that grant is in. Revoked and superseded grants whose record reads stop +nobody; a grant past its own date stops nobody unless it is still suspended — a date does not lift +a stop, a person does. Otherwise one grant that passes is enough, and when none does, the refusal +that got furthest speaks. + +**`among()` asks a third thing, and it has no default: whether everything the host keeps of standing +grants reads.** A document a host kept and can no longer read is no grant it can hand in, and it may +be the one that names this call, stopped; so may a line it cannot attribute to one grant. The host +says `null` when its whole record reads and the reason when it does not — and then **nothing is lent +to any seat**, whatever grants were handed in: the answer carries no grant, and it is never `null`. + +**Decided by Rod, 2026-10-12: under doubt, a stop stops.** "The grant names the call" was made for lending, where being exact fails closed. It also +decided which calls a stop reaches, and there being exact fails open: a stopped grant fixes `channel` +to `stable`, and the seat calls with no `channel` at all — the operation's default —, with an empty +one, with `"stable "`, or with the number where the text is fixed. The stopped grant "does not name" +that call, and a wider grant lent it — or nobody's grant did, and the seat's own word decided. + +So a stopped grant (suspended, or its record not read whole) that lends to the seat +and covers the operation **refuses the call** (`stopped_beside`), whether or not another grant would +lend it. Only one thing is outside it, and the rule is said from that side: **a call that says, +beyond doubt, another value for something that entry fixes — a value of the same kind as the fixed +one (text for text, a whole number for a whole number, true or false for true or false), written the +way a grant can fix it (visible ASCII, single spaces inside, none around, never the empty text), and +different.** Everything else is doubt, and stops: not saying it, `null`, the empty text, another kind +(text for a number, a number for text, a word or a number for true or false, a number with a +fraction), spaces around the value, a list, an object. Nothing is listed of what PHP reads as one +value, because that list never ends: `"07"`, `" 7"`, `"7.0"`, `"+7"` and `"7e0"` are all 7 to some +reader, and the word `"false"` is true to `if`. + +- **For lending nothing changes**: a call is lent only under a grant that names it exactly. +- **The refusal names the way out of its own kind of stop**: a person lifts a suspension or gives a + grant that supersedes the stopped one by name; a record that does not read is out of the way when + it reads again — superseding leaves it where it was. +- **The same question is the host's to ask of a grant in force**: `StandingJudge::mayBeNaming()`. + `among()` asks it of stopped grants only. Where no grant names a call exactly the answer is `null`, + "nobody's call", and a host that then lets a seat act on what the seat holds on its own lets it + leave the *conditions* of a grant in force behind by leaving out the argument that grant fixes. + Such a host asks before the seat's own word decides; what it does with a yes is its rule. +- **What it does not reach, and a host owes**: an operation's *own* reading of a value. A call that + says `Stable` where `stable` is fixed, or the text `"07"` where the text `"7"` is, says another + value; an operation that folds capitals or reads both as one number makes it the stopped act. A + host hands the judge the arguments **as the operation reads them** — defaults filled in, texts as + it trims or folds them, types as declared (`StandingCall`). **It was decided with that limit + said**: no rule that compares values closes it — a list in one text, a pattern, a flag given as a + word, an argument the act does not turn on. Only a stop over the whole operation would, and that + was not taken: it also stops every call that is certainly another act. +- **What it costs**: while a grant is stopped, a call over that operation by that seat is refused + unless it says, in the grant's own kind and spelling, another value for one thing each of that + grant's entries over the operation fixes — also where the operation's default, or its reading of + `"7"`, is certainly another value than the fixed one. +- **How it is filed**: under the check of its kind, as the judge files the same stop when it names + the call — `signature` for a record that does not read, `in_force` for a suspension that reads. 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 @@ -225,6 +297,103 @@ unclassified axis, a ceiling above `compensatable`, a ceiling whose **authority* `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 built in code is held to what a grant read from its text is held to.** Every text, every +name and the expiry are refused by the constructors for the same reasons the reader refuses them, so +a grant put together by hand reads back from its own spelling. And a name is given **as text**: a +named call, a receipt and a call are not built from an `OperationId` made elsewhere — an identity has +already had its ends taken off, and nothing can ask for them back. Anything but a text is a +`TypeError` **whatever the caller's file declares**: `strict_types` is the caller's, and from PHP's +default mode an `OperationId` would turn itself into text before the constructor looked. + +**The text a person signs is read with `StandingGrant::fromJson()`**, and it is read as it is shown: + +- it says each thing **once** — a key said twice in one object is refused, at every depth, however + it is spelled (a reader of JSON would take the last one and never mention the first); +- a list is written as a list — never as an object with the keys `"0"`, `"1"`; +- **decided by Rod, 2026-10-12**: it spells plainly **what names are made of** — a `\u` escape that names a letter, a digit, the colon, + the dot, the underscore, the dash or the space is refused. A text is read after its escapes are, so + an operation written entirely in escapes was shown as escapes and read as its name. In a + **sentence** — an undo, a statement — what a writer that is "safe for HTML" escapes is read: the + apostrophe, `<`, `>`, `&` (PHP's `JSON_HEX_*`, and Go, Gson and System.Text.Json by default). + **Where the text is not a sentence — a seat (`to`), the expiry (`expires_at`), and the name and the + value of an argument (`arguments`) — the escape of every printable ASCII character is refused**: a + value may be fixed to any visible ASCII, and one made of what a sentence is forgiven (`*`, `/`, + `<=`) was written whole in escapes, shown as noise and read as the value. *What that costs*: a + writer that escapes the plus cannot write an expiry with a positive offset — it writes the same + instant with `Z` —, and one that escapes `<`, `&` or the quote cannot fix an argument to a value + that holds one. The strict alternative — that second rule everywhere — was not taken: it costs + the sentences of those writers; +- it is **at most 256 KB long** (`StandingGrant::LONGEST`): a longer text is refused before a byte of + it is read — a grant is a page a person reads before signing it. So is a text whose own + **canonical spelling** would be longer (it says `supersedes`, `lifts` and `expires_at` also when + the text left them out): what the reader takes, it reads back. The limit is the reader's: a grant + put together in code has no limit of its own; +- **nothing is taken off the ends of a text.** A reader that trims repairs: the document a person + signed and the one that is kept are then two. An id, the name of an operation, a seat, a sentence + — each is given as it reads, and one with a space, a line break, a tab or a null byte before or + after it is refused. So is the name of an operation with a separator at either end + (`:release:publish:`); how a surface spells the name inside — `release:publish`, + `release.publish`, `release_publish`, in capitals or not — is still one operation; +- its **ceiling is read as it is written, or refused** — each axis is one of its values, an inverse + (`rollback_contract`) is the name of one operation under any ceiling, never a sentence, and what + the lenient reader of a stored profile would drop or change is not taken; +- `expires_at` is one form — the date, a `T`, the time to the second, then `Z` or the offset — whose + own spelling in UTC is that form too; +- **every text it keeps is one line of plain text, with nothing in it that is not seen** — `undo`, + the `name` and `statement` of a condition and of the falsifier, each seat, `lifts` and the inverse + a ceiling names. A grant is read by a person before they sign it and every time a house lists it + or a judge names it in a refusal; a text that is read otherwise than its bytes could clear the + line a terminal printed it on, and two names that read the same could be two. + +**What one line of plain text keeps:** the letters of every language, their accents, digits, +punctuation and symbols, and the ordinary space (U+0020) inside it. **What it refuses, exactly** +(`Canonical::oneLine()`): + +| Refused | Which characters | +|---------|------------------| +| control characters | U+0000–U+001F, U+007F–U+009F — the line break and the tab among them | +| format characters, every one (general category `Cf`) | U+00AD (soft hyphen), U+0600–U+0605, U+061C, U+06DD, U+070F, U+0890–U+0891, U+08E2, U+180E, U+200B–U+200F (zero-width space, non-joiner, joiner, the two direction marks), U+202A–U+202E, U+2060–U+2064, U+2066–U+206F, U+FEFF, U+FFF9–U+FFFB, U+110BD, U+110CD, U+13430–U+1343F, U+1BCA0–U+1BCA3, U+1D173–U+1D17A, U+E0001, U+E0020–U+E007F (the tags) | +| the line and the paragraph separator | U+2028, U+2029 | +| every space that is not U+0020 (`Zs`) | U+00A0, U+1680, U+2000–U+200A, U+202F, U+205F, U+3000 | +| variation selectors, and the combining grapheme joiner | U+180B–U+180D, U+180F, U+FE00–U+FE0F, U+E0100–U+E01EF; U+034F | +| vowels and fillers that print as nothing | U+17B4, U+17B5 (the Khmer inherent vowels); U+115F, U+1160, U+3164, U+FFA0, U+2800; U+13441, U+13442 (the full and the half blank of Egyptian hieroglyphs), U+16FE4 (the Khitan small script filler), U+1D159 (the musical null notehead) | +| private use (`Co`), unassigned (`Cn`, the noncharacters and the rest of the tag block among them) | by category | +| bytes that are not text | anything that is not UTF-8 — a surrogate among them | +| a space at either end | U+0020 first or last | + +What that costs: a no-break space, a soft hyphen, a zero-width joiner or non-joiner and a variation +selector are part of how some languages and some emoji are written, and a text that needs one is not +taken. Which places are assigned, and which characters are format characters, is read from the +Unicode tables of the PCRE the host runs — the lists above are those of Unicode 16; a host with older +tables refuses a letter added since. + +**The rule behind the list: no code point Unicode itself says is shown as nothing is taken** — its +property `Default_Ignorable_Code_Point`. The table covers that whole set, and a test asks every code +point there is — of the list alone, too. Where the PCRE knows the property by name (10.40 and later) +the pattern names it beside the list, so that a character added to that set is refused the day a +house runs on tables that know it. The fillers are not of that set, and are named one by one. Of the +1,112,064 code points, this reader refuses 957,526. What it does not promise is that two texts that +look the same are one: a letter of another alphabet that looks the same is text. + +**Decided by Rod, 2026-10-12**: a text that **begins with a combining mark**, or +carries **more than eight of them in a row**, is refused too. Ordinary writing stacks few — two in +Vietnamese, three in Thai, four or five in pointed Hebrew — and three hundred on one letter are +drawn over the lines around it. + +`fromArray()` reads a document already in an array, and cannot see a key said twice: the array holds +the last one only. + +A refusal says back what the drafter wrote **as JSON spells it, in plain characters, and at most a +line of it** (`Canonical::shown()`). A document may be drafted by someone other than the person who +reads the refusal; said back raw, a key could clear the line a terminal printed it on. The judge +names a condition, a falsifier and the content the same way — `condition "no-host-names" was never +measured` — so a name ends where its quote ends, and a letter that is not plain ASCII is written as +its escape there. A sentence the **host** hands the judge (why a grant is stopped, why its signature +or its record does not hold) is repeated as it came when it is one line of plain text, up to 480 +characters of it, and spelled out the same way when it is anything else (`Canonical::said()`). The +name of an operation is plain by its own pattern and has no length of its own, so a sentence says it +whole when it is at most a line, and the start of it with its length otherwise (`Canonical::short()`). + 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 @@ -232,6 +401,27 @@ matched closed (`NamedCall::matchesExactly()`): besides the fixed arguments a ca 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. +That is for what speaks **for** a condition. A receipt that says **no** counts from any run: + +| The last receipt about this content, by this instrument | The condition | +|----------------------------------------------------------|---------------| +| green, from the run the condition describes | holds | +| green, from any other run — one argument more, the fixed one missing or at another value, no argument at all | is not measured by it: the receipt before it goes on speaking | +| **red, from any run of that operation**, whatever its arguments | does not hold, until the content is measured again by the run the condition describes | +| about another content, or by another operation | says nothing of this condition | + +An argument is **fixed to a value a person can read, and no other**: text that is visible ASCII — +the characters from `!` to `~`, at least one of them, single spaces inside, none around — a whole +number, or true or false. Never the empty text: `channel=` and no `channel` at all read alike. A fixed value is compared byte by byte with what a caller sends, so a value that reads +`strict` and carries a zero-width space is another run. What that costs: an argument cannot be fixed +to a value with an accent or a letter of another alphabet. A host that lists a grant, or asks a +person to sign one, shows a fixed value through `Canonical::shown()`. + +An argument — fixed or free — is named in **letters, digits and underscores, starting with a +letter**, at most 64 of them. A name with a space after it, or with a letter of another alphabet +that looks the same, is a name no operation gives an argument: a grant that fixed it would cover a +call carrying it beside the real one. + ```json "conditions": [{"name": "no-host-names", "statement": "what is published names no machine", "by": {"operation": "release:scan", "arguments": {"strict": true}, "free": ["archive"]}}] @@ -241,6 +431,25 @@ A grant that supersedes a **suspended** one says which suspension it lifts (`lif `supersedes`): taking a suspended grant out of the way is growing back, and growing back names what it lifts. +**One spelling, and what it is.** `StandingGrant::digest()` is the digest of `Canonical::json()`: +every map sorted **by the bytes of its keys**, every list left alone. Not every value survives that +spelling — negative zero, a whole float, a float's precision (`serialize_precision`), a map whose +keys are 0, 1, 2…, a structure nested deeper than JSON is read — and a grant carries none of them. A +host that writes a line with it reads the line back before it trusts it. + +**What a host owes a line it can no longer read.** There is one reader for a new document and for +one already signed, and a later version of this contract may refuse what an earlier one took. A +host that keeps signed documents, or receipts, and reads them again treats one it can no longer read +as a **fault of its record** — never as a grant that is not there, or a measurement that was never +taken: the first would bring back whatever that grant had replaced or stopped, and the second would +leave the green receipt before a red one speaking. It says so to the judge in +`GrantStanding::$recordFault`, and the judge refuses on it. `ConditionReceipt::fromArray()` answers +`null` for a line that is not a receipt — one whose `by` names no operation, or whose digests are +not as they read (a space or a line break around one) — and `null` is that fault, not "nothing was +measured". The judge does not skip it either: anything in the list of receipts that is not a receipt +refuses the call. What a host cannot attribute to one grant, it says for the whole record, in +`among()`. + **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. diff --git a/src/Effect/ContextFacts.php b/src/Effect/ContextFacts.php index 5121049..5487c43 100644 --- a/src/Effect/ContextFacts.php +++ b/src/Effect/ContextFacts.php @@ -44,8 +44,9 @@ public function __construct( /** A stable digest of the facts, so a receipt can cite exactly what was judged. */ public function fingerprint(): string { + // By the bytes, never the way PHP compares what looks like a number: «1.0» and «1.00» are two scopes. $scopes = $this->scopes; - sort($scopes); + sort($scopes, \SORT_STRING); return 'sha256:' . hash('sha256', (string) json_encode([ 'channel' => $this->channel, diff --git a/src/Effect/DeclaredAuthorityPolicy.php b/src/Effect/DeclaredAuthorityPolicy.php index 443a54e..7cb3348 100644 --- a/src/Effect/DeclaredAuthorityPolicy.php +++ b/src/Effect/DeclaredAuthorityPolicy.php @@ -81,10 +81,10 @@ public function digest(): string $reglas = []; foreach ($this->rules as $operacion => $regla) { $scopes = $regla['scopes']; - sort($scopes); + sort($scopes, \SORT_STRING); $reglas[$operacion] = ['authority' => $regla['authority']->value, 'scopes' => $scopes]; } - ksort($reglas); + ksort($reglas, \SORT_STRING); return 'sha256:' . hash('sha256', (string) json_encode(['id' => $this->id, 'rules' => $reglas])); } diff --git a/src/Effect/DescentCertificate.php b/src/Effect/DescentCertificate.php index 47afd9b..371abc7 100644 --- a/src/Effect/DescentCertificate.php +++ b/src/Effect/DescentCertificate.php @@ -90,12 +90,14 @@ public function canonicalPayload(): string 'subject' => $p->subject->value, ]; + // By the bytes of the names, never the way PHP compares what looks like a number: two keys that compare + // equal as numbers kept the order they were written in, and one certificate had two spellings to sign. $predicado = $this->predicate; - ksort($predicado); + ksort($predicado, \SORT_STRING); $sobre = $this->envelope; $this->ordenaProfundo($sobre); $cubre = $this->covers; - sort($cubre); + sort($cubre, \SORT_STRING); return (string) json_encode([ 'certificate_subject' => [ @@ -195,7 +197,7 @@ public function coversAll(array $loweredAxes): bool /** @param array $bag */ private function ordenaProfundo(array &$bag): void { - ksort($bag); + ksort($bag, \SORT_STRING); foreach ($bag as &$v) { if (\is_array($v) && ! array_is_list($v)) { /** @var array $v */ diff --git a/src/Effect/EffectProfile.php b/src/Effect/EffectProfile.php index ea84263..b71bc9f 100644 --- a/src/Effect/EffectProfile.php +++ b/src/Effect/EffectProfile.php @@ -15,6 +15,7 @@ namespace Milpa\Command\Effect; use Milpa\Command\Consent\OperationId; +use Milpa\Command\Standing\Canonical; /** * What an operation can do at WORST — the upper bound, declared by the operation itself. @@ -53,6 +54,15 @@ */ final class EffectProfile { + /** + * The rollback contract, when reversibility claims to be `Guaranteed`. + * + * Required for that one level and for no other, because `Guaranteed` is the only claim that + * buys the operation LESS scrutiny. A claim that lowers controls has to name the artifact + * that backs it, or it is exactly the self-certification GOV-00 exists to forbid. + */ + public readonly ?string $rollbackContract; + /** * @param list $escalatesOn argument names whose value can RAISE this ceiling — declared * so a caller knows which unresolved argument is the one that @@ -73,13 +83,15 @@ public function __construct( */ public readonly Subject $subject = Subject::Unknown, /** - * The rollback contract, when reversibility claims to be `Guaranteed`. + * The rollback contract ({@see self::$rollbackContract}): text, or `null`. * - * Required for that one level and for no other, because `Guaranteed` is the only claim that - * buys the operation LESS scrutiny. A claim that lowers controls has to name the artifact - * that backs it, or it is exactly the self-certification GOV-00 exists to forbid. + * THE TYPE IS ASKED HERE, NOT LEFT TO THE CALLER'S FILE (greenhouse decisions/0615, a ninth review). + * A standing grant holds the inverse its ceiling names to «the name of one operation, as it reads», and + * it reads it from here. From PHP's default mode an `OperationId` — it is `Stringable` — turned itself + * into text before this constructor looked: an identity built elsewhere, its ends already taken off. + * Anything but a text or `null` is a `TypeError` for every caller. */ - public readonly ?string $rollbackContract = null, + mixed $rollbackContract = null, /** * Arguments that LOWER this ceiling for one call — the dangerous direction (decisions/0029). * @@ -91,6 +103,11 @@ public function __construct( */ public readonly array $descents = [], ) { + if ($rollbackContract !== null && !\is_string($rollbackContract)) { + throw new \TypeError(\sprintf('the rollback contract of an effect profile is given as text; got %s', get_debug_type($rollbackContract))); + } + $this->rollbackContract = $rollbackContract; + // A READ HAS NO SUBJECT, AND SAYING OTHERWISE IS IMPOSSIBLE RATHER THAN MERELY WRONG. // // Four blind judges classified thirty-three operations from the definition alone, and every @@ -151,12 +168,12 @@ public function __construct( // one profile, and it is asked where the table exists. if ($reversibility === Reversibility::Guaranteed && !self::namesAnOperation((string) $rollbackContract)) { throw new \InvalidArgumentException(\sprintf( - 'the rollback contract «%s» describes an inverse instead of naming one: reversibility ' + 'the rollback contract %s describes an inverse instead of naming one: reversibility ' . '«guaranteed» must name the operation that undoes this (e.g. «plugins.disable»), ' . 'because prose cannot be run, cannot be checked, and cannot be put through a gate. ' . 'An operation whose inverse is not an operation is not guaranteed — declare what it ' . 'really is.', - $rollbackContract, + Canonical::shown($rollbackContract), )); } } @@ -170,7 +187,7 @@ public function __construct( */ private static function namesAnOperation(string $contract): bool { - return preg_match('/^[a-z0-9]+(?:[-][a-z0-9]+)*(?:\.[a-z0-9]+(?:[-][a-z0-9]+)*)*$/', OperationId::canonizar($contract)) === 1; + return preg_match('/^[a-z0-9]+(?:[-][a-z0-9]+)*(?:\.[a-z0-9]+(?:[-][a-z0-9]+)*)*$/D', OperationId::canonizar($contract)) === 1; } /** @@ -391,8 +408,8 @@ public static function fromPartial(array $axes): self foreach (array_keys($axes) as $key) { if (!\in_array($key, $known, true)) { throw new \InvalidArgumentException(sprintf( - '«%s» is not an effect axis (%s): changing it is a new proposal, not a tightening — use a counter', - (string) $key, + '%s is not an effect axis (%s): changing it is a new proposal, not a tightening — use a counter', + Canonical::shown((string) $key), implode(', ', $known), )); } @@ -405,7 +422,7 @@ public static function fromPartial(array $axes): self $raw = $axes[$axis]; $case = \is_string($raw) ? $enum::tryFrom($raw) : null; if ($case === null) { - throw new \InvalidArgumentException(sprintf('%s is not a level of %s', json_encode($raw) ?: get_debug_type($raw), $axis)); + throw new \InvalidArgumentException(sprintf('%s is not a level of %s', Canonical::shown($raw), $axis)); } return $case; @@ -456,7 +473,7 @@ public static function fromArray(array $data): self $raw = $data[$key] ?? null; $case = \is_string($raw) ? $enum::tryFrom($raw) : null; if ($case === null) { - throw new \InvalidArgumentException(sprintf('a stored profile needs a valid «%s»; got %s', $key, json_encode($raw) ?: get_debug_type($raw))); + throw new \InvalidArgumentException(sprintf('a stored profile needs a valid «%s»; got %s', $key, Canonical::shown($raw))); } return $case; diff --git a/src/Operation.php b/src/Operation.php index 7c2fad8..900d5ca 100644 --- a/src/Operation.php +++ b/src/Operation.php @@ -372,7 +372,7 @@ public function instrumentDigest(): ?string $effects = $this->effectCeiling(); $scopes = $this->scopes; - sort($scopes); + sort($scopes, \SORT_STRING); return 'sha256:' . hash('sha256', Standing\Canonical::json([ 'acts_on' => $subject, diff --git a/src/Standing/Canonical.php b/src/Standing/Canonical.php index 70966ad..eefe74c 100644 --- a/src/Standing/Canonical.php +++ b/src/Standing/Canonical.php @@ -24,9 +24,57 @@ * * 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. + * + * THE ORDER IS THE ORDER OF THE BYTES. A map is sorted by the bytes of its keys, the way any language + * sorts text — never the way one language compares what looks like a number: `"10"` comes before `"9"`, + * and `"1.0"` and `"1.00"` are two keys with one place each. (Sorted as numbers they were equal, so the + * order a document was written in showed in its digest: one grant, two or three digests.) + * + * WHAT THIS SPELLING IS NOT: something every value survives. A number that is negative zero is written + * with its sign and read back without it; a whole float comes back a whole number; a float is written + * with the precision of the process that writes it (`serialize_precision`), so two processes may spell + * one differently; a map whose keys are 0, 1, 2… in that order comes back a list; a structure nested + * deeper than JSON is read cannot be read back at all; and what is not a number or not text — `INF`, + * `NAN`, bytes that are not UTF-8 — is not spelled: it throws `JsonException`. A grant carries none of + * these: its values are text, whole numbers and true or false. Nothing in this contract reads back + * what it digested, so its own digests hold; a host that WRITES a line with this spelling and reads it + * later has to read it back before it trusts it, and not write one that does not say the same. */ final class Canonical { + /** How many bytes of what a drafter wrote a refusal says back, spelled out ({@see self::shown()}). */ + private const SHOWN = 96; + + /** How many characters of a host's own sentence a verdict repeats ({@see self::said()}). */ + private const SAID = 480; + + /** + * What one line of plain text never carries ({@see self::oneLine()} lists it). By Unicode's general categories + * where there is one — controls, format characters, private use, unassigned, the line and paragraph separators, + * and every space but U+0020 — and by code point where there is none: the variation selectors, the combining + * grapheme joiner, and the vowels and fillers that print as nothing. A surrogate needs no place here: UTF-8 has + * no spelling for one, so it arrives as bytes that are not text, and those match nothing and are refused for that. + * + * Together they leave out NO code point Unicode calls `Default_Ignorable_Code_Point`. That property is the rule, + * and a test asks every code point there is for it — of this list ALONE too. The name of the property is added + * to the list where the PCRE knows it ({@see self::unseen()}); it cannot stand in for the list, because a PCRE + * older than 10.40 does not know the name, and a pattern that does not compile refuses every text there is. + * + * This is the class of characters, still open: what closes it is put after it. + */ + private const UNSEEN = '[\p{Cc}\p{Cf}\p{Co}\p{Cn}\p{Zl}\p{Zp}\x{034F}\x{180B}-\x{180F}\x{FE00}-\x{FE0F}\x{E0100}-\x{E01EF}\x{17B4}\x{17B5}\x{115F}\x{1160}\x{3164}\x{FFA0}\x{2800}\x{13441}\x{13442}\x{16FE4}\x{1D159}'; + + /** + * DECIDED BY ROD, 2026-10-12 (greenhouse decisions/0615, D11 — what a sixth review found): a text does not begin + * with a combining mark, and carries no more than eight of them in a row. + * + * Every mark is text, in any number and anywhere: a letter under three hundred stacked marks is drawn over the + * lines above and below it wherever glyphs are not clipped, and a mark with nothing before it sits on whatever a + * host printed just before the text — its own label, its opening quote. Ordinary writing stacks few: Vietnamese + * two, Thai three, pointed Hebrew four or five. + */ + private const MARKS = '^\p{M}|\p{M}{9}'; + /** * The canonical bytes of a structure. * @@ -37,6 +85,189 @@ 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 JSON text that says a key twice in one object — or that writes a list as an object. + * + * A reader of JSON takes the last one and says nothing of the first: the text a person is shown and the + * document the judge reads are then not the same, and whoever drafts it chooses which the person sees. A + * document a person signs says each thing once — at every depth, and however a key is spelled. + * + * AND A LIST IS WRITTEN AS A LIST. An object whose keys are "0", "1", "2"… in that order IS a list once it is + * read into an array: a second spelling of the same document, with the same digest, in a contract that asks for + * one. Which of the two was written can only be told from the text, and this scan is the one reading of the + * text there is — so it is told here, and nothing decodes the text a second time to ask. + * + * It answers for any text. One that does not parse — or that is nested deeper than a document is read — is + * refused before a key is looked at: the scan below walks a text it can trust to be closed, and it used to walk + * one it could not, and never came back. And it costs what the text is long: a frame is asked for its name, never + * kept by a second hand, which copied every key an object had said each time one more was read. + * + * @throws \InvalidArgumentException naming the key, and the object it is said twice in; naming where the list + * is; or saying the text is not JSON + */ + public static function saidOnce(string $json, string $where): void + { + if (!json_validate($json)) { + throw new \InvalidArgumentException($where . ' is not JSON this contract reads: it does not parse, or it is nested deeper than a document is read'); + } + // One frame per object or list that is open: the keys an object has said, the name it is known by, and + // whether its keys so far are the places of a list — "0", then "1", then "2". + $open = []; + $text = null; + $valueOf = null; + for ($at = 0, $length = \strlen($json); $at < $length; ++$at) { + $byte = $json[$at]; + if ($byte === '"') { + $from = $at; + // DECIDED BY ROD, 2026-10-12 (greenhouse decisions/0615, D12 — what a sixth review found, as the seventh + // and the eighth adjusted it): what a person signs spells plainly what NAMES are made of — and, where the text is + // not a sentence, EVERY character. A text is read after its escapes are, so `lab:` followed by seven + // `\u00..` groups was shown as escapes and read as `lab:publish`, with the digest of the plain text; + // and a value an argument is fixed to, `*` or `<=`, is made of the very characters a sentence is + // forgiven. + $exact = \in_array($open[\count($open) - 1]['name'] ?? null, self::EXACT, true) || $valueOf === self::EXACTLY; + $valueOf = null; + for (++$at; $json[$at] !== '"'; ++$at) { + if ($json[$at] === '\\' && $json[$at + 1] === 'u' && self::spellsWhatIsWrittenPlainly(substr($json, $at + 2, 4), $exact)) { + throw new \InvalidArgumentException(\sprintf( + '%s writes %s as an escape (\\u%s): what a person signs spells plainly %s — a reader would show the escape and read the character', + $where, + self::shown(\chr((int) hexdec(substr($json, $at + 2, 4)))), + strtolower(substr($json, $at + 2, 4)), + $exact ? 'every character of a seat, of an expiry, and of the name and the value of an argument' : 'what a name is made of', + )); + } + $at += $json[$at] === '\\' ? 1 : 0; + } + $text = substr($json, $from, $at - $from + 1); + } elseif ($byte === ':') { + // Outside a text, a colon follows a key and nothing else. + $key = (string) json_decode((string) $text); + $valueOf = $key; + $frame = \count($open) - 1; + if (isset($open[$frame]['said']['«' . $key])) { + throw new \InvalidArgumentException(\sprintf( + '%s says %s twice%s: a document a person signs says each thing once — a reader would take the last one and never mention the first', + $where, + self::shown($key), + $open[$frame]['name'] === null ? '' : ', inside ' . self::shown($open[$frame]['name']), + )); + } + $open[$frame]['places'] = $open[$frame]['places'] && $key === (string) \count($open[$frame]['said']); + $open[$frame]['said']['«' . $key] = true; + $open[$frame]['last'] = $key; + } elseif ($byte === '{' || $byte === '[') { + $last = \count($open) - 1; + $open[] = ['said' => [], 'places' => true, 'last' => null, 'name' => $last < 0 ? null : ($open[$last]['last'] ?? $open[$last]['name'])]; + } elseif ($byte === '}' || $byte === ']') { + $closed = array_pop($open); + // Only an object says keys, so only an object can have said the places of a list. + if ($closed['said'] !== [] && $closed['places']) { + throw new \InvalidArgumentException(\sprintf('%s writes a list as an object with the keys "0", "1"…%s: a list is written as a list', $where, $closed['name'] === null ? '' : ', in ' . self::shown($closed['name']))); + } + } + } + } + + /** + * Decided by Rod, 2026-10-12 ({@see self::saidOnce()}): the frames whose texts are NOT SENTENCES — every text inside + * `arguments` (the name of an argument and the value it is fixed to) and inside `to` (a seat). + */ + private const EXACT = ['arguments', 'to']; + + /** Decided by Rod, 2026-10-12 ({@see self::saidOnce()}): the one key whose own text is not a sentence either. */ + private const EXACTLY = 'expires_at'; + + /** + * Decided by Rod, 2026-10-12 ({@see self::saidOnce()}): whether four hexadecimal digits name a character this contract + * asks to be WRITTEN PLAINLY where it stands. + * + * IN A SENTENCE — an undo, a statement, a name — the characters NAMES ARE MADE OF: a letter, a digit, the colon, + * the dot, the underscore, the dash, or the space. Those are what an id, an operation, the name of a condition + * and an axis are spelled with: written as escapes, a name is shown as noise and read as itself. What is spared + * is what ordinary writers DO escape. A writer that is «safe for HTML» writes the apostrophe, `<`, `>`, `&` — + * and some `=`, `+` and the backtick — as escapes: PHP's own `json_encode` with `JSON_HEX_APOS`, `JSON_HEX_TAG` + * or `JSON_HEX_AMP`, and Go, Gson and System.Text.Json by default. A sentence that carries one is read. + * + * WHERE THE TEXT IS NOT A SENTENCE — a seat, an expiry, the name of an argument and the value it is fixed to — + * EVERY printable ASCII character. A value may be fixed to any visible ASCII, so one made of what a sentence is + * forgiven (`*`, `/`, `~`, `<=`) was written whole in escapes: shown as noise, read as the value. So was the + * plus of an expiry, and a seat. WHAT IT COSTS: a writer that escapes the plus cannot write an expiry with a + * positive offset — it writes the same instant with `Z` —, and one that escapes `<`, `>`, `&`, the apostrophe or + * the quote cannot fix an argument to a value that holds one, nor name a seat that does. + * + * THE STRICT ALTERNATIVE WAS NOT TAKEN: the second rule everywhere, in a sentence too. It costs the documents of + * those writers — a sentence with an apostrophe or an ampersand, written by one of them, would not be read. + */ + private static function spellsWhatIsWrittenPlainly(string $hex, bool $exact): bool + { + $code = (int) hexdec($hex); + if ($exact) { + return $code >= 0x20 && $code <= 0x7E; + } + + return $code <= 0x7F && preg_match('/^[A-Za-z0-9:._ -]$/D', \chr($code)) === 1; + } + + /** + * What a drafter wrote, the way a refusal may say it back: spelled as JSON spells it, in plain characters, and + * at most a line of it. + * + * A refusal is read by a person, often at a terminal, and what it quotes was written by whoever drafted the + * document — who may not be that person. Said back raw, a key or an id can clear the line it is printed on and + * leave «OK: … is in force» in its place, or run for three hundred thousand bytes. So nothing a terminal obeys + * and no character that reorders what is read is ever said back as it came: every one is written as its escape + * — every letter that is not plain ASCII too, so that nothing said back can pass for this contract's own «…». + * And the line it is cut to ends where a character ends: half an escape reads as something else. + */ + public static function shown(mixed $written): string + { + // Whatever cannot be spelled is spelled in part: this is asked of what a document held, never refused. JSON + // writes every control character as its escape but one — DEL — so that one is written as its escape here. + $spelled = str_replace("\x7f", '\u007f', (string) json_encode($written, \JSON_INVALID_UTF8_SUBSTITUTE | \JSON_PARTIAL_OUTPUT_ON_ERROR | \JSON_UNESCAPED_SLASHES)); + if (\strlen($spelled) <= self::SHOWN) { + return $spelled; + } + // The longest run of WHOLE characters within the line: a backslash alone, or `\u00`, is left out. + preg_match('/^(?:\\\\u[0-9a-f]{4}|\\\\[^u]|[^\\\\])*/', substr($spelled, 0, self::SHOWN), $whole); + + return $whole[0] . '… (' . \strlen($spelled) . ' bytes as written)'; + } + + /** + * A name this contract already holds to plain characters — the identity of an operation — the way a sentence says + * it: whole when it is at most a line, and the start of it with its length when it is longer. + * + * Such a name needs no escape: its own pattern leaves nothing in it to obey. It has no length of its own though — + * a hundred thousand letters are one name — and every sentence that said «run `…`» repeated them all. + */ + public static function short(string $plain): string + { + return \strlen($plain) <= self::SHOWN ? $plain : substr($plain, 0, self::SHOWN) . '… (' . \strlen($plain) . ' bytes as written)'; + } + + /** + * A sentence a HOST hands the judge — why a grant is stopped, why its signature does not hold — the way a + * verdict repeats it: as it came when it is one line of plain text, and at most {@see self::SAID} characters of + * it; spelled out by {@see self::shown()} when it is anything else. + * + * A reason is what a person reads to know what to do next, and it names things they have to cite — the digest + * of a suspension, the key that left: cut to the line `shown()` allows and written in escapes, it would no + * longer say them. So a sentence that is plain is repeated plain. What it never does is reach a terminal with a + * byte the terminal obeys, or run for as long as whoever wrote it chose. + */ + public static function said(string $sentence): string + { + if (!self::oneLine($sentence)) { + return self::shown($sentence); + } + if (preg_match('/^.{' . self::SAID . '}(?=.)/us', $sentence, $start) !== 1) { + return $sentence; + } + + return $start[0] . '… (' . \strlen($sentence) . ' bytes as written)'; + } + /** * Refuse a key this contract does not read. * @@ -52,9 +283,9 @@ 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.', + '%s carries %s, which nothing reads (it reads: %s). A line the judge ignores is a limit nobody keeps — remove it.', $where, - (string) $key, + self::shown((string) $key), implode(', ', $known), )); } @@ -62,7 +293,12 @@ public static function only(array $data, array $known, string $where): void } /** - * A required, non-blank string of a document, trimmed. + * A required, non-blank string of a document, AS IT WAS WRITTEN — and one line of plain text + * ({@see self::oneLine()}). + * + * Nothing is taken off its ends. A reader that trims a text repairs it: the document a person signed and the + * one that is kept are then two, and what was trimmed — a line break, a tab, a null byte — was written by + * someone. A text that is not as it reads is refused, and whoever drafted it writes it again. * * @param array $data */ @@ -72,8 +308,102 @@ public static function text(array $data, string $key, string $where): string if (!\is_string($value) || trim($value) === '') { throw new \InvalidArgumentException(\sprintf('%s needs «%s», in words', $where, $key)); } + if (!self::oneLine($value)) { + throw new \InvalidArgumentException(self::notOneLine("{$where} gives «{$key}»", $value)); + } + + return $value; + } + + /** + * Whether a text is ONE LINE OF PLAIN TEXT: read as it is written, with nothing in it that is not seen + * (decided by Rod, 2026-10-12). + * + * A grant is read by a person before they sign it, and again every time a house lists it or a judge names it in + * a refusal. A text that carried a line break, an escape, a character that turns the direction text is read in — + * or one that is not shown at all — would be read otherwise than its bytes: a condition's name could clear the + * line a terminal printed it on and leave «OK» in its place, and two names that read the same could be two. + * + * WHAT IS KEPT: the letters of every language, their accents, digits, punctuation and symbols — and the ordinary + * space (U+0020), as many as the sentence has, inside it. + * + * WHAT IS REFUSED, exactly — every text a grant keeps is refused when it is read if it carries one: + * + * · a control character: the C0 controls, DEL and the C1 controls (U+0000–U+001F, U+007F–U+009F) — the line + * break and the tab are two of them; + * · a FORMAT character, every one (general category Cf): the direction controls (U+061C, U+200E, U+200F, + * U+202A–U+202E, U+2066–U+2069), the zero-width space, non-joiner and joiner (U+200B–U+200D), the soft + * hyphen (U+00AD), the word joiner and the invisible operators (U+2060–U+2064), U+206A–U+206F, the byte order + * mark (U+FEFF), the interlinear annotation marks (U+FFF9–U+FFFB), the tags (U+E0001, U+E0020–U+E007F), the + * Mongolian vowel separator (U+180E) — and the rest of that category, some of which do draw something: + * U+0600–U+0605, U+06DD, U+070F, U+0890–U+0891, U+08E2, U+110BD, U+110CD, U+13430–U+1343F, U+1BCA0–U+1BCA3, + * U+1D173–U+1D17A; + * · the line and the paragraph separator (U+2028, U+2029); + * · every space that is not U+0020 (general category Zs): U+00A0, U+1680, U+2000–U+200A, U+202F, U+205F, + * U+3000; + * · a variation selector (U+180B–U+180D, U+180F, U+FE00–U+FE0F, U+E0100–U+E01EF) and the combining grapheme + * joiner (U+034F); + * · a vowel or a filler that prints as nothing: U+17B4, U+17B5 (the Khmer inherent vowels), U+115F, U+1160, + * U+3164, U+FFA0, U+2800, and four that are blank by their own definition — U+13441 and U+13442 (the full and + * the half blank of Egyptian hieroglyphs), U+16FE4 (the Khitan small script filler), U+1D159 (the musical + * null notehead); + * · a private-use character (Co), a place nothing is assigned to (Cn — the noncharacters and the rest of the + * tag block among them), and bytes that are not text at all — a surrogate among them, which UTF-8 does not + * spell; + * · and a SPACE AT EITHER END: a text is given as it reads, and nothing trims it. + * + * AND ({@see self::MARKS}; decided by Rod, 2026-10-12): a text that begins with a combining mark, or carries more + * than eight of them in a row, is refused too. + * + * WHAT THAT COSTS, and was accepted: a no-break space, a soft hyphen, a zero-width joiner or non-joiner and a + * variation selector are part of how some languages and some emoji are written, and a text that needs one is + * not taken. + * + * WHICH PLACES ARE ASSIGNED, and which characters are format characters, is read from the Unicode tables of the + * PCRE the host runs (the lists above are those of Unicode 16). A host with older tables refuses a character a + * newer one takes — a letter added to Unicode since — and a document that uses none of those is read the same + * everywhere. + * + * THE RULE BEHIND THE LIST: no code point Unicode itself says is shown as nothing — its property + * `Default_Ignorable_Code_Point` — is taken. The list above covers that whole set, and more; the fillers are + * not of that set, and are named one by one. + * + * WHAT THIS IS NOT: a promise that two texts that look the same are one. A letter of another alphabet that looks + * the same is text, and so is a mark that draws nothing of its own after a letter of another script. + */ + public static function oneLine(string $text): bool + { + return preg_match(self::unseen(), $text) === 0 && !str_starts_with($text, ' ') && !str_ends_with($text, ' '); + } - return trim($value); + /** + * The pattern that finds what one line of plain text never carries: the list, and — where this PCRE knows its + * name — Unicode's own property for «shown as nothing» beside it. + * + * The list alone leaves out no code point of that property under the tables it was written against. The property + * is there for the tables that come later: a character added to the set is refused the day a house runs on a + * PCRE that knows it, not the day this package is read again. + * + * @param bool|null $byProperty whether to name the property; `null` asks this PCRE whether it knows it + */ + private static function unseen(?bool $byProperty = null): string + { + static $known = null; + $byProperty ??= $known ??= self::knows('DI'); + + return '/' . self::UNSEEN . ($byProperty ? '\p{DI}' : '') . ']|(?! )\p{Zs}|' . self::MARKS . '/u'; + } + + /** Whether the PCRE this runs on knows a Unicode property by that name: one it does not is a pattern that does not compile. */ + private static function knows(string $property): bool + { + return @preg_match('/\p{' . $property . '}/u', '') !== false; + } + + /** What a refusal says of a text that is not one line of plain text; `$what` names it, as «X gives «key»». */ + public static function notOneLine(string $what, string $text): string + { + return \sprintf('%s in one line of plain text — no line break, no tab, no control or format character, nothing that is not shown, no space but the ordinary one and none at its ends: what a person signs is read as it is written. Got %s', $what, self::shown($text)); } /** @@ -84,7 +414,7 @@ public static function text(array $data, string $key, string $where): string private static function sorted(array $value): array { if (!array_is_list($value)) { - ksort($value); + ksort($value, \SORT_STRING); } foreach ($value as $key => $item) { if (\is_array($item)) { diff --git a/src/Standing/ConditionReceipt.php b/src/Standing/ConditionReceipt.php index d8b8bd4..efbf543 100644 --- a/src/Standing/ConditionReceipt.php +++ b/src/Standing/ConditionReceipt.php @@ -36,21 +36,50 @@ */ final readonly class ConditionReceipt { + public OperationId $by; + + /** What the run itself answered: true or false, and nothing that stands for one. */ + public bool $ok; + /** + * @param mixed $by the operation that measured, by its name as TEXT, as it reads — never an + * identity built elsewhere, which has already had its ends taken off: + * anything but a text is a `TypeError`, whatever the caller's file declares * @param array $arguments the arguments the measuring call carried + * @param mixed $ok what the run answered: `true` or `false`. THE TYPE IS ASKED HERE, NOT LEFT + * TO THE CALLER'S FILE: from PHP's default mode a parameter typed `bool` + * reads every text but "" and "0" as true — the word «false», «no», + * «failed» — before a constructor looks, and a no that was said would be + * kept as a yes. Anything but a boolean is a `TypeError` for every caller + * + * @throws \TypeError when the operation is not given as text, or the answer is not a boolean */ public function __construct( - public OperationId $by, + mixed $by, public array $arguments, public string $subject, - public bool $ok, + mixed $ok, public string $instrument, public ?string $who, public \DateTimeImmutable $at, ) { + if (!\is_bool($ok)) { + throw new \TypeError(\sprintf('what a run answered is given as true or false, never as a word or a number that stands for one; got %s', get_debug_type($ok))); + } + $this->ok = $ok; 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'); } + // NOTHING IS TRIMMED HERE EITHER. The judge joins a receipt to an act by the text of these two digests: one + // with a line break after it is another text, and a red receipt spelled that way said nothing of the content + // it measured — the green before it went on speaking. + foreach (['the content that was measured' => $subject, 'what measured it' => $instrument] as $which => $digest) { + if (!Canonical::oneLine($digest)) { + throw new \InvalidArgumentException(Canonical::notOneLine("a condition receipt gives the digest of {$which}", $digest)); + } + } + $this->by = NamedCall::named($by) + ?? throw new \InvalidArgumentException('a condition receipt says which operation measured, by its name as it reads; got ' . Canonical::shown($by)); } /** @@ -74,8 +103,27 @@ public function toArray(): array /** * 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. + * `null` and not an exception: this is asked line by line, and it says of ONE line that it is not a + * receipt. It decides nothing about the book that line is in. + * + * WHAT A HOST OWES A LINE IT CAN NO LONGER READ. The judge listens to the LAST receipt about a content, + * so a red one that stops reading as a receipt leaves the green before it speaking. A host that kept a + * line as a receipt and gets `null` for it here — because this reader, or its own writer, changed since + * — treats that line as it treats a grant it can no longer read ({@see StandingGrant::fromJson()}): it + * is a FAULT of its record, never «nothing was measured», and it says so to the judge in + * {@see GrantStanding::$recordFault} — which the judge refuses on, and which a host that says nothing is + * read as not having checked. The `at` of a receipt is for the reader of the record and is read loosely; the + * judge joins by content, not by clock. + * + * `null` IS NOT A RECEIPT, AND THE JUDGE DOES NOT SKIP IT. A host that builds its list with + * `array_map(ConditionReceipt::fromArray(...), $lines)` hands the judge that `null`, and the judge refuses on it + * ({@see StandingJudge::judge()}): anything in the list that is not a receipt is a line that was not read. + * + * A line whose digests are not as they read — a space or a line break around one, a character nobody sees in + * one — is not a receipt either: nothing is trimmed, and the judge compares them as text. + * + * A line whose `by` does not name one operation is not a receipt: it would be matched against no condition and + * no instrument, and a red one kept that way stops speaking. * * @param array $data */ @@ -84,11 +132,15 @@ 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']) === '') { + || trim($data['subject']) === '' || trim($data['instrument']) === '' + || !Canonical::oneLine($data['subject']) || !Canonical::oneLine($data['instrument'])) { + return null; + } + if (NamedCall::named($data['by']) === null) { 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); + return new self($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 index c4ca52f..792dc0d 100644 --- a/src/Standing/Falsifier.php +++ b/src/Standing/Falsifier.php @@ -33,8 +33,13 @@ public function __construct( if (trim($name) === '' || trim($statement) === '') { throw new \InvalidArgumentException('a falsifier names itself and says what would show the grant wrong'); } + foreach (['name' => $name, 'statement' => $statement] as $key => $text) { + if (!Canonical::oneLine($text)) { + throw new \InvalidArgumentException(Canonical::notOneLine("a falsifier gives «{$key}»", $text)); + } + } 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"); + throw new \InvalidArgumentException('falsifier ' . Canonical::shown($name) . ' needs «within_seconds»: how recently its watcher must have given a sign of life, as a whole number of seconds above zero'); } } @@ -61,10 +66,12 @@ public static function fromArray(mixed $data): self $name = Canonical::text($data, 'name', 'the falsifier'); $within = $data['within_seconds'] ?? null; + $where = 'falsifier ' . Canonical::shown($name); + return new self( $name, - Canonical::text($data, 'statement', "falsifier «{$name}»"), - NamedCall::fromArray($data['watcher'] ?? null, "falsifier «{$name}», in «watcher»,"), + Canonical::text($data, 'statement', $where), + NamedCall::fromArray($data['watcher'] ?? null, "{$where}, in «watcher»,"), \is_int($within) ? $within : 0, ); } diff --git a/src/Standing/GrantCondition.php b/src/Standing/GrantCondition.php index 389ff74..4ba7991 100644 --- a/src/Standing/GrantCondition.php +++ b/src/Standing/GrantCondition.php @@ -32,6 +32,11 @@ public function __construct( 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'); } + foreach (['name' => $name, 'statement' => $statement] as $key => $text) { + if (!Canonical::oneLine($text)) { + throw new \InvalidArgumentException(Canonical::notOneLine("a condition gives «{$key}»", $text)); + } + } } /** @@ -53,6 +58,8 @@ public static function fromArray(mixed $data): self 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»,")); + $where = 'condition ' . Canonical::shown($name); + + return new self($name, Canonical::text($data, 'statement', $where), NamedCall::fromArray($data['by'] ?? null, "{$where}, in «by»,")); } } diff --git a/src/Standing/GrantStanding.php b/src/Standing/GrantStanding.php index 20bc1e6..dc9c99e 100644 --- a/src/Standing/GrantStanding.php +++ b/src/Standing/GrantStanding.php @@ -24,21 +24,47 @@ */ final readonly class GrantStanding { + /** What a standing says of the signature when the host that built it said nothing: it fails closed. */ + public const NOT_VERIFIED = 'the host that asked did not say it verified the signature'; + + /** What a standing says of the host's record when the host said nothing of it: it fails closed too. */ + public const NOT_READ = 'the host that asked did not say its record of this grant reads whole'; + /** - * @param GrantState $state what the ledger derives for the grant now + * NOTHING HERE SAYS «YES» BY DEFAULT. Where the grant stands has to be said; the signature and the record are + * faulted until the host says they hold. A host that forgets one of the three lends nothing. + * + * @param GrantState $state what the ledger derives for the grant now — said by the host, always. + * It still has to be said when the line that would decide it cannot be + * read: the host says what it can still derive, AND the fault, in + * `$recordFault` — the judge refuses on the fault before it looks at + * the state, alone and among other grants * @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 string|null $signatureFault why the grant's signature does not hold NOW — or `null`: it was re-verified and it holds. + * A host says which: the default is that nobody verified it, + * and a grant nobody verified is not honoured * @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 $receipts every receipt the host holds ({@see ConditionReceipt}), in ledger order — the + * last one about a content says what is known of it now. Receipts + * and nothing else, and it is the judge that holds the list to + * that: what is in it and is not one — the `null` a reader answered + * for a line — is refused on, never skipped * @param array $instruments operation (canonical id) => its instrument digest today + * @param string|null $recordFault why what the host keeps of this grant cannot be trusted whole — a line it + * wrote as a receipt, a sign of the watcher or a stop, and can no longer + * read as one ({@see ConditionReceipt::fromArray()}) — or `null`: every + * line read. A line that is skipped is the easy thing to do, and a red + * receipt skipped leaves the green before it speaking; so the default is + * that nobody checked */ public function __construct( - public GrantState $state = GrantState::InForce, + public GrantState $state, public ?string $because = null, - public ?string $signatureFault = null, + public ?string $signatureFault = self::NOT_VERIFIED, public ?\DateTimeImmutable $watcherSignalAt = null, public array $receipts = [], public array $instruments = [], + public ?string $recordFault = self::NOT_READ, ) { } } diff --git a/src/Standing/NamedCall.php b/src/Standing/NamedCall.php index cec5aa1..1fad579 100644 --- a/src/Standing/NamedCall.php +++ b/src/Standing/NamedCall.php @@ -34,9 +34,42 @@ * 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). + * + * WHAT AN ARGUMENT IS FIXED TO (decided by Rod, 2026-10-12). A value that is text is VISIBLE ASCII and nothing + * else — the characters from `!` to `~`, single spaces inside it, none around it. A fixed value is compared byte + * by byte with what a caller sends, so a value that reads «strict» and is «strict» with a zero-width space after + * it is another run: the run a person read may have said no, and the look-alike said yes. What that costs, and was + * accepted: an argument cannot be fixed to a value with an accent, an ñ, or a letter of another alphabet — nor to + * the empty text, which is no character at all. A value that is not text — a whole number, true or false — is as + * it always was. + * + * AND WHAT A HOST OWES IT: a host that lists a grant, or asks a person to sign one, shows it to whoever signs + * through {@see Canonical::shown()} — the value as it is spelled, in quotes, so that the word «true» and the + * answer true are never the same line on a screen. */ final readonly class NamedCall { + /** + * How an argument is named: letters, digits and underscores, starting with a letter, at most 64 of them + * (decided by Rod, 2026-10-12). + * + * A name a person can read, and no other. «channel » with a space after it, or with a letter of another + * alphabet that looks the same, is a name no operation gives an argument: a grant that fixed it would cover a + * call carrying it BESIDE the real one, whatever the real one said. Measured on the published catalogue, the 76 + * names its operations declare use letters and the underscore alone; digits are taken as well, so that an + * argument named `sha256` can be fixed. + */ + private const ARGUMENT = '/^[A-Za-z][A-Za-z0-9_]{0,63}$/D'; + + /** + * The text an argument is fixed to: visible ASCII, single spaces inside, none around. Never the empty text: + * nothing is not a value a person can read, and on a line `channel=` and no `channel` at all read alike. + */ + private const VALUE = '/^[\x21-\x7E]+(?: [\x21-\x7E]+)*$/D'; + + /** The identity of one operation: segments of lowercase letters and digits, a dash inside a segment, dots between. */ + private const OPERATION = '/^[a-z0-9]+(?:-[a-z0-9]+)*(?:\.[a-z0-9]+(?:-[a-z0-9]+)*)*$/D'; + public OperationId $operation; /** @var array */ @@ -46,52 +79,107 @@ public array $free; /** + * @param mixed $operation the operation, by its name as TEXT — anything else is a `TypeError`, + * whatever the caller's file declares ({@see self::named()}) * @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 = []) + public function __construct(mixed $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) { + $id = self::named($operation); + if ($id === null) { 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, + '%s does not name one operation. A standing grant names each operation whole — no pattern, no wildcard: what is not named is not covered.', + Canonical::shown($operation), )); } $fixed = []; foreach ($arguments as $name => $value) { - if (!\is_string($name) || trim($name) === '' || !(\is_string($value) || \is_int($value) || \is_bool($value))) { + if (!\is_string($name) || preg_match(self::ARGUMENT, $name) !== 1 || !(\is_string($value) || \is_int($value) || \is_bool($value))) { + throw new \InvalidArgumentException(\sprintf( + 'an argument fixed for «%s» is a name — letters, digits and underscores, starting with a letter — and one plain value: text, a whole number, or true/false; got %s', + Canonical::short($id->canonical), + Canonical::shown([$name => $value]), + )); + } + if (\is_string($value) && preg_match(self::VALUE, $value) !== 1) { 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), + 'the argument «%s» fixed for «%s» is fixed to text a person can read and no other — visible ASCII, at least one character, single spaces inside, none around: a call is compared with it byte by byte; got %s', + $name, + Canonical::short($id->canonical), + Canonical::shown($value), )); } $fixed[$name] = $value; } - ksort($fixed); + ksort($fixed, \SORT_STRING); $open = []; foreach ($free as $name) { - if (!\is_string($name) || trim($name) === '' || \array_key_exists($name, $fixed)) { + if (!\is_string($name) || preg_match(self::ARGUMENT, $name) !== 1 || \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), + '«free» of «%s» lists the names of the arguments a call may carry with any value — each in letters, digits and underscores, starting with a letter, and never one it already fixes; got %s', + Canonical::short($id->canonical), + Canonical::shown($name), )); } $open[$name] = true; } $open = array_map(strval(...), array_keys($open)); - sort($open); + sort($open, \SORT_STRING); $this->operation = $id; $this->arguments = $fixed; $this->free = $open; } + /** + * The one operation a text names — or `null` when it names none. + * + * A name is given as it reads, with nothing before or after it. The identity of an operation is forgiving about + * HOW a surface spells it — `lab:scan`, `lab.scan`, `lab_scan`, in capitals or not — and that forgiveness used + * to reach what stood at either end: a space, a line break, a tab or a null byte, and a separator too + * (`:lab:scan:`), each taken off before anything looked. Nothing is taken off here: a spelling names an + * operation when its identity is that same spelling, separator for separator — and an identity is lowercase + * letters, digits, dashes and dots, so nothing else is in it either. + * + * IT IS ASKED OF THE TEXT, NEVER OF AN IDENTITY BUILT ELSEWHERE. An {@see OperationId} has already had its ends + * taken off, and nothing can ask for them back: so a named call, a receipt and a call are given the name as + * text, and make the identity from it here, after it has been looked at. + * + * THE TYPE IS ASKED HERE, NOT LEFT TO THE CALLER'S FILE. `strict_types` belongs to whoever calls: from PHP's + * default mode an `OperationId` — it is `Stringable` — turns itself into text before a parameter typed `string` + * ever sees it, and the identity built elsewhere is in. So the parameter takes anything, and what is not a text + * is a `TypeError` for every caller. + * + * @param mixed $operation the name, as text + * + * @throws \TypeError when what is handed in is not a text + */ + public static function named(mixed $operation): ?OperationId + { + if (!\is_string($operation)) { + throw new \TypeError(\sprintf('the name of an operation is given as text, never as an identity built elsewhere; got %s', get_debug_type($operation))); + } + $id = new OperationId($operation); + if (str_replace([':', '_'], '.', strtolower($operation)) !== $id->canonical) { + return null; + } + + return preg_match(self::OPERATION, $id->canonical) === 1 ? $id : null; + } + + /** + * Whether a value is one an argument can be fixed to: a whole number, true or false, or text a person can read + * and no other — visible ASCII, at least one character, single spaces inside, none around. + */ + public static function fixable(mixed $value): bool + { + return \is_int($value) || \is_bool($value) || (\is_string($value) && preg_match(self::VALUE, $value) === 1); + } + /** * Whether a call is the one this names: that operation, carrying every fixed argument with that value. * diff --git a/src/Standing/StandingCall.php b/src/Standing/StandingCall.php index 674ac81..eabc301 100644 --- a/src/Standing/StandingCall.php +++ b/src/Standing/StandingCall.php @@ -29,20 +29,30 @@ public OperationId $operation; /** - * @param array $arguments the arguments of the call, as the handler will receive them + * @param mixed $operation the operation that is called, by its name as TEXT, as it reads: a text + * that names no operation is not a call a grant could be asked about, and + * an identity built elsewhere has already had its ends taken off — + * anything but a text is a `TypeError`, whatever the caller's file declares + * @param array $arguments the arguments of the call AS THE OPERATION READS THEM: its defaults + * filled in, its texts as it trims or folds them, its types as it + * declares them. A grant fixes a value and the judge compares it with + * what is handed in here; a host that hands in the raw spelling of a + * surface leaves a stop, and a condition, as exact as that spelling + * ({@see StandingJudge::mayBeNaming()} says how far the judge covers it) * @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, + mixed $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); + $this->operation = NamedCall::named($operation) + ?? throw new \InvalidArgumentException('a call is of one operation, named as it reads; got ' . Canonical::shown($operation)); } } diff --git a/src/Standing/StandingCheck.php b/src/Standing/StandingCheck.php index a6bf1be..48aae9f 100644 --- a/src/Standing/StandingCheck.php +++ b/src/Standing/StandingCheck.php @@ -27,7 +27,10 @@ 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? */ + /** + * Does the grant's signature hold NOW — bytes unaltered, the signer still entitled to give it — and does what + * the host keeps of the grant read whole? Both ask whether what is about to be judged can be believed at all. + */ case Signature = 'signature'; /** Is it in force — not suspended, superseded, revoked, or past its own date? */ diff --git a/src/Standing/StandingGrant.php b/src/Standing/StandingGrant.php index dab0942..fcbf602 100644 --- a/src/Standing/StandingGrant.php +++ b/src/Standing/StandingGrant.php @@ -17,7 +17,10 @@ use Milpa\Command\Consent\OperationId; use Milpa\Command\Effect\Authority; use Milpa\Command\Effect\EffectProfile; +use Milpa\Command\Effect\Externality; +use Milpa\Command\Effect\Mutation; use Milpa\Command\Effect\Reversibility; +use Milpa\Command\Effect\Subject; /** * A STANDING GRANT: what a person signs once so that a seat may repeat an act without asking — under @@ -80,10 +83,19 @@ /** 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 ID = '/^[a-z0-9][a-z0-9._-]{0,62}$/D'; private const KEYS = ['id', 'supersedes', 'lifts', 'to', 'covers', 'ceiling', 'conditions', 'undo', 'falsifier', 'expires_at']; + /** + * The longest text read as a standing grant, in bytes (256 KB). + * + * A grant is a page a person reads before signing it. A text a thousand times that long is not one, and reading + * it is work whoever wrote it chose for the reader: keys picked to land in one bucket of PHP's own table held + * this reader for twenty seconds at 2.4 MB. So a longer text is refused before a byte of it is looked at. + */ + public const LONGEST = 262_144; + /** @var list */ public array $to; @@ -113,19 +125,23 @@ public function __construct( 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}»"); - } + self::known($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}»"); + throw new \InvalidArgumentException("standing grant «{$id}» supersedes another grant by its id, and never itself; got " . Canonical::shown($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"); } + if ($lifts !== null && !Canonical::oneLine($lifts)) { + throw new \InvalidArgumentException(Canonical::notOneLine("standing grant «{$id}» gives «lifts»", $lifts)); + } $seats = []; foreach ($to as $seat) { + if (\is_string($seat) && !Canonical::oneLine($seat)) { + throw new \InvalidArgumentException(Canonical::notOneLine("standing grant «{$id}» gives a seat in «to»", $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"); } @@ -135,7 +151,7 @@ public function __construct( 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); + sort($seats, \SORT_STRING); if ($covers === []) { throw new \InvalidArgumentException("standing grant «{$id}» covers nothing: name at least one operation in «covers»"); @@ -145,13 +161,14 @@ public function __construct( 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"); + $named = Canonical::short($act->operation->canonical); + throw new \InvalidArgumentException("standing grant «{$id}» gives «free» for «{$named}» 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, + Canonical::short($act->operation->canonical), )); } } @@ -159,6 +176,11 @@ public function __construct( 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"); } + // The inverse a ceiling names is the name of ONE operation, as it reads — under any ceiling, not only one + // that says «guaranteed»: a sentence there is a line nothing can run, check or put through a gate. + if ($ceiling->rollbackContract !== null && NamedCall::named($ceiling->rollbackContract) === null) { + throw new \InvalidArgumentException("standing grant «{$id}» gives the inverse its ceiling names («rollback_contract») as the name of one operation, as it reads — or does not give it; got " . Canonical::shown($ceiling->rollbackContract)); + } 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.', @@ -191,6 +213,14 @@ public function __construct( if (trim($undo) === '') { throw new \InvalidArgumentException("standing grant «{$id}» needs «undo»: how an act absorbed under it is taken back, in words"); } + if (!Canonical::oneLine($undo)) { + throw new \InvalidArgumentException(Canonical::notOneLine("standing grant «{$id}» gives «undo»", $undo)); + } + // A grant built by hand ends at an instant its own spelling says: to the second, in a year of four digits. + // Built with half a second more, it lent for half a second after the document it writes said it had ended. + if ($expiresAt !== null && ($expiresAt->format('u') !== '000000' || self::instant($expiresAt->setTimezone(new \DateTimeZone('UTC'))->format('Y-m-d\TH:i:s\Z')) === null)) { + throw new \InvalidArgumentException("standing grant «{$id}» expires at an instant its own spelling can say: to the whole second, in a year of four digits"); + } $this->to = $seats; $this->covers = array_values($covers); @@ -258,6 +288,47 @@ public function toArray(): array ]; } + /** + * The instant a text names when it is a date and time with its offset, in ISO 8601 — the date, a `T`, the time + * to the second, then `Z` or the offset as hours and minutes with its sign — and `null` for anything else. + * + * ONE FORM, AND NOTHING THE CLOCK OR THE MACHINE DECIDES (greenhouse decisions/0615, «Lo que la segunda revisión + * encontró», 6). A reader that takes whatever reads as a date takes «+1 hour»: a grant whose expiry is another + * instant at every reading, and so another grant than the one a person signed. It takes a day that does not exist + * and reads the days after it; a date with no offset and reads it in the zone of whichever process asks. A person + * who wrote any of those did not say when the grant ends — so it is refused, and they are told the form. + */ + private static function instant(string $text): ?\DateTimeImmutable + { + if (preg_match('/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:Z|[+-](?:[01]\d|2[0-3]):[0-5]\d)$/D', $text) !== 1) { + return null; + } + $instant = \DateTimeImmutable::createFromFormat('Y-m-d\TH:i:sP', $text); + + // A day, an hour or a second that does not exist is read by rolling over to one that does: what it reads + // back as is then not what was written. + if ($instant === false || $instant->format('Y-m-d\TH:i:s') !== substr($text, 0, 19)) { + return null; + } + + // AND IT IS AN INSTANT THIS READER TAKES IN ITS OWN SPELLING. A grant spells its expiry in UTC, and the last + // second of year 9999 written west of UTC is year 10000 there: a text this very reader refuses. A grant that + // could be read and whose own spelling could not would be a document its house cannot read back. + return preg_match('/^\d{4}$/D', $instant->setTimezone(new \DateTimeZone('UTC'))->format('Y')) === 1 ? $instant : null; + } + + /** + * Refuse an id that is not one: a short name in plain lowercase, and nothing after it. + * + * @throws \InvalidArgumentException + */ + private static function known(string $id): void + { + 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 ' . Canonical::shown($id)); + } + } + /** The exact bytes that identify this grant — one spelling, whatever order its author wrote it in. */ public function canonical(): string { @@ -271,7 +342,12 @@ public function digest(): string } /** - * A grant from the document a person wrote. Every refusal names what is missing or what is not read. + * A grant from the document a person wrote, already read into an array. Every refusal names what is missing or + * what is not read. + * + * WHAT AN ARRAY NO LONGER SAYS. A text that says a key twice, or writes a list as an object, is refused by + * {@see self::fromJson()}, which reads the text: this cannot see a key said twice, because the array holds the + * last one only. Whoever holds the text a person signed reads it with `fromJson()`. * * @param array $data */ @@ -279,6 +355,8 @@ public static function fromArray(array $data): self { Canonical::only($data, self::KEYS, 'a standing grant'); $id = Canonical::text($data, 'id', 'a standing grant'); + // The id is looked at before a sentence is built around it: every refusal below names the grant by it. + self::known($id); $where = "standing grant «{$id}»"; $ceiling = $data['ceiling'] ?? null; @@ -286,10 +364,11 @@ public static function fromArray(array $data): self 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}"); + $read = self::ceiling($ceiling, $where); $expires = $data['expires_at'] ?? null; - $expiresAt = \is_string($expires) ? date_create_immutable($expires) : null; - if ($expires !== null && !$expiresAt instanceof \DateTimeImmutable) { + $expiresAt = \is_string($expires) ? self::instant($expires) : null; + if ($expires !== null && $expiresAt === null) { throw new \InvalidArgumentException("{$where} gives «expires_at» as a date and time with its offset, in ISO 8601"); } @@ -306,7 +385,7 @@ public static function fromArray(array $data): 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), + ceiling: $read, conditions: array_map(GrantCondition::fromArray(...), self::listOf($data, 'conditions', $where)), undo: Canonical::text($data, 'undo', $where), falsifier: Falsifier::fromArray($data['falsifier'] ?? null), @@ -316,15 +395,88 @@ public static function fromArray(array $data): self ); } - /** A grant from its JSON text. */ + /** + * A grant from its JSON text — the text a person signed. + * + * ONE READER, FOR A NEW DOCUMENT AND FOR ONE ALREADY SIGNED. What this refuses, it refuses the same way whether + * the text was never a grant or was one under an earlier version of this contract: a host that keeps signed + * documents and reads them again with a later contract cannot tell the two apart here. So such a host treats a + * document it kept and can no longer read as a FAULT of its record — never as a grant that is not there, which + * would bring back whatever that grant had replaced or stopped. And it says so to the judge: such a document is + * no grant it can hand in, so the fault is the third thing {@see StandingJudge::among()} asks for, and with it + * nothing is lent to any seat. + * + * A text longer than {@see self::LONGEST} bytes is refused before it is read — and so is one whose own + * CANONICAL spelling would be longer: that spelling says `supersedes`, `lifts` and `expires_at` also when the + * text left them out, and a text the reader took and whose spelling it refused would be a document its house + * cannot read back. The limit is of this reader: a grant put together in code has none of its own. + */ public static function fromJson(string $json): self { + if (\strlen($json) > self::LONGEST) { + throw new \InvalidArgumentException(\sprintf('a text of %d bytes is longer than a standing grant is: at most %d are read — a grant is a page a person reads before signing it', \strlen($json), self::LONGEST)); + } $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"}'); } + // One reading of the text says both what an array no longer can: a key said twice, and a list written as an + // object — read into an array, `{"0": …, "1": …}` is one. + Canonical::saidOnce($json, 'a standing grant'); + + $grant = self::fromArray($data); + if (\strlen($grant->canonical()) > self::LONGEST) { + throw new \InvalidArgumentException(\sprintf('the canonical spelling of this standing grant is %d bytes, longer than a standing grant is: at most %d are read, and a house reads back what it keeps', \strlen($grant->canonical()), self::LONGEST)); + } + + return $grant; + } + + /** + * The ceiling a document writes — read as it is written, or refused. + * + * A VALUE THE READER DOES NOT KEEP IS A LINE NOBODY KEEPS (greenhouse decisions/0615, «Lo que la cuarta revisión + * encontró», L2). The reader of a stored profile is lenient on purpose: it reads history, and it normalises what + * an older rule wrote. A grant is not history — a person is signing it now — so whatever that reader would drop + * or change is refused here instead: an inverse that is not the name of one operation in words, and an axis + * that is read as another than the one written. Otherwise two documents a person reads differently would be + * one grant, with one digest, and the record could not say which was signed. + * + * @param array $written + */ + private static function ceiling(array $written, string $where): EffectProfile + { + $axes = ['mutation' => Mutation::class, 'externality' => Externality::class, 'reversibility' => Reversibility::class, 'authority' => Authority::class, 'subject' => Subject::class]; + foreach ($axes as $axis => $enum) { + $value = $written[$axis] ?? null; + if (!\is_string($value) || $enum::tryFrom($value) === null) { + throw new \InvalidArgumentException(\sprintf( + '%s gives «%s» of its ceiling as one of: %s; got %s', + $where, + $axis, + implode(', ', array_map(static fn (\BackedEnum $case): string => (string) $case->value, $enum::cases())), + Canonical::shown($value), + )); + } + } + $inverse = $written['rollback_contract'] ?? null; + if ($inverse !== null && !\is_string($inverse)) { + throw new \InvalidArgumentException("{$where} gives «rollback_contract» of its ceiling as the name of the one operation that undoes, in words — or does not give it; got " . Canonical::shown($inverse)); + } + if ($inverse !== null && !Canonical::oneLine($inverse)) { + throw new \InvalidArgumentException(Canonical::notOneLine("{$where} gives «rollback_contract» of its ceiling", $inverse)); + } + $read = EffectProfile::fromArray($written); + if ($read->rollbackContract !== $inverse) { + throw new \InvalidArgumentException("{$where} gives «rollback_contract» of its ceiling, and nothing of it is kept: a line the judge ignores is a limit nobody keeps — remove it"); + } + foreach (['mutation' => $read->mutation, 'externality' => $read->externality, 'reversibility' => $read->reversibility, 'authority' => $read->authority, 'subject' => $read->subject] as $axis => $kept) { + if ($kept->value !== $written[$axis]) { + throw new \InvalidArgumentException(\sprintf('%s writes «%s» of its ceiling as «%s», and it is read as «%s»: a grant says what it is read as — write that', $where, $axis, (string) $written[$axis], $kept->value)); + } + } - return self::fromArray($data); + return $read; } /** diff --git a/src/Standing/StandingJudge.php b/src/Standing/StandingJudge.php index 8dd2fb2..9775a10 100644 --- a/src/Standing/StandingJudge.php +++ b/src/Standing/StandingJudge.php @@ -48,7 +48,19 @@ */ final class StandingJudge { - /** The seven checks, in order; the first that fails is the verdict. */ + /** + * The seven checks for ONE grant, in order; the first that fails is the verdict. + * + * IT DOES NOT ANSWER A STOP BESIDE THE CALL. This says whether THIS grant names the call as it is spelled, and + * what follows when it does. A stopped grant that only MAY be naming the call — the argument it fixes left + * out, or empty, or in another kind — is «not named» here, nobody's call. The refusal for that, + * `stopped_beside`, is {@see self::among()}'s: the one criterion that door adds, and this one does not ask. + * + * SO A HOST ASKS among() ALSO WHEN IT HOLDS ONE GRANT — and hands it every grant that lends to the seat and + * covers the operation, not only those that name the call. A host that asks this door alone, and lets «not + * named» go on to what the seat holds on its own, has let the seat walk around a stop by leaving one argument + * out. One that must ask here asks {@see self::mayBeNaming()} of the same grant too. + */ public static function judge(StandingGrant $grant, GrantStanding $standing, StandingCall $call): StandingVerdict { $id = $grant->id; @@ -56,22 +68,46 @@ public static function judge(StandingGrant $grant, GrantStanding $standing, Stan if ($act === null) { return StandingVerdict::refuse(StandingCheck::Named, 'not_named', "Standing grant «{$id}» does not name this call."); } - $operation = $call->operation->forCli(); + $operation = Canonical::short($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, + Canonical::said($standing->signatureFault), + )); + } + // What the host keeps of this grant is asked with the signature, before any of it is read: a receipt, a + // sign of the watcher or a stop that can no longer be read is not «none» — the line may be the one that says no. + if ($standing->recordFault !== null) { + return StandingVerdict::refuse(StandingCheck::Signature, 'record_unread', \sprintf( + 'Standing grant «%s» names this call and is not honoured: what the house keeps of it does not read whole — %s. Nothing is lent under it until its record reads again; a line that cannot be read is never read as «nothing happened».', + $id, + Canonical::said($standing->recordFault), )); } + // …AND WHAT IS IN THE LIST OF RECEIPTS AND IS NOT ONE IS A LINE THAT WAS NOT READ. The reader of a line answers + // `null` for one it cannot read, and a list built by mapping that reader over the lines holds the `null`: + // skipped, a red receipt that no longer reads left the green before it speaking. + $receipts = []; + foreach ($standing->receipts as $at => $receipt) { + if (!$receipt instanceof ConditionReceipt) { + return StandingVerdict::refuse(StandingCheck::Signature, 'record_unread', \sprintf( + 'Standing grant «%s» names this call and is not honoured: what the house keeps of it does not read whole — entry %d of its receipts is not a receipt (%s). Nothing is lent under it until its record reads again; a line that cannot be read is never read as «nothing happened».', + $id, + (int) $at + 1, + get_debug_type($receipt), + )); + } + $receipts[] = $receipt; + } 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->because === null ? '' : ' — ' . Canonical::said($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.', @@ -85,24 +121,24 @@ public static function judge(StandingGrant $grant, GrantStanding $standing, Stan )); } - $watcher = $grant->falsifier->watcher->operation->forCli(); + $watcher = Canonical::short($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`.', + '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, + Canonical::shown($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`.', + '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, + Canonical::shown($grant->falsifier->name), $signal->format(\DATE_ATOM), $grant->falsifier->withinSeconds, $watcher, @@ -128,37 +164,44 @@ public static function judge(StandingGrant $grant, GrantStanding $standing, Stan $cited = []; foreach ($grant->conditions as $condition) { - $measuring = $condition->by->operation->forCli(); + $measuring = Canonical::short($condition->by->operation->forCli()); $last = null; - foreach ($standing->receipts as $receipt) { - if ($receipt->subject === $call->subject && $condition->by->matchesExactly($receipt->by, $receipt->arguments)) { + // WHAT SPEAKS FOR A CONDITION IS READ CLOSED, AND A «NO» COUNTS FROM ANY RUN (decided by Rod, 2026-10-12). + // A green receipt counts only from the run the condition describes — a lenient run that says yes measured + // something else. A red one is that instrument saying no of this very content, whatever arguments the run + // carried: one more, the fixed one missing or at another value, or none at all. It stops the act until + // the content is measured again by the run the condition describes. Another content, or another + // operation, says nothing of this condition. + foreach ($receipts as $receipt) { + if ($receipt->subject === $call->subject + && ($condition->by->matchesExactly($receipt->by, $receipt->arguments) || (!$receipt->ok && $condition->by->operation->is($receipt->by)))) { $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.', + '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, + Canonical::shown($condition->name), + Canonical::shown($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.', + '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, + Canonical::shown($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.', + '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, + Canonical::shown($condition->name), + Canonical::shown($call->subject), $measuring, )); } @@ -169,45 +212,99 @@ public static function judge(StandingGrant $grant, GrantStanding $standing, Stan '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, + Canonical::shown($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: + * Each grant is judged by {@see self::judge()}, alone, and this says how their answers combine. IT ADDS ONE + * CRITERION of its own, which `judge()` does not ask: the stop BESIDE the call (the third point below; + * decided by Rod, 2026-10-12). So this is the door a host asks, ALSO WITH ONE GRANT: * * · 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. + * a grant that supersedes it — and a host then no longer reports it as suspended. Revoked and superseded + * grants WHOSE RECORD READS lend nothing and stop nobody; a grant past its own date stops nobody UNLESS IT + * IS STILL SUSPENDED: where a grant stands is what the house's record says, and a date does not lift a stop. + * · A grant that names the call and WHOSE RECORD DOES NOT READ WHOLE ({@see GrantStanding::$recordFault}) + * refuses it too, whatever another grant would lend: the line that cannot be read may be the one that + * stopped it, and a stop that cannot be read is not «no stop» — whatever state the grant is in, a dead + * one too. A suspension that reads speaks before it. + * · UNDER DOUBT, A STOP STOPS (decided by Rod, 2026-10-12): a stopped grant that only MAY be naming the call + * ({@see self::mayBeNaming()}) + * refuses it as well, `stopped_beside`, whether or not another grant would lend it. A stop that names the + * call speaks before it. It is filed under the check of its kind, as the judge files a stop that names + * the call: a record that does not read under the signature, a suspension that reads under «in force». * · 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 + * AND THE RECORD AS A WHOLE HAS TO BE SPOKEN FOR (`$recordFault`). Not everything a host can no longer read is + * the record of ONE grant it can hand in. A document it kept and can no longer read is no {@see StandingGrant} + * at all ({@see StandingGrant::fromJson()}), so it cannot be in the list — and it may be the grant that names + * this call, stopped. So may a line it cannot attribute to one grant. A host says here whether everything it + * keeps of standing grants reads: `null` when it does, the reason when it does not — and there is no default, + * because skipping what cannot be read is the easy thing to do. When it does not read whole, NOTHING IS LENT TO + * ANY SEAT, whatever grants were handed in and whether or not one of them names the call: the answer carries no + * grant, and it is never `null`. A host that would rather refuse before it asks may; one that asks says it. + * + * @param list $grants every grant the host holds that + * lends to the seat and covers the + * operation — NOT only those that + * name the call: a stopped one that + * may be naming it is heard here + * and nowhere else —, in the order + * they were given + * @param string|null $recordFault why the host's record of standing + * grants does not read whole, or + * `null`: every line and every + * document it keeps reads * - * @return array{grant: StandingGrant, verdict: StandingVerdict}|null + * @return array{grant: StandingGrant|null, verdict: StandingVerdict}|null */ - public static function among(array $grants, StandingCall $call): ?array + public static function among(array $grants, StandingCall $call, ?string $recordFault): ?array { + if ($recordFault !== null) { + return ['grant' => null, 'verdict' => StandingVerdict::refuse(StandingCheck::Signature, 'record_unread', \sprintf( + 'What the house keeps of its standing grants does not read whole — %s. Nothing is lent to any seat until it reads again: a grant that cannot be read may be the one that names this call, and a stop that cannot be read is never read as «no stop».', + Canonical::said($recordFault), + ))]; + } $suspended = null; + $unread = null; + $beside = null; $passed = null; $furthest = null; $naming = 0; foreach ($grants as ['grant' => $grant, 'standing' => $standing]) { $verdict = self::judge($grant, $standing, $call); if (!$verdict->named()) { + // DECIDED BY ROD, 2026-10-12 (greenhouse decisions/0615, D10 — what a seventh review found, as the + // eighth rebuilt it): UNDER DOUBT, A STOP STOPS. «The grant names the call» was made for LENDING, where being exact fails + // closed. It also decided which calls a stop reaches, and there being exact fails open: a stopped + // grant fixes `channel` to "stable", and the seat calls with no `channel` at all — the operation's + // default —, with an empty one, with "stable " or with the number where the text is fixed, so the + // stopped grant «does not name» the call. + $unreadable = $standing->recordFault !== null || self::holdsALineNotRead($standing); + if ($beside === null && ($standing->state === GrantState::Suspended || $unreadable) && self::mayBeNaming($grant, $call)) { + $beside = ['grant' => $grant, 'suspended' => $standing->state === GrantState::Suspended, 'unreadable' => $unreadable]; + } + continue; } ++$naming; $answer = ['grant' => $grant, 'verdict' => $verdict]; - if ($standing->state === GrantState::Suspended) { + if ($standing->recordFault !== null || self::holdsALineNotRead($standing)) { + // …by what the host SAID of its record, or by what the judge itself found in the list of receipts — + // asked of the list, not of the reason the grant was refused for: a signature that does not hold is + // said first, and would hide it. Asked BEFORE the state: a grant that is suspended and whose record + // does not read is told what takes it out of the way — its record reading again — and not that a + // grant superseding it would, which leaves the unread record where it was. + $unread ??= $answer; + } elseif ($standing->state === GrantState::Suspended) { $suspended = $answer; } elseif ($verdict->passes()) { $passed ??= $answer; @@ -229,7 +326,122 @@ public static function among(array $grants, StandingCall $call): ?array )]; } - return $suspended ?? $passed ?? $furthest; + // Reached with more than one grant only when no suspension spoke: a stop that reads is said first, above. + if ($unread !== null && $naming > 1) { + $verdict = $unread['verdict']; + + return ['grant' => $unread['grant'], 'verdict' => StandingVerdict::refuse( + $verdict->failed ?? StandingCheck::Signature, + $verdict->reason, + $verdict->sentence . \sprintf( + ' Another grant names this call too, and it lends nothing while what the house keeps of «%1$s» does not read whole: the line that cannot be read may be the one that stopped it.', + $unread['grant']->id, + ), + )]; + } + + // DECIDED BY ROD, 2026-10-12 (D10): a stop BESIDE the call — a + // stopped grant that may be the one that names it. It speaks whether or not another grant would lend the + // call, so that a host can refuse on it where a seat holds the word on its own: with `null`, «nobody's + // call», the seat walked around the stop by leaving out the argument it fixes. A stop that NAMES the call + // spoke first, above and below. And the way out it names is the one that takes THIS stop away. + if ($beside !== null && $suspended === null && $unread === null) { + // Filed as the judge files the same stop when it NAMES the call: a record under the signature. + return ['grant' => $beside['grant'], 'verdict' => StandingVerdict::refuse($beside['unreadable'] ? StandingCheck::Signature : StandingCheck::InForce, 'stopped_beside', \sprintf( + 'Standing grant «%1$s» %2$s, and it may be the one that names this call: it lends to this seat and covers `%3$s`, and the call does not say, beyond doubt, another value for what it fixes. Under doubt a stop stops: nothing is lent to this call until %4$s. Only a call that says another value for what «%1$s» fixes — of the same kind, spelled as a grant spells it — is certainly not its call.', + $beside['grant']->id, + match (true) { + !$beside['unreadable'] => 'is suspended', + !$beside['suspended'] => 'has a record that does not read whole', + default => 'is suspended and has a record that does not read whole', + }, + Canonical::short($call->operation->forCli()), + $beside['unreadable'] + ? \sprintf('what the house keeps of «%s» reads again', $beside['grant']->id) + : \sprintf('a person lifts that stop or gives a grant that supersedes «%s» by name', $beside['grant']->id), + ))]; + } + + return $suspended ?? $unread ?? $passed ?? $furthest; + } + + /** Whether the list of receipts a host handed in holds something that is not one: a line that was not read. */ + private static function holdsALineNotRead(GrantStanding $standing): bool + { + foreach ($standing->receipts as $receipt) { + if (!$receipt instanceof ConditionReceipt) { + return true; + } + } + + return false; + } + + /** + * DECIDED BY ROD, 2026-10-12 (greenhouse decisions/0615, D10): WHETHER A GRANT MAY BE THE ONE THAT NAMES THIS CALL, said + * another way — it lends to the seat, it covers the operation, and the call does not say, beyond doubt, another + * value for anything that entry fixes. + * + * TWO QUESTIONS, AND THEY FAIL IN OPPOSITE DIRECTIONS. Lending asks {@see StandingGrant::covering()}: the exact + * spelling, and nothing is lent on less. A STOP and a CONDITION ask this one: could this be the act the grant + * is about? There, exact is the open side — a seat that leaves out the argument a grant fixes, or sends it empty, + * or as text where a number is fixed, is «not named», and what stopped or measured that act is walked around. + * Nothing is ever LENT on this answer. + * + * WHAT A HOST OWES IT. {@see self::among()} asks it of every STOPPED grant and answers `stopped_beside`. It does + * not ask it of a grant IN FORCE: where no grant names the call exactly the answer is `null`, «nobody's call». + * A host that then lets a seat act on what the seat holds on its own lets it leave the CONDITIONS of a grant in + * force behind by leaving out the argument that grant fixes. Such a host asks this of every grant it holds for + * that seat before the seat's own word decides. What it does with a yes is the host's rule. + * + * AND WHAT IT DOES NOT REACH, said so that nobody counts on it: an operation's OWN reading of a value. A call + * that says `Stable` where a grant fixes `stable` says another value here; if the operation lower-cases what it + * is given, it is the stopped act. A host owes the judge the arguments as the operation READS them + * ({@see StandingCall}). IT WAS DECIDED WITH THAT LIMIT SAID: no rule that compares values closes it — a list in + * one text, a pattern, a flag given as a word, an argument the act does not turn on. Only a stop over the WHOLE + * operation would, and that was not taken: it also stops every call that is certainly another act. + */ + public static function mayBeNaming(StandingGrant $grant, StandingCall $call): bool + { + if (!$grant->lendsTo($call->seat)) { + return false; + } + foreach ($grant->covers as $act) { + if (!$act->operation->is($call->operation)) { + continue; + } + foreach ($act->arguments as $name => $fixed) { + if (self::saysAnother($call->arguments[$name] ?? null, $fixed)) { + continue 2; + } + } + + return true; + } + + return false; + } + + /** + * Decided by Rod, 2026-10-12 ({@see self::mayBeNaming()}): whether what a call carries for an argument is, BEYOND + * DOUBT, another value than the one a grant fixes. + * + * THE RULE IS SAID FROM THE CLOSED SIDE. Only one thing says another value: a value OF THE SAME KIND as the fixed + * one — text for text, a whole number for a whole number, true or false for true or false —, written the way a + * grant can fix it ({@see NamedCall::fixable()}: visible ASCII with single spaces inside and none around, never + * the empty text), and DIFFERENT. Everything else is doubt, and under doubt a stop stops: the argument not + * there, `null`, the empty text, another kind (text for a number, a number for text, a word or a number for + * true or false, a number with a fraction), spaces around the value, a list, an object. Listing what PHP reads + * as one value never ends — "07", " 7", "7.0", "+7" and "7e0" are all 7 to some reader, and the word "false" is + * true to `if` —, so nothing is listed. + */ + private static function saysAnother(mixed $carried, string|int|bool $fixed): bool + { + return match (true) { + \is_string($fixed) => \is_string($carried) && NamedCall::fixable($carried) && $carried !== $fixed, + \is_int($fixed) => \is_int($carried) && $carried !== $fixed, + default => \is_bool($carried) && $carried !== $fixed, + }; } /** How far through the seven checks a refusal got. */ diff --git a/src/Standing/StandingVerdict.php b/src/Standing/StandingVerdict.php index f49a417..ca83207 100644 --- a/src/Standing/StandingVerdict.php +++ b/src/Standing/StandingVerdict.php @@ -19,7 +19,11 @@ * * 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. + * the call and lets it pass. A verdict never grants, widens or restores anything. + * + * {@see StandingJudge::among()} has a fourth of its own, and {@see StandingJudge::judge()} never gives it: the + * grant does not name the call as it is spelled, is STOPPED, and may be the one that names it — `stopped_beside`. + * It is a refusal, and `named()` answers true for it. * * `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. @@ -59,7 +63,14 @@ 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. */ + /** + * Whether this is a grant's answer ABOUT this call — false means the grant does not name it as it is spelled: + * nobody's refusal, and the call goes on to whatever judged it before. + * + * A STOP BESIDE THE CALL answers true (`stopped_beside`, which only {@see StandingJudge::among()} gives): the + * grant does not name the call exactly, and refuses it all the same. So «false» from + * {@see StandingJudge::judge()} does not say that no stop stands in the way — that is `among()`'s to say. + */ public function named(): bool { return $this->failed !== StandingCheck::Named; diff --git a/tests/Standing/AConditionReceiptBindsContentAndInstrumentTest.php b/tests/Standing/AConditionReceiptBindsContentAndInstrumentTest.php index 1d7615a..5558903 100644 --- a/tests/Standing/AConditionReceiptBindsContentAndInstrumentTest.php +++ b/tests/Standing/AConditionReceiptBindsContentAndInstrumentTest.php @@ -4,7 +4,6 @@ namespace Milpa\Command\Tests\Standing; -use Milpa\Command\Consent\OperationId; use Milpa\Command\Standing\ConditionReceipt; use PHPUnit\Framework\TestCase; @@ -28,7 +27,7 @@ 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()); + new ConditionReceipt('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()); diff --git a/tests/Standing/AProposalOfASeventhReviewTest.php b/tests/Standing/AProposalOfASeventhReviewTest.php new file mode 100644 index 0000000..e1280f2 --- /dev/null +++ b/tests/Standing/AProposalOfASeventhReviewTest.php @@ -0,0 +1,420 @@ + + * + * @license Apache-2.0 + * + * @link https://github.com/getmilpa/command + */ + +declare(strict_types=1); + +namespace Milpa\Command\Tests\Standing; + +use Milpa\Command\Standing\GrantStanding; +use Milpa\Command\Standing\GrantState; +use Milpa\Command\Standing\StandingCheck; +use Milpa\Command\Standing\NamedCall; +use Milpa\Command\Standing\StandingCall; +use Milpa\Command\Standing\StandingGrant; +use Milpa\Command\Standing\StandingJudge; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\TestCase; + +/** + * UNDER DOUBT, A STOP STOPS — DECIDED BY ROD, 2026-10-12 (greenhouse decisions/0615, D10). It was a proposal of a + * seventh review, rebuilt as the eighth asked; this file keeps the name it had while it was one. + * + * A stopped grant — suspended, or its record not read whole — that lends to the seat and covers the operation refuses + * the call, whether or not another grant would lend it, unless the call says BEYOND DOUBT another value for something + * that entry fixes: a value of the same kind as the fixed one, written the way a grant can fix it, and different. + * Everything else is doubt. Decided with its limit said: an operation's own reading of a value is not reached, and + * the alternative that would reach it — a stop over the whole operation — was not taken. + * + * The first test is the seventh reviewer's own, and the data of the third are the eighth's, as they were written. + * + * @subject-in milpa/command + */ +final class AProposalOfASeventhReviewTest extends TestCase +{ + /** @param array $facts */ + private static function standing(array $facts = []): GrantStanding + { + return new GrantStanding(...$facts + ['state' => GrantState::InForce, 'signatureFault' => null, 'watcherSignalAt' => Grants::at('2026-10-11T11:50:00Z'), 'receipts' => [Grants::receipt()], 'instruments' => ['lab.scan' => Grants::INSTRUMENT], 'recordFault' => null]); + } + + private static function suspended(): GrantStanding + { + return self::standing(['state' => GrantState::Suspended, 'because' => 'its falsifier fired']); + } + + /** @param array $fixes */ + private static function narrow(array $fixes): StandingGrant + { + return Grants::grant(['id' => 'narrow', 'covers' => [['operation' => 'lab:publish', 'arguments' => $fixes]]]); + } + + private static function wide(string $id = 'wide'): StandingGrant + { + return Grants::grant(['id' => $id, 'covers' => [['operation' => 'lab:publish']]]); + } + + /** + * M1 - «a suspension that a second grant walks around is not a suspension»: a grant that fixes an argument is + * stopped, a wider grant is in force, and the seat spells the same act without that argument (the operation's + * default), or with its value as another type. The stopped grant no longer «names» the call; the wider one lends. + * The same walk passes a grant whose record does not read whole. + */ + public function testAStopIsNotWalkedAroundBySpellingTheCallAnotherWay(): void + { + $narrow = Grants::grant(['id' => 'narrow']); // lab:publish, channel fixed to "stable" + $wide = Grants::grant(['id' => 'wide', 'covers' => [['operation' => 'lab:publish']]]); // lab:publish, nothing fixed + $stops = [ + 'suspended' => self::standing(['state' => GrantState::Suspended, 'because' => 'its falsifier fired']), + 'its record does not read whole' => self::standing(['recordFault' => 'the line that stopped it cannot be read today']), + ]; + foreach ($stops as $how => $stopped) { + $held = [['grant' => $narrow, 'standing' => $stopped], ['grant' => $wide, 'standing' => self::standing()]]; + + // control: spelled as the stopped grant spells it, the call is refused whatever the wide grant would lend + $answer = StandingJudge::among($held, Grants::call(arguments: ['channel' => 'stable', 'path' => 'candidate.tar']), null); + self::assertFalse($answer['verdict']->passes() ?? true, "control ({$how})"); + // control: a call that is certainly another act - another channel, said - is the wide grant's to lend + $answer = StandingJudge::among($held, Grants::call(arguments: ['channel' => 'beta', 'path' => 'candidate.tar']), null); + self::assertTrue($answer['verdict']->passes() ?? false, "control ({$how}): another channel, said"); + + // the same act, spelled without the argument the stopped grant fixes + $answer = StandingJudge::among($held, Grants::call(arguments: ['path' => 'candidate.tar']), null); + self::assertFalse($answer['verdict']->passes() ?? true, "«narrow» is {$how}, and the call passes under «" . ($answer['grant']->id ?? '?') . '» when it does not say the channel'); + } + + // and with the fixed value as another type: a number a terminal sends as text + $numbered = Grants::grant(['id' => 'narrow', 'covers' => [['operation' => 'lab:publish', 'arguments' => ['build' => 7]]]]); + $held = [['grant' => $numbered, 'standing' => $stops['suspended']], ['grant' => $wide, 'standing' => self::standing()]]; + self::assertFalse(StandingJudge::among($held, Grants::call(arguments: ['build' => 7, 'path' => 'candidate.tar']), null)['verdict']->passes() ?? true, 'control'); + $answer = StandingJudge::among($held, Grants::call(arguments: ['build' => '7', 'path' => 'candidate.tar']), null); + self::assertFalse($answer['verdict']->passes() ?? true, 'the stopped grant fixes build to the number 7, and the call passes under «' . ($answer['grant']->id ?? '?') . '» with build as the text "7"'); + } + + /** + * What a call may carry and still be, for all the judge can tell, the act a stopped grant fixes: EVERYTHING but + * a value of the same kind, written as a grant writes one, and different. Nothing here is a list of what PHP + * reads as one value — that list never ends; these are samples of the doubt. + * + * @return iterable}> what the stopped grant fixes for «it», and what the call carries + */ + public static function spellingsThatMayBeTheSameAct(): iterable + { + // It says nothing. + yield 'the argument is not there: the default of the operation' => ['stable', []]; + yield 'the argument is null' => ['stable', ['it' => null]]; + yield 'the empty text: what a handler reads where the argument is not there' => ['stable', ['it' => '']]; + yield 'the empty text, against a number' => [7, ['it' => '']]; + yield 'the empty text, against true: it says nothing, it does not say no' => [true, ['it' => '']]; + yield 'nothing but spaces' => ['stable', ['it' => ' ']]; + yield 'the empty list' => ['stable', ['it' => []]]; + // It is not written the way a grant writes a value. + yield 'a space after the value' => ['stable', ['it' => 'stable ']]; + yield 'a space before the value' => ['stable', ['it' => ' stable']]; + yield 'the end of a line after the value, as a pipe leaves it' => ['stable', ['it' => "stable\n"]]; + yield 'a tab before the value' => ['stable', ['it' => "\tstable"]]; + yield 'a no-break space after the value' => ['stable', ['it' => 'stable' . "\u{00A0}"]]; + yield 'another value, with spaces around it: not a value a grant could fix' => ['stable', ['it' => ' beta ']]; + yield 'another value, with two spaces inside it' => ['stable', ['it' => 'be ta']]; + yield 'another value, with a letter that is not ASCII' => ['stable', ['it' => 'bet' . "\u{00E1}"]]; + yield 'a list that holds the value' => ['stable', ['it' => ['stable']]]; + yield 'a list that holds another value' => ['stable', ['it' => ['beta']]]; + yield 'an object' => ['stable', ['it' => new \stdClass()]]; + // It is of another kind than the fixed one — the same value, or another, to some reader. + yield 'a number, sent as the text a terminal sends' => [7, ['it' => '7']]; + yield 'a number, sent as text with a zero before it' => [7, ['it' => '07']]; + yield 'a number, sent as text with spaces around it' => [7, ['it' => ' 7 ']]; + yield 'a number, sent as a number with no fraction' => [7, ['it' => 7.0]]; + yield 'a number, sent as a number with a fraction' => [7, ['it' => 7.5]]; + yield 'ANOTHER number, sent as text' => [7, ['it' => '8']]; + yield 'ANOTHER number, sent with a fraction' => [7, ['it' => 8.0]]; + yield 'a number, and the call says true' => [7, ['it' => true]]; + yield 'a text that is a number, sent as the number' => ['7', ['it' => 7]]; + yield 'a text, and the call says zero' => ['stable', ['it' => 0]]; + yield 'a text, and the call says false' => ['stable', ['it' => false]]; + yield 'the word true, sent as the answer' => ['true', ['it' => true]]; + yield 'true, sent as the word' => [true, ['it' => 'true']]; + yield 'true, sent as the text a bare flag is at a terminal' => [true, ['it' => '1']]; + yield 'true, sent as the number one' => [true, ['it' => 1]]; + yield 'true, sent as yes' => [true, ['it' => 'yes']]; + yield 'true, sent as the WORD false, which `if` reads as true' => [true, ['it' => 'false']]; + yield 'true, sent as zero: the opposite answer, in another kind' => [true, ['it' => 0]]; + yield 'false, sent as the word' => [false, ['it' => 'false']]; + yield 'false, sent as zero' => [false, ['it' => 0]]; + yield 'false, sent as the text zero' => [false, ['it' => '0']]; + yield 'false, sent as the word true: the opposite answer, in another kind' => [false, ['it' => 'true']]; + } + + /** + * The eighth reviewer's eight, as they were written: each walked around the stop at the head they read. + * + * @return iterable what the stopped grant fixes, and what the call carries + */ + public static function whatSaysNothingOrTheSame(): iterable + { + // «Never the empty text: `channel=` and no `channel` at all read alike» (README, NamedCall) - and the rule takes + // the absent one for doubt, and the empty one for another value. + yield 'a text, and the call carries the empty text' => ['stable', '']; + yield 'a number, and the call carries the empty text' => [7, '']; + // PHP's own readers of a text as a number: is_numeric(), `==`, a typed parameter, (int) + yield 'a number, and the call carries its text with a zero before' => [7, '07']; + yield 'a number, and the call carries its text with a space before' => [7, ' 7']; + yield 'a number, and the call carries its text with .0 after' => [7, '7.0']; + // PHP's own reader of a text as a boolean: FILTER_VALIDATE_BOOLEAN - and the family's coercer (yes, on) + yield 'true, and the call carries the word yes' => [true, 'yes']; + yield 'true, and the call carries the word on' => [true, 'on']; + // ...and PHP's truth: every text but "" and "0" is true - the word «false» among them + yield 'true, and the call carries the WORD false, which PHP reads as true' => [true, 'false']; + } + + #[DataProvider('whatSaysNothingOrTheSame')] + public function testAStopIsNotWalkedAroundByAValueThatSaysNothingOrThatPhpReadsAsTheFixedOne(string|int|bool $fixed, mixed $carried): void + { + $held = static fn (): array => [['grant' => self::narrow(['it' => $fixed]), 'standing' => self::suspended()], ['grant' => self::wide(), 'standing' => self::standing()]]; + $call = Grants::call(arguments: ['it' => $carried, 'path' => 'candidate.tar']); + // controls: not carried at all, the stop reaches the call; with another value said, the wide grant lends + self::assertFalse(StandingJudge::among($held(), Grants::call(arguments: ['path' => 'candidate.tar']), null)['verdict']->passes() ?? true, 'control: under doubt a stop stops'); + self::assertTrue(StandingJudge::among($held(), Grants::call(arguments: ['it' => \is_bool($fixed) ? !$fixed : (\is_int($fixed) ? $fixed + 1 : 'beta'), 'path' => 'candidate.tar']), null)['verdict']->passes() ?? false, 'control: another value, said'); + + $answer = StandingJudge::among($held(), $call, null); + + self::assertFalse($answer['verdict']->passes() ?? true, '«narrow» fixes it to ' . json_encode($fixed) . ' and is suspended, and the call passes under «' . ($answer['grant']->id ?? '?') . '» carrying ' . json_encode($carried)); + } + + /** @param array $carried */ + #[DataProvider('spellingsThatMayBeTheSameAct')] + public function testUnderDoubtAStopStops(string|int|bool $fixed, array $carried): void + { + $call = Grants::call(arguments: $carried + ['path' => 'candidate.tar']); + $narrow = self::narrow(['it' => $fixed]); + self::assertFalse(StandingJudge::judge($narrow, self::standing(), $call)->named(), 'control: spelled so, it is not the call the narrow grant names — nothing is LENT on a spelling that is not exact'); + self::assertTrue(StandingJudge::among([['grant' => self::wide(), 'standing' => self::standing()]], $call, null)['verdict']->passes() ?? false, 'control: the wide grant alone lends it'); + + $stops = ['is suspended' => self::suspended(), 'has a record that does not read whole' => self::standing(['recordFault' => 'line 9 cannot be read today'])]; + foreach ($stops as $said => $stopped) { + foreach ([[$narrow, $stopped, self::wide(), self::standing()], [self::wide(), self::standing(), $narrow, $stopped]] as [$first, $how, $second, $other]) { + $answer = StandingJudge::among([['grant' => $first, 'standing' => $how], ['grant' => $second, 'standing' => $other]], $call, null); + + self::assertFalse($answer['verdict']->passes() ?? true, 'the stopped grant may be the one that names this call, and the wide one lent it'); + // Filed under the check of its kind (a ninth review, L2): a record that does not read is a fault of the record. + self::assertSame(['narrow', $said === 'is suspended' ? StandingCheck::InForce : StandingCheck::Signature, 'stopped_beside'], [$answer['grant']->id ?? null, $answer['verdict']->failed, $answer['verdict']->reason]); + self::assertStringContainsString("Standing grant «narrow» {$said}, and it may be the one that names this call: it lends to this seat and covers `lab:publish`", $answer['verdict']->sentence); + self::assertStringContainsString('does not say, beyond doubt, another value for what it fixes', $answer['verdict']->sentence); + } + } + } + + /** + * What SAYS another value, beyond doubt: of the same kind as the fixed one, written as a grant writes a value, + * and different. The stop is not this call's, and the wide grant lends. Those marked are what the rule does NOT + * reach, and says so: an operation's OWN reading of a value — one that folds capitals, or reads the text "07" + * as the text "7" — makes the stopped act of a call that says another value here. + * + * @return iterable}> + */ + public static function spellingsThatSayAnotherValue(): iterable + { + yield 'another text' => ['stable', ['it' => 'beta']]; + yield 'another text, with one space inside it, as a grant may fix one' => ['stable', ['it' => 'be ta']]; + yield 'another number' => [7, ['it' => 8]]; + yield 'zero, where another number is fixed' => [7, ['it' => 0]]; + yield 'the opposite answer' => [true, ['it' => false]]; + yield 'the opposite answer, the other way' => [false, ['it' => true]]; + yield 'another text, where the fixed text is a number' => ['7', ['it' => '8']]; + yield 'NOT REACHED, AND SAID: the value in capitals' => ['stable', ['it' => 'Stable']]; + yield 'NOT REACHED, AND SAID: a text that is the same number to PHP' => ['7', ['it' => '07']]; + yield 'NOT REACHED, AND SAID: the text zero, which `?:` reads as nothing' => ['stable', ['it' => '0']]; + } + + /** @param array $carried */ + #[DataProvider('spellingsThatSayAnotherValue')] + public function testACallThatSaysAnotherValueIsNotTheStoppedGrantsCall(string|int|bool $fixed, array $carried): void + { + $call = Grants::call(arguments: $carried + ['path' => 'candidate.tar']); + + $answer = StandingJudge::among([['grant' => self::narrow(['it' => $fixed]), 'standing' => self::suspended()], ['grant' => self::wide(), 'standing' => self::standing()]], $call, null); + + self::assertTrue($answer['verdict']->passes() ?? false); + self::assertSame('wide', $answer['grant']->id ?? null); + } + + public function testAStopStopsOnlyWhatItMayBeNaming(): void + { + $call = Grants::call(arguments: ['path' => 'candidate.tar']); + $wide = ['grant' => self::wide(), 'standing' => self::standing()]; + $lends = static fn (StandingGrant $grant, GrantStanding $standing): bool => StandingJudge::among([['grant' => $grant, 'standing' => $standing], $wide], $call, null)['verdict']->passes() ?? false; + + self::assertFalse($lends(self::narrow(['channel' => 'stable']), self::suspended()), 'control: the stop this file is about'); + // Not stopped at all: a grant in force that does not name the call says nothing of it. + self::assertTrue($lends(self::narrow(['channel' => 'stable']), self::standing())); + // Stopped, for another seat; stopped, over another operation; gone for good, which stops nobody. + self::assertTrue($lends(Grants::grant(['id' => 'narrow', 'to' => ['ANOTHER-SEAT'], 'covers' => [['operation' => 'lab:publish', 'arguments' => ['channel' => 'stable']]]]), self::suspended())); + self::assertTrue($lends(Grants::grant(['id' => 'narrow', 'covers' => [['operation' => 'lab:merge', 'arguments' => ['channel' => 'stable']]]]), self::suspended())); + self::assertTrue($lends(self::narrow(['channel' => 'stable']), self::standing(['state' => GrantState::Revoked]))); + // Stopped, with two entries over the operation: one the call says another value for, one it may be naming. + $two = Grants::grant(['id' => 'narrow', 'covers' => [['operation' => 'lab:publish', 'arguments' => ['path' => 'another.tar']], ['operation' => 'lab:publish', 'arguments' => ['channel' => 'stable']]]]); + self::assertFalse($lends($two, self::suspended())); + $none = Grants::grant(['id' => 'narrow', 'covers' => [['operation' => 'lab:publish', 'arguments' => ['path' => 'another.tar']], ['operation' => 'lab:publish', 'arguments' => ['path' => 'a-third.tar', 'channel' => 'stable']]]]); + self::assertTrue($lends($none, self::suspended()), 'the call says another value for something EACH entry fixes'); + // …and a list of receipts that holds a line that was not read is a stop of that kind too. + /** @phpstan-ignore-next-line */ + self::assertFalse($lends(self::narrow(['channel' => 'stable']), self::standing(['receipts' => [null]]))); + } + + /** A call that says one fixed value exactly and leaves another out is still, for all the judge can tell, that act. */ + public function testEveryFixedArgumentHasToSayAnotherValueOrBeTheGrantsOwn(): void + { + $two = self::narrow(['channel' => 'stable', 'build' => 7]); + $held = static fn (): array => [['grant' => $two, 'standing' => self::suspended()], ['grant' => self::wide(), 'standing' => self::standing()]]; + $reason = static function (array $arguments) use ($held): string { + $answer = StandingJudge::among($held(), Grants::call(arguments: $arguments + ['path' => 'candidate.tar']), null); + + return ($answer['verdict']->passes() ?? false) ? 'PASSED under ' . ($answer['grant']->id ?? '?') : ($answer['verdict']->reason ?? 'nobody\'s call'); + }; + + self::assertSame('suspended', $reason(['channel' => 'stable', 'build' => 7]), 'control: both said exactly — the grant names the call'); + self::assertSame('stopped_beside', $reason(['channel' => 'stable']), 'one said exactly, the other left out'); + self::assertSame('stopped_beside', $reason(['channel' => 'stable', 'build' => '7']), 'one said exactly, the other in another kind'); + self::assertSame('PASSED under wide', $reason(['channel' => 'stable', 'build' => 8]), 'one said exactly, another value for the other: it is not this grant\'s call'); + self::assertSame('PASSED under wide', $reason(['channel' => 'beta']), 'another value for one, the other left out'); + + // The same for each kind of value: said exactly, it does not say another — so what is left out still decides. + $three = self::narrow(['channel' => 'stable', 'build' => 7, 'dry' => false]); + $asked = static fn (array $arguments): bool => StandingJudge::mayBeNaming($three, Grants::call(arguments: $arguments + ['path' => 'candidate.tar'])); + self::assertTrue($asked(['channel' => 'stable']), 'the text said exactly, the number and the answer left out'); + self::assertTrue($asked(['build' => 7]), 'the number said exactly'); + self::assertTrue($asked(['dry' => false]), 'the answer said exactly'); + self::assertFalse($asked(['channel' => 'stable', 'build' => 7, 'dry' => true]), 'control: another answer, said'); + } + + /** With two stops beside the call, the first that was given speaks: the answer does not depend on which was found last. */ + public function testOfTwoStopsBesideTheCallTheFirstGivenSpeaks(): void + { + $call = Grants::call(arguments: ['path' => 'candidate.tar']); + $first = ['grant' => Grants::grant(['id' => 'first', 'covers' => [['operation' => 'lab:publish', 'arguments' => ['channel' => 'stable']]]]), 'standing' => self::suspended()]; + $second = ['grant' => Grants::grant(['id' => 'second', 'covers' => [['operation' => 'lab:publish', 'arguments' => ['channel' => 'beta']]]]), 'standing' => self::standing(['recordFault' => 'line 9 cannot be read today'])]; + + self::assertSame('first', StandingJudge::among([$first, $second], $call, null)['grant']->id ?? null); + self::assertSame('second', StandingJudge::among([$second, $first], $call, null)['grant']->id ?? null); + } + + /** + * What a grant can fix an argument to, asked on its own: the rule says «another value» only of one of these. + * + * @return iterable + */ + public static function whatAnArgumentCanBeFixedTo(): iterable + { + yield 'a text a person can read' => ['stable', true]; + yield 'a text with one space inside' => ['stable release', true]; + yield 'one visible character' => ['*', true]; + yield 'a whole number' => [7, true]; + yield 'zero' => [0, true]; + yield 'true' => [true, true]; + yield 'false' => [false, true]; + yield 'the empty text' => ['', false]; + yield 'a space before' => [' stable', false]; + yield 'a space after' => ['stable ', false]; + yield 'two spaces inside' => ['stable release', false]; + yield 'the end of a line after' => ["stable\n", false]; + yield 'a letter that is not ASCII' => ['estable' . "\u{00E9}", false]; + yield 'a number with a fraction' => [7.0, false]; + yield 'nothing' => [null, false]; + yield 'a list' => [['stable'], false]; + yield 'an object' => [new \stdClass(), false]; + } + + #[DataProvider('whatAnArgumentCanBeFixedTo')] + public function testWhatAnArgumentCanBeFixedToIsAskedOnItsOwn(mixed $value, bool $fixable): void + { + self::assertSame($fixable, NamedCall::fixable($value)); + // …and it is what the constructor of a named call takes, value for value. + try { + new NamedCall('lab:publish', ['it' => $value]); + $taken = true; + } catch (\InvalidArgumentException) { + $taken = false; + } + self::assertSame($fixable, $taken); + } + + /** THE SECOND HALF: a stop beside the call speaks whether or not another grant would lend it. */ + public function testAStopBesideTheCallSpeaksWhetherOrNotAnotherGrantWouldLend(): void + { + $call = Grants::call(arguments: ['path' => 'candidate.tar']); + $stopped = ['grant' => self::narrow(['channel' => 'stable']), 'standing' => self::suspended()]; + $beside = static fn (?array $answer): array => [$answer['grant']->id ?? null, $answer['verdict']->failed ?? null, $answer['verdict']->reason ?? null, $answer === null ? null : $answer['verdict']->passes(), $answer === null ? null : $answer['verdict']->named()]; + $stop = ['narrow', StandingCheck::InForce, 'stopped_beside', false, true]; + + // Alone: the answer was `null`, «nobody's call», and a host let what the seat holds on its own decide. + self::assertSame($stop, $beside(StandingJudge::among([$stopped], $call, null))); + // Beside a grant in force that does not name the call either. + self::assertSame($stop, $beside(StandingJudge::among([$stopped, ['grant' => Grants::grant(['id' => 'for-beta', 'covers' => [['operation' => 'lab:publish', 'arguments' => ['channel' => 'beta']]]]), 'standing' => self::standing()]], $call, null))); + // Beside a grant that names the call and refuses it for its own reason: the stop is said, in either order. + $unmeasured = ['grant' => self::wide(), 'standing' => self::standing(['receipts' => []])]; + self::assertSame('not_measured', StandingJudge::among([$unmeasured], $call, null)['verdict']->reason ?? null, 'control'); + self::assertSame($stop, $beside(StandingJudge::among([$stopped, $unmeasured], $call, null))); + self::assertSame($stop, $beside(StandingJudge::among([$unmeasured, $stopped], $call, null))); + // It cites nothing, and it is never a pass. + self::assertSame([], StandingJudge::among([$stopped], $call, null)['verdict']->cited ?? null); + + // A stop that NAMES the call as it is spelled still speaks first, as it always did. + $exact = StandingJudge::among([$stopped, ['grant' => Grants::grant(['id' => 'exact']), 'standing' => self::suspended()], ['grant' => self::wide(), 'standing' => self::standing()]], Grants::call(arguments: ['channel' => 'stable', 'path' => 'candidate.tar']), null); + self::assertSame('suspended', $exact['verdict']->reason ?? null); + $unread = StandingJudge::among([['grant' => self::narrow(['channel' => 'stable']), 'standing' => self::suspended()], ['grant' => self::wide('reads-no-more'), 'standing' => self::standing(['recordFault' => 'line 9 cannot be read today'])]], $call, null); + self::assertSame(['reads-no-more', 'record_unread'], [$unread['grant']->id ?? null, $unread['verdict']->reason ?? null]); + // …also when ANOTHER stopped grant is only beside the call: «finer» fixes one argument more, which the call + // leaves out, and «exact» names the call as it is spelled. The one that names it speaks, in either order. + $said = Grants::call(arguments: ['channel' => 'stable', 'path' => 'candidate.tar']); + $finer = ['grant' => Grants::grant(['id' => 'finer', 'covers' => [['operation' => 'lab:publish', 'arguments' => ['channel' => 'stable', 'build' => 7]]]]), 'standing' => self::suspended()]; + $named = ['grant' => Grants::grant(['id' => 'exact']), 'standing' => self::suspended()]; + self::assertSame('stopped_beside', StandingJudge::among([$finer], $said, null)['verdict']->reason ?? null, 'control: alone, «finer» is a stop beside this call'); + foreach ([[$finer, $named], [$named, $finer]] as $held) { + $answer = StandingJudge::among($held, $said, null); + self::assertSame(['exact', 'suspended'], [$answer['grant']->id ?? null, $answer['verdict']->reason ?? null]); + } + + // And where nothing is stopped, nothing changed: a grant IN FORCE that only may be naming the call says + // nothing of it — the answer is «nobody's call», and the question is the host's to ask. + self::assertNull(StandingJudge::among([['grant' => self::narrow(['channel' => 'stable']), 'standing' => self::standing()]], $call, null)); + // A grant that is gone and whose record reads stops nobody, alone either. + self::assertNull(StandingJudge::among([['grant' => self::narrow(['channel' => 'stable']), 'standing' => self::standing(['state' => GrantState::Revoked])]], $call, null)); + } + + /** The question the contract hands a host, to ask of a grant IN FORCE before a seat's own word decides. */ + public function testAHostCanAskWhetherAGrantMayBeNamingTheCall(): void + { + $narrow = self::narrow(['channel' => 'stable']); + $asks = static fn (array $arguments, ?StandingGrant $grant = null): bool => StandingJudge::mayBeNaming($grant ?? $narrow, Grants::call(arguments: $arguments + ['path' => 'candidate.tar'])); + + self::assertTrue($asks([]), 'the fixed argument left out'); + self::assertTrue($asks(['channel' => ''])); + self::assertTrue($asks(['channel' => 'stable']), 'said exactly: it names it, so it may be naming it'); + self::assertFalse($asks(['channel' => 'beta']), 'another value, said'); + self::assertFalse($asks([], Grants::grant(['id' => 'narrow', 'to' => ['ANOTHER-SEAT'], 'covers' => [['operation' => 'lab:publish', 'arguments' => ['channel' => 'stable']]]])), 'it lends to another seat'); + self::assertFalse($asks([], Grants::grant(['id' => 'narrow', 'covers' => [['operation' => 'lab:merge', 'arguments' => ['channel' => 'stable']]]])), 'it covers another operation'); + self::assertTrue(StandingJudge::mayBeNaming($narrow, new StandingCall('LAB_PUBLISH', ['path' => 'candidate.tar'], Grants::SEAT, Grants::CANDIDATE, Grants::effect(), Grants::at('2026-10-11T12:00:00Z'))), 'the operation under another of its spellings is that operation'); + + // Nothing is ever LENT on that answer: asked of the grant in force, the call is not named, and is nobody's. + $call = Grants::call(arguments: ['path' => 'candidate.tar']); + self::assertFalse(StandingJudge::judge($narrow, self::standing(), $call)->named()); + self::assertNull(StandingJudge::among([['grant' => $narrow, 'standing' => self::standing()]], $call, null)); + } + + /** What a host owes the judge is said where a host reads it. */ + public function testTheCallSaysWhatAHostOwesIt(): void + { + $said = static fn (string $class, ?string $method = null): string => (string) preg_replace('/\s*\*\s*/', ' ', (string) ($method === null ? (new \ReflectionClass($class))->getConstructor()?->getDocComment() : (new \ReflectionMethod($class, $method))->getDocComment())); + + self::assertStringContainsString('the arguments of the call AS THE OPERATION READS THEM', $said(StandingCall::class)); + self::assertStringContainsString('Such a host asks this of every grant it holds for that seat before the seat\'s own word decides', $said(StandingJudge::class, 'mayBeNaming')); + } +} diff --git a/tests/Standing/AStandingGrantNamesWhatItCoversTest.php b/tests/Standing/AStandingGrantNamesWhatItCoversTest.php index 9946cc4..c548596 100644 --- a/tests/Standing/AStandingGrantNamesWhatItCoversTest.php +++ b/tests/Standing/AStandingGrantNamesWhatItCoversTest.php @@ -79,6 +79,33 @@ public function testItsBytesDoNotDependOnTheOrderItWasWrittenIn(): void self::assertSame('the-one-before', $grant->supersedes); } + /** @return iterable */ + public static function theFormsAnExpiryIsWrittenIn(): iterable + { + yield 'in UTC, as the grant itself writes it' => ['2026-12-31T00:00:00Z', '2026-12-31T00:00:00Z']; + yield 'with an offset of zero' => ['2026-12-31T00:00:00+00:00', '2026-12-31T00:00:00Z']; + yield 'west of UTC' => ['2026-12-31T18:00:00-06:00', '2027-01-01T00:00:00Z']; + yield 'east of UTC, by a quarter of an hour' => ['2026-12-31T05:45:00+05:45', '2026-12-31T00:00:00Z']; + yield 'the last second of a leap day' => ['2028-02-29T23:59:59Z', '2028-02-29T23:59:59Z']; + } + + #[\PHPUnit\Framework\Attributes\DataProvider('theFormsAnExpiryIsWrittenIn')] + public function testAnExpiryIsOneInstantWhenItIsWrittenAndTheSameOneWheneverItIsRead(string $written, string $instant): void + { + $grant = Grants::grant(['expires_at' => $written]); + + self::assertSame($instant, $grant->toArray()['expires_at']); + // Read again later, in a process that keeps another clock and another zone: the same grant. + $zone = date_default_timezone_get(); + date_default_timezone_set('Pacific/Kiritimati'); + try { + self::assertSame($grant->digest(), Grants::grant(['expires_at' => $written])->digest()); + self::assertSame($grant->digest(), StandingGrant::fromJson($grant->canonical())->digest()); + } finally { + date_default_timezone_set($zone); + } + } + public function testItReadsBackWhatItWrote(): void { $grant = Grants::grant(['expires_at' => '2026-12-31T00:00:00Z']); diff --git a/tests/Standing/AStandingGrantNeverCrossesItsFloorsTest.php b/tests/Standing/AStandingGrantNeverCrossesItsFloorsTest.php index f20ee48..9969eb0 100644 --- a/tests/Standing/AStandingGrantNeverCrossesItsFloorsTest.php +++ b/tests/Standing/AStandingGrantNeverCrossesItsFloorsTest.php @@ -64,7 +64,7 @@ public static function documentsThatAreRefused(): iterable 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»']; + 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»']; @@ -78,11 +78,11 @@ public static function documentsThatAreRefused(): iterable 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']; + 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»']; @@ -91,10 +91,10 @@ public static function documentsThatAreRefused(): iterable 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 'a ceiling missing an axis' => [['ceiling' => ['mutation' => 'persistent']], 'gives «externality» of its ceiling as one of: none, same_principal, third_party, public, unknown; got null']; 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 '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']], @@ -102,6 +102,35 @@ public static function documentsThatAreRefused(): iterable 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 with its offset, in ISO 8601']; yield 'an expiry that is not text' => [['expires_at' => 1790000000], 'gives «expires_at» as a date and time with its offset, in ISO 8601']; + + // ONE FORM, AND NOTHING THE CLOCK DECIDES (greenhouse decisions/0615, «Lo que la segunda revisión encontró», 6). + // A date that is read from the moment of reading is another date at the next reading, and so another grant + // than the one a person signed; a date that does not exist is silently another day. + foreach ([ + 'relative to now' => '+1 hour', + 'a word for a day' => 'tomorrow', + 'a phrase' => 'last day of december 2099', + 'empty' => '', + 'a date with no time' => '2026-10-12', + 'a date and time with no offset' => '2026-12-31T00:00:00', + 'a day that does not exist' => '2026-02-30T00:00:00Z', + 'a month that does not exist' => '2026-13-01T00:00:00Z', + 'an hour that does not exist' => '2026-12-31T25:00:00Z', + 'a second that does not exist' => '2026-12-31T00:00:60Z', + 'an offset that does not exist' => '2026-12-31T00:00:00+24:00', + 'an offset of minutes that do not exist' => '2026-12-31T00:00:00+05:60', + 'an offset with no colon' => '2026-12-31T00:00:00+0000', + 'an offset of hours alone' => '2026-12-31T00:00:00-06', + 'a zone by its name' => '2026-12-31T00:00:00 Europe/Madrid', + 'a zone by its abbreviation' => '2026-12-31T00:00:00UTC', + 'a fraction of a second' => '2026-12-31T00:00:00.5Z', + 'lower case' => '2026-12-31t00:00:00z', + 'a space for the T' => '2026-12-31 00:00:00Z', + 'something after it' => "2026-12-31T00:00:00Z\n", + 'something before it' => ' 2026-12-31T00:00:00Z', + ] as $what => $text) { + yield 'an expiry that is ' . $what => [['expires_at' => $text], 'gives «expires_at» as a date and time with its offset, in ISO 8601']; + } } public function testTheValuesRefuseTheSameThingsWhenBuiltByHand(): void @@ -125,8 +154,8 @@ public function testTheValuesRefuseTheSameThingsWhenBuiltByHand(): void 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::assertStringContainsString('and one plain value: text, a whole number, or true/false', $refused(static fn () => new NamedCall('lab:scan', [' ' => 'x']))); + self::assertStringContainsString('does not name one operation', $refused(static fn () => new NamedCall('lab scan'))); self::assertSame('ACCEPTED', $refused(static fn () => new StandingGrant('g', [Grants::SEAT], [$by], $ceiling, [$condition], 'undo', $falsifier))); } diff --git a/tests/Standing/Grants.php b/tests/Standing/Grants.php index e613386..b9e48dc 100644 --- a/tests/Standing/Grants.php +++ b/tests/Standing/Grants.php @@ -4,7 +4,6 @@ namespace Milpa\Command\Tests\Standing; -use Milpa\Command\Consent\OperationId; use Milpa\Command\Effect\Authority; use Milpa\Command\Effect\EffectProfile; use Milpa\Command\Effect\Externality; @@ -13,6 +12,7 @@ use Milpa\Command\Effect\Subject; use Milpa\Command\Standing\ConditionReceipt; use Milpa\Command\Standing\GrantStanding; +use Milpa\Command\Standing\GrantState; use Milpa\Command\Standing\StandingCall; use Milpa\Command\Standing\StandingGrant; @@ -89,21 +89,24 @@ public static function call(?string $subject = self::CANDIDATE, array $arguments /** @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')); + return new ConditionReceipt($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. + * The house as it stands when everything is in order: its signature verified, 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( + GrantState::InForce, + signatureFault: null, watcherSignalAt: $watcherSignalAt === null ? null : self::at($watcherSignalAt), receipts: $receipts ?? [self::receipt()], instruments: ['lab.scan' => self::INSTRUMENT], + recordFault: null, ); } } diff --git a/tests/Standing/TheStandingJudgeAsksSevenThingsTest.php b/tests/Standing/TheStandingJudgeAsksSevenThingsTest.php index 69f5948..6f9df71 100644 --- a/tests/Standing/TheStandingJudgeAsksSevenThingsTest.php +++ b/tests/Standing/TheStandingJudgeAsksSevenThingsTest.php @@ -60,7 +60,7 @@ public function testAnotherOperationIsNotNamed(): void 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]); + $standing = new GrantStanding(GrantState::InForce, 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()); @@ -71,7 +71,7 @@ public function testASignatureThatDoesNotHoldNowRefusesAndSaysWhy(): void 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]); + $standing = new GrantStanding(GrantState::Suspended, 'its falsifier fired at 2026-10-11T11:55:00Z', signatureFault: null, watcherSignalAt: Grants::at('2026-10-11T11:50:00Z'), receipts: [Grants::receipt()], instruments: ['lab.scan' => Grants::INSTRUMENT], recordFault: null); $verdict = StandingJudge::judge(Grants::grant(), $standing, Grants::call()); self::assertSame(StandingCheck::InForce, $verdict->failed); @@ -84,7 +84,7 @@ public function testASuspendedGrantRefusesItsWholeClassAndSaysOnlyAPersonLiftsIt public function testASupersededOrRevokedGrantLendsNothingAndNothingBringsItBack(): void { foreach ([GrantState::Superseded, GrantState::Revoked] as $state) { - $verdict = StandingJudge::judge(Grants::grant(), new GrantStanding($state, receipts: [Grants::receipt()]), Grants::call()); + $verdict = StandingJudge::judge(Grants::grant(), new GrantStanding($state, signatureFault: null, receipts: [Grants::receipt()], recordFault: null), Grants::call()); self::assertSame(StandingCheck::InForce, $verdict->failed); self::assertSame($state->value, $verdict->reason); @@ -208,7 +208,7 @@ public function testWhatAnOlderInstrumentFoundDoesNotHoldForTheOneThatReplacedIt public function testAnInstrumentTheHouseNoLongerHasMeasuresNothing(): void { - $standing = new GrantStanding(watcherSignalAt: Grants::at('2026-10-11T11:50:00Z'), receipts: [Grants::receipt()], instruments: []); + $standing = new GrantStanding(GrantState::InForce, signatureFault: null, watcherSignalAt: Grants::at('2026-10-11T11:50:00Z'), receipts: [Grants::receipt()], instruments: [], recordFault: null); self::assertSame('instrument_changed', StandingJudge::judge(Grants::grant(), $standing, Grants::call())->reason); } @@ -224,12 +224,12 @@ public function testEveryConditionNeedsItsOwnReceipt(): void $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()); + $missing = StandingJudge::judge($grant, new GrantStanding(GrantState::InForce, signatureFault: null, watcherSignalAt: $watched, receipts: [$scan], instruments: $instruments, recordFault: null), Grants::call()); self::assertSame('not_measured', $missing->reason); - self::assertStringContainsString('«builds»', $missing->sentence); + self::assertStringContainsString('condition "builds" was never measured', $missing->sentence); self::assertSame([], $missing->cited); - $both = StandingJudge::judge($grant, new GrantStanding(watcherSignalAt: $watched, receipts: [$build, $scan], instruments: $instruments), Grants::call()); + $both = StandingJudge::judge($grant, new GrantStanding(GrantState::InForce, signatureFault: null, watcherSignalAt: $watched, receipts: [$build, $scan], instruments: $instruments, recordFault: null), Grants::call()); self::assertTrue($both->passes()); self::assertSame([$scan, $build], $both->cited); } @@ -237,7 +237,7 @@ public function testEveryConditionNeedsItsOwnReceipt(): void 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'); + $standing = new GrantStanding(GrantState::Suspended, 'someone asked for it to stop', signatureFault: null, recordFault: null); $verdict = StandingJudge::judge(Grants::grant(), $standing, Grants::call(subject: null, effect: Grants::effect(Reversibility::Irreversible))); self::assertSame(StandingCheck::InForce, $verdict->failed); @@ -259,13 +259,13 @@ private static function held(string $id, ?GrantStanding $standing = null, array 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]); + return new GrantStanding(GrantState::Suspended, 'its falsifier fired', signatureFault: null, watcherSignalAt: Grants::at('2026-10-11T11:50:00Z'), receipts: [Grants::receipt()], instruments: ['lab.scan' => Grants::INSTRUMENT], recordFault: null); } 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()); + $answer = StandingJudge::among([$unmeasured, self::held('first-that-passes'), self::held('also-passes')], Grants::call(), null); self::assertNotNull($answer); self::assertTrue($answer['verdict']->passes()); @@ -274,8 +274,8 @@ public function testAmongSeveralGrantsOneThatPassesIsEnough(): void 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'))); + self::assertNull(StandingJudge::among([], Grants::call(), null)); + self::assertNull(StandingJudge::among([self::held('for-another-seat'), self::held('suspended-for-another-seat', self::suspended())], Grants::call(seat: 'ANOTHER-SEAT'), null)); } public function testASuspendedGrantThatNamesTheCallRefusesItWhateverAnotherWouldLend(): void @@ -284,7 +284,7 @@ public function testASuspendedGrantThatNamesTheCallRefusesItWhateverAnotherWould 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()); + $answer = StandingJudge::among($grants, Grants::call(), null); self::assertNotNull($answer); self::assertFalse($answer['verdict']->passes()); @@ -300,7 +300,7 @@ public function testASuspendedGrantThatNamesTheCallRefusesItWhateverAnotherWould 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()); + $answer = StandingJudge::among([self::held('stopped', self::suspended()), self::held('unmeasured', Grants::standing([]))], Grants::call(), null); self::assertSame('stopped', $answer['grant']->id ?? null); self::assertSame('suspended', $answer['verdict']->reason ?? null); @@ -308,7 +308,7 @@ public function testASuspendedGrantSpeaksEvenWhenAnotherRefusalGotFurther(): voi public function testASuspendedGrantAloneSaysOnlyWhatItSaidBefore(): void { - $alone = StandingJudge::among([self::held('stopped', self::suspended())], Grants::call()); + $alone = StandingJudge::among([self::held('stopped', self::suspended())], Grants::call(), null); $judged = StandingJudge::judge(Grants::grant(['id' => 'stopped']), self::suspended(), Grants::call()); self::assertSame($judged->sentence, $alone['verdict']->sentence ?? null); @@ -317,8 +317,10 @@ public function testASuspendedGrantAloneSaysOnlyWhatItSaidBefore(): void 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()); + // Its record READS: a grant that is suspended and whose record does not read is told that its record has to + // read again, not that a grant superseding it would take the stop away (an eighth review, L1). + $broken = new GrantStanding(GrantState::Suspended, 'stopped', signatureFault: 'the key that signed it no longer holds the word', recordFault: null); + $answer = StandingJudge::among([self::held('stopped', $broken), self::held('would-lend')], Grants::call(), null); self::assertFalse($answer['verdict']->passes() ?? true); self::assertSame(StandingCheck::Signature, $answer['verdict']->failed ?? null); @@ -328,7 +330,7 @@ public function testASuspendedGrantWhoseSignatureDoesNotHoldStillStopsTheCall(): 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()); + $answer = StandingJudge::among([$elsewhere, self::held('would-lend')], Grants::call(), null); self::assertTrue($answer['verdict']->passes() ?? false); self::assertSame('would-lend', $answer['grant']->id ?? null); @@ -337,17 +339,17 @@ public function testASuspendedGrantThatDoesNotNameTheCallStopsNothing(): void public function testExpiredRevokedAndSupersededGrantsStopNobody(): void { $gone = [ - self::held('revoked', new GrantStanding(GrantState::Revoked)), - self::held('superseded', new GrantStanding(GrantState::Superseded)), + self::held('revoked', new GrantStanding(GrantState::Revoked, signatureFault: null, recordFault: null)), + self::held('superseded', new GrantStanding(GrantState::Superseded, signatureFault: null, recordFault: null)), self::held('expired', change: ['expires_at' => '2026-10-11T00:00:00Z']), ]; - $answer = StandingJudge::among([...$gone, self::held('would-lend')], Grants::call()); + $answer = StandingJudge::among([...$gone, self::held('would-lend')], Grants::call(), null); 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); + self::assertSame('expired', StandingJudge::among($gone, Grants::call(), null)['verdict']->reason ?? null); } public function testWhenNoneLendsTheRefusalThatGotFurthestSpeaksWhicheverWasGivenFirst(): void @@ -356,7 +358,7 @@ public function testWhenNoneLendsTheRefusalThatGotFurthestSpeaksWhicheverWasGive $unmeasured = self::held('unmeasured', Grants::standing([])); foreach ([[$unwatched, $unmeasured], [$unmeasured, $unwatched]] as $grants) { - $answer = StandingJudge::among($grants, Grants::call()); + $answer = StandingJudge::among($grants, Grants::call(), null); self::assertSame('unmeasured', $answer['grant']->id ?? null, 'the one the caller can do something about'); self::assertSame(StandingCheck::Conditions, $answer['verdict']->failed ?? null); @@ -365,7 +367,7 @@ public function testWhenNoneLendsTheRefusalThatGotFurthestSpeaksWhicheverWasGive 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()); + $answer = StandingJudge::among([self::held('older', new GrantStanding(GrantState::Superseded, signatureFault: null, recordFault: null)), self::held('newer', change: ['expires_at' => '2026-10-11T00:00:00Z'])], Grants::call(), null); self::assertSame('newer', $answer['grant']->id ?? null); self::assertSame('expired', $answer['verdict']->reason ?? null); diff --git a/tests/Standing/TwoProposalsOfASixthReviewTest.php b/tests/Standing/TwoProposalsOfASixthReviewTest.php new file mode 100644 index 0000000..b37ad7f --- /dev/null +++ b/tests/Standing/TwoProposalsOfASixthReviewTest.php @@ -0,0 +1,328 @@ + + * + * @license Apache-2.0 + * + * @link https://github.com/getmilpa/command + */ + +declare(strict_types=1); + +namespace Milpa\Command\Tests\Standing; + +use Milpa\Command\Standing\Canonical; +use Milpa\Command\Standing\GrantStanding; +use Milpa\Command\Standing\GrantState; +use Milpa\Command\Standing\StandingGrant; +use Milpa\Command\Standing\StandingJudge; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\TestCase; + +/** + * TWO RULES DECIDED BY ROD, 2026-10-12 (greenhouse decisions/0615, D11 and D12). They were proposals of a sixth + * review; this file keeps the name it had while they were. + * + * · D11 — a text does not begin with a combining mark, and carries no more than eight in a row. + * · D12, as the seventh and the eighth reviews adjusted it — the text a person signs spells plainly what NAMES + * are made of (letters, digits, the colon, the dot, the underscore, the dash, the space), never as a `\u` + * escape. In a SENTENCE, what a writer that is «safe for HTML» escapes — the apostrophe, `<`, `>`, `&` — is + * read. Where the text is NOT a sentence — a seat, an expiry, the name of an argument and the value it is fixed + * to — every printable ASCII character is spelled plainly. + * + * The tests the reviewers wrote are here as they were written; every text is built from code points. + * + * @subject-in milpa/command + */ +final class TwoProposalsOfASixthReviewTest extends TestCase +{ + /** @param array $change */ + private static function refused(array $change): bool + { + try { + Grants::grant($change); + } catch (\InvalidArgumentException) { + return true; + } + + return false; + } + + /** @param array $facts */ + private static function standing(array $facts = []): GrantStanding + { + return new GrantStanding(...$facts + ['state' => GrantState::InForce, 'signatureFault' => null, 'watcherSignalAt' => Grants::at('2026-10-11T11:50:00Z'), 'receipts' => [Grants::receipt()], 'instruments' => ['lab.scan' => Grants::INSTRUMENT], 'recordFault' => null]); + } + + // ── D11 · a text is not a stack of marks, and does not begin with one ──────────────────────────────────── + + public function testATextIsNotAStackOfMarksAndDoesNotBeginWithOne(): void + { + $acute = mb_chr(0x0301); + $below = mb_chr(0x0316); + self::assertFalse(self::refused(['undo' => 'versio' . $acute . 'n']), 'control: a letter and its accent written apart'); + self::assertFalse(self::refused(['undo' => 'Vie' . mb_chr(0x0302) . $acute . 't']), 'control: two marks on one letter'); + self::assertTrue(self::refused(['undo' => 'n' . str_repeat($acute . $below, 150) . 'ame']), 'accepted: «undo» with 300 marks stacked on one letter'); + self::assertTrue(self::refused(['conditions' => [['name' => $acute . 'name', 'statement' => 's', 'by' => ['operation' => 'lab:scan']]]]), 'accepted: a condition whose name begins with a combining mark'); + self::assertTrue(self::refused(['undo' => mb_chr(0x20E0) . 'take it back']), 'accepted: «undo» that begins with COMBINING ENCLOSING CIRCLE BACKSLASH'); + // …and the judge repeats a host's sentence «as it came» when it is such a line. + $reason = 'stopped' . str_repeat($acute . $below, 200); + self::assertStringNotContainsString($acute . $below . $acute . $below . $acute . $below . $acute . $below . $acute, StandingJudge::judge(Grants::grant(), self::standing(['state' => GrantState::Suspended, 'because' => $reason]), Grants::call())->sentence, 'the judge says back 400 stacked marks as they came'); + } + + public function testEightMarksInARowAreTextAndNineAreNot(): void + { + $mark = mb_chr(0x0301); + self::assertTrue(Canonical::oneLine('a' . str_repeat($mark, 8) . 'b')); + self::assertFalse(Canonical::oneLine('a' . str_repeat($mark, 9) . 'b')); + self::assertTrue(Canonical::oneLine(str_repeat('a' . str_repeat($mark, 8), 40)), 'eight on each of forty letters: none of them nine in a row'); + self::assertFalse(Canonical::oneLine($mark), 'a mark alone begins with one'); + self::assertFalse(Canonical::oneLine(mb_chr(0x0BCD) . 'a'), 'a spacing or a non-spacing mark of any script, first'); + self::assertFalse(Canonical::oneLine(mb_chr(0x20DD) . 'a'), 'an enclosing mark, first'); + self::assertTrue(Canonical::oneLine('a ' . 'b' . $mark), 'a mark after a letter that comes after a space'); + } + + /** @return iterable */ + public static function whatLanguagesStackOnALetter(): iterable + { + // The most marks a letter carries in ordinary writing, per the reviewer: Vietnamese two, Thai three, pointed + // Hebrew four or five. Each written decomposed, so that every mark is a character of its own. + yield 'Spanish, the accent written apart' => ['versio' . mb_chr(0x0301) . 'n y an' . mb_chr(0x0303) . 'o']; + yield 'Vietnamese, two marks on a letter' => ['Vie' . mb_chr(0x0302) . mb_chr(0x0323) . 't Nam']; + yield 'Thai, a vowel above and a tone mark' => [mb_chr(0x0E17) . mb_chr(0x0E35) . mb_chr(0x0E48) . mb_chr(0x0E19) . mb_chr(0x0E31) . mb_chr(0x0E49) . mb_chr(0x0E19)]; + yield 'Hebrew with its points: a letter, dagesh, a vowel, a cantillation mark and meteg' => [mb_chr(0x05D1) . mb_chr(0x05BC) . mb_chr(0x05B0) . mb_chr(0x0591) . mb_chr(0x05BD) . mb_chr(0x05E8) . mb_chr(0x05B5) . mb_chr(0x05D0)]; + yield 'Arabic with its vowels and shadda' => [mb_chr(0x0645) . mb_chr(0x064F) . mb_chr(0x062D) . mb_chr(0x064E) . mb_chr(0x0645) . mb_chr(0x0651) . mb_chr(0x064E) . mb_chr(0x062F)]; + yield 'Hindi, a consonant cluster with a vowel sign and a nasal mark' => [mb_chr(0x0915) . mb_chr(0x094D) . mb_chr(0x0937) . mb_chr(0x093F) . mb_chr(0x0902)]; + } + + #[DataProvider('whatLanguagesStackOnALetter')] + public function testWhatALanguageStacksOnALetterIsStillRead(string $text): void + { + $grant = Grants::grant(['undo' => $text, 'conditions' => [['name' => $text, 'statement' => $text, 'by' => ['operation' => 'lab:scan', 'free' => ['path']]]]]); + + self::assertSame([$text, $text], [$grant->undo, $grant->conditions[0]->name]); + self::assertSame($grant->digest(), StandingGrant::fromJson($grant->canonical())->digest()); + } + + // ── D12 · what a person signs spells plain letters plainly ─────────────────────────────────────────────── + + public function testWhatAPersonSignsSpellsPlainLettersPlainly(): void + { + $text = (string) json_encode(Grants::document(), \JSON_UNESCAPED_SLASHES); + self::assertSame(Grants::grant()->digest(), StandingGrant::fromJson($text)->digest(), 'control'); + $escaped = str_replace('"lab:publish"', '"lab:' . implode('', array_map(static fn (string $letter): string => \sprintf('%su%04x', \chr(0x5C), \ord($letter)), str_split('publish'))) . '"', $text); + self::assertNotSame($text, $escaped); + try { + $grant = StandingGrant::fromJson($escaped); + } catch (\InvalidArgumentException) { + self::assertTrue(true); + + return; + } + self::fail('a text that shows «' . substr($escaped, (int) strpos($escaped, '"lab:'), 52) . '» is read as covering ' . $grant->covers[0]->operation->forCli() . ', with the digest of the plain text: ' . var_export($grant->digest() === Grants::grant()->digest(), true)); + } + + /** @return iterable */ + public static function escapesInATextAPersonSigns(): iterable + { + $u = \chr(0x5C) . 'u'; + yield 'a plain letter' => [$u . '0070', true]; + yield 'a plain letter, the hexadecimal in capitals' => [$u . '004A', true]; + yield 'the first printable character, the space' => [$u . '0020', true]; + yield 'a digit' => [$u . '0037', true]; + yield 'the colon, which is in every operation' => [$u . '003a', true]; + yield 'the dot' => [$u . '002E', true]; + yield 'the underscore' => [$u . '005f', true]; + yield 'the dash' => [$u . '002d', true]; + yield 'the first letter and the last, in each case' => [$u . '0041', true]; + yield 'the last capital' => [$u . '005A', true]; + yield 'the last small letter' => [$u . '007a', true]; + yield 'the first small letter' => [$u . '0061', true]; + yield 'the last digit' => [$u . '0039', true]; + yield 'the first digit' => [$u . '0030', true]; + yield 'the apostrophe, which writers that are safe for HTML escape' => [$u . '0027', false]; + yield 'the less-than sign, the same' => [$u . '003c', false]; + yield 'the greater-than sign' => [$u . '003E', false]; + yield 'the ampersand' => [$u . '0026', false]; + yield 'the equals sign' => [$u . '003d', false]; + yield 'the plus sign' => [$u . '002b', false]; + yield 'the backtick' => [$u . '0060', false]; + yield 'the slash' => [$u . '002f', false]; + yield 'the tilde, the last printable character' => [$u . '007E', false]; + yield 'the at sign, just before the capitals' => [$u . '0040', false]; + yield 'the bracket just after them' => [$u . '005b', false]; + yield 'the brace just after the small letters' => [$u . '007b', false]; + yield 'a letter of another alphabet whose low byte is a plain letter' => [$u . '0170', false]; + yield 'the quote, which a text can only say as an escape' => [$u . '0022', false]; + yield 'the backslash, the same' => [$u . '005C', false]; + yield 'a letter with an accent, which many writers escape' => [$u . '00e9', false]; + yield 'a letter of another alphabet' => [$u . '044f', false]; + yield 'the short escape of the quote' => [\chr(0x5C) . '"', false]; + yield 'the short escape of the slash, which PHP writes by default' => [\chr(0x5C) . '/', false]; + yield 'a backslash said twice and then the letters u0070' => [\chr(0x5C) . \chr(0x5C) . 'u0070', false]; + } + + public function testWhatAWriterThatIsSafeForHtmlWritesIsRead(): void + { + // The sentence this rule carried while it was a proposal said «no writer makes one». Writers do: PHP's own, asked to be safe for + // HTML, and others by default. Their documents are read, and are the grant the plain text is. + $document = Grants::document(['undo' => "don't publish & stop"]); + $plain = StandingGrant::fromJson((string) json_encode($document, \JSON_UNESCAPED_SLASHES)); + foreach ([\JSON_HEX_APOS, \JSON_HEX_TAG, \JSON_HEX_AMP, \JSON_HEX_QUOT, \JSON_HEX_APOS | \JSON_HEX_TAG | \JSON_HEX_AMP | \JSON_HEX_QUOT] as $flags) { + $written = (string) json_encode($document, $flags); + self::assertSame($plain->digest(), StandingGrant::fromJson($written)->digest(), 'refused, or read as another grant, with flags ' . $flags); + } + self::assertStringContainsString('\u0027', (string) json_encode($document, \JSON_HEX_APOS), 'control: that writer does escape the apostrophe'); + self::assertSame("don't publish & stop", $plain->undo); + } + + #[DataProvider('escapesInATextAPersonSigns')] + public function testOnlyAnEscapeThatSpellsAPlainCharacterIsRefused(string $escape, bool $refused): void + { + // In a VALUE a grant keeps, and in a KEY: the scan reads every text of the document. + $text = (string) json_encode(Grants::document(['undo' => 'take it @@ back']), \JSON_UNESCAPED_SLASHES | \JSON_UNESCAPED_UNICODE); + foreach ([str_replace('@@', $escape, $text), str_replace('"undo"', '"und' . $escape . '"', $text)] as $at => $written) { + try { + StandingGrant::fromJson($written); + $said = null; + } catch (\InvalidArgumentException $no) { + $said = $no->getMessage(); + } + if ($refused) { + self::assertStringContainsString('as an escape', (string) $said, $at === 0 ? 'in a value' : 'in a key'); + self::assertStringContainsString('what a person signs spells plainly what a name is made of —', (string) $said, 'an undo and a key are a sentence and a name: the rule that speaks is the one of names'); + self::assertSame(0, preg_match('/[^\x20-\x7E«»—…]/u', (string) $said)); + } else { + self::assertStringNotContainsString('as an escape', (string) $said, $at === 0 ? 'in a value' : 'in a key'); + } + } + } + + // ── as the eighth review adjusted it: where a text is not a sentence, every character is spelled plainly ── + + /** The reviewer's test, as written. */ + public function testAFixedValueIsNotSpelledInEscapes(): void + { + $plain = (string) json_encode(Grants::document(['covers' => [['operation' => 'lab:publish', 'arguments' => ['target' => '*']]]]), \JSON_UNESCAPED_SLASHES); + $escape = \chr(0x5C) . 'u002a'; + $noise = str_replace('"target":"*"', '"target":"' . $escape . '"', $plain, $count); + self::assertSame(1, $count, 'control: the probe wrote the escape'); + self::assertSame('*', StandingGrant::fromJson($plain)->covers[0]->arguments['target'], 'control: the plain text is read'); + + try { + $read = StandingGrant::fromJson($noise); + } catch (\InvalidArgumentException) { + self::assertTrue(true); + + return; + } + self::fail('the value an argument is fixed to was written as an escape, shown as six characters of noise, and read as ' . json_encode($read->covers[0]->arguments['target']) . ' with the digest of the plain text'); + } + + /** One character of a text, written as its `\u` escape — the text given with «@@» where the character stands. */ + private static function withAnEscape(string $text, int $code): string + { + $count = 0; + $written = str_replace('@@', \chr(0x5C) . \sprintf('u%04x', $code), $text, $count); + self::assertSame(1, $count, 'control: the escape was written'); + + return $written; + } + + /** @return string|null what the reader said, or `null` when it read the text */ + private static function saidOf(string $text): ?string + { + try { + StandingGrant::fromJson($text); + } catch (\InvalidArgumentException $refused) { + return $refused->getMessage(); + } + + return null; + } + + /** + * Where a text is not a sentence, and the plain text that stands there in a control. + * + * @return iterable, string}> a document with «@@» in the place; what «@@» is when written plainly + */ + public static function whereATextIsNotASentence(): iterable + { + yield 'the value an argument is fixed to' => [['covers' => [['operation' => 'lab:publish', 'arguments' => ['target' => 'a@@b']]]], 'x']; + yield 'the name of an argument that is fixed' => [['covers' => [['operation' => 'lab:publish', 'arguments' => ['tar@@get' => 'stable']]]], 'x']; + yield 'the value an instrument is run with' => [['conditions' => [['name' => 'no-host-names', 'statement' => 'what is published names no machine', 'by' => ['operation' => 'lab:scan', 'arguments' => ['mode' => 'a@@b'], 'free' => ['path']]]]], 'x']; + yield 'the value a watcher is run with' => [['falsifier' => ['name' => 'a-published-name', 'statement' => 'a published release names a machine', 'watcher' => ['operation' => 'lab:watch', 'arguments' => ['scope' => 'a@@b']], 'within_seconds' => 3600]], 'x']; + yield 'a seat' => [['to' => ['key:ab@@cd']], 'x']; + yield 'a second seat' => [['to' => [Grants::SEAT, 'key:ab@@cd']], 'x']; + yield 'the expiry' => [['expires_at' => '2027-01-01T00:00:0@@Z'], '0']; + } + + /** @param array $document */ + #[DataProvider('whereATextIsNotASentence')] + public function testWhereATextIsNotASentenceEveryCharacterIsSpelledPlainly(array $document, string $plainly): void + { + $text = (string) json_encode(Grants::document($document), \JSON_UNESCAPED_SLASHES); + self::assertNull(self::saidOf(str_replace('@@', $plainly, $text)), 'control: written plainly, the document is read'); + + $read = []; + for ($code = 0x20; $code <= 0x7E; ++$code) { + $said = self::saidOf(self::withAnEscape($text, $code)); + if ($said === null || !str_contains($said, 'as an escape') || !str_contains($said, 'spells plainly every character of a seat, of an expiry, and of the name and the value of an argument')) { + $read[] = \sprintf('U+%04X %s', $code, $said === null ? 'READ' : substr($said, 0, 60)); + } + } + self::assertSame([], $read, 'of the 95 printable ASCII characters, written as an escape here'); + // …and no more than those: what is not printable ASCII is not this rule's to refuse. + self::assertStringNotContainsString('as an escape', (string) self::saidOf(self::withAnEscape($text, 0x00E9))); + } + + public function testThePlusOfAnExpiryAndWhatASeatIsMadeOfAreSpelledPlainly(): void + { + $written = static fn (array $document): string => (string) json_encode(Grants::document($document), \JSON_UNESCAPED_SLASHES); + $u = \chr(0x5C) . 'u'; + + $expiry = $written(['expires_at' => '2027-01-01T00:00:00+02:00']); + self::assertNull(self::saidOf($expiry), 'control'); + self::assertStringContainsString('writes "+" as an escape (\u002b)', (string) self::saidOf(str_replace('00+02', '00' . $u . '002b02', $expiry))); + // What it costs, and the way a writer that escapes the plus still has: the same instant, with Z. + $utc = $written(['expires_at' => '2026-12-31T22:00:00Z']); + self::assertSame(StandingGrant::fromJson($expiry)->digest(), StandingGrant::fromJson($utc)->digest()); + + $seat = $written(['to' => ['key:ab+cd/ef==']]); + self::assertNull(self::saidOf($seat), 'control'); + self::assertStringContainsString('as an escape', (string) self::saidOf(str_replace('key:ab+cd/ef==', 'key:ab' . $u . '002bcd' . $u . '002fef' . $u . '003d' . $u . '003d', $seat))); + } + + public function testASentenceIsStillASentenceWhereverItStandsInTheText(): void + { + // The frames that are not sentences close, and the key before a colon is forgotten at the next one: a sentence + // AFTER a seat, an expiry or the arguments of a call — in whatever order a writer says the keys — is read with + // what a writer that is safe for HTML escapes. + $apostrophe = \chr(0x5C) . 'u0027'; + $document = Grants::document([ + 'expires_at' => '2027-01-01T00:00:00Z', + 'conditions' => [['by' => ['arguments' => ['mode' => 'strict'], 'operation' => 'lab:scan', 'free' => ['path']], 'name' => 'no-host-names', 'statement' => "what is published doesn't name a machine"]], + 'falsifier' => ['watcher' => ['arguments' => ['scope' => 'all'], 'operation' => 'lab:watch'], 'name' => 'a-published-name', 'statement' => "a published release shouldn't name a machine", 'within_seconds' => 3600], + 'undo' => "don't keep it: withdraw it", + ]); + $plain = StandingGrant::fromJson((string) json_encode($document, \JSON_UNESCAPED_SLASHES)); + foreach ([ + 'as the document says its keys' => $document, + 'the expiry said last but one, the undo after it' => array_diff_key($document, ['undo' => 0, 'expires_at' => 0]) + ['expires_at' => $document['expires_at'], 'undo' => $document['undo']], + 'no expiry said but as null, the undo after it' => array_diff_key($document, ['undo' => 0, 'expires_at' => 0]) + ['supersedes' => null, 'undo' => $document['undo']], + 'the seats said last but one, the undo after them' => array_diff_key($document, ['undo' => 0, 'to' => 0]) + ['to' => $document['to'], 'undo' => $document['undo']], + ] as $how => $ordered) { + $written = (string) json_encode($ordered, \JSON_HEX_APOS); + self::assertSame(3, substr_count($written, $apostrophe), 'control: three apostrophes, each written as an escape'); + self::assertNull(self::saidOf($written), $how); + } + self::assertSame($plain->digest(), StandingGrant::fromJson((string) json_encode($document, \JSON_HEX_APOS))->digest()); + + // …and a text that IS NOT a sentence stays one after a sentence: the order of the keys does not hide it. + $last = array_diff_key($document, ['to' => 0]) + ['to' => ['key:ab@@cd']]; + self::assertStringContainsString('as an escape', (string) self::saidOf(self::withAnEscape((string) json_encode($last, \JSON_UNESCAPED_SLASHES), 0x2B))); + } +} diff --git a/tests/Standing/WhatAFifthReviewFoundInTheContractTest.php b/tests/Standing/WhatAFifthReviewFoundInTheContractTest.php new file mode 100644 index 0000000..683b2cb --- /dev/null +++ b/tests/Standing/WhatAFifthReviewFoundInTheContractTest.php @@ -0,0 +1,747 @@ + + * + * @license Apache-2.0 + * + * @link https://github.com/getmilpa/command + */ + +declare(strict_types=1); + +namespace Milpa\Command\Tests\Standing; + +use Milpa\Command\Effect\Authority; +use Milpa\Command\Effect\ContextFacts; +use Milpa\Command\Effect\DeclaredAuthorityPolicy; +use Milpa\Command\Effect\DescentCertificate; +use Milpa\Command\Effect\EffectProfile; +use Milpa\Command\Effect\Externality; +use Milpa\Command\Effect\Mutation; +use Milpa\Command\Effect\Reversibility; +use Milpa\Command\Effect\Subject; +use Milpa\Command\Operation; +use Milpa\Command\Standing\Canonical; +use Milpa\Command\Standing\ConditionReceipt; +use Milpa\Command\Standing\Falsifier; +use Milpa\Command\Standing\GrantCondition; +use Milpa\Command\Standing\GrantStanding; +use Milpa\Command\Standing\GrantState; +use Milpa\Command\Standing\NamedCall; +use Milpa\Command\Standing\StandingCall; +use Milpa\Command\Standing\StandingCheck; +use Milpa\Command\Standing\StandingGrant; +use Milpa\Command\Standing\StandingJudge; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\TestCase; + +/** + * What a fifth independent review found in the contract of standing grants, and what Rod decided about it on + * 2026-10-12 (greenhouse decisions/0615, «Lo que la quinta revisión encontró»). Nine of these tests are the + * reviewer's own, moved here as they were written; every text is built from code points, never typed. + * + * @subject-in milpa/command + */ +final class WhatAFifthReviewFoundInTheContractTest extends TestCase +{ + /** @param array $change */ + private static function refused(array $change): bool + { + try { + Grants::grant($change); + } catch (\InvalidArgumentException) { + return true; + } + + return false; + } + + /** @return array */ + private static function measuredBy(string|int|bool $mode): array + { + return ['conditions' => [['name' => 'no-host-names', 'statement' => 'what is published names no machine', 'by' => ['operation' => 'lab:scan', 'arguments' => ['mode' => $mode], 'free' => ['path']]]]]; + } + + /** No byte a terminal obeys, and nothing that is not plain ASCII or the contract's own prose. */ + private static function assertSafeToRead(string $said, int $atMost = 2000): void + { + self::assertSame(0, preg_match('/[^\x20-\x7E«»—…]/u', $said), 'it carries a byte a terminal obeys, or a character that is not shown as it is written: ' . json_encode(substr($said, 0, 300))); + self::assertLessThan($atMost, \strlen($said), 'it is ' . \strlen($said) . ' bytes long'); + } + + // ── D8 (M1) · an argument is fixed to a value a person can read, and no other ─────────────────────────── + + /** The value an argument is fixed to is compared byte by byte with what a caller sends: it is read as it is shown. */ + public function testAnArgumentIsFixedToAValueAPersonCanReadAndNoOther(): void + { + self::assertFalse(self::refused(self::measuredBy('strict')), 'control: «mode = strict» is a grant'); + self::assertTrue(self::refused(self::measuredBy('strict' . mb_chr(0x200B))), 'accepted: «mode» fixed to "strict" and a ZERO WIDTH SPACE — on a line it reads «mode=strict»'); + self::assertTrue(self::refused(self::measuredBy('strict ')), 'accepted: «mode» fixed to "strict" and a space after it'); + self::assertTrue(self::refused(self::measuredBy('strict' . mb_chr(0xE0073))), 'accepted: «mode» fixed to "strict" and a TAG character nobody sees'); + } + + /** What it costs in the judge: the run a person read said NO last, and the grant lends on another run's yes. */ + public function testTheRunAPersonReadSayingNoIsNotOutvotedByARunThatOnlyLooksLikeIt(): void + { + $asWritten = 'strict' . mb_chr(0x200B); + try { + $grant = Grants::grant(self::measuredBy($asWritten)); + } catch (\InvalidArgumentException) { + self::assertTrue(true, 'the reader refuses the look-alike: nothing to judge'); + + return; + } + $verdict = StandingJudge::judge($grant, Grants::standing([ + Grants::receipt(ok: true, arguments: ['mode' => $asWritten, 'path' => 'candidate.tar']), + Grants::receipt(ok: false, arguments: ['mode' => 'strict', 'path' => 'candidate.tar']), + ]), Grants::call()); + + self::assertFalse($verdict->passes(), 'the grant lends: `lab:scan mode=strict` said NO of this candidate last, and a green receipt from the run spelled with a zero-width space speaks for the condition'); + } + + /** @return iterable */ + public static function valuesAPersonCannotTellApart(): iterable + { + yield 'a letter of another alphabet that looks the same' => ['str' . mb_chr(0x0456) . 'ct']; + yield 'a space before it' => [' strict']; + yield 'two spaces inside it' => ['very strict']; + yield 'a no-break space inside it' => ['very' . mb_chr(0xA0) . 'strict']; + yield 'a letter with an accent — what the decision costs, pinned' => ['estr' . mb_chr(0xED) . 'cto']; + yield 'only a space' => [' ']; + yield 'a delete' => ["str\x7fict"]; + } + + #[DataProvider('valuesAPersonCannotTellApart')] + public function testAFixedValueIsVisibleAsciiWithSingleSpacesInsideAndNoneAround(string $value): void + { + foreach ([self::measuredBy($value), ['covers' => [['operation' => 'lab:publish', 'arguments' => ['channel' => $value]]]]] as $change) { + try { + Grants::grant($change); + self::fail('a grant fixed an argument to ' . json_encode($value)); + } catch (\InvalidArgumentException $refused) { + self::assertSafeToRead($refused->getMessage()); + } + } + } + + public function testWhatAFixedValueMayStillBe(): void + { + foreach (['strict', 'very strict', 'a b c', 'v1.2.3-rc.1', 'refs/heads/main', '~!@#$%^&*()_+{}|:"<>?', '!'] as $value) { + self::assertSame($value, Grants::grant(self::measuredBy($value))->conditions[0]->by->arguments['mode']); + } + // The empty text was in that list, and this test pinned it as a value an argument may be fixed to. It is not + // (the sixth review, L3): D8 says visible ASCII, and nothing is no character at all. + self::assertTrue(self::refused(self::measuredBy(''))); + // A value that is not text is as it was: a whole number, and true or false. + self::assertSame(7, Grants::grant(self::measuredBy(7))->conditions[0]->by->arguments['mode']); + self::assertTrue(Grants::grant(self::measuredBy(true))->conditions[0]->by->arguments['mode']); + // …and the class says what a host owes whoever signs: the value, shown as it is spelled. + $said = (string) preg_replace('/\s+\*\s+/', ' ', (string) (new \ReflectionClass(NamedCall::class))->getDocComment()); + self::assertStringContainsString('shows it to whoever signs through {@see Canonical::shown()}', $said); + } + + // ── L1 · nothing is taken off the ends of a text: a text that is not as it reads is refused ───────────── + + /** «A name ends where it ends: an id, the name of an operation […] take nothing after them, not even a newline». */ + public function testAnIdAndAnOperationTakeNothingAfterThemNotEvenANewline(): void + { + self::assertTrue(self::refused(['supersedes' => "older\n"]), 'control: the fourth review\'s case is refused'); + self::assertTrue(self::refused(['id' => "release-line\n"]), 'accepted: an id followed by a newline'); + self::assertTrue(self::refused(['covers' => [['operation' => "lab:publish\n"]]]), 'accepted: an operation named "lab:publish" and a newline'); + } + + /** No line break, no tab, in a text a grant is given — also at its two ends. */ + public function testATextWithALineBreakAtItsEndIsNotOneLine(): void + { + self::assertTrue(self::refused(['undo' => "take it\nback"]), 'control: a line break inside the text is refused'); + self::assertTrue(self::refused(['undo' => "take it back\n"]), 'accepted: «undo» with a line break at its end'); + self::assertTrue(self::refused(['undo' => "\t\0take it back"]), 'accepted: «undo» that begins with a tab and a NUL'); + } + + /** + * The seventeen places a grant holds a text, each with a place for the text. + * + * @return array, string}> the change, and a text that place takes + */ + private static function everyTextOfAGrant(): array + { + $condition = static fn (array $change): array => ['conditions' => [$change + ['name' => 'no-host-names', 'statement' => 'what is published names no machine', 'by' => ['operation' => 'lab:scan', 'free' => ['path']]]]]; + $falsifier = static fn (array $change): array => ['falsifier' => $change + ['name' => 'a-published-name', 'statement' => 'a published release names a machine', 'watcher' => ['operation' => 'lab:watch'], 'within_seconds' => 3600]]; + + return [ + 'its id' => [static fn (string $text): array => ['id' => $text], 'another-line'], + 'the grant it supersedes' => [static fn (string $text): array => ['supersedes' => $text], 'older'], + 'the stop it lifts' => [static fn (string $text): array => ['supersedes' => 'older', 'lifts' => $text], 'sha256:abc'], + 'a seat' => [static fn (string $text): array => ['to' => [$text]], 'SEAT1'], + 'how an act is taken back' => [static fn (string $text): array => ['undo' => $text], 'take it back'], + 'the name of a condition' => [static fn (string $text): array => $condition(['name' => $text]), 'no-host-names'], + 'what a condition says' => [static fn (string $text): array => $condition(['statement' => $text]), 'it names no machine'], + 'the name of the falsifier' => [static fn (string $text): array => $falsifier(['name' => $text]), 'a-published-name'], + 'what the falsifier says' => [static fn (string $text): array => $falsifier(['statement' => $text]), 'a release names a machine'], + 'the operation it covers' => [static fn (string $text): array => ['covers' => [['operation' => $text]]], 'lab:publish'], + 'the operation that measures' => [static fn (string $text): array => $condition(['by' => ['operation' => $text]]), 'lab:scan'], + 'the operation that watches' => [static fn (string $text): array => $falsifier(['watcher' => ['operation' => $text]]), 'lab:watch'], + 'the name of an argument it fixes' => [static fn (string $text): array => ['covers' => [['operation' => 'lab:publish', 'arguments' => [$text => 'stable']]]], 'channel'], + 'the name of an argument it leaves free' => [static fn (string $text): array => $condition(['by' => ['operation' => 'lab:scan', 'free' => [$text]]]), 'path'], + 'the text an argument is fixed to' => [static fn (string $text): array => ['covers' => [['operation' => 'lab:publish', 'arguments' => ['channel' => $text]]]], 'stable'], + 'the inverse its ceiling names' => [static fn (string $text): array => ['ceiling' => Grants::document()['ceiling'] + ['rollback_contract' => $text]], 'lab:unpublish'], + 'when it expires' => [static fn (string $text): array => ['expires_at' => $text], '2099-01-01T00:00:00Z'], + ]; + } + + /** @return iterable */ + public static function textsWithSomethingAtAnEnd(): iterable + { + $ends = ['a space' => ' ', 'a line feed' => "\n", 'a carriage return' => "\r", 'a tab' => "\t", 'a vertical tab' => "\x0B", 'a null byte' => "\0"]; + foreach (array_keys(self::everyTextOfAGrant()) as $field) { + foreach ($ends as $what => $byte) { + yield "{$field}: {$what} after it" => [$field, '', $byte]; + yield "{$field}: {$what} before it" => [$field, $byte, '']; + } + } + } + + #[DataProvider('textsWithSomethingAtAnEnd')] + public function testNoTextOfAGrantIsRepairedByTakingSomethingOffItsEnds(string $field, string $before, string $after): void + { + [$change, $text] = self::everyTextOfAGrant()[$field]; + self::assertFalse(self::refused($change($text)), "control: {$field} given as it reads is a grant"); + + self::assertTrue(self::refused($change($before . $text . $after)), "accepted: {$field} given as " . json_encode($before . $text . $after)); + } + + /** @return iterable */ + public static function namesWithASeparatorAtAnEnd(): iterable + { + yield 'a colon before it' => [':lab:publish']; + yield 'a colon after it' => ['lab:publish:']; + yield 'a dot before it' => ['.lab.publish']; + yield 'a dot after it' => ['lab.publish.']; + yield 'an underscore after it' => ['lab_publish_']; + yield 'separators at both ends' => ['.:lab:publish:.']; + } + + #[DataProvider('namesWithASeparatorAtAnEnd')] + public function testTheNameOfAnOperationIsNotRepairedByTakingASeparatorOffItsEnds(string $written): void + { + // The identity of an operation forgives how a surface SPELLS it — and that forgiveness took a separator off + // either end as well: «:lab:publish:» was kept as «lab.publish». It is the same repair as a line break taken off. + $places = [ + 'covers' => ['covers' => [['operation' => $written]]], + 'by' => ['conditions' => [['name' => 'c', 'statement' => 's', 'by' => ['operation' => $written, 'free' => ['path']]]]], + 'watcher' => ['falsifier' => ['name' => 'f', 'statement' => 's', 'watcher' => ['operation' => $written], 'within_seconds' => 60]], + 'the inverse' => ['ceiling' => Grants::document()['ceiling'] + ['rollback_contract' => $written]], + ]; + foreach ($places as $where => $change) { + self::assertTrue(self::refused($change), "accepted in {$where}: " . json_encode($written)); + } + self::assertNull(ConditionReceipt::fromArray(['by' => $written, 'arguments' => [], 'subject' => Grants::CANDIDATE, 'ok' => true, 'instrument' => Grants::INSTRUMENT, 'who' => null, 'at' => '2026-10-11T11:55:00Z']), 'a receipt: ' . json_encode($written)); + } + + public function testHowASurfaceSpellsAnOperationIsStillOneIdentity(): void + { + // Control: the three separators this family's surfaces use, and capitals, spell one operation. + foreach (['lab:publish', 'lab.publish', 'lab_publish', 'Lab:Publish', 'LAB.PUBLISH'] as $spelled) { + self::assertSame('lab.publish', (new NamedCall($spelled))->operation->canonical, $spelled); + self::assertSame(Grants::grant(['covers' => [['operation' => 'lab.publish']]])->digest(), Grants::grant(['covers' => [['operation' => $spelled]]])->digest(), $spelled); + } + } + + // ── L2 · what the judge says back is bounded, and nothing in it is obeyed by a terminal ────────────────── + + public function testWhatTheJudgeSaysBackOfAGrantIsBounded(): void + { + // The reviewer's name ended in a space, which a grant no longer takes (L1): it is the same name without it. + $grant = Grants::grant(['conditions' => [['name' => rtrim(str_repeat('a condition whose name goes on ', 4000)), 'statement' => 's', 'by' => ['operation' => 'lab:scan', 'free' => ['path']]]]]); + $verdict = StandingJudge::judge($grant, Grants::standing([]), Grants::call()); + + self::assertSame('not_measured', $verdict->reason); + self::assertLessThan(2000, \strlen($verdict->sentence), 'the refusal is ' . \strlen($verdict->sentence) . ' bytes long'); + } + + /** What a host hands the judge as «because» is said back as it came: an escape reaches the terminal. */ + public function testTheJudgeSaysNothingATerminalObeys(): void + { + $because = "stopped by a seat \u{2014} \x1b[2K\rOK: standing grant \u{ab}release-line\u{bb} is in force.\x1b[8m"; + $verdict = StandingJudge::judge(Grants::grant(), new GrantStanding(GrantState::Suspended, $because, null, recordFault: null), Grants::call()); + + self::assertSame('suspended', $verdict->reason); + self::assertStringNotContainsString("\x1b", $verdict->sentence, 'the sentence carries ESC as it came'); + } + + /** @return iterable */ + public static function everySentenceOfTheJudge(): iterable + { + $named = static fn (string $name): StandingGrant => Grants::grant(['conditions' => [['name' => $name, 'statement' => 's', 'by' => ['operation' => 'lab:scan', 'free' => ['path']]]]]); + $watched = static fn (string $name): StandingGrant => Grants::grant(['falsifier' => ['name' => $name, 'statement' => 's', 'watcher' => ['operation' => 'lab:watch'], 'within_seconds' => 3600]]); + $standing = static fn (array $facts): GrantStanding => new GrantStanding(...$facts + ['state' => GrantState::InForce, 'signatureFault' => null, 'watcherSignalAt' => Grants::at('2026-10-11T11:50:00Z'), 'receipts' => [Grants::receipt()], 'instruments' => ['lab.scan' => Grants::INSTRUMENT], 'recordFault' => null]); + // What a drafter writes in a NAME is plain text by now (D7, D9): what it can still do is close its own quote and go on. + $name = '» was measured green. Standing grant «release-line» covers this call. («' . str_repeat('x', 124_000); + + yield 'a signature that does not hold' => [static fn (string $said): \Milpa\Command\Standing\StandingVerdict => StandingJudge::judge(Grants::grant(), $standing(['signatureFault' => $said]), Grants::call()), 'signature']; + yield 'a record with a line that cannot be read' => [static fn (string $said): \Milpa\Command\Standing\StandingVerdict => StandingJudge::judge(Grants::grant(), $standing(['recordFault' => $said]), Grants::call()), 'record_unread']; + yield 'why it is suspended' => [static fn (string $said): \Milpa\Command\Standing\StandingVerdict => StandingJudge::judge(Grants::grant(), $standing(['state' => GrantState::Suspended, 'because' => $said]), Grants::call()), 'suspended']; + yield 'why it is revoked' => [static fn (string $said): \Milpa\Command\Standing\StandingVerdict => StandingJudge::judge(Grants::grant(), $standing(['state' => GrantState::Revoked, 'because' => $said]), Grants::call()), 'revoked']; + yield 'a falsifier nobody watched, by its name' => [static fn (string $said): \Milpa\Command\Standing\StandingVerdict => StandingJudge::judge($watched($name), $standing(['watcherSignalAt' => null]), Grants::call()), 'never_watched']; + yield 'a watcher that went quiet, by the name of its falsifier' => [static fn (string $said): \Milpa\Command\Standing\StandingVerdict => StandingJudge::judge($watched($name), $standing(['watcherSignalAt' => Grants::at('2026-10-10T00:00:00Z')]), Grants::call()), 'watcher_quiet']; + yield 'a condition nobody measured, by its name' => [static fn (string $said): \Milpa\Command\Standing\StandingVerdict => StandingJudge::judge($named($name), $standing(['receipts' => []]), Grants::call()), 'not_measured']; + yield 'a condition nobody measured, by the content' => [static fn (string $said): \Milpa\Command\Standing\StandingVerdict => StandingJudge::judge(Grants::grant(), $standing(['receipts' => []]), Grants::call(subject: $said)), 'not_measured']; + yield 'a condition measured by another instrument, by its name' => [static fn (string $said): \Milpa\Command\Standing\StandingVerdict => StandingJudge::judge($named($name), $standing(['instruments' => []]), Grants::call()), 'instrument_changed']; + yield 'a condition that does not hold, by its name' => [static fn (string $said): \Milpa\Command\Standing\StandingVerdict => StandingJudge::judge($named($name), $standing(['receipts' => [Grants::receipt(ok: false)]]), Grants::call()), 'measured_red']; + yield 'a condition that does not hold, by the content' => [static fn (string $said): \Milpa\Command\Standing\StandingVerdict => StandingJudge::judge(Grants::grant(), $standing(['receipts' => [Grants::receipt(subject: $said, ok: false)]]), Grants::call(subject: $said)), 'measured_red']; + yield 'a call that passes, by the content' => [static fn (string $said): \Milpa\Command\Standing\StandingVerdict => StandingJudge::judge(Grants::grant(), $standing(['receipts' => [Grants::receipt(subject: $said)]]), Grants::call(subject: $said)), 'passes']; + } + + #[DataProvider('everySentenceOfTheJudge')] + public function testNoSentenceOfTheJudgeSaysBackRawOrAtAnyLength(\Closure $judged, string $reason): void + { + // What a host hands in — or a content names itself with — that a terminal obeys, and then 124 000 bytes. + foreach (["stopped \u{2014} \x1b[2K\rOK: standing grant \u{ab}release-line\u{bb} is in force.\x1b[8m", 'sha256:' . str_repeat('ab', 62_000)] as $said) { + try { + $verdict = $judged($said); + } catch (\InvalidArgumentException $refused) { + // A receipt can no longer be BUILT about a content named with what a terminal obeys (the sixth + // review: «nothing is trimmed» reaches the digests of a receipt). The sentence is then never said. + self::assertFalse(Canonical::oneLine($said)); + self::assertStringContainsString('a condition receipt gives the digest of the content that was measured', $refused->getMessage()); + self::assertSafeToRead($refused->getMessage()); + + continue; + } + + self::assertSame($reason, $verdict->passes() ? 'passes' : $verdict->reason); + self::assertSafeToRead($verdict->sentence); + } + } + + public function testTheNameOfAnOperationIsSaidBackAtMostALineOfItToo(): void + { + // The name of an operation is plain by its own pattern — and has no length of its own: a hundred thousand + // letters are one name, and every sentence that says «run `…`» repeated them all. + $long = 'lab:' . str_repeat('a', 100_000); + $grant = Grants::grant([ + 'covers' => [['operation' => $long]], + 'conditions' => [['name' => 'no-host-names', 'statement' => 's', 'by' => ['operation' => $long, 'free' => ['path']]]], + 'falsifier' => ['name' => 'a-published-name', 'statement' => 's', 'watcher' => ['operation' => $long], 'within_seconds' => 3600], + ]); + $asked = Grants::call(); + $call = static fn (?string $subject): StandingCall => new StandingCall($long, $asked->arguments, $asked->seat, $subject, $asked->effect, $asked->at); + $red = new ConditionReceipt($long, ['path' => 'candidate.tar'], Grants::CANDIDATE, false, Grants::INSTRUMENT, null, Grants::at('2026-10-11T11:50:00Z')); + $facts = ['state' => GrantState::InForce, 'signatureFault' => null, 'watcherSignalAt' => Grants::at('2026-10-11T11:50:00Z'), 'receipts' => [$red], 'instruments' => [$red->by->canonical => Grants::INSTRUMENT], 'recordFault' => null]; + $verdicts = [ + 'never_watched' => StandingJudge::judge($grant, new GrantStanding(...['watcherSignalAt' => null] + $facts), $call(Grants::CANDIDATE)), + 'watcher_quiet' => StandingJudge::judge($grant, new GrantStanding(...['watcherSignalAt' => Grants::at('2026-10-10T00:00:00Z')] + $facts), $call(Grants::CANDIDATE)), + 'no_subject' => StandingJudge::judge($grant, new GrantStanding(...$facts), $call(null)), + 'not_measured' => StandingJudge::judge($grant, new GrantStanding(...['receipts' => []] + $facts), $call(Grants::CANDIDATE)), + 'instrument_changed' => StandingJudge::judge($grant, new GrantStanding(...['instruments' => []] + $facts), $call(Grants::CANDIDATE)), + 'measured_red' => StandingJudge::judge($grant, new GrantStanding(...$facts), $call(Grants::CANDIDATE)), + ]; + foreach ($verdicts as $reason => $verdict) { + self::assertSame($reason, $verdict->reason); + self::assertSafeToRead($verdict->sentence); + self::assertStringContainsString('`lab:' . str_repeat('a', 92) . '… (100004 bytes as written)`', $verdict->sentence, $reason); + } + // …and the refusals that name the operation a document is refused under. + $refusals = [ + 'a name of an argument that is not one' => ['covers' => [['operation' => $long, 'arguments' => ['not a name' => 1]]]], + 'a value that is not one' => ['covers' => [['operation' => $long, 'arguments' => ['channel' => 'estándar']]]], + 'a free argument that is not one' => ['conditions' => [['name' => 'c', 'statement' => 's', 'by' => ['operation' => $long, 'free' => ['not a name']]]]], + 'a covered call that says what is free' => ['covers' => [['operation' => $long, 'free' => ['path']]]], + 'a covered call that is the grant itself' => ['covers' => [['operation' => 'standing:' . str_repeat('a', 100_000)]]], + ]; + foreach ($refusals as $what => $change) { + try { + Grants::grant($change); + self::fail("{$what}: taken"); + } catch (\InvalidArgumentException $refused) { + self::assertSafeToRead($refused->getMessage(), 1200); + self::assertStringContainsString(str_repeat('a', 80) . '… (10000', $refused->getMessage(), $what); + } + } + // Control: a name of the length names have is said whole, as it always was. + self::assertStringContainsString('Run `lab:scan` on it first', StandingJudge::judge(Grants::grant(), Grants::standing([]), Grants::call())->sentence); + self::assertSame(str_repeat('a', 96), Canonical::short(str_repeat('a', 96))); + } + + public function testASentenceAHostHandsInIsSaidAsItCameWhenItIsAPlainLine(): void + { + // The other side of it: the reason a person has to read is not cut to a line, nor spelled in escapes. + $because = 'its falsifier «a-published-name» fired: `lab:watch` reported it, at 2026-10-11T11:55:00+00:00, run by key:' . Grants::SEAT . ' (suspension sha256:' . str_repeat('ab', 32) . ')'; + $verdict = StandingJudge::judge(Grants::grant(), new GrantStanding(GrantState::Suspended, $because, null, recordFault: null), Grants::call()); + + self::assertStringContainsString('is suspended — ' . $because . '. While it is', $verdict->sentence); + // …and the name of a condition is said in quotes, so that it ends where it ends. + $unmeasured = StandingJudge::judge(Grants::grant(), Grants::standing([]), Grants::call()); + self::assertStringContainsString('condition "no-host-names" was never measured', $unmeasured->sentence); + } + + public function testAProfileSaysBackWhatItRefusesBoundedToo(): void + { + $payload = "\x1b[2K\rOK\x1b[8m" . str_repeat('x', 300_000); + $refusals = [ + 'an inverse that is a sentence' => static fn () => new EffectProfile(Mutation::Persistent, Externality::None, Reversibility::Guaranteed, Authority::WriteAsUser, rollbackContract: $payload), + 'an axis that is not one' => static fn () => EffectProfile::fromPartial([$payload => 'read']), + 'a level that is not one' => static fn () => EffectProfile::fromPartial(['authority' => $payload]), + 'a stored axis that is not one' => static fn () => EffectProfile::fromArray(['mutation' => $payload, 'externality' => 'none', 'reversibility' => 'compensatable', 'authority' => 'read', 'subject' => 'data']), + ]; + foreach ($refusals as $what => $refuses) { + try { + $refuses(); + self::fail("{$what} was taken"); + } catch (\InvalidArgumentException $refused) { + self::assertSafeToRead($refused->getMessage(), 1200); + } + } + } + + // ── L3 · a line that cannot be read is not the easy thing to skip ──────────────────────────────────────── + + public function testALineThatNamesNoOperationIsNotAReceipt(): void + { + $line = ['by' => 'lab:scan', 'arguments' => ['path' => 'candidate.tar'], 'subject' => Grants::CANDIDATE, 'ok' => false, 'instrument' => Grants::INSTRUMENT, 'who' => null, 'at' => '2026-10-11T11:55:00Z']; + + self::assertNotNull(ConditionReceipt::fromArray($line), 'control: the line as the contract writes it'); + self::assertNull(ConditionReceipt::fromArray(['by' => ''] + $line), 'a receipt: «by» is empty'); + self::assertNull(ConditionReceipt::fromArray(['by' => 'not an operation !!'] + $line), 'a receipt: «by» is a sentence'); + } + + public function testAReceiptBuiltByHandNamesAnOperationToo(): void + { + // What the reader of a line refuses, the constructor refuses: a receipt is matched against a condition by + // the operation it names, and one that names none would be matched against nothing. + self::assertSame('lab.scan', Grants::receipt(by: 'lab:scan')->by->canonical, 'control'); + // The last one is a name that ends in a line break once its identity is taken: nothing comes after a name. + foreach (['', 'not an operation !!', 'lab:*', "lab:scan\n."] as $by) { + try { + Grants::receipt(by: $by); + self::fail('a receipt was built that names ' . json_encode($by)); + } catch (\InvalidArgumentException $refused) { + self::assertStringContainsString('says which operation measured, by its name', $refused->getMessage()); + } + } + } + + public function testAHostThatDoesNotSayItsRecordReadsWholeIsNotReadAsHavingSaidSo(): void + { + // The safe reading is the default, as it is for the signature: a host that skipped a line it could not read + // — the easy thing — has to SAY its record reads whole, or nothing is lent. + $facts = ['signatureFault' => null, 'watcherSignalAt' => Grants::at('2026-10-11T11:50:00Z'), 'receipts' => [Grants::receipt()], 'instruments' => ['lab.scan' => Grants::INSTRUMENT]]; + $silent = StandingJudge::judge(Grants::grant(), new GrantStanding(GrantState::InForce, ...$facts), Grants::call()); + + self::assertSame([StandingCheck::Signature, 'record_unread'], [$silent->failed, $silent->reason], 'a host said nothing of its record, and the grant lent'); + self::assertStringContainsString(GrantStanding::NOT_READ, $silent->sentence); + $faulted = StandingJudge::judge(Grants::grant(), new GrantStanding(GrantState::InForce, ...$facts + ['recordFault' => 'line 4 is a «measured» line this house cannot read today']), Grants::call()); + self::assertSame('record_unread', $faulted->reason); + self::assertStringContainsString('line 4 is a «measured» line this house cannot read today', $faulted->sentence); + self::assertTrue(StandingJudge::judge(Grants::grant(), new GrantStanding(GrantState::InForce, ...$facts + ['recordFault' => null]), Grants::call())->passes(), 'control: a host that says its record reads whole'); + // The same for where the grant stands — the one default that still said «yes»: now it has to be said. + $this->expectException(\ArgumentCountError::class); + new GrantStanding(...$facts + ['recordFault' => null]); + } + + // ── L5 · a grant's text has a length ───────────────────────────────────────────────────────────────────── + + public function testATextLongerThanAGrantIsRefusedBeforeItIsRead(): void + { + $document = (string) json_encode(Grants::document()); + $padded = static fn (int $bytes): string => substr($document, 0, -1) . str_repeat(' ', $bytes - \strlen($document)) . '}'; + self::assertSame(Grants::grant()->digest(), StandingGrant::fromJson($padded(StandingGrant::LONGEST))->digest(), 'control: a text of the longest length a grant has is read'); + + $from = hrtime(true); + try { + StandingGrant::fromJson($padded(StandingGrant::LONGEST + 1)); + self::fail('a text longer than a grant was read'); + } catch (\InvalidArgumentException $refused) { + self::assertStringContainsString('is longer than a standing grant is', $refused->getMessage()); + } + // Keys chosen to land in one bucket of PHP's own table held the reader for twenty seconds at 2.4 MB: no + // such text is read at all now, whatever its keys are. + $colliding = '{' . implode(',', array_map(static fn (int $i): string => '"' . str_repeat('Ez', $i % 7) . str_repeat('FY', 7 - $i % 7) . $i . '":1', range(1, 60_000))) . '}'; + self::assertGreaterThan(StandingGrant::LONGEST, \strlen($colliding)); + self::assertSame(262_144, StandingGrant::LONGEST, '256 KB'); + try { + StandingGrant::fromJson($colliding); + self::fail('read'); + } catch (\InvalidArgumentException $refused) { + self::assertStringContainsString('is longer than a standing grant is', $refused->getMessage()); + } + self::assertLessThan(0.5, (hrtime(true) - $from) / 1e9, 'refusing a text for its length took as long as reading it'); + } + + public function testTheShapeOfAListIsReadFromTheTextByTheSameScanThatReadsItsKeys(): void + { + // One reading of the text decides both: a key said twice, and a list written as an object. + $text = str_replace('"to":["' . Grants::SEAT . '"]', '"to":{"0":"' . Grants::SEAT . '"}', (string) json_encode(Grants::document())); + try { + Canonical::saidOnce($text, 'a standing grant'); + self::fail('the scan of keys does not see a list written as an object'); + } catch (\InvalidArgumentException $refused) { + self::assertStringContainsString('a standing grant writes a list as an object with the keys "0", "1"', $refused->getMessage()); + self::assertStringContainsString('in "to"', $refused->getMessage()); + } + // An object whose keys only look like the start of a list, or are out of order, or begin at one, is an object. + foreach (['{"0":1,"2":2}', '{"1":1,"0":2}', '{"1":1}', '{"00":1}', '{"0":1,"a":2}', '{"a":1,"1":2}', '{"a":{"x":1},"b":[{"0x":1}]}', '{}', '[[],{}]'] as $object) { + Canonical::saidOnce($object, 'a text'); + } + self::assertFalse(method_exists(Canonical::class, 'listsAreLists'), 'the text is decoded a second time to ask what the scan already saw'); + } + + // ── L6 · one digest, whatever order what it digests was written in ─────────────────────────────────────── + + public function testAnInstrumentHasOneDigestWhateverOrderItsScopesWereWrittenIn(): void + { + $handler = static fn (array $input): array => ['ok' => true]; + $actsOn = static fn (array $input): string => 'sha256:x'; + $make = static fn (array $scopes): ?string => (new Operation(name: 'lab:scan', description: 'd', handler: $handler, scopes: $scopes, effects: EffectProfile::readOnly(), actsOn: $actsOn))->instrumentDigest(); + + self::assertNotNull($make(['a'])); + self::assertSame($make(['b', 'a']), $make(['a', 'b']), 'control: plain scopes'); + self::assertSame($make(['1.0', '1.00']), $make(['1.00', '1.0'])); + } + + public function testWhatIsJudgedAndWhatJudgesHaveOneDigestWhateverOrderTheyWereWrittenIn(): void + { + // The same default comparison, in the three other places of this package a digest is taken of a sorted list. + self::assertSame((new ContextFacts(scopes: ['1.0', '1.00']))->fingerprint(), (new ContextFacts(scopes: ['1.00', '1.0']))->fingerprint()); + $policy = static fn (array $rules): string => (new DeclaredAuthorityPolicy('lab', $rules))->digest(); + $rule = static fn (array $scopes): array => ['authority' => Authority::Read, 'scopes' => $scopes]; + self::assertSame($policy(['lab.scan' => $rule(['1.0', '1.00'])]), $policy(['lab.scan' => $rule(['1.00', '1.0'])])); + self::assertSame($policy(['1.0' => $rule(['a']), '1.00' => $rule(['a'])]), $policy(['1.00' => $rule(['a']), '1.0' => $rule(['a'])])); + $certificate = static fn (array $predicate, array $covers, array $envelope): string => (new DescentCertificate('lab.verifier', 'lab.publish', $predicate, $covers, EffectProfile::readOnly(), envelope: $envelope))->canonicalPayload(); + self::assertSame($certificate(['1.0' => 'a', '1.00' => 'b'], ['x'], []), $certificate(['1.00' => 'b', '1.0' => 'a'], ['x'], [])); + self::assertSame($certificate(['a' => 1], ['1.0', '1.00'], []), $certificate(['a' => 1], ['1.00', '1.0'], [])); + self::assertSame($certificate(['a' => 1], ['x'], ['k' => ['1.0' => 1, '1.00' => 2]]), $certificate(['a' => 1], ['x'], ['k' => ['1.00' => 2, '1.0' => 1]])); + // Control: names that do not look like numbers were always one digest, and are the digest they were. + self::assertSame((new ContextFacts(scopes: ['b', 'a']))->fingerprint(), (new ContextFacts(scopes: ['a', 'b']))->fingerprint()); + } + + // ── L7 · a grant built by hand is held to what the reader holds a document to ──────────────────────────── + + public function testAGrantBuiltByHandReadsBackFromItsOwnSpelling(): void + { + $by = new NamedCall('lab:scan'); + try { + $grant = new StandingGrant( + 'g', + [Grants::SEAT], + [new NamedCall('lab:publish')], + new EffectProfile(Mutation::Persistent, Externality::Public, Reversibility::Compensatable, Authority::WriteAsUser, subject: Subject::Executable, rollbackContract: "ask a person\nOK: in force"), + [new GrantCondition('c', 's', $by)], + 'u', + new Falsifier('f', 's', new NamedCall('lab:watch'), 60), + ); + } catch (\InvalidArgumentException) { + self::assertTrue(true, 'refused at construction, like the other nine texts'); + + return; + } + + self::assertSame($grant->digest(), StandingGrant::fromJson($grant->canonical())->digest()); + } + + /** @return iterable}> */ + public static function whatTheReaderRefusesAndAConstructorTook(): iterable + { + $profile = static fn (?string $inverse): EffectProfile => new EffectProfile(Mutation::Persistent, Externality::Public, Reversibility::Compensatable, Authority::WriteAsUser, subject: Subject::Executable, rollbackContract: $inverse); + yield 'an inverse with what a terminal obeys' => [['ceiling' => $profile("lab:unpublish\x1b[8m")]]; + yield 'an inverse that is a sentence, under a ceiling that asks for none' => [['ceiling' => $profile('ask a person to delete the release by hand')]]; + yield 'how an act is taken back, with spaces around it' => [['undo' => ' take it back ']]; + yield 'an expiry with a fraction of a second' => [['expiresAt' => new \DateTimeImmutable('2027-01-01T00:00:00.500000Z')]]; + yield 'an expiry in a year its own spelling cannot say' => [['expiresAt' => (new \DateTimeImmutable('9999-12-31T23:59:59Z'))->modify('+1 day')]]; + yield 'a stop it lifts, with a space after it' => [['supersedes' => 'older', 'lifts' => 'sha256:abc ']]; + yield 'a seat with a space before it' => [['to' => [' ' . Grants::SEAT]]]; + } + + /** @param array $change */ + #[DataProvider('whatTheReaderRefusesAndAConstructorTook')] + public function testTheConstructorRefusesWhatTheReaderRefuses(array $change): void + { + $parts = Grants::grant(); + $build = static fn (array $with): StandingGrant => new StandingGrant(...$with + ['id' => 'g', 'to' => $parts->to, 'covers' => $parts->covers, 'ceiling' => $parts->ceiling, 'conditions' => $parts->conditions, 'undo' => 'u', 'falsifier' => $parts->falsifier]); + $control = $build([]); + self::assertSame($control->digest(), StandingGrant::fromJson($control->canonical())->digest(), 'control: the grant as it is built reads back'); + + $this->expectException(\InvalidArgumentException::class); + $build($change); + } + + public function testAnInverseIsTheNameOfOneOperationUnderAnyCeiling(): void + { + // The README said so; under a ceiling that is not «guaranteed» any sentence was kept. + $ceiling = Grants::document()['ceiling']; + self::assertTrue(self::refused(['ceiling' => $ceiling + ['rollback_contract' => 'ask a person to delete the release by hand']]), 'accepted: a sentence as the inverse of a compensatable ceiling'); + self::assertSame('lab:unpublish', Grants::grant(['ceiling' => $ceiling + ['rollback_contract' => 'lab:unpublish']])->toArray()['ceiling']['rollback_contract'], 'control'); + } + + public function testWhatIsSaidBackIsNeverCutInsideAnEscape(): void + { + // At most a line of it — and a line that ends in half an escape is one a reader takes for something else. + foreach (range(80, 100) as $plain) { + $said = Canonical::shown(str_repeat('x', $plain) . "\x1b\x1b\u{202e}" . str_repeat('y', 60)); + $shown = substr($said, 0, (int) strpos($said, '…')); + + self::assertSame(0, preg_match('/\\\\(?:u[0-9a-f]{0,3})?$/', $shown), 'cut inside an escape: ' . $said); + self::assertGreaterThanOrEqual(91, \strlen($shown), 'more was left out than the half of one escape: ' . $said); + + // The short escapes too — a line break, a quote, a backslash: an odd run of backslashes at the end is half of one. + $said = Canonical::shown(str_repeat('x', $plain) . "\n\"\\\n\"\\" . str_repeat('y', 60)); + $shown = substr($said, 0, (int) strpos($said, '…')); + self::assertSame(0, preg_match('/(?expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('does not name one operation'); + new NamedCall("lab:scan\n."); + } + + // ── D5, complete · a NO of that instrument on that content stops the act, from whatever run ───────────── + + /** @return iterable}> */ + public static function runsOfTheInstrumentThatAreNotTheOneDescribed(): iterable + { + yield 'one argument more' => [['path' => 'candidate.tar', 'mode' => 'strict', 'deep' => true]]; + yield 'without the argument the condition fixes' => [['path' => 'candidate.tar']]; + yield 'the fixed argument at another value' => [['path' => 'candidate.tar', 'mode' => 'loose']]; + yield 'the fixed argument as another type' => [['path' => 'candidate.tar', 'mode' => true]]; + yield 'the bare instrument, with no argument at all' => [[]]; + } + + /** @param array $arguments */ + #[DataProvider('runsOfTheInstrumentThatAreNotTheOneDescribed')] + public function testANoOfThatInstrumentOnThatContentStopsTheActFromWhateverRun(array $arguments): void + { + $grant = Grants::grant(self::measuredBy('strict')); + $green = Grants::receipt(arguments: ['path' => 'candidate.tar', 'mode' => 'strict']); + self::assertTrue(StandingJudge::judge($grant, Grants::standing([$green]), Grants::call())->passes(), 'control'); + + $verdict = StandingJudge::judge($grant, Grants::standing([$green, Grants::receipt(ok: false, arguments: $arguments)]), Grants::call()); + + self::assertSame('measured_red', $verdict->passes() ? 'PASSES' : $verdict->reason, 'the instrument said no of this very content, and the call passed'); + // …until that content is measured again by the run the condition describes. + self::assertTrue(StandingJudge::judge($grant, Grants::standing([$green, Grants::receipt(ok: false, arguments: $arguments), $green]), Grants::call())->passes()); + // A YES from that same run is still not this condition measured: green stays closed. + self::assertSame('not_measured', StandingJudge::judge($grant, Grants::standing([Grants::receipt(ok: true, arguments: $arguments)]), Grants::call())->reason); + } + + public function testANoAboutOtherContentOrFromAnotherOperationSaysNothingOfThisCondition(): void + { + $grant = Grants::grant(self::measuredBy('strict')); + $run = ['path' => 'candidate.tar', 'mode' => 'strict']; + $green = Grants::receipt(arguments: $run); + + self::assertTrue(StandingJudge::judge($grant, Grants::standing([$green, Grants::receipt(subject: 'sha256:another', ok: false, arguments: $run)]), Grants::call())->passes(), 'a no about other content'); + self::assertTrue(StandingJudge::judge($grant, Grants::standing([$green, Grants::receipt(ok: false, by: 'lab:other', arguments: $run)]), Grants::call())->passes(), 'a no from another operation'); + } + + // ── D9 · a text a grant keeps is refused, too, for what cannot be seen in it ───────────────────────────── + + /** @return iterable */ + public static function whatCannotBeSeen(): iterable + { + $named = [ + 'a zero-width space' => 0x200B, 'a zero-width non-joiner' => 0x200C, 'a zero-width joiner' => 0x200D, 'a soft hyphen' => 0x00AD, + 'a word joiner' => 0x2060, 'an invisible times' => 0x2062, 'an invisible plus' => 0x2064, 'a deprecated format character' => 0x206A, 'the last of them' => 0x206F, + 'a byte order mark' => 0xFEFF, 'an interlinear annotation anchor' => 0xFFF9, 'an interlinear annotation terminator' => 0xFFFB, + 'the language tag' => 0xE0001, 'a tag letter' => 0xE0073, 'the cancel tag' => 0xE007F, 'an unassigned place of the tag block' => 0xE0002, + 'a variation selector' => 0xFE0F, 'the first variation selector' => 0xFE00, 'a variation selector of the supplement' => 0xE0100, 'the last of them' . ' ' => 0xE01EF, + 'a combining grapheme joiner' => 0x034F, 'a mongolian free variation selector' => 0x180B, 'the third of them' => 0x180D, 'the fourth of them' => 0x180F, + 'a no-break space' => 0x00A0, 'an ogham space mark' => 0x1680, 'a mongolian vowel separator' => 0x180E, 'an en quad' => 0x2000, 'a hair space' => 0x200A, + 'a narrow no-break space' => 0x202F, 'a medium mathematical space' => 0x205F, 'an ideographic space' => 0x3000, + 'a khmer inherent vowel' => 0x17B4, 'the other khmer inherent vowel' => 0x17B5, + 'a hangul choseong filler' => 0x115F, 'a hangul jungseong filler' => 0x1160, 'a hangul filler' => 0x3164, 'a halfwidth hangul filler' => 0xFFA0, 'a blank braille pattern' => 0x2800, + 'a private-use character' => 0xE000, 'a private-use character of plane 15' => 0xF0000, 'a noncharacter' => 0xFDD0, 'the last place of the first plane' => 0xFFFF, 'a place nothing is assigned to' => 0x0378, + 'a shorthand format control' => 0x1BCA0, 'a musical format control' => 0x1D173, 'an arabic number sign' => 0x0600, + ]; + foreach ($named as $what => $point) { + yield trim($what) . \sprintf(' (U+%04X)', $point) => [$point]; + } + } + + #[DataProvider('whatCannotBeSeen')] + public function testATextAGrantKeepsCarriesNothingThatCannotBeSeen(int $point): void + { + $character = mb_chr($point); + $kept = ['undo' => 'take it' . $character . ' back', 'conditions' => [['name' => 'no-host' . $character . 'names', 'statement' => 'it names' . $character . 'no machine', 'by' => ['operation' => 'lab:scan', 'free' => ['path']]]]]; + foreach ($kept as $field => $value) { + try { + $grant = Grants::grant([$field => $value]); + self::fail("accepted: «{$field}» carrying " . \sprintf('U+%04X', $point) . ', and written back as ' . json_encode($grant->toArray()[$field])); + } catch (\InvalidArgumentException $refused) { + self::assertStringContainsString('one line of plain text', $refused->getMessage()); + self::assertSafeToRead($refused->getMessage()); + } + } + foreach (['to' => [Grants::SEAT . $character], 'lifts' => 'sha256:ab' . $character . 'cd'] as $field => $value) { + self::assertTrue(self::refused(['supersedes' => 'older', $field => $value]), "accepted: «{$field}» carrying " . \sprintf('U+%04X', $point)); + } + } + + /** + * THE RULE, SAID BY THE PROPERTY AND NOT BY A LIST (decided by Rod, 2026-10-12): no code point Unicode itself + * says is shown as nothing — `Default_Ignorable_Code_Point` — is taken in a text a grant keeps. + * + * Asked of every code point there is, through the reader, with a second instrument: the Unicode tables of the + * PCRE this suite runs on. A character added to that set tomorrow turns this test red the day the tables know it. + */ + public function testNoCodePointUnicodeSaysIsShownAsNothingIsTaken(): void + { + self::assertSame(0, @preg_match('/\p{DI}/u', 'a'), 'this PCRE does not know Default_Ignorable_Code_Point (it needs PCRE2 10.40): the rule cannot be asked here'); + $every = ''; + for ($point = 0; $point <= 0x10FFFF; ++$point) { + $every .= $point >= 0xD800 && $point <= 0xDFFF ? '' : mb_chr($point, 'UTF-8'); + } + preg_match_all('/\p{DI}/u', $every, $found); + $ignorable = $found[0]; + // Controls of the instrument: it sees the set, and the set is the one Unicode publishes — the soft hyphen, + // the zero-width space, a variation selector of the supplement and the reserved places of the tag block. + self::assertGreaterThan(4000, \count($ignorable)); + foreach ([0x00AD, 0x200B, 0x17B4, 0x3164, 0xE0100, 0xE0FFF] as $known) { + self::assertContains(mb_chr($known, 'UTF-8'), $ignorable, \sprintf('U+%04X', $known)); + } + self::assertNotContains('a', $ignorable); + self::assertNotContains(mb_chr(0x00E9, 'UTF-8'), $ignorable); + + $taken = []; + foreach ($ignorable as $character) { + if (Canonical::oneLine('a' . $character . 'b')) { + $taken[] = \sprintf('U+%04X', mb_ord($character, 'UTF-8')); + } + } + + self::assertSame([], $taken, 'shown as nothing, and taken in a text a grant keeps'); + } + + public function testWhatAPersonWritesInTheirOwnLanguageIsStillAGrant(): void + { + // The control the decision asked for: accents, the letter eñe, the marks that open a question and an + // exclamation, both kinds of quotes — and the ordinary space, as many as the sentence has. + $spanish = '¿Se retira la versión y se publica la anterior? ¡Sí! — «sin excepción», "siempre" y \'cuanto antes\'; año, pingüino, niño.'; + $others = ['ce qui est publié ne nomme aucune machine ; jamais', "\u{5e7}\u{5d5}\u{5d1}\u{5e5} release \u{5d0}\u{5d7}\u{5d3}", "\u{0645}\u{0644}\u{0641} release", "\u{767a}\u{884c}\u{3092}\u{53d6}\u{308a}\u{6d88}\u{3059}", 'two spaces, and an e' . "\u{0301}" . ' written with its accent apart', 'the replacement character ' . "\u{fffd}" . ' is a character', 'a smile ' . "\u{1f600}"]; + foreach ([$spanish, ...$others] as $text) { + $grant = Grants::grant(['undo' => $text, 'conditions' => [['name' => $text, 'statement' => $text, 'by' => ['operation' => 'lab:scan', 'free' => ['path']]]], 'falsifier' => ['name' => $text, 'statement' => $text, 'watcher' => ['operation' => 'lab:watch'], 'within_seconds' => 60]]); + + self::assertSame([$text, $text, $text, $text, $text], [$grant->undo, $grant->conditions[0]->name, $grant->conditions[0]->statement, $grant->falsifier->name, $grant->falsifier->statement]); + self::assertSame($grant->digest(), StandingGrant::fromJson($grant->canonical())->digest()); + } + } +} diff --git a/tests/Standing/WhatAFourthReviewFoundInTheContractTest.php b/tests/Standing/WhatAFourthReviewFoundInTheContractTest.php new file mode 100644 index 0000000..31a05f3 --- /dev/null +++ b/tests/Standing/WhatAFourthReviewFoundInTheContractTest.php @@ -0,0 +1,695 @@ + + * + * @license Apache-2.0 + * + * @link https://github.com/getmilpa/command + */ + +declare(strict_types=1); + +namespace Milpa\Command\Tests\Standing; + +use Milpa\Command\Effect\Authority; +use Milpa\Command\Effect\EffectProfile; +use Milpa\Command\Effect\Externality; +use Milpa\Command\Effect\Mutation; +use Milpa\Command\Effect\Reversibility; +use Milpa\Command\Standing\Canonical; +use Milpa\Command\Standing\ConditionReceipt; +use Milpa\Command\Standing\GrantStanding; +use Milpa\Command\Standing\GrantState; +use Milpa\Command\Standing\NamedCall; +use Milpa\Command\Standing\StandingCheck; +use Milpa\Command\Standing\StandingGrant; +use Milpa\Command\Standing\StandingJudge; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\TestCase; + +/** + * What a fourth independent review found in the contract of standing grants, each as its own probe reproduced it + * (greenhouse decisions/0615, «Lo que la cuarta revisión encontró»). Nine of these tests are the reviewer's own, + * moved here as they were written; every one was red on the head it read. + * + * @subject-in milpa/command + */ +final class WhatAFourthReviewFoundInTheContractTest extends TestCase +{ + /** @param array $change */ + private static function refused(array $change): bool + { + try { + Grants::grant($change); + } catch (\InvalidArgumentException) { + return true; + } + + return false; + } + + /** What the reader says of a text it refuses — or `null`: it took it. */ + private static function refusal(string $text): ?string + { + try { + StandingGrant::fromJson($text); + } catch (\InvalidArgumentException $refused) { + return $refused->getMessage(); + } + + return null; + } + + /** The lab grant as text, with something put in place of something else. */ + private static function written(string $find, string $put): string + { + $text = (string) json_encode(Grants::document(), \JSON_UNESCAPED_SLASHES); + self::assertSame(1, substr_count($text, $find), "the lab grant says {$find} once"); + + return str_replace($find, $put, $text); + } + + // ── M1 · a name ends where it ends ────────────────────────────────────────────────────────────────────── + + /** «supersedes another grant by its id»: an id followed by a newline is not an id. */ + public function testWhatAGrantSupersedesIsAnIdAndNothingAfterIt(): void + { + self::assertTrue(self::refused(['supersedes' => 'the old one']), 'control: a name that is not an id is refused'); + self::assertTrue(self::refused(['supersedes' => "older\n"]), 'accepted: «supersedes» = "older" and a newline — it names no grant a house holds'); + self::assertFalse(self::refused(['supersedes' => 'older']), 'control: an id is taken'); + } + + /** «and never itself». */ + public function testAGrantNeverSupersedesItselfHoweverItIsSpelled(): void + { + self::assertTrue(self::refused(['supersedes' => 'release-line']), 'control: superseding itself is refused'); + self::assertTrue(self::refused(['supersedes' => "release-line\n"]), 'accepted: «release-line» supersedes "release-line" and a newline'); + } + + /** An operation is named whole: nothing after the name — where a grant covers, measures and watches. */ + public function testAnOperationNameEndsWhereItEnds(): void + { + self::assertTrue(self::refused(['covers' => [['operation' => 'standing']]]), 'control: «standing» is refused'); + self::assertTrue(self::refused(['covers' => [['operation' => "standing\n."]]]), 'accepted: an operation named "standing" and a newline'); + self::assertTrue(self::refused(['covers' => [['operation' => "lab:publish\n."]]]), 'accepted: an operation that names no call'); + foreach (["lab:scan\n.", "lab.scan\n:", "lab\n.scan"] as $name) { + try { + new NamedCall($name); + self::fail('a named call took ' . json_encode($name)); + } catch (\InvalidArgumentException $refused) { + self::assertStringContainsString('does not name one operation', $refused->getMessage()); + } + } + // What is blank around a name WAS taken off it, and this test pinned that. It is refused now (the fifth + // review, L1): a name is given as it reads. What stays is that a surface's spelling is one identity. + foreach ([" lab:scan\n", "lab:scan\n", ' lab:scan', "lab:scan\0"] as $name) { + try { + new NamedCall($name); + self::fail('a named call took ' . json_encode($name)); + } catch (\InvalidArgumentException $refused) { + self::assertStringContainsString('does not name one operation', $refused->getMessage()); + } + } + self::assertSame('lab.scan', (new NamedCall('lab:scan'))->operation->canonical, 'control: a name as a surface spells it'); + } + + /** The third place the same pattern stands: the operation a ceiling names as its inverse. */ + public function testTheOperationThatUndoesIsNamedWholeToo(): void + { + $guaranteed = static fn (string $inverse): EffectProfile => new EffectProfile(Mutation::Persistent, Externality::None, Reversibility::Guaranteed, Authority::WriteAsUser, rollbackContract: $inverse); + self::assertSame('lab.unpublish', $guaranteed('lab:unpublish')->rollbackOperation()?->canonical, 'control: an operation is taken as the inverse'); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('describes an inverse instead of naming one'); + $guaranteed("lab:unpublish\n."); + } + + // ── M2 · the scan costs what the text is long ─────────────────────────────────────────────────────────── + + /** The key scan is linear in the text: four times the text, about four times the time (and never sixteen). */ + public function testTheKeyScanCostsWhatTheTextIsLong(): void + { + $text = static fn (int $n): string => '{' . implode(',', array_map(static fn (int $i): string => '"k' . $i . '":[]', range(1, $n))) . '}'; + // The best of three: a machine doing something else makes one run slow, never all of them fast. + $seconds = static function (string $json): float { + $best = \INF; + for ($run = 0; $run < 3; ++$run) { + $from = hrtime(true); + Canonical::saidOnce($json, 'a text'); + $best = min($best, (hrtime(true) - $from) / 1e9); + } + + return $best; + }; + $small = $seconds($text(5000)); + $large = $seconds($text(20000)); + + self::assertLessThan(8, $large / $small, \sprintf('four times the text took %.1f times as long (%.3f s → %.3f s)', $large / $small, $small, $large)); + } + + // ── L1 · one spelling, whatever order its author wrote it in ──────────────────────────────────────────── + + public function testOneDocumentHasOneDigestWhateverOrderItsArgumentsWereWrittenIn(): void + { + // The reviewer's test named its arguments «1.0» and «1.00», which sorted as numbers are equal: two orders, + // two digests. Such a name is refused now (L7, below), so the same is asked with names that are taken — + // and of the spelling itself, where a map may still carry keys that look like numbers + // ({@see self::testTheCanonicalOrderIsTheOrderOfTheBytesAndTheClassSaysSo()}). + $one = Grants::grant(['covers' => [['operation' => 'lab:publish', 'arguments' => ['b' => 'x', 'B' => 'y', 'a_b' => 'z']]]]); + $other = Grants::grant(['covers' => [['operation' => 'lab:publish', 'arguments' => ['a_b' => 'z', 'b' => 'x', 'B' => 'y']]]]); + + self::assertSame($one->digest(), $other->digest()); + self::assertSame('{"1.0":"a","1.00":"b"}', Canonical::json(['1.00' => 'b', '1.0' => 'a'])); + } + + /** The seats are a set. */ + public function testOneDocumentHasOneDigestWhateverOrderItsSeatsWereWrittenIn(): void + { + self::assertSame(Grants::grant(['to' => ['7', '007']])->digest(), Grants::grant(['to' => ['007', '7']])->digest()); + } + + public function testTheArgumentsACallMayCarryAreASetToo(): void + { + $spelled = static fn (array $free): string => (string) json_encode((new NamedCall('lab:scan', free: $free))->toArray()); + + self::assertSame($spelled(['b', 'B', 'a_b']), $spelled(['a_b', 'b', 'B'])); + self::assertSame('{"operation":"lab.scan","free":["B","a_b","b"]}', $spelled(['b', 'a_b', 'B'])); + } + + public function testTheCanonicalOrderIsTheOrderOfTheBytesAndTheClassSaysSo(): void + { + // Sorted the way any implementation sorts text — not the way one language compares what looks like a number. + self::assertSame('{"10":2,"9":1}', Canonical::json(['9' => 1, '10' => 2])); + self::assertSame(Canonical::json(['0e1' => 'a', '0e2' => 'b']), Canonical::json(['0e2' => 'b', '0e1' => 'a'])); + self::assertSame('{"1.0":"a","1.00":"b"}', Canonical::json(['1.00' => 'b', '1.0' => 'a'])); + self::assertSame('{"B":1,"a":2,"b":{"10":1,"9":2}}', Canonical::json(['b' => ['9' => 2, '10' => 1], 'a' => 2, 'B' => 1])); + $says = (string) (new \ReflectionClass(Canonical::class))->getDocComment(); + self::assertStringContainsString('by the bytes of its keys', $says); + self::assertStringContainsString('serialize_precision', $says); + } + + // ── L2 · a value the reader does not read ─────────────────────────────────────────────────────────────── + + /** «Any key it does not read» is refused; a value it does not read is a line nobody keeps too. */ + public function testARollbackContractThatIsNotTextIsNotDroppedInSilence(): void + { + $ceiling = Grants::document()['ceiling']; + $said = ['ceiling' => $ceiling + ['rollback_contract' => ['operation' => 'lab:unpublish']]]; + + self::assertTrue(self::refused($said) || Grants::grant($said)->digest() !== Grants::grant()->digest(), 'accepted, with the digest of the grant that names no rollback at all'); + } + + /** @return iterable, string}> */ + public static function ceilingsReadAsSomethingElse(): iterable + { + $readOnly = ['mutation' => 'none', 'externality' => 'none', 'reversibility' => 'guaranteed', 'authority' => 'read', 'subject' => 'none']; + $written = Grants::document()['ceiling']; + yield 'an inverse given as an object' => [$written + ['rollback_contract' => ['operation' => 'lab:unpublish']], 'rollback_contract']; + yield 'an inverse given as a list of two' => [$written + ['rollback_contract' => ['lab:unpublish', 'lab:announce-withdrawal']], 'rollback_contract']; + yield 'an inverse given as a number' => [$written + ['rollback_contract' => 7], 'rollback_contract']; + yield 'an inverse given as blank text' => [$written + ['rollback_contract' => ' '], 'rollback_contract']; + yield 'a ceiling that changes nothing, said «guaranteed»' => [$readOnly, 'reversibility']; + yield 'a ceiling «guaranteed» by an inverse that is a sentence' => [['mutation' => 'persistent', 'externality' => 'none', 'reversibility' => 'guaranteed', 'authority' => 'write_as_user', 'subject' => 'data', 'rollback_contract' => 'delete the file by hand'], 'reversibility']; + yield 'an axis that is not one of its values' => [['mutation' => 'a lot'] + $written, 'mutation']; + yield 'an axis given as a list' => [['subject' => ['data']] + $written, 'subject']; + yield 'a ceiling that changes nothing, said «guaranteed» by an inverse' => [$readOnly + ['rollback_contract' => 'lab:undo'], 'rollback_contract']; + } + + /** @param array $ceiling */ + #[DataProvider('ceilingsReadAsSomethingElse')] + public function testACeilingIsReadAsItIsWrittenOrItIsRefused(array $ceiling, string $what): void + { + try { + $grant = Grants::grant(['ceiling' => $ceiling]); + } catch (\InvalidArgumentException $refused) { + self::assertStringContainsString("«{$what}»", $refused->getMessage()); + + return; + } + self::fail('accepted, and written back as ' . json_encode($grant->toArray()['ceiling'])); + } + + public function testACeilingThatSaysWhatItIsReadAsIsTaken(): void + { + // Controls: the same ceilings, written as they are read; an inverse in words on a ceiling that asks none; + // and an inverse that says nothing at all. + $written = Grants::document()['ceiling']; + $readOnly = ['mutation' => 'none', 'externality' => 'none', 'reversibility' => 'not_applicable', 'authority' => 'read', 'subject' => 'none']; + + self::assertSame($readOnly, Grants::grant(['ceiling' => $readOnly])->toArray()['ceiling']); + self::assertSame('lab:unpublish', Grants::grant(['ceiling' => $written + ['rollback_contract' => 'lab:unpublish']])->toArray()['ceiling']['rollback_contract']); + self::assertSame(Grants::grant()->digest(), Grants::grant(['ceiling' => $written + ['rollback_contract' => null]])->digest(), 'an inverse given as nothing is no inverse'); + $guaranteed = ['mutation' => 'persistent', 'externality' => 'none', 'reversibility' => 'guaranteed', 'authority' => 'write_as_user', 'subject' => 'data', 'rollback_contract' => 'lab:unpublish']; + self::assertSame($guaranteed, Grants::grant(['ceiling' => $guaranteed])->toArray()['ceiling']); + } + + // ── L3 · an expiry the reader takes is one it reads back ──────────────────────────────────────────────── + + /** @return iterable */ + public static function expiriesWhoseOwnSpellingIsNotTheForm(): iterable + { + yield 'the last second of 9999, west of UTC' => ['9999-12-31T23:59:59-06:00']; + yield 'the last second of 9999, one minute west' => ['9999-12-31T23:59:59-00:01']; + yield 'the first second of 0000, one minute east' => ['0000-01-01T00:00:00+00:01']; + yield 'the first second of 0000, far east' => ['0000-01-01T00:00:00+23:59']; + } + + #[DataProvider('expiriesWhoseOwnSpellingIsNotTheForm')] + public function testAnExpiryWhoseOwnSpellingTheReaderRefusesIsRefusedWhenItIsRead(string $written): void + { + // It was accepted, and its canonical form — the instant in UTC — was a text the reader refuses. + self::assertTrue(self::refused(['expires_at' => $written]), 'accepted: ' . $written); + // …and whoever wrote it is told the form, by the reader: not that a grant built in code ends badly. + self::assertStringContainsString('gives «expires_at» as a date and time with its offset, in ISO 8601', (string) self::refusal((string) json_encode(Grants::document(['expires_at' => $written])))); + } + + public function testAnExpiryTheReaderTakesIsOneItReadsBackFromItsOwnSpelling(): void + { + foreach (['9999-12-31T23:59:59Z', '9999-12-31T23:59:59+06:00', '0000-01-01T00:00:00Z', '0000-01-01T00:00:00-06:00', '2099-01-01T00:00:00-06:00'] as $written) { + $grant = Grants::grant(['expires_at' => $written]); + + self::assertSame($grant->digest(), StandingGrant::fromJson($grant->canonical())->digest(), $written); + } + } + + // ── L4 · the scan of keys answers for any text ────────────────────────────────────────────────────────── + + /** @return iterable */ + public static function textsThatDoNotParse(): iterable + { + yield 'a text that is never closed' => ['{"a']; + yield 'a backslash at the very end' => ['{"a\\']; + yield 'one quote and nothing else' => ['"']; + yield 'a million lists opened and none closed' => [str_repeat('[', 1_000_000)]; + yield 'nothing' => ['']; + } + + #[DataProvider('textsThatDoNotParse')] + public function testTheScanOfKeysRefusesATextThatDoesNotParseAndItReturns(string $text): void + { + // The method is part of the contract and public: asked of a text that does not parse, it never returned. + // Asked in a process of its own, with a limit, so that a reader that does not return is a failure and not a + // suite that never ends. + $script = 'require $argv[1]; error_reporting(0); set_time_limit(5); $text = (string) file_get_contents("php://stdin");' + . ' try { Milpa\\Command\\Standing\\Canonical::saidOnce($text, "a text"); echo "RETURNED"; }' + . ' catch (InvalidArgumentException $refused) { echo "REFUSED: ", $refused->getMessage(); }'; + $child = proc_open([\PHP_BINARY, '-d', 'display_errors=0', '-d', 'memory_limit=64M', '-r', $script, '--', \dirname(__DIR__, 2) . '/vendor/autoload.php'], [['pipe', 'r'], ['pipe', 'w'], ['file', '/dev/null', 'w']], $pipes); + self::assertIsResource($child); + fwrite($pipes[0], $text); + fclose($pipes[0]); + $said = (string) stream_get_contents($pipes[1]); + proc_close($child); + + self::assertStringStartsWith('REFUSED: a text is not JSON', $said, 'it did not return, or it answered for a text it could not read'); + } + + public function testTheScanOfKeysStillAnswersForJsonThatIsNotAnObject(): void + { + foreach (['[]', '"text"', '7', 'null', '[{"a":1},{"a":2}]', '{"a":{"a":1}}'] as $json) { + Canonical::saidOnce($json, 'a text'); + } + // …and, asked here, of texts that do not parse and that the scan always came back from. + foreach (['', '{"a":1,}', '{"a":1}{"a":2}', "{'a':1}", str_repeat('[', 513) . str_repeat(']', 513)] as $text) { + try { + Canonical::saidOnce($text, 'a text'); + self::fail('it answered for ' . json_encode(substr($text, 0, 20))); + } catch (\InvalidArgumentException $refused) { + self::assertStringStartsWith('a text is not JSON this contract reads', $refused->getMessage()); + } + } + } + + public function testWhatIsSaidBackOfAValueThatIsNotTextIsBoundedToo(): void + { + self::assertSame('"budget"', Canonical::shown('budget')); + self::assertSame('{"channel":["a","b"]}', Canonical::shown(['channel' => ['a', 'b']])); + self::assertSame('null', Canonical::shown(null)); + self::assertSame('"a/b \\u00e9 \\u001b"', Canonical::shown("a/b \u{e9} \x1b")); + self::assertSame('"\\ufffd"', Canonical::shown("\xB1"), 'bytes that are not text are said as what they are not'); + self::assertSame('"a\\u007fb \\u0085 \\u202e"', Canonical::shown("a\x7fb \u{85} \u{202e}"), 'the one control character JSON leaves as it is, and the ones above it'); + $long = Canonical::shown(str_repeat('x', 500)); + self::assertSame(96, strpos($long, '…')); + self::assertStringEndsWith('… (502 bytes as written)', $long); + self::assertSame(98, \strlen(Canonical::shown(str_repeat('x', 94))) + 2, 'what fits in a line is said whole'); + self::assertStringNotContainsString('…', Canonical::shown(str_repeat('x', 94))); + } + + // ── L5 · a refusal does not say back what a drafter wrote, raw and at any length ──────────────────────── + + /** @return iterable */ + public static function whereADrafterWrites(): iterable + { + yield 'a key said twice' => [static fn (string $said): string => '{' . json_encode($said) . ':1,' . json_encode($said) . ':2,' . substr((string) json_encode(Grants::document()), 1)]; + yield 'a key said twice inside an object named by the drafter' => [static fn (string $said): string => '{' . json_encode($said) . ':{"a":1,"a":2},' . substr((string) json_encode(Grants::document()), 1)]; + yield 'a key nothing reads' => [static fn (string $said): string => (string) json_encode(Grants::document() + [$said => 1])]; + yield 'the id, in a document refused for something else' => [static fn (string $said): string => (string) json_encode(Grants::document(['id' => $said, 'ceiling' => null]))]; + yield 'the id' => [static fn (string $said): string => (string) json_encode(Grants::document(['id' => $said]))]; + yield 'what it supersedes' => [static fn (string $said): string => (string) json_encode(Grants::document(['supersedes' => $said]))]; + yield 'an operation it covers' => [static fn (string $said): string => (string) json_encode(Grants::document(['covers' => [['operation' => $said]]]))]; + yield 'the name of an argument it fixes' => [static fn (string $said): string => (string) json_encode(Grants::document(['covers' => [['operation' => 'lab:publish', 'arguments' => [$said => ['not', 'plain']]]]]))]; + yield 'the value of an argument it fixes' => [static fn (string $said): string => (string) json_encode(Grants::document(['covers' => [['operation' => 'lab:publish', 'arguments' => ['channel' => [$said]]]]]))]; + yield 'an argument it leaves free and also fixes' => [static fn (string $said): string => (string) json_encode(Grants::document(['conditions' => [['name' => 'c', 'statement' => 's', 'by' => ['operation' => 'lab:scan', 'arguments' => [$said => 1], 'free' => [$said]]]]]))]; + yield 'an argument it leaves free' => [static fn (string $said): string => (string) json_encode(Grants::document(['conditions' => [['name' => 'c', 'statement' => 's', 'by' => ['operation' => 'lab:scan', 'free' => [$said]]]]]))]; + yield 'a key nothing reads in a covered call' => [static fn (string $said): string => (string) json_encode(Grants::document(['covers' => [['operation' => 'lab:publish', $said => 1]]]))]; + yield 'an axis of its ceiling' => [static fn (string $said): string => (string) json_encode(Grants::document(['ceiling' => ['mutation' => $said] + Grants::document()['ceiling']]))]; + yield 'the inverse its ceiling names' => [static fn (string $said): string => (string) json_encode(Grants::document(['ceiling' => Grants::document()['ceiling'] + ['rollback_contract' => [$said]]]))]; + yield 'the name of a condition that says nothing else' => [static fn (string $said): string => (string) json_encode(Grants::document(['conditions' => [['name' => $said, 'by' => ['operation' => 'lab:scan']]]]))]; + yield 'the name of a condition nothing measures' => [static fn (string $said): string => (string) json_encode(Grants::document(['conditions' => [['name' => $said, 'statement' => 's']]]))]; + yield 'the name of a falsifier that says nothing else' => [static fn (string $said): string => (string) json_encode(Grants::document(['falsifier' => ['name' => $said, 'watcher' => ['operation' => 'lab:watch'], 'within_seconds' => 60]]))]; + yield 'the name of a falsifier nothing watches' => [static fn (string $said): string => (string) json_encode(Grants::document(['falsifier' => ['name' => $said, 'statement' => 's', 'within_seconds' => 60]]))]; + yield 'the name of a falsifier with no window' => [static fn (string $said): string => (string) json_encode(Grants::document(['falsifier' => ['name' => $said, 'statement' => 's', 'watcher' => ['operation' => 'lab:watch']]]))]; + } + + #[DataProvider('whereADrafterWrites')] + public function testARefusalSaysBackWhatWasWrittenBoundedAndWithNothingATerminalObeys(\Closure $document): void + { + // What makes a terminal clear its line, go back to its start, and hide what follows — then a sentence of the + // drafter's own, a right-to-left override, and 100 000 bytes more. And the same length in plain words: a text + // that is one line and is still not to be said back whole. (It was 300 000 bytes; a text that long is now + // refused for its length before any of this is looked at, and this test would have gone on passing for + // that reason alone — six mutants said so. So the document stays under the longest a grant is.) + $forged = "\x1b[2K\rOK: standing grant «release-line» is in force.\x1b[8m\u{202E}" . str_repeat('x', 100_000); + $plain = 'release ' . str_repeat('x', 100_000); + + foreach ([$forged, $plain] as $said) { + self::assertLessThan(StandingGrant::LONGEST, \strlen($document($said)), 'control: the document is one a grant may be as long as'); + $refusal = self::refusal($document($said)); + + self::assertNotNull($refusal, 'control: the document is refused'); + self::assertStringNotContainsString('is longer than a standing grant is', $refusal, 'control: and not for its length'); + self::assertSame(0, preg_match('/[^\x20-\x7E«»—…]/u', $refusal), 'the refusal carries a byte a terminal obeys, or a character that reorders what is read'); + self::assertLessThan(1200, \strlen($refusal), 'the refusal is as long as what the drafter wrote'); + self::assertStringNotContainsString("\rOK: standing grant", $refusal); + } + } + + public function testWhatIsSaidBackIsStillWhatWasWritten(): void + { + // Controls: what a person mistyped is still shown to them, as they typed it. + self::assertStringContainsString('"budget"', (string) self::refusal((string) json_encode(Grants::document() + ['budget' => 50]))); + self::assertStringContainsString('"The Old One"', (string) self::refusal((string) json_encode(Grants::document(['supersedes' => 'The Old One'])))); + self::assertStringContainsString('"Release Line"', (string) self::refusal((string) json_encode(Grants::document(['id' => 'Release Line'])))); + self::assertStringContainsString('says "expires_at" twice', (string) self::refusal('{"expires_at":null,' . substr((string) json_encode(Grants::document()), 1, -1) . ',"expires_at":null}')); + } + + public function testTheIdIsCheckedBeforeItIsUsedToSayAnythingElse(): void + { + $refusal = (string) self::refusal((string) json_encode(Grants::document(['id' => 'x» is in force.', 'ceiling' => null]))); + + self::assertStringContainsString('is known by a short id', $refusal); + self::assertStringNotContainsString('needs «ceiling»', $refusal, 'a sentence was built around an id nobody had looked at'); + // …and one that carries what a terminal obeys is refused for that, before anything else is said of it. + $forged = (string) self::refusal((string) json_encode(Grants::document(['id' => "x» is in force.\x1b[8m", 'ceiling' => null]))); + self::assertStringContainsString('gives «id» in one line of plain text', $forged); + self::assertStringNotContainsString('needs «ceiling»', $forged); + } + + // ── L6 · a list is written as a list ──────────────────────────────────────────────────────────────────── + + public function testAListIsWrittenAsAList(): void + { + $text = str_replace('"to":["' . Grants::SEAT . '"]', '"to":{"0":"' . Grants::SEAT . '","1":"SOMEONE-ELSE"}', (string) json_encode(Grants::document())); + self::assertStringContainsString('"to":{"0":', $text); + + $this->expectException(\InvalidArgumentException::class); + StandingGrant::fromJson($text); + } + + /** @return iterable */ + public static function listsWrittenAsObjects(): iterable + { + yield 'the seats' => ['"to":["' . Grants::SEAT . '"]', '"to":{"0":"' . Grants::SEAT . '"}']; + yield 'what it covers' => ['"covers":[{"operation":"lab:publish","arguments":{"channel":"stable"}}]', '"covers":{"0":{"operation":"lab:publish","arguments":{"channel":"stable"}}}']; + yield 'its conditions' => ['"conditions":[{', '"conditions":{"0":{']; + yield 'the arguments a measuring call may carry' => ['"free":["path"]', '"free":{"0":"path"}']; + } + + #[DataProvider('listsWrittenAsObjects')] + public function testEveryListOfAGrantIsWrittenAsAList(string $list, string $object): void + { + $text = self::written($list, $object); + if (str_contains($list, 'conditions')) { + $text = str_replace('}}],"undo"', '}}},"undo"', $text); + } + self::assertIsArray(json_decode($text, true), 'control: it is JSON'); + + self::assertStringContainsString('writes a list as an object', (string) (self::refusal($text) ?? 'ACCEPTED')); + } + + // ── L8 and what was informational · the contract says what it does ────────────────────────────────────── + + public function testTheReaderOfAReceiptSaysWhatAHostOwesALineItCanNoLongerRead(): void + { + // Pinned as it is: this reader answers `null` for a line that is not a receipt. What it owes a host is to + // say what such a line means in a book the host kept. + self::assertNull(ConditionReceipt::fromArray(['by' => 'lab:scan'])); + self::assertStringContainsString('is a FAULT of its record', (string) (new \ReflectionMethod(ConditionReceipt::class, 'fromArray'))->getDocComment()); + self::assertStringContainsString('cannot see a key said twice', (string) (new \ReflectionMethod(StandingGrant::class, 'fromArray'))->getDocComment()); + } + + // ── D7 · what a person signs is read as it is written: every text a grant keeps is one line of text ───── + + /** + * Every text of a grant that the reader keeps, each with a place for what a drafter writes in it. + * + * @return array> + */ + private static function textsAGrantKeeps(): array + { + $condition = static fn (array $change): array => ['conditions' => [$change + ['name' => 'no-host-names', 'statement' => 'what is published names no machine', 'by' => ['operation' => 'lab:scan', 'free' => ['path']]]]]; + $falsifier = static fn (array $change): array => ['falsifier' => $change + ['name' => 'a-published-name', 'statement' => 'a published release names a machine', 'watcher' => ['operation' => 'lab:watch'], 'within_seconds' => 3600]]; + + return [ + 'how an act is taken back' => static fn (string $text): array => ['undo' => $text], + 'the name of a condition' => static fn (string $text): array => $condition(['name' => $text]), + 'what a condition says must hold' => static fn (string $text): array => $condition(['statement' => $text]), + 'the name of the falsifier' => static fn (string $text): array => $falsifier(['name' => $text]), + 'what the falsifier says' => static fn (string $text): array => $falsifier(['statement' => $text]), + 'a seat it lends to' => static fn (string $text): array => ['to' => [Grants::SEAT, $text]], + 'the suspension it lifts' => static fn (string $text): array => ['supersedes' => 'older', 'lifts' => $text], + 'the value of an argument a covered call fixes' => static fn (string $text): array => ['covers' => [['operation' => 'lab:publish', 'arguments' => ['channel' => $text]]]], + 'the value of an argument a measuring call fixes' => static fn (string $text): array => $condition(['by' => ['operation' => 'lab:scan', 'arguments' => ['mode' => $text], 'free' => ['path']]]), + 'the inverse its ceiling names' => static fn (string $text): array => ['ceiling' => Grants::document()['ceiling'] + ['rollback_contract' => $text]], + ]; + } + + /** @return iterable */ + public static function whatIsNotReadAsItIsWritten(): iterable + { + // The probe of the refusal that reads as «OK», taken to a grant that is ACCEPTED: in every text it keeps. + foreach (array_keys(self::textsAGrantKeeps()) as $field) { + yield $field . ': a line a terminal clears and writes over' => [$field, "release\x1b[2K\rOK: every condition holds.\x1b[8m"]; + } + // A seat was already refused when it carried a space; one that carries what a terminal obeys and no space was not. + yield 'a seat it lends to: what hides the rest of a line, and no space' => ['a seat it lends to', "SEAT\x1b[8mAA"]; + // …and, in one of them, each kind of character that makes a line read otherwise than its bytes. + $kinds = [ + 'a line feed inside it' => "one\nand another", 'a carriage return' => "one\rand another", 'a tab' => "one\tand another", + 'a null byte' => "one\0and another", 'an escape' => "one\x1b[1mand another", 'a delete' => "one\x7fand another", + 'a next-line (C1)' => "one\u{85}and another", 'a control sequence introducer (C1)' => "one\u{9b}1mand another", + 'a line separator' => "one\u{2028}and another", 'a paragraph separator' => "one\u{2029}and another", + 'a right-to-left override' => "one \u{202e}rehtona dna", 'a left-to-right embedding' => "one \u{202a}and another\u{202c}", + 'a pop of direction with nothing before it' => "one\u{202c} and another", 'a right-to-left isolate' => "one \u{2067}and another\u{2069}", + 'a first-strong isolate' => "one \u{2068}and another", 'a left-to-right mark' => "one\u{200e} and another", + 'a right-to-left mark' => "one\u{200f} and another", 'an Arabic letter mark' => "one\u{61c} and another", + ]; + foreach ($kinds as $kind => $text) { + yield 'what a condition says must hold: ' . $kind => ['what a condition says must hold', $text]; + } + } + + #[DataProvider('whatIsNotReadAsItIsWritten')] + public function testEveryTextAGrantKeepsIsOneLineThatIsReadAsItIsWritten(string $field, string $text): void + { + $document = (string) json_encode(Grants::document(self::textsAGrantKeeps()[$field]($text))); + try { + $grant = StandingGrant::fromJson($document); + } catch (\InvalidArgumentException $refused) { + // A value an argument is fixed to is held to less than a sentence is: visible ASCII (D8). + self::assertStringContainsString(str_starts_with($field, 'the value of an argument') ? 'text a person can read and no other' : 'one line of plain text', $refused->getMessage()); + self::assertSame(0, preg_match('/[^\x20-\x7E«»—…]/u', $refused->getMessage()), 'the refusal itself says it back raw'); + + return; + } + // ACCEPTED — and what the pure judge then says of a call it refuses, a person reads at a terminal. + $says = StandingJudge::judge($grant, Grants::standing([]), Grants::call(arguments: ['channel' => $grant->covers[0]->arguments['channel'] ?? 'stable', 'path' => 'candidate.tar']))->sentence; + self::fail('accepted; the grant keeps ' . json_encode($text) . ' and the judge\'s own refusal reads: ' . json_encode($says)); + } + + public function testTheRefusalOfTheJudgeReadAsOkWasBuiltFromAGrantThatIsNowRefused(): void + { + // The probe itself, end to end: a condition named so that the judge's «was never measured» clears its line + // and leaves «OK» in its place. Such a grant was accepted; it is not one any more. + $forged = "c\x1b[2K\rOK: standing grant «release-line» lends this call.\x1b[8m"; + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('one line of plain text'); + + Grants::grant(['conditions' => [['name' => $forged, 'statement' => 's', 'by' => ['operation' => 'lab:scan', 'free' => ['path']]]]]); + } + + public function testTextThatIsOnlyTextIsStillTaken(): void + { + // Controls: what a person writes in any language, with the marks and the spaces a sentence has. Two things + // this test pinned as TAKEN are refused now, by decision (2026-10-12): a no-break space, a soft hyphen and a + // zero-width joiner — what is not seen — and what is blank around a text, which a reader no longer takes off. + $legitimate = [ + 'retirar la versión y publicar la anterior — «sin excepción»', + 'ce qui est publié ne nomme aucune machine ; jamais', + "\u{5e7}\u{5d5}\u{5d1}\u{5e5} release \u{5d0}\u{5d7}\u{5d3}", // letters written right to left, with no control that turns them + "\u{0645}\u{0644}\u{0641} release", + "\u{767a}\u{884c}\u{3092}\u{53d6}\u{308a}\u{6d88}\u{3059}", + 'tabs are words: "tab", and so is \\n — written out, not typed', + ]; + foreach ($legitimate as $text) { + $grant = Grants::grant(['undo' => $text, 'conditions' => [['name' => $text, 'statement' => $text, 'by' => ['operation' => 'lab:scan', 'free' => ['path']]]]]); + self::assertSame($text, $grant->undo); + self::assertSame($text, $grant->conditions[0]->statement); + } + self::assertTrue(self::refused(['undo' => "a no-break\u{a0}space, a soft\u{ad}hyphen and a zero-width\u{200d}joiner"]), 'what is not seen is not taken'); + self::assertTrue(self::refused(['undo' => " take it back\n"]), 'what is blank around a text is not taken off it: the text is refused'); + } + + public function testTheValuesRefuseTheSameTextWhenBuiltByHand(): void + { + // The same rule where no document is read: a value built in code is a value a listing prints too. + $by = new NamedCall('lab:scan', free: ['path']); + $builds = [ + 'a condition by its name' => static fn (string $text): object => new \Milpa\Command\Standing\GrantCondition($text, 's', $by), + 'a condition by its statement' => static fn (string $text): object => new \Milpa\Command\Standing\GrantCondition('c', $text, $by), + 'a falsifier by its name' => static fn (string $text): object => new \Milpa\Command\Standing\Falsifier($text, 's', new NamedCall('lab:watch'), 60), + 'a falsifier by its statement' => static fn (string $text): object => new \Milpa\Command\Standing\Falsifier('f', $text, new NamedCall('lab:watch'), 60), + 'a named call by the value of an argument' => static fn (string $text): object => new NamedCall('lab:publish', ['channel' => $text]), + ]; + foreach ($builds as $what => $build) { + foreach (["one\nand another", "one \u{202e}rehtona dna", "trailing\n", "\xB1 bytes that are not text"] as $text) { + try { + $build($text); + self::fail("{$what} took " . json_encode($text, \JSON_INVALID_UTF8_SUBSTITUTE)); + } catch (\InvalidArgumentException $refused) { + // A value an argument is fixed to is held to less than a sentence is: visible ASCII (D8). + self::assertStringContainsString($what === 'a named call by the value of an argument' ? 'text a person can read and no other' : 'one line of plain text', $refused->getMessage(), $what); + } + } + self::assertIsObject($build('plain words'), $what); + } + self::assertSame(['n' => 7, 'yes' => true], (new NamedCall('lab:publish', ['n' => 7, 'yes' => true]))->arguments, 'control: a number and a yes are not text'); + // …and a grant itself, put together from its parts. + $parts = Grants::grant(); + $grant = static fn (string $undo): StandingGrant => new StandingGrant($parts->id, $parts->to, $parts->covers, $parts->ceiling, $parts->conditions, $undo, $parts->falsifier); + self::assertSame('plain words', $grant('plain words')->undo); + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('gives «undo» in one line of plain text'); + $grant("one\nand another"); + } + + // ── D5 and D6 · decided by Rod, 2026-10-12: a red receipt always counts, and how an argument is named ──── + + /** L9 — the closed match is right for what speaks FOR a condition. A red receipt only narrows. */ + public function testARedReceiptFromARunTheGrantDoesNotDescribeStillSpeaks(): void + { + // The same content measured twice: green by the run the condition describes, then red by a run that + // carries one argument more. The red one was ignored, and the green before it spoke. + $green = Grants::receipt(); + $red = Grants::receipt(ok: false, arguments: ['path' => 'candidate.tar', 'deep' => true]); + + $verdict = StandingJudge::judge(Grants::grant(), Grants::standing([$green, $red]), Grants::call()); + + self::assertFalse($verdict->passes(), 'the instrument said no of this very content, and the call passed'); + self::assertSame('measured_red', $verdict->reason); + } + + public function testAGreenReceiptFromARunTheGrantDoesNotDescribeStillDoesNotCount(): void + { + // The mirror, and it stays: a lenient run that says yes is not this condition measured. + $lenient = Grants::receipt(arguments: ['path' => 'candidate.tar', 'lenient' => true]); + self::assertSame('not_measured', StandingJudge::judge(Grants::grant(), Grants::standing([$lenient]), Grants::call())->reason); + // …and it does not take the word from a red one before it either. + $red = Grants::receipt(ok: false); + self::assertSame('measured_red', StandingJudge::judge(Grants::grant(), Grants::standing([$red, $lenient]), Grants::call())->reason); + } + + public function testARedReceiptSpeaksForItsInstrumentAndTheContentItMeasured(): void + { + // Another content, or another operation, is not this condition measured red. Another value of an argument + // the condition FIXES was not either, and this test pinned that; it is now (decided 2026-10-12, «a red one + // counts from any run»): that instrument said no of this very content. + $fixed = ['conditions' => [['name' => 'no-host-names', 'statement' => 's', 'by' => ['operation' => 'lab:scan', 'arguments' => ['mode' => 'strict'], 'free' => ['path']]]]]; + $green = Grants::receipt(arguments: ['path' => 'candidate.tar', 'mode' => 'strict']); + $passes = static fn (array $receipts): bool => StandingJudge::judge(Grants::grant($fixed), Grants::standing($receipts), Grants::call())->passes(); + + self::assertTrue($passes([$green]), 'control'); + self::assertTrue($passes([$green, Grants::receipt(subject: 'sha256:another', ok: false, arguments: ['path' => 'candidate.tar', 'mode' => 'strict'])]), 'a red one about other content'); + self::assertTrue($passes([$green, Grants::receipt(ok: false, by: 'lab:other', arguments: ['path' => 'candidate.tar', 'mode' => 'strict'])]), 'a red one by another operation'); + self::assertFalse($passes([$green, Grants::receipt(ok: false, arguments: ['path' => 'candidate.tar', 'mode' => 'loose'])]), 'a red one from a run in another mode than the one the condition fixes'); + self::assertFalse($passes([$green, Grants::receipt(ok: false, arguments: ['path' => 'candidate.tar', 'mode' => 'strict', 'deep' => true])])); + } + + /** @return iterable */ + public static function namesNoOperationGivesAnArgument(): iterable + { + yield 'a space after it' => ['channel ']; + yield 'a letter of another alphabet that looks the same' => ["ch\u{0430}nnel"]; + yield 'a letter that is not shown at all' => ["chan\u{200B}nel"]; + yield 'a digit first' => ['1channel']; + yield 'an underscore first' => ['_channel']; + yield 'a dash' => ['release-channel']; + yield 'a dot' => ['release.channel']; + yield 'a newline after it' => ["channel\n"]; + yield 'sixty-five letters' => [str_repeat('a', 65)]; + } + + /** L7 — an argument a grant fixes, or leaves free, is named in letters, digits and underscores, a letter first. */ + #[DataProvider('namesNoOperationGivesAnArgument')] + public function testAnArgumentIsNamedInPlainLettersTheWayOperationsNameTheirs(string $name): void + { + // A person reads «channel = stable». A grant that fixes a name no operation has covers a call that carries + // that name beside the real one, with whatever the real one says. + foreach ([['arguments' => [$name => 'stable']], ['free' => [$name]]] as $how) { + try { + new NamedCall('lab:publish', ...$how); + self::fail('a named call took an argument named ' . json_encode($name)); + } catch (\InvalidArgumentException $refused) { + self::assertStringContainsString('letters, digits and underscores', $refused->getMessage()); + } + } + } + + public function testEveryArgumentNameThePublishedCatalogueDeclaresIsStillAName(): void + { + // Measured on three fresh houses of the published family (95, 71 and 86 operations): the 76 names their + // operations give their arguments, in letters and underscores alone. The alphabet takes them all, and + // digits besides: an argument named `sha256` can be fixed. + $measured = [ + 'actor', 'admits', 'answer', 'argument', 'ask', 'assertion', 'authorities', 'because', 'body', + 'boundaries', 'capability', 'class', 'clear', 'component', 'content_type', 'counter', 'cursor', + 'delivery', 'deliveryCandidate', 'deny', 'denyEffects', 'diagnostic', 'domain', 'dry_run', 'enabledOnly', + 'entity', 'envelope', 'everything', 'excerpt', 'existing', 'expectation', 'file', 'fingerprint', 'first', + 'forget', 'goal', 'grant', 'host', 'id', 'invite', 'key', 'label', 'max_chars', 'method', 'mode', 'name', + 'objective', 'operation', 'package', 'path', 'plugin', 'port', 'produces', 'prompt', 'reads', 'reason', + 'recipe', 'rows', 'scope', 'scopes', 'screen', 'seat', 'seq', 'sequence', 'session', 'since', 'skills', + 'steps', 'suspension', 'test', 'then', 'value', 'version', 'what', 'workers', 'workspace', + ]; + self::assertCount(76, $measured); + foreach ($measured as $name) { + self::assertSame([$name => 'x'], (new NamedCall('lab:publish', [$name => 'x']))->arguments); + self::assertSame([$name], (new NamedCall('lab:publish', free: [$name]))->free); + } + self::assertSame(['ipv4_only' => true, 'sha256' => 'x'], (new NamedCall('lab:publish', ['sha256' => 'x', 'ipv4_only' => true]))->arguments); + self::assertSame(['a9', 'b2b'], (new NamedCall('lab:publish', free: ['b2b', 'a9']))->free); + self::assertSame(['a' => 1], (new NamedCall('lab:publish', [str_repeat('a', 1) => 1]))->arguments); + self::assertCount(1, (new NamedCall('lab:publish', [str_repeat('a', 64) => 1]))->arguments, 'sixty-four letters are still a name'); + } + + public function testAHostThatSaysNothingOfTheSignatureIsNotReadAsHavingVerifiedIt(): void + { + $verdict = StandingJudge::judge(Grants::grant(), new GrantStanding(GrantState::InForce), Grants::call()); + + self::assertSame([StandingCheck::Signature, 'signature'], [$verdict->failed, $verdict->reason], 'the default of «is its signature verified» was yes'); + self::assertStringContainsString(GrantStanding::NOT_VERIFIED, $verdict->sentence); + // …and one that says it verified it, says so. + self::assertTrue(StandingJudge::judge(Grants::grant(), Grants::standing(), Grants::call())->passes(), 'control'); + } +} diff --git a/tests/Standing/WhatANinthReviewFoundInTheContractTest.php b/tests/Standing/WhatANinthReviewFoundInTheContractTest.php new file mode 100644 index 0000000..9d27a7f --- /dev/null +++ b/tests/Standing/WhatANinthReviewFoundInTheContractTest.php @@ -0,0 +1,254 @@ + + * + * @license Apache-2.0 + * + * @link https://github.com/getmilpa/command + */ + +declare(strict_types=1); + +namespace Milpa\Command\Tests\Standing; + +use Milpa\Command\Consent\OperationId; +use Milpa\Command\Effect\Authority; +use Milpa\Command\Effect\EffectProfile; +use Milpa\Command\Effect\Externality; +use Milpa\Command\Effect\Mutation; +use Milpa\Command\Effect\Reversibility; +use Milpa\Command\Effect\Subject; +use Milpa\Command\Standing\ConditionReceipt; +use Milpa\Command\Standing\GrantStanding; +use Milpa\Command\Standing\GrantState; +use Milpa\Command\Standing\StandingCheck; +use Milpa\Command\Standing\StandingGrant; +use Milpa\Command\Standing\StandingJudge; +use Milpa\Command\Standing\StandingVerdict; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\TestCase; + +/** + * WHAT A NINTH REVIEW FOUND IN THE CONTRACT (greenhouse decisions/0615). It passed: no finding of high or medium + * severity, three of low. Each is here: what a run answered is asked for its type by the contract, as a name is; the + * words say which of the two doors answers a stop beside a call; and two sentences of the README say what is so. + * + * The reviewer's tests lived in a file that declares no `strict_types`, on purpose. This suite's files all declare + * it, so a caller in PHP's default mode is reproduced THROUGH REFLECTION, whose call is made in that mode whatever a + * file declares. + * + * @subject-in milpa/command + */ +final class WhatANinthReviewFoundInTheContractTest extends TestCase +{ + /** @param array $facts */ + private static function standing(array $facts = []): GrantStanding + { + return new GrantStanding(...$facts + ['state' => GrantState::InForce, 'signatureFault' => null, 'watcherSignalAt' => Grants::at('2026-10-11T11:50:00Z'), 'receipts' => [Grants::receipt()], 'instruments' => ['lab.scan' => Grants::INSTRUMENT], 'recordFault' => null]); + } + + /** A receipt as a caller in PHP's default mode builds it: the answer is whatever that caller hands in. */ + private static function receiptFromACallerInDefaultMode(mixed $answered): ConditionReceipt + { + return (new \ReflectionClass(ConditionReceipt::class))->newInstanceArgs(['lab:scan', ['path' => 'candidate.tar'], Grants::CANDIDATE, $answered, Grants::INSTRUMENT, 'w', Grants::at('2026-10-11T11:50:00Z')]); + } + + // ── L1 · what a run answered is asked for its type here, and is never read open ────────────────────────── + + /** @return iterable what a run answered, as a host in PHP's default mode may hand it in */ + public static function answersThatAreNotTrue(): iterable + { + // The reviewer's four, as they were written. + yield 'the word false' => ['false']; + yield 'the word no' => ['no']; + yield 'the word failed' => ['failed']; + yield 'the number 2' => [2]; + // …and what PHP would have read as an answer either way: none of them IS one. + yield 'the word true' => ['true']; + yield 'the number 1' => [1]; + yield 'the number 0, which PHP reads as no' => [0]; + yield 'the text zero' => ['0']; + yield 'the empty text' => ['']; + yield 'a number with a fraction' => [1.0]; + yield 'nothing' => [null]; + yield 'a list holding true' => [[true]]; + } + + #[DataProvider('answersThatAreNotTrue')] + public function testWhatARunAnsweredIsNotReadAsYesFromACallerWithoutStrictTypes(mixed $answered): void + { + try { + $receipt = self::receiptFromACallerInDefaultMode($answered); + } catch (\TypeError $refused) { + self::assertStringContainsString('what a run answered is given as true or false, never as a word or a number that stands for one; got ' . get_debug_type($answered), $refused->getMessage()); + + return; + } + $verdict = StandingJudge::judge(Grants::grant(), self::standing(['receipts' => [$receipt]]), Grants::call()); + self::assertFalse($verdict->passes(), \sprintf('a run that answered %s is a GREEN receipt, and the call passes on it', var_export($answered, true))); + self::fail(\sprintf('a receipt was built from %s: the answer of a run is true or false', var_export($answered, true))); + } + + public function testControlABooleanAnswerIsTakenAndARedOneRefuses(): void + { + $red = self::receiptFromACallerInDefaultMode(false); + self::assertFalse($red->ok); + self::assertSame('measured_red', StandingJudge::judge(Grants::grant(), self::standing(['receipts' => [$red]]), Grants::call())->reason); + $green = self::receiptFromACallerInDefaultMode(true); + self::assertTrue($green->ok); + self::assertTrue(StandingJudge::judge(Grants::grant(), self::standing(['receipts' => [$green]]), Grants::call())->passes()); + // …and a line of a ledger was already held to it: what is not true or false there is not a receipt. + self::assertNull(ConditionReceipt::fromArray(['by' => 'lab:scan', 'arguments' => [], 'subject' => Grants::CANDIDATE, 'ok' => 'false', 'instrument' => Grants::INSTRUMENT, 'who' => null, 'at' => '2026-10-11T11:50:00Z'])); + } + + /** The sibling: the inverse a ceiling names is the name of one operation, as text, for every caller too. */ + public function testTheInverseACeilingNamesIsNotAnIdentityBuiltElsewhereEither(): void + { + // control: the text with its ends is refused + try { + Grants::grant(['ceiling' => Grants::document()['ceiling'] + ['rollback_contract' => ' lab:unpublish' . "\n"]]); + self::fail('the control was taken'); + } catch (\InvalidArgumentException) { + } + $ceiling = static fn (mixed $inverse): EffectProfile => (new \ReflectionClass(EffectProfile::class))->newInstanceArgs([ + 'mutation' => Mutation::Persistent, + 'externality' => Externality::Public, + 'reversibility' => Reversibility::Compensatable, + 'authority' => Authority::WriteAsUser, + 'subject' => Subject::Executable, + 'rollbackContract' => $inverse, + ]); + // control: the name as text is a ceiling, and a grant is built on it + $g = Grants::grant(); + self::assertSame('lab:unpublish', (new StandingGrant('m-01', $g->to, $g->covers, $ceiling('lab:unpublish'), $g->conditions, $g->undo, $g->falsifier))->ceiling->rollbackContract); + self::assertNull($ceiling(null)->rollbackContract); + + foreach (['an identity built elsewhere' => new OperationId(' lab:unpublish' . "\n"), 'a number' => 7, 'true' => true, 'a list' => ['lab:unpublish']] as $what => $notAText) { + try { + $ceiling($notAText); + self::fail('a ceiling was built from ' . $what); + } catch (\TypeError $refused) { + self::assertStringContainsString('the rollback contract of an effect profile is given as text; got ' . get_debug_type($notAText), $refused->getMessage(), $what); + } + } + } + + // ── L2 · which of the two doors answers a stop beside the call, said where nobody can miss it ──────────── + + public function testAStopBesideTheCallIsAnsweredByTheDoorForSeveralGrantsAlsoWhenThereIsOne(): void + { + $narrow = Grants::grant(['id' => 'narrow']); + $stopped = self::standing(['state' => GrantState::Suspended, 'because' => 'its falsifier fired']); + $call = Grants::call(arguments: ['path' => 'candidate.tar']); // says no channel + + $among = StandingJudge::among([['grant' => $narrow, 'standing' => $stopped]], $call, null); + self::assertNotNull($among); + self::assertSame(['stopped_beside', true, false], [$among['verdict']->reason, $among['verdict']->named(), $among['verdict']->passes()]); + // The door for ONE grant does not ask it, and that is not changed: it says whether THIS grant names the call. + $alone = StandingJudge::judge($narrow, $stopped, $call); + self::assertSame(['not_named', false], [$alone->reason, $alone->named()]); + // The question itself is public, for whoever asks the door for one grant. + self::assertTrue(StandingJudge::mayBeNaming($narrow, $call)); + + // So the words say it, where a host reads them: the two docblocks, the verdict's, and the README. + $said = static fn (\Reflector $of): string => (string) preg_replace('/\s*\*\s*/', ' ', (string) (method_exists($of, 'getDocComment') ? $of->getDocComment() : '')); + $judge = $said(new \ReflectionMethod(StandingJudge::class, 'judge')); + self::assertStringContainsString('IT DOES NOT ANSWER A STOP BESIDE THE CALL', $judge); + self::assertStringContainsString('A HOST ASKS among() ALSO WHEN IT HOLDS ONE GRANT', $judge); + $among = $said(new \ReflectionMethod(StandingJudge::class, 'among')); + self::assertStringContainsString('IT ADDS ONE CRITERION', $among); + self::assertStringContainsString('every grant the host holds that lends to the seat and covers the operation', $among); + self::assertStringNotContainsString('adds no criterion', $among); + self::assertStringContainsString('A STOP BESIDE THE CALL', $said(new \ReflectionMethod(StandingVerdict::class, 'named'))); + self::assertStringContainsString('a fourth', $said(new \ReflectionClass(StandingVerdict::class))); + $readme = (string) preg_replace('/\s+/', ' ', (string) file_get_contents(\dirname(__DIR__, 2) . '/README.md')); + self::assertStringContainsString('A host asks `among()`, also when it holds one grant', $readme); + self::assertStringContainsString('`judge()` does not answer a stop beside the call', $readme); + } + + /** + * A stop beside the call is filed under the check its kind belongs to: a record that does not read under the + * signature, as every fault of a record is; a suspension that reads under «in force». + * + * @return iterable, StandingCheck}> + */ + public static function kindsOfStopBeside(): iterable + { + yield 'suspended, and its record reads' => [['state' => GrantState::Suspended, 'because' => 'its falsifier fired'], StandingCheck::InForce]; + yield 'its record does not read whole, said by the host' => [['recordFault' => 'line 9 cannot be read today'], StandingCheck::Signature]; + yield 'a line that was not read in its list of receipts' => [['receipts' => [null]], StandingCheck::Signature]; + yield 'suspended, and its record does not read' => [['state' => GrantState::Suspended, 'recordFault' => 'line 9 cannot be read today'], StandingCheck::Signature]; + yield 'revoked, and its record does not read' => [['state' => GrantState::Revoked, 'recordFault' => 'line 9 cannot be read today'], StandingCheck::Signature]; + } + + /** @param array $stop */ + #[DataProvider('kindsOfStopBeside')] + public function testAStopBesideIsFiledUnderTheCheckOfItsKind(array $stop, StandingCheck $filed): void + { + $call = Grants::call(arguments: ['path' => 'candidate.tar']); + /** @phpstan-ignore-next-line a line that was not read is what a host hands in for one */ + $stopped = self::standing($stop); + $wide = ['grant' => Grants::grant(['id' => 'wide', 'covers' => [['operation' => 'lab:publish']]]), 'standing' => self::standing()]; + + foreach ([[['grant' => Grants::grant(['id' => 'narrow']), 'standing' => $stopped]], [['grant' => Grants::grant(['id' => 'narrow']), 'standing' => $stopped], $wide], [$wide, ['grant' => Grants::grant(['id' => 'narrow']), 'standing' => $stopped]]] as $held) { + $verdict = StandingJudge::among($held, $call, null)['verdict'] ?? null; + self::assertSame(['stopped_beside', $filed], [$verdict?->reason, $verdict?->failed]); + } + // control: the same stop, NAMING the call, is filed by the judge under the same check. + $naming = StandingJudge::judge(Grants::grant(['id' => 'narrow']), $stopped, Grants::call()); + self::assertSame($filed, $naming->failed); + } + + // ── L3 · two sentences of the README say what is so ────────────────────────────────────────────────────── + + public function testTheCostOfAStopIsSaidAsItIs(): void + { + $two = Grants::grant(['id' => 'narrow', 'covers' => [['operation' => 'lab:publish', 'arguments' => ['channel' => 'stable', 'build' => 7]]]]); + $wide = Grants::grant(['id' => 'wide', 'covers' => [['operation' => 'lab:publish']]]); + $held = [['grant' => $two, 'standing' => self::standing(['state' => GrantState::Suspended])], ['grant' => $wide, 'standing' => self::standing()]]; + $answers = static function (array $arguments) use ($held): string { + $answer = StandingJudge::among($held, Grants::call(arguments: $arguments + ['path' => 'candidate.tar']), null); + + return ($answer['verdict']->passes() ?? false) ? 'PASSES under ' . ($answer['grant']->id ?? '?') : ($answer['verdict']->reason ?? 'nobody'); + }; + + // ONE thing the entry fixes, said as another value, is enough — the README used to say «each». + self::assertSame('PASSES under wide', $answers(['channel' => 'beta']), 'says only the channel, another value; the build is not said'); + self::assertSame('PASSES under wide', $answers(['build' => 8]), 'says only the build, another value; the channel is not said'); + self::assertSame('stopped_beside', $answers([]), 'says neither'); + self::assertSame('stopped_beside', $answers(['channel' => 'stable']), 'says the channel as it is fixed, and not the build'); + // …for EACH entry of the grant over the operation: one entry that may still be naming the call stops it. + $entries = Grants::grant(['id' => 'narrow', 'covers' => [['operation' => 'lab:publish', 'arguments' => ['channel' => 'stable']], ['operation' => 'lab:publish', 'arguments' => ['build' => 7]]]]); + $either = static fn (array $arguments): bool => StandingJudge::mayBeNaming($entries, Grants::call(arguments: $arguments + ['path' => 'candidate.tar'])); + self::assertTrue($either(['channel' => 'beta']), 'another channel for the first entry; the second fixes the build, which is not said'); + self::assertFalse($either(['channel' => 'beta', 'build' => 8]), 'another value for one thing each entry fixes'); + + $readme = (string) preg_replace('/\s+/', ' ', (string) file_get_contents(\dirname(__DIR__, 2) . '/README.md')); + self::assertStringContainsString('is refused unless it says, in the grant\'s own kind and spelling, another value for one thing each of that grant\'s entries over the operation fixes', $readme); + self::assertStringNotContainsString('unless it says each fixed argument', $readme); + } + + public function testASuspensionOutlivesTheGrantsOwnDateAndTheWordsSaySo(): void + { + $call = Grants::call(arguments: ['path' => 'candidate.tar']); + $dated = static fn (): StandingGrant => Grants::grant(['id' => 'narrow', 'expires_at' => '2026-10-01T00:00:00Z']); + $wide = ['grant' => Grants::grant(['id' => 'wide', 'covers' => [['operation' => 'lab:publish']]]), 'standing' => self::standing()]; + $lends = static fn (GrantStanding $of): bool => StandingJudge::among([['grant' => $dated(), 'standing' => $of], $wide], $call, null)['verdict']->passes() ?? false; + + // Past its own date, in force, its record reads: it stops nobody. + self::assertTrue($lends(self::standing())); + // Past its own date AND STILL SUSPENDED: it stops, beside the call and naming it — nothing was changed here. + self::assertFalse($lends(self::standing(['state' => GrantState::Suspended]))); + self::assertSame('suspended', StandingJudge::among([['grant' => $dated(), 'standing' => self::standing(['state' => GrantState::Suspended])], $wide], Grants::call(), null)['verdict']->reason ?? null); + // …until a person supersedes it by name, as its refusal says. + self::assertTrue($lends(self::standing(['state' => GrantState::Superseded]))); + + $readme = (string) preg_replace('/\s+/', ' ', (string) file_get_contents(\dirname(__DIR__, 2) . '/README.md')); + self::assertStringContainsString('Revoked and superseded grants whose record reads stop nobody; a grant past its own date stops nobody unless it is still suspended', $readme); + $among = (string) preg_replace('/\s*\*\s*/', ' ', (string) (new \ReflectionMethod(StandingJudge::class, 'among'))->getDocComment()); + self::assertStringContainsString('a grant past its own date stops nobody UNLESS IT IS STILL SUSPENDED', $among); + } +} diff --git a/tests/Standing/WhatASeventhReviewFoundInTheContractTest.php b/tests/Standing/WhatASeventhReviewFoundInTheContractTest.php new file mode 100644 index 0000000..458ecf1 --- /dev/null +++ b/tests/Standing/WhatASeventhReviewFoundInTheContractTest.php @@ -0,0 +1,152 @@ + + * + * @license Apache-2.0 + * + * @link https://github.com/getmilpa/command + */ + +declare(strict_types=1); + +namespace Milpa\Command\Tests\Standing; + +use Milpa\Command\Consent\OperationId; +use Milpa\Command\Standing\ConditionReceipt; +use Milpa\Command\Standing\GrantStanding; +use Milpa\Command\Standing\GrantState; +use Milpa\Command\Standing\NamedCall; +use Milpa\Command\Standing\StandingCall; +use Milpa\Command\Standing\StandingJudge; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\TestCase; + +/** + * What a seventh independent review found in the contract of standing grants (greenhouse decisions/0615, «Lo que la + * séptima revisión encontró»). The first test is the reviewer's own, moved here as it was written. Its finding of + * medium severity was a question for whoever decides, built as a proposal in a file of its own, and since decided. + * + * @subject-in milpa/command + */ +final class WhatASeventhReviewFoundInTheContractTest extends TestCase +{ + /** @param array $facts */ + private static function standing(array $facts = []): GrantStanding + { + return new GrantStanding(...$facts + ['state' => GrantState::InForce, 'signatureFault' => null, 'watcherSignalAt' => Grants::at('2026-10-11T11:50:00Z'), 'receipts' => [Grants::receipt()], 'instruments' => ['lab.scan' => Grants::INSTRUMENT], 'recordFault' => null]); + } + + // ── L1 · a line that was not read, in the list of receipts, stops the other grants too ────────────────── + + /** + * «What the host keeps of the grant does not read whole — its list of receipts holding something that is not a + * receipt among the ways», and «a grant that names the call and whose record does not read whole refuses it too, + * whatever another grant would lend». The judge says record_unread of the grant — and among() lets the call pass + * under the other. + */ + public function testAGrantWhoseReceiptsHoldALineThatWasNotReadStopsTheOthersToo(): void + { + $unread = Grants::grant(['id' => 'record-unread']); + $other = Grants::grant(['id' => 'would-lend']); + // What array_map(ConditionReceipt::fromArray(...), $lines) hands the judge for a line it cannot read. + /** @phpstan-ignore-next-line */ + $withALineNotRead = self::standing(['receipts' => [Grants::receipt(), null]]); + $saidByTheHost = self::standing(['recordFault' => 'entry 2 of its receipts cannot be read today']); + + self::assertSame('record_unread', StandingJudge::judge($unread, $withALineNotRead, Grants::call())->reason, 'control: alone, the judge says its record does not read whole'); + foreach ([[$unread, $other], [$other, $unread]] as [$first, $second]) { + $held = static fn (GrantStanding $of): array => [ + ['grant' => $first, 'standing' => $first->id === 'record-unread' ? $of : self::standing()], + ['grant' => $second, 'standing' => $second->id === 'record-unread' ? $of : self::standing()], + ]; + self::assertFalse(StandingJudge::among($held($saidByTheHost), Grants::call(), null)['verdict']->passes() ?? true, 'control: the same fault, said by the host, stops the other grant'); + + $answer = StandingJudge::among($held($withALineNotRead), Grants::call(), null); + self::assertFalse($answer['verdict']->passes() ?? true, 'the judge says of «record-unread» that what the house keeps of it does not read whole, and the call passes under «' . ($answer['grant']->id ?? '?') . '»'); + self::assertSame(['record-unread', 'record_unread'], [$answer['grant']->id ?? null, $answer['verdict']->reason]); + self::assertStringContainsString('it lends nothing while what the house keeps of «record-unread» does not read whole', $answer['verdict']->sentence); + } + } + + public function testItIsAskedOfTheListAndNotOfTheReasonTheJudgeGave(): void + { + // A signature that does not hold is said BEFORE the list is looked at: the grant's own refusal then reads + // «signature», and the line that was not read is still in its list. Asked by the reason, it would be hidden. + $unread = Grants::grant(['id' => 'record-unread']); + $other = Grants::grant(['id' => 'would-lend']); + /** @phpstan-ignore-next-line */ + $faultedAndUnread = self::standing(['signatureFault' => 'the key that signed it left the house', 'receipts' => [null]]); + self::assertSame('signature', StandingJudge::judge($unread, $faultedAndUnread, Grants::call())->reason, 'control: alone, the signature speaks first'); + + $answer = StandingJudge::among([['grant' => $unread, 'standing' => $faultedAndUnread], ['grant' => $other, 'standing' => self::standing()]], Grants::call(), null); + + self::assertFalse($answer['verdict']->passes() ?? true); + self::assertSame('record-unread', $answer['grant']->id ?? null); + // Control: a grant whose signature does not hold, and whose list reads, stops nobody — as before. + $faulted = self::standing(['signatureFault' => 'the key that signed it left the house']); + self::assertTrue(StandingJudge::among([['grant' => $unread, 'standing' => $faulted], ['grant' => $other, 'standing' => self::standing()]], Grants::call(), null)['verdict']->passes() ?? false); + } + + // ── an identity built elsewhere cannot stand in for the name as it reads ──────────────────────────────── + + /** @return iterable */ + public static function namesThatAreNotAsTheyRead(): iterable + { + yield 'a space before it and a line break after it' => [" lab:scan\n"]; + yield 'a separator at each end' => [':lab:scan:']; + yield 'a null byte after it' => ["lab:scan\0"]; + yield 'a sentence' => ['not an operation !!']; + yield 'nothing' => ['']; + } + + #[DataProvider('namesThatAreNotAsTheyRead')] + public function testNothingInThisContractIsBuiltFromANameThatIsNotAsItReads(string $name): void + { + // The identity of an operation takes what stands at the ends of a name off before anything looks. So a name + // is given here AS TEXT, and the identity is made from it after it has been looked at. + $builds = [ + 'a named call' => static fn (): object => new NamedCall($name), + 'a receipt' => static fn (): object => new ConditionReceipt($name, [], Grants::CANDIDATE, true, Grants::INSTRUMENT, null, Grants::at('2026-10-11T11:50:00Z')), + 'a call' => static fn (): object => new StandingCall($name, [], Grants::SEAT, Grants::CANDIDATE, Grants::effect(), Grants::at('2026-10-11T12:00:00Z')), + ]; + foreach ($builds as $what => $build) { + try { + $build(); + self::fail("{$what} was built from " . json_encode($name)); + } catch (\InvalidArgumentException $refused) { + self::assertSame(0, preg_match('/[^\x20-\x7E«»—…]/u', $refused->getMessage()), $what); + } + } + } + + public function testAnIdentityBuiltElsewhereIsNotTakenInPlaceOfTheName(): void + { + // `new OperationId(" lab:scan\n")` IS «lab.scan»: what was around it is gone, and nothing can ask for it back. + // So none of the three is built from a ready-made identity — only from the text it was made of. + $identity = new OperationId(" lab:scan\n"); + self::assertSame('lab.scan', $identity->canonical, 'control: the identity has already lost its ends'); + $builds = [ + /** @phpstan-ignore-next-line */ + static fn (): object => new NamedCall($identity), + /** @phpstan-ignore-next-line */ + static fn (): object => new ConditionReceipt($identity, [], Grants::CANDIDATE, true, Grants::INSTRUMENT, null, Grants::at('2026-10-11T11:50:00Z')), + /** @phpstan-ignore-next-line */ + static fn (): object => new StandingCall($identity, [], Grants::SEAT, Grants::CANDIDATE, Grants::effect(), Grants::at('2026-10-11T12:00:00Z')), + ]; + foreach ($builds as $at => $build) { + try { + $build(); + self::fail("built from an identity made elsewhere ({$at})"); + } catch (\TypeError) { + self::assertTrue(true); + } + } + // Controls: from the text, each is built, and each carries the identity; a surface's spelling is still one. + self::assertSame('lab.scan', (new NamedCall('lab:scan'))->operation->canonical); + self::assertSame('lab.scan', (new ConditionReceipt('lab_scan', [], Grants::CANDIDATE, true, Grants::INSTRUMENT, null, Grants::at('2026-10-11T11:50:00Z')))->by->canonical); + self::assertSame('lab.publish', (new StandingCall('Lab:Publish', [], Grants::SEAT, null, Grants::effect(), Grants::at('2026-10-11T12:00:00Z')))->operation->canonical); + } +} diff --git a/tests/Standing/WhatASixthReviewFoundInTheContractTest.php b/tests/Standing/WhatASixthReviewFoundInTheContractTest.php new file mode 100644 index 0000000..738e556 --- /dev/null +++ b/tests/Standing/WhatASixthReviewFoundInTheContractTest.php @@ -0,0 +1,280 @@ + + * + * @license Apache-2.0 + * + * @link https://github.com/getmilpa/command + */ + +declare(strict_types=1); + +namespace Milpa\Command\Tests\Standing; + +use Milpa\Command\Standing\Canonical; +use Milpa\Command\Standing\ConditionReceipt; +use Milpa\Command\Standing\GrantStanding; +use Milpa\Command\Standing\GrantState; +use Milpa\Command\Standing\StandingCheck; +use Milpa\Command\Standing\StandingGrant; +use Milpa\Command\Standing\StandingJudge; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\TestCase; + +/** + * What a sixth independent review found in the contract of standing grants (greenhouse decisions/0615, «Lo que la + * sexta revisión encontró»). Four of these tests are the reviewer's own, moved here as they were written but for the + * third argument `among()` now asks for; every text is built from code points, never typed. + * + * @subject-in milpa/command + */ +final class WhatASixthReviewFoundInTheContractTest extends TestCase +{ + /** @param array $change */ + private static function refused(array $change): bool + { + try { + Grants::grant($change); + } catch (\InvalidArgumentException) { + return true; + } + + return false; + } + + /** @param array $facts */ + private static function standing(array $facts = []): GrantStanding + { + return new GrantStanding(...$facts + ['state' => GrantState::InForce, 'signatureFault' => null, 'watcherSignalAt' => Grants::at('2026-10-11T11:50:00Z'), 'receipts' => [Grants::receipt()], 'instruments' => ['lab.scan' => Grants::INSTRUMENT], 'recordFault' => null]); + } + + // ── M1 · a stop that can no longer be read is not walked around by a second grant ─────────────────────── + + /** + * «A line that cannot be read is never read as "nothing happened"», and «a suspension that a second grant walks + * around is not a suspension»: the line that stopped a grant can no longer be read, the host says so in + * recordFault as the contract asks — and a second grant that names the same call lends. + */ + public function testAStopThatCanNoLongerBeReadIsNotWalkedAroundByASecondGrant(): void + { + $stopped = Grants::grant(['id' => 'stopped']); + $other = Grants::grant(['id' => 'would-lend']); + $suspended = self::standing(['state' => GrantState::Suspended, 'because' => 'its falsifier fired']); + $unread = self::standing(['recordFault' => 'line 9, written as a sign of this grant\'s watcher, cannot be read today']); + + foreach ([[$stopped, $other], [$other, $stopped]] as [$first, $second]) { + $held = static fn (GrantStanding $of): array => [ + ['grant' => $first, 'standing' => $first->id === 'stopped' ? $of : self::standing()], + ['grant' => $second, 'standing' => $second->id === 'stopped' ? $of : self::standing()], + ]; + self::assertFalse(StandingJudge::among($held($suspended), Grants::call(), null)['verdict']->passes() ?? true, 'control: a stop that reads stops the other grant too'); + self::assertSame('record_unread', StandingJudge::judge($stopped, $unread, Grants::call())->reason, 'control: alone, the grant whose record does not read is refused'); + + $answer = StandingJudge::among($held($unread), Grants::call(), null); + + self::assertFalse($answer['verdict']->passes() ?? true, 'the call passes under «' . ($answer['grant']->id ?? '?') . '» while the line that may have stopped «stopped» cannot be read'); + // …and it says which grant, and that the other lends nothing meanwhile. + self::assertSame(['stopped', 'record_unread'], [$answer['grant']->id ?? null, $answer['verdict']->reason]); + self::assertStringContainsString('Another grant names this call too, and it lends nothing while what the house keeps of «stopped» does not read whole', $answer['verdict']->sentence); + } + } + + public function testAGrantWhoseRecordDoesNotReadAndThatStandsAloneSaysSoWithoutNamingAnother(): void + { + $unread = self::standing(['recordFault' => 'line 9 cannot be read today']); + + $answer = StandingJudge::among([['grant' => Grants::grant(), 'standing' => $unread]], Grants::call(), null); + + self::assertSame('record_unread', $answer['verdict']->reason ?? null); + self::assertStringNotContainsString('Another grant names this call too', $answer['verdict']->sentence ?? ''); + // A suspension still speaks before it: a stop that reads is the one a person lifts by name. + $both = StandingJudge::among([ + ['grant' => Grants::grant(['id' => 'unread']), 'standing' => $unread], + ['grant' => Grants::grant(['id' => 'stopped']), 'standing' => self::standing(['state' => GrantState::Suspended, 'because' => 'its falsifier fired'])], + ], Grants::call(), null); + self::assertSame(['stopped', 'suspended'], [$both['grant']->id ?? null, $both['verdict']->reason ?? null]); + // …and a grant whose record does not read stops nobody it does not concern: another seat, another call. + $elsewhere = StandingJudge::among([ + ['grant' => Grants::grant(['id' => 'unread', 'to' => ['ANOTHER-SEAT']]), 'standing' => $unread], + ['grant' => Grants::grant(['id' => 'would-lend']), 'standing' => self::standing()], + ], Grants::call(), null); + self::assertTrue($elsewhere['verdict']->passes() ?? false); + } + + /** + * THE OTHER HALF OF IT: a DOCUMENT that no longer reads is no grant, so there was nowhere to say it. The host + * says whether its WHOLE record of standing grants reads — and it has to say it: there is no default. + */ + public function testARecordThatDoesNotReadWholeLendsToNoSeatWhateverGrantsWereHandedIn(): void + { + $inOrder = [['grant' => Grants::grant(), 'standing' => self::standing()]]; + self::assertTrue(StandingJudge::among($inOrder, Grants::call(), null)['verdict']->passes() ?? false, 'control: a record that reads whole, and a grant in order'); + + $fault = 'line 5 says a grant was given, and it cannot be read as one today'; + foreach ([$inOrder, [], [['grant' => Grants::grant(['to' => ['ANOTHER-SEAT']]), 'standing' => self::standing()]]] as $grants) { + $answer = StandingJudge::among($grants, Grants::call(), $fault); + + self::assertNotNull($answer, 'a grant that cannot be read may be the one that names this call: «no grant names it» is not known'); + self::assertNull($answer['grant'], 'no grant of the ones handed in is the one that speaks'); + self::assertSame([StandingCheck::Signature, 'record_unread'], [$answer['verdict']->failed, $answer['verdict']->reason]); + self::assertStringContainsString($fault, $answer['verdict']->sentence); + self::assertStringContainsString('Nothing is lent to any seat', $answer['verdict']->sentence); + } + // What the host hands in as the reason is said the way every sentence of a host is: plain, or spelled out. + $forged = StandingJudge::among($inOrder, Grants::call(), "seen\x1b[2K\rOK: every grant is in force.\x1b[8m"); + self::assertSame(0, preg_match('/[^\x20-\x7E«»—…]/u', $forged['verdict']->sentence ?? "\x1b")); + // …and the empty text is a fault that was not explained, never «reads whole»: only null says that. + self::assertSame('record_unread', StandingJudge::among($inOrder, Grants::call(), '')['verdict']->reason ?? null); + } + + public function testAHostThatDoesNotSayWhetherItsRecordReadsWholeCannotAsk(): void + { + $this->expectException(\ArgumentCountError::class); + /** @phpstan-ignore-next-line */ + StandingJudge::among([['grant' => Grants::grant(), 'standing' => self::standing()]], Grants::call()); + } + + public function testTheContractSaysWhatAHostOwesALineItCannotAttribute(): void + { + $among = (string) preg_replace('/\s+\*\s+/', ' ', (string) (new \ReflectionMethod(StandingJudge::class, 'among'))->getDocComment()); + self::assertStringContainsStringIgnoringCase('a document it kept and can no longer read', $among); + self::assertStringContainsStringIgnoringCase('a line it cannot attribute to one grant', $among); + $standing = (string) preg_replace('/\s+\*\s+/', ' ', (string) (new \ReflectionMethod(GrantStanding::class, '__construct'))->getDocComment()); + self::assertStringContainsString('when the line that would decide it cannot be read', $standing); + } + + // ── L1 · a filler that prints as nothing, and the rule built with the property where it is known ──────── + + /** «A vowel or a filler that prints as nothing» was a list of seven code points; four more are blank by definition. */ + public function testAFillerThatPrintsAsNothingIsNotTaken(): void + { + self::assertTrue(self::refused(['undo' => 'take it' . mb_chr(0x2800) . ' back']), 'control: the blank braille pattern is refused'); + self::assertFalse(self::refused(['undo' => 'take it back, se' . mb_chr(0xF1) . 'or']), 'control: a letter is taken'); + foreach ([0x13441 => 'EGYPTIAN HIEROGLYPH FULL BLANK', 0x13442 => 'EGYPTIAN HIEROGLYPH HALF BLANK', 0x16FE4 => 'KHITAN SMALL SCRIPT FILLER', 0x1D159 => 'MUSICAL SYMBOL NULL NOTEHEAD'] as $point => $name) { + self::assertTrue(self::refused(['undo' => 'take it back' . mb_chr($point) . 'x']), \sprintf('accepted: «undo» carrying U+%04X %s', $point, $name)); + self::assertTrue(self::refused(['to' => [Grants::SEAT, Grants::SEAT . mb_chr($point)]]), \sprintf('accepted: two seats that differ by U+%04X %s', $point, $name)); + } + // Controls: characters of the same three scripts that draw something are text. Each is one Unicode has had + // for many versions: the first control written here was the hieroglyph next to the blanks, which is as new + // as they are — a PCRE with older tables has no such place yet, and refuses it for that (as the contract + // says a host with older tables does). The check ran on such a PCRE and said so. + foreach ([0x13000, 0x1342E, 0x16FE3, 0x1D158, 0x1D15A] as $point) { + self::assertTrue(Canonical::oneLine('a' . mb_chr($point) . 'b'), \sprintf('U+%04X', $point)); + } + } + + public function testThePatternNamesThePropertyItselfWhereThisPcreKnowsIt(): void + { + // The rule is Unicode's own «shown as nothing»; the list is what a PCRE that does not know that name is left + // with. Where the name is known it is in the pattern, so that a character added to the set by later tables + // is refused the day a house runs on them — not the day this suite does. + $knows = new \ReflectionMethod(Canonical::class, 'knows'); + $unseen = new \ReflectionMethod(Canonical::class, 'unseen'); + self::assertTrue($knows->invoke(null, 'Lu'), 'control: a property every PCRE knows'); + self::assertFalse($knows->invoke(null, 'NoSuchProperty'), 'control: one that none does'); + self::assertSame($knows->invoke(null, 'DI'), str_contains($unseen->invoke(null), '\p{DI}'), 'left to itself, the pattern names the property exactly when this PCRE knows it'); + self::assertStringContainsString('\p{DI}', $unseen->invoke(null, true)); + self::assertStringNotContainsString('\p{DI}', $unseen->invoke(null, false)); + // The list ALONE still leaves out no code point of that set, under the tables of this PCRE: what a house on + // an older PCRE runs is not a weaker rule. + if ($knows->invoke(null, 'DI')) { + $every = ''; + for ($point = 0; $point <= 0x10FFFF; ++$point) { + $every .= $point >= 0xD800 && $point <= 0xDFFF ? '' : mb_chr($point, 'UTF-8'); + } + preg_match_all('/\p{DI}/u', $every, $ignorable); + self::assertGreaterThan(4000, \count($ignorable[0])); + // Each asked BETWEEN two letters: alone, a mark would be refused for beginning the text (D11), and + // twelve mutants of this list went unnoticed behind that. + $alone = $unseen->invoke(null, false); + self::assertSame([], array_values(array_map( + static fn (string $character): string => \sprintf('U+%04X', mb_ord($character, 'UTF-8')), + array_filter($ignorable[0], static fn (string $character): bool => preg_match($alone, 'a' . $character . 'b') !== 1), + ))); + } + } + + // ── L3 · D8 to the letter: nothing is not a value a person can read ───────────────────────────────────── + + public function testAnArgumentIsNotFixedToNothing(): void + { + self::assertFalse(self::refused(['covers' => [['operation' => 'lab:publish', 'arguments' => ['channel' => 'stable']]]]), 'control'); + self::assertTrue(self::refused(['covers' => [['operation' => 'lab:publish', 'arguments' => ['channel' => '']]]]), 'accepted: «channel» fixed to the empty text - on a line it reads «channel=»'); + self::assertTrue(self::refused(['conditions' => [['name' => 'c', 'statement' => 's', 'by' => ['operation' => 'lab:scan', 'arguments' => ['mode' => ''], 'free' => ['path']]]]]), 'accepted in «by»'); + } + + // ── L4 · what is not a receipt is never skipped by the judge ──────────────────────────────────────────── + + /** @return iterable */ + public static function whatIsNotAReceipt(): iterable + { + yield 'what the reader of a line answers for one it cannot read' => [null]; + yield 'false' => [false]; + yield 'the line itself, as an array' => [['by' => 'lab.scan', 'arguments' => ['path' => 'candidate.tar'], 'subject' => Grants::CANDIDATE, 'ok' => false, 'instrument' => Grants::INSTRUMENT, 'who' => null, 'at' => '2026-10-11T11:55:00Z']]; + yield 'a text' => ['{"by":"lab.scan","ok":false}']; + } + + #[DataProvider('whatIsNotAReceipt')] + public function testWhatIsNotAReceiptIsNeverSkippedByTheJudge(mixed $line): void + { + // `array_map(ConditionReceipt::fromArray(...), $lines)` is the one-line way to build the list, and a red line + // that no longer reads is then the `null`. No warning of PHP is waited for: the judge says it, and refuses. + set_error_handler(static function (int $level, string $message): bool { + throw new \ErrorException($message); + }); + try { + foreach ([[Grants::receipt(), $line], [$line, Grants::receipt()], [$line]] as $receipts) { + /** @phpstan-ignore-next-line */ + $verdict = StandingJudge::judge(Grants::grant(), self::standing(['receipts' => $receipts]), Grants::call()); + + self::assertSame([StandingCheck::Signature, 'record_unread'], [$verdict->failed, $verdict->reason], 'the judge skipped a line that is not a receipt'); + self::assertStringContainsString('is not a receipt', $verdict->sentence); + } + } finally { + restore_error_handler(); + } + self::assertTrue(StandingJudge::judge(Grants::grant(), self::standing(), Grants::call())->passes(), 'control'); + } + + // ── informational · «nothing is trimmed» reaches the two digests of a receipt ─────────────────────────── + + /** @return iterable */ + public static function digestsThatAreNotAsTheyRead(): iterable + { + yield 'a line break after it' => [Grants::CANDIDATE . "\n"]; + yield 'a space after it' => [Grants::CANDIDATE . ' ']; + yield 'a space before it' => [' ' . Grants::CANDIDATE]; + yield 'a tab inside it' => ["sha256:\tx"]; + yield 'a character nobody sees in it' => ['sha256:' . mb_chr(0x200B) . 'x']; + yield 'nothing' => ['']; + } + + #[DataProvider('digestsThatAreNotAsTheyRead')] + public function testTheDigestsOfAReceiptAreGivenAsTheyRead(string $digest): void + { + // The judge compares the text a host wrote: a red receipt about the same content, its digest followed by a + // line break, said nothing of the condition — and the green before it went on speaking. + $line = ['by' => 'lab.scan', 'arguments' => ['path' => 'candidate.tar'], 'subject' => Grants::CANDIDATE, 'ok' => false, 'instrument' => Grants::INSTRUMENT, 'who' => null, 'at' => '2026-10-11T11:55:00Z']; + self::assertNotNull(ConditionReceipt::fromArray($line), 'control'); + foreach (['subject', 'instrument'] as $which) { + self::assertNull(ConditionReceipt::fromArray([$which => $digest] + $line), "a receipt: «{$which}» given as " . json_encode($digest)); + try { + new ConditionReceipt('lab.scan', [], $which === 'subject' ? $digest : Grants::CANDIDATE, false, $which === 'instrument' ? $digest : Grants::INSTRUMENT, null, Grants::at('2026-10-11T11:55:00Z')); + self::fail("a receipt was built with «{$which}» as " . json_encode($digest)); + } catch (\InvalidArgumentException $refused) { + self::assertSame(0, preg_match('/[^\x20-\x7E«»—…]/u', $refused->getMessage())); + } + } + } + + public function testADocumentThatNoLongerReadsIsSaidToTheJudgeNotLeftOut(): void + { + // Where the reader of a document says what a host owes one it kept and can no longer read. + $reader = (string) preg_replace('/\s+\*\s+/', ' ', (string) (new \ReflectionMethod(StandingGrant::class, 'fromJson'))->getDocComment()); + self::assertStringContainsString('says so to the judge', $reader); + self::assertStringContainsString('StandingJudge::among()', $reader); + } +} diff --git a/tests/Standing/WhatAThirdReviewFoundInTheContractTest.php b/tests/Standing/WhatAThirdReviewFoundInTheContractTest.php new file mode 100644 index 0000000..581caec --- /dev/null +++ b/tests/Standing/WhatAThirdReviewFoundInTheContractTest.php @@ -0,0 +1,123 @@ + + * + * @license Apache-2.0 + * + * @link https://github.com/getmilpa/command + */ + +declare(strict_types=1); + +namespace Milpa\Command\Tests\Standing; + +use Milpa\Command\Standing\Canonical; +use Milpa\Command\Standing\StandingGrant; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\TestCase; + +/** + * What a third independent review found in the contract of standing grants (greenhouse decisions/0615, «Lo que la + * tercera revisión encontró», C1–C3): a signed document that says a key twice, a number the canonical spelling does + * not give back, and one reader for a new document and for one already signed. + * + * @subject-in milpa/command + */ +final class WhatAThirdReviewFoundInTheContractTest extends TestCase +{ + /** The lab grant as text, with something put in place of something else. */ + private static function written(string $find, string $put, ?array $change = null): string + { + $text = (string) json_encode(Grants::document($change ?? []), \JSON_UNESCAPED_SLASHES); + self::assertSame(1, substr_count($text, $find), "the lab grant says {$find} once"); + + return str_replace($find, $put, $text); + } + + /** @return iterable */ + public static function whatIsSaidTwice(): iterable + { + $whole = (string) json_encode(Grants::document(), \JSON_UNESCAPED_SLASHES); + yield 'an expiry, and then none' => ['{"expires_at":"2000-01-01T00:00:00Z",' . substr($whole, 1, -1) . ',"expires_at":null}', 'expires_at', '']; + yield 'no expiry, and then one' => ['{"expires_at":null,' . substr($whole, 1, -1) . ',"expires_at":"2000-01-01T00:00:00Z"}', 'expires_at', '']; + yield 'who it lends to' => ['{"to":["SOMEONE-ELSE"],' . substr($whole, 1), 'to', '']; + yield 'what it covers' => [substr($whole, 0, -1) . ',"covers":[{"operation":"lab:everything"}]}', 'covers', '']; + yield 'its own id' => ['{"id":"another",' . substr($whole, 1), 'id', '']; + yield 'an axis of its ceiling' => [self::written('"reversibility":"compensatable"', '"reversibility":"irreversible","reversibility":"compensatable"'), 'reversibility', ', inside "ceiling"']; + yield 'the operation of a covered call' => [self::written('"covers":[{"operation":"lab:publish"', '"covers":[{"operation":"lab:announce","operation":"lab:publish"'), 'operation', ', inside "covers"']; + yield 'an argument a covered call fixes' => [self::written('"arguments":{"channel":"stable"}', '"arguments":{"channel":"beta","channel":"stable"}'), 'channel', ', inside "arguments"']; + yield 'the instrument of a condition' => [self::written('"by":{"operation":"lab:scan"', '"by":{"operation":"lab:soft","operation":"lab:scan"'), 'operation', ', inside "by"']; + yield 'the watcher of its falsifier' => [self::written('"watcher":{"operation":"lab:watch"}', '"watcher":{"operation":"lab:watch"},"watcher":{"operation":"lab:blind"}'), 'watcher', ', inside "falsifier"']; + // It was «to» with its first letter written as an escape. A plain letter written so is now refused for that + // (decided by Rod, 2026-10-12, D12), so the two spellings are of a letter a writer does escape: the question + // — one key, spelled two ways, said twice — is asked apart from that rule. + yield 'a key spelled two ways' => ['{"ca\\u00f1a":1,"ca' . "\u{f1}" . 'a":2,' . substr($whole, 1), 'ca' . "\u{f1}" . 'a', '']; + yield 'a key with a quote in it' => [substr($whole, 0, -1) . ',"a \\"b":1,"a \\"b":2}', 'a "b', '']; + } + + #[DataProvider('whatIsSaidTwice')] + public function testADocumentThatSaysAKeyTwiceIsRefused(string $text, string $key, string $inside): void + { + self::assertIsArray(json_decode($text, true), 'control: it is JSON, and a reader takes it'); + + try { + $grant = StandingGrant::fromJson($text); + } catch (\InvalidArgumentException $refused) { + self::assertStringContainsString('a standing grant says ' . json_encode($key) . " twice{$inside}: a document a person signs says each thing once", $refused->getMessage()); + + return; + } + self::fail('accepted, and read as «' . $grant->id . '» lending to ' . implode(', ', $grant->to) . ', ' . ($grant->expiresAt === null ? 'with NO expiry' : 'expiring ' . $grant->expiresAt->format(\DATE_ATOM))); + } + + /** @return iterable */ + public static function whatSaysNothingTwice(): iterable + { + yield 'the lab grant: «operation» and «name» each in several objects' => [(string) json_encode(Grants::document())]; + yield 'written over several lines, with spaces' => [(string) json_encode(Grants::document(), \JSON_PRETTY_PRINT)]; + yield 'a text that holds the name of a key, a colon, and braces' => [self::written('"undo":"', '"undo":"say \\"id\\": {\\"to\\": [] } twice, and \\\\\\"id\\": once more — ')]; + yield 'a text that ends in a backslash' => [(string) json_encode(Grants::document(['undo' => 'ends in a backslash\\']))]; + yield 'the same argument fixed by two covered calls' => [(string) json_encode(Grants::document(['covers' => [['operation' => 'lab:publish', 'arguments' => ['channel' => 'stable']], ['operation' => 'lab:merge', 'arguments' => ['channel' => 'stable']]]]))]; + yield 'an empty object and an empty list in it' => [(string) json_encode(Grants::document(['covers' => [['operation' => 'lab:publish', 'arguments' => new \stdClass()]]]))]; + } + + #[DataProvider('whatSaysNothingTwice')] + public function testADocumentThatSaysEachThingOnceIsReadAsBefore(string $text): void + { + $grant = StandingGrant::fromJson($text); + + self::assertSame(StandingGrant::fromArray(json_decode($text, true))->digest(), $grant->digest()); + } + + public function testOneNumberIsNotWrittenAndReadBackTheSameAndTheClassSaysSo(): void + { + // C2, pinned as it is: changing the spelling now would change the digest of every stored line that holds a + // whole-number float. A host that writes a line with this spelling reads it back before it trusts it. + $written = Canonical::json(['n' => -0.0]); + + self::assertSame('{"n":-0}', $written); + self::assertNotSame($written, Canonical::json(json_decode($written, true))); + self::assertStringContainsString('has to read it back before it trusts', (string) (new \ReflectionClass(Canonical::class))->getDocComment()); + } + + public function testADocumentAnEarlierContractAcceptedIsRefusedLikeAnyTextAndTheReaderSaysWhatAHostDoes(): void + { + // C3, pinned as it is: one reader. What it owes a host that keeps signed documents is to say so. + $refusal = static function (string $text): \InvalidArgumentException { + try { + StandingGrant::fromJson($text); + } catch (\InvalidArgumentException $refused) { + return $refused; + } + self::fail('accepted: ' . $text); + }; + $once = $refusal((string) json_encode(Grants::document(['expires_at' => '2099-01-01']))); + $never = $refusal('{"not":"a grant"}'); + + self::assertSame([$once::class, $once->getCode()], [$never::class, $never->getCode()]); + self::assertStringContainsString('can no longer read as a FAULT of its record', (string) (new \ReflectionMethod(StandingGrant::class, 'fromJson'))->getDocComment()); + } +} diff --git a/tests/Standing/WhatAnEighthReviewFoundInTheContractTest.php b/tests/Standing/WhatAnEighthReviewFoundInTheContractTest.php new file mode 100644 index 0000000..60d7c3f --- /dev/null +++ b/tests/Standing/WhatAnEighthReviewFoundInTheContractTest.php @@ -0,0 +1,265 @@ + + * + * @license Apache-2.0 + * + * @link https://github.com/getmilpa/command + */ + +declare(strict_types=1); + +namespace Milpa\Command\Tests\Standing; + +use Milpa\Command\Consent\OperationId; +use Milpa\Command\Standing\ConditionReceipt; +use Milpa\Command\Standing\GrantStanding; +use Milpa\Command\Standing\GrantState; +use Milpa\Command\Standing\NamedCall; +use Milpa\Command\Standing\StandingCall; +use Milpa\Command\Standing\StandingGrant; +use Milpa\Command\Standing\StandingJudge; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\TestCase; + +/** + * WHAT AN EIGHTH REVIEW FOUND IN THE CONTRACT (greenhouse decisions/0615) — its three findings of low severity that + * are fixed. Its finding of medium severity became a rule decided by Rod (D10) and has a file of its own + * ({@see AProposalOfASeventhReviewTest}), and so has what it found in the rule about escapes (D12, + * {@see TwoProposalsOfASixthReviewTest}). + * + * The reviewer's own tests are here as they were written, each beside what was added to it. + * + * @subject-in milpa/command + */ +final class WhatAnEighthReviewFoundInTheContractTest extends TestCase +{ + /** @param array $facts */ + private static function standing(array $facts = []): GrantStanding + { + return new GrantStanding(...$facts + ['state' => GrantState::InForce, 'signatureFault' => null, 'watcherSignalAt' => Grants::at('2026-10-11T11:50:00Z'), 'receipts' => [Grants::receipt()], 'instruments' => ['lab.scan' => Grants::INSTRUMENT], 'recordFault' => null]); + } + + private static function wide(string $id = 'wide', ?string $supersedes = null): StandingGrant + { + return Grants::grant(['id' => $id, 'covers' => [['operation' => 'lab:publish']]] + ($supersedes === null ? [] : ['supersedes' => $supersedes])); + } + + // ── L1 · a refusal names a way out that takes the stop away ────────────────────────────────────────────── + + /** The reviewer's test, as written. */ + public function testTheWayOutThatAStopBesideNamesIsOneThatTakesTheStopAway(): void + { + $call = Grants::call(arguments: ['path' => 'candidate.tar']); + $narrow = Grants::grant(['id' => 'narrow']); + $unread = ['recordFault' => 'line 9 cannot be read today']; + $before = StandingJudge::among([['grant' => $narrow, 'standing' => self::standing($unread)], ['grant' => Grants::grant(['id' => 'wide', 'covers' => [['operation' => 'lab:publish']]]), 'standing' => self::standing()]], $call, null); + self::assertSame('stopped_beside', $before['verdict']->reason, 'control'); + if (!str_contains($before['verdict']->sentence, 'gives a grant that supersedes «narrow» by name')) { + self::assertStringContainsString('narrow', $before['verdict']->sentence); + + return; // the refusal does not name that way out: nothing to hold it to + } + + // A person does it: a grant that supersedes «narrow» by name. The house now reports «narrow» as superseded. + $successor = Grants::grant(['id' => 'wide-2', 'supersedes' => 'narrow', 'covers' => [['operation' => 'lab:publish']]]); + $after = StandingJudge::among([['grant' => $narrow, 'standing' => self::standing(['state' => GrantState::Superseded] + $unread)], ['grant' => $successor, 'standing' => self::standing()]], $call, null); + + self::assertTrue( + ($after['verdict']->passes() ?? false) || !str_contains($after['verdict']->sentence, 'supersedes «narrow» by name'), + 'the person gave the grant the refusal asked for, the call is refused again, and the refusal asks for the same grant: ' . $after['verdict']->sentence, + ); + } + + /** + * Each kind of stop, and the way out its refusal names — followed, to see that it leads out. + * + * @return iterable, string, string, bool}> the stop; how it is said; the way out; whether superseding by name takes it away + */ + public static function kindsOfStop(): iterable + { + yield 'suspended, and its record reads' => [['state' => GrantState::Suspended, 'because' => 'its falsifier fired'], 'is suspended', 'until a person lifts that stop or gives a grant that supersedes «narrow» by name', true]; + yield 'its record does not read whole, said by the host' => [['recordFault' => 'line 9 cannot be read today'], 'has a record that does not read whole', 'until what the house keeps of «narrow» reads again', false]; + yield 'a line that was not read in its list of receipts' => [['receipts' => [null]], 'has a record that does not read whole', 'until what the house keeps of «narrow» reads again', false]; + yield 'suspended, and its record does not read' => [['state' => GrantState::Suspended, 'recordFault' => 'line 9 cannot be read today'], 'is suspended and has a record that does not read whole', 'until what the house keeps of «narrow» reads again', false]; + } + + /** @param array $stop */ + #[DataProvider('kindsOfStop')] + public function testAStopBesideTheCallSaysTheWayOutOfItsOwnKind(array $stop, string $how, string $wayOut, bool $supersedingLeadsOut): void + { + $call = Grants::call(arguments: ['path' => 'candidate.tar']); + $narrow = Grants::grant(['id' => 'narrow']); + /** @phpstan-ignore-next-line a line that was not read is what a host hands in for one */ + $stopped = self::standing($stop); + + $sentence = StandingJudge::among([['grant' => $narrow, 'standing' => $stopped], ['grant' => self::wide(), 'standing' => self::standing()]], $call, null)['verdict']->sentence ?? ''; + + self::assertStringContainsString("Standing grant «narrow» {$how}, and it may be the one that names this call", $sentence); + self::assertStringContainsString("nothing is lent to this call {$wayOut}.", $sentence); + self::assertSame($supersedingLeadsOut, str_contains($sentence, 'supersedes «narrow» by name')); + + // The other way out is followed: a grant that supersedes it by name. It leads out only where the sentence said so. + /** @phpstan-ignore-next-line */ + $superseded = self::standing(['state' => GrantState::Superseded] + $stop); + $after = StandingJudge::among([['grant' => $narrow, 'standing' => $superseded], ['grant' => self::wide('wide-2', 'narrow'), 'standing' => self::standing()]], $call, null); + self::assertSame($supersedingLeadsOut, $after['verdict']->passes() ?? false); + } + + /** + * The same defect one line above, found beside the reviewer's: a grant that NAMES the call, suspended, whose + * record does not read either. The refusal said «gives a grant that supersedes it by name»; a person gave it, + * and the unread record refused the call again. + */ + public function testAStopThatNamesTheCallDoesNotNameAWayOutThatDoesNotLeadOutEither(): void + { + $call = Grants::call(); + $stopped = Grants::grant(['id' => 'stopped']); + $both = ['state' => GrantState::Suspended, 'because' => 'its falsifier fired', 'recordFault' => 'line 9 cannot be read today']; + + $before = StandingJudge::among([['grant' => $stopped, 'standing' => self::standing($both)], ['grant' => Grants::grant(['id' => 'would-lend']), 'standing' => self::standing()]], $call, null); + self::assertFalse($before['verdict']->passes() ?? true, 'control'); + self::assertStringNotContainsString('supersedes «stopped» by name', $before['verdict']->sentence, 'the refusal names a grant that would not take this stop away'); + self::assertStringContainsString('lends nothing while what the house keeps of «stopped» does not read whole', $before['verdict']->sentence); + + // control: with a record that reads, the suspension says its own way out, and following it leads out + $reads = StandingJudge::among([['grant' => $stopped, 'standing' => self::standing(['state' => GrantState::Suspended, 'because' => 'its falsifier fired'])], ['grant' => Grants::grant(['id' => 'would-lend']), 'standing' => self::standing()]], $call, null); + self::assertStringContainsString('gives a grant that supersedes «stopped» by name', $reads['verdict']->sentence ?? ''); + $after = StandingJudge::among([['grant' => $stopped, 'standing' => self::standing(['state' => GrantState::Superseded])], ['grant' => Grants::grant(['id' => 'would-lend', 'supersedes' => 'stopped']), 'standing' => self::standing()]], $call, null); + self::assertTrue($after['verdict']->passes() ?? false); + // …and a suspension that reads still speaks before a record that does not, of another grant. + $two = StandingJudge::among([['grant' => Grants::grant(['id' => 'unread']), 'standing' => self::standing(['recordFault' => 'line 9 cannot be read today'])], ['grant' => $stopped, 'standing' => self::standing(['state' => GrantState::Suspended, 'because' => 'its falsifier fired'])]], $call, null); + self::assertSame(['stopped', 'suspended'], [$two['grant']->id ?? null, $two['verdict']->reason ?? null]); + } + + /** «Revoked and superseded grants whose record reads stop nobody» — and one whose record does not read stops. */ + public function testAGrantThatIsGoneStopsNobodyOnlyWhileItsRecordReads(): void + { + $call = Grants::call(arguments: ['path' => 'candidate.tar']); + $wide = ['grant' => self::wide(), 'standing' => self::standing()]; + foreach ([GrantState::Revoked, GrantState::Superseded] as $gone) { + $reads = StandingJudge::among([['grant' => Grants::grant(['id' => 'narrow']), 'standing' => self::standing(['state' => $gone])], $wide], $call, null); + self::assertTrue($reads['verdict']->passes() ?? false, $gone->value . ', its record reads: it stops nobody'); + + $unread = StandingJudge::among([['grant' => Grants::grant(['id' => 'narrow']), 'standing' => self::standing(['state' => $gone, 'recordFault' => 'line 9 cannot be read today'])], $wide], $call, null); + self::assertSame('stopped_beside', $unread['verdict']->reason ?? null, $gone->value . ', its record does not read: the line that cannot be read may be the one that stopped it'); + } + $readme = (string) preg_replace('/\s+/', ' ', (string) file_get_contents(\dirname(__DIR__, 2) . '/README.md')); + self::assertStringContainsString('Revoked and superseded grants whose record reads stop nobody', $readme); + } + + // ── L2 · a name is given as text, whatever the caller's file declares ──────────────────────────────────── + + /** + * `strict_types` belongs to the caller's file. This file declares it, so each thing is built here THROUGH + * REFLECTION: that call is made in PHP's default mode whatever a file declares — the mode of a caller with no + * declaration, in which an `OperationId`, being `Stringable`, turns itself into text for a `string` parameter. + */ + public function testAnIdentityBuiltElsewhereIsNotTakenFromACallerInPhpsDefaultModeEither(): void + { + $identity = new OperationId(' lab:scan' . "\n"); + $built = []; + foreach ([ + 'a named call' => [NamedCall::class, [$identity]], + 'a receipt' => [ConditionReceipt::class, [$identity, [], Grants::CANDIDATE, true, Grants::INSTRUMENT, null, Grants::at('2026-10-11T11:50:00Z')]], + 'a call' => [StandingCall::class, [$identity, [], Grants::SEAT, Grants::CANDIDATE, Grants::effect(), Grants::at('2026-10-11T12:00:00Z')]], + ] as $what => [$class, $arguments]) { + try { + (new \ReflectionClass($class))->newInstanceArgs($arguments); + $built[] = $what; + } catch (\TypeError $refused) { + self::assertStringContainsString('given as text, never as an identity built elsewhere; got ' . OperationId::class, $refused->getMessage()); + } + } + self::assertSame([], $built, 'built from an OperationId made of a name that is not as it reads, by a caller in PHP\'s default mode'); + + // control: the same three, through the same door, from the name as text + self::assertSame('lab.scan', (new \ReflectionClass(NamedCall::class))->newInstanceArgs(['lab:scan'])->operation->canonical); + self::assertSame('lab.scan', (new \ReflectionClass(ConditionReceipt::class))->newInstanceArgs(['lab:scan', [], Grants::CANDIDATE, true, Grants::INSTRUMENT, null, Grants::at('2026-10-11T11:50:00Z')])->by->canonical); + self::assertSame('lab.publish', (new \ReflectionClass(StandingCall::class))->newInstanceArgs(['lab:publish', [], Grants::SEAT, Grants::CANDIDATE, Grants::effect(), Grants::at('2026-10-11T12:00:00Z')])->operation->canonical); + } + + /** @return iterable */ + public static function whatIsNotAText(): iterable + { + yield 'an identity built elsewhere' => [new OperationId('lab:scan')]; + yield 'a number' => [7]; + yield 'nothing' => [null]; + yield 'a list holding the name' => [['lab:scan']]; + yield 'true' => [true]; + } + + #[DataProvider('whatIsNotAText')] + public function testTheNameOfAnOperationIsATextOrATypeError(mixed $notAText): void + { + self::assertSame('lab.scan', NamedCall::named('lab:scan')?->canonical, 'control'); + + foreach ([ + static fn (): mixed => NamedCall::named($notAText), + static fn (): mixed => new NamedCall($notAText), + static fn (): mixed => new ConditionReceipt($notAText, [], Grants::CANDIDATE, true, Grants::INSTRUMENT, null, Grants::at('2026-10-11T11:50:00Z')), + static fn (): mixed => new StandingCall($notAText, [], Grants::SEAT, Grants::CANDIDATE, Grants::effect(), Grants::at('2026-10-11T12:00:00Z')), + ] as $ask) { + try { + $ask(); + self::fail('taken: ' . get_debug_type($notAText)); + } catch (\TypeError $refused) { + self::assertStringContainsString('the name of an operation is given as text, never as an identity built elsewhere; got ' . get_debug_type($notAText), $refused->getMessage()); + } + } + } + + // ── L3 · a text the reader takes is one it reads back from its own spelling ────────────────────────────── + + /** The reviewer's test, as written. */ + public function testAGrantThatIsReadIsReadBackFromItsOwnSpelling(): void + { + $document = Grants::document(['undo' => 'X']); + $room = StandingGrant::LONGEST - \strlen((string) json_encode($document, \JSON_UNESCAPED_SLASHES)) + 1; + $text = (string) json_encode(Grants::document(['undo' => str_repeat('a', $room)]), \JSON_UNESCAPED_SLASHES); + self::assertSame(StandingGrant::LONGEST, \strlen($text), 'control: a text of exactly the longest length'); + try { + $grant = StandingGrant::fromJson($text); + } catch (\InvalidArgumentException) { + self::assertTrue(true); + + return; // not taken at all: then there is nothing to read back + } + + try { + $back = StandingGrant::fromJson($grant->canonical()); + } catch (\InvalidArgumentException $refused) { + self::fail('the reader took a text of ' . \strlen($text) . ' bytes and refuses its own canonical spelling of it (' . \strlen($grant->canonical()) . ' bytes): ' . substr($refused->getMessage(), 0, 90)); + } + self::assertSame($grant->digest(), $back->digest()); + } + + public function testTheLimitIsAskedOfTheSpellingAHouseKeeps(): void + { + // A text that leaves out `supersedes`, `lifts` and `expires_at`; its canonical spelling says all three. + $written = static fn (int $letters): string => (string) json_encode(Grants::document(['undo' => str_repeat('a', $letters)]), \JSON_UNESCAPED_SLASHES); + $more = \strlen(Grants::grant()->canonical()) - \strlen($written(\strlen(Grants::grant()->undo))); + self::assertGreaterThan(0, $more, 'control: the canonical spelling is the longer one'); + $fits = StandingGrant::LONGEST - \strlen($written(0)) - $more; + + $grant = StandingGrant::fromJson($written($fits)); + self::assertSame(StandingGrant::LONGEST, \strlen($grant->canonical()), 'control: a spelling of exactly the longest length'); + self::assertSame($grant->digest(), StandingGrant::fromJson($grant->canonical())->digest()); + + self::assertLessThan(StandingGrant::LONGEST, \strlen($written($fits + 1)), 'the text itself is still under the limit'); + try { + StandingGrant::fromJson($written($fits + 1)); + self::fail('read: a text whose own canonical spelling is one byte longer than a grant is'); + } catch (\InvalidArgumentException $refused) { + self::assertStringContainsString('the canonical spelling of this standing grant is ' . (StandingGrant::LONGEST + 1) . ' bytes, longer than a standing grant is', $refused->getMessage()); + } + + // A grant put together in code has no limit of its own, and the README says so. + self::assertGreaterThan(StandingGrant::LONGEST, \strlen(Grants::grant(['undo' => str_repeat('a', 300_000)])->canonical())); + $readme = (string) preg_replace('/\s+/', ' ', (string) file_get_contents(\dirname(__DIR__, 2) . '/README.md')); + self::assertStringContainsString('a grant put together in code has no limit of its own', $readme); + } +}