From 1d15c66f9997f34ed8b72b0103838cfd1cbc69aa Mon Sep 17 00:00:00 2001 From: Rod Vince Date: Sun, 11 Oct 2026 17:08:47 -0600 Subject: [PATCH 01/11] fix: a standing grant's expiry is one form, the same at every reading (greenhouse decisions/0615) `expires_at` was read by whatever reads as a date. So a grant could carry a date relative to the moment of reading: another instant each time, and so another grant than the one a person signed. It was written, and then read as not honoured, for a digest that no longer matched. A day that does not exist was read as the days after it. A date with no offset was read in the zone of whichever process asked. It is now a date and time with its offset, in ISO 8601, and nothing else: the date, a `T`, the time to the second, then `Z` or the offset as hours and minutes with its sign. Anything else is refused with the sentence this contract already had. What a grant writes back is unchanged. No house holds a grant yet, because no published runtime writes one: no stored document changes its reading. Found by a second independent review of getmilpa/app-runtime#797, and reproduced in this suite before the change: 18 of 20 forms that are not that one were accepted. --- src/Standing/StandingGrant.php | 26 +++++++++++++++-- .../AStandingGrantNamesWhatItCoversTest.php | 27 +++++++++++++++++ ...StandingGrantNeverCrossesItsFloorsTest.php | 29 +++++++++++++++++++ 3 files changed, 80 insertions(+), 2 deletions(-) diff --git a/src/Standing/StandingGrant.php b/src/Standing/StandingGrant.php index dab0942..8fa0bb3 100644 --- a/src/Standing/StandingGrant.php +++ b/src/Standing/StandingGrant.php @@ -258,6 +258,28 @@ 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. + return $instant !== false && $instant->format('Y-m-d\TH:i:s') === substr($text, 0, 19) ? $instant : null; + } + /** The exact bytes that identify this grant — one spelling, whatever order its author wrote it in. */ public function canonical(): string { @@ -288,8 +310,8 @@ public static function fromArray(array $data): self Canonical::only($ceiling, ['mutation', 'externality', 'reversibility', 'authority', 'subject', 'rollback_contract'], "the ceiling of {$where}"); $expires = $data['expires_at'] ?? null; - $expiresAt = \is_string($expires) ? date_create_immutable($expires) : null; - if ($expires !== null && !$expiresAt instanceof \DateTimeImmutable) { + $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"); } 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..cd25e6b 100644 --- a/tests/Standing/AStandingGrantNeverCrossesItsFloorsTest.php +++ b/tests/Standing/AStandingGrantNeverCrossesItsFloorsTest.php @@ -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 From d7d438a44f2b3c50558403c9ec57f56654e06c79 Mon Sep 17 00:00:00 2001 From: Rod Vince Date: Sun, 11 Oct 2026 19:06:07 -0600 Subject: [PATCH 02/11] fix: a signed grant says each thing once, and its reader says what a host owes it (greenhouse decisions/0615) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A JSON text may say a key twice, and a reader takes the last one and says nothing of the first. For a document a person signs, that is a way to show one thing and mean another: an expiry and then none, one seat and then another. Measured on this branch: such a document was accepted, and read with no expiry. A standing grant whose text says a key twice in one object is now refused, naming the key and the object it is in — at every depth, and however the key is spelled. Two things are said in the classes and changed in nothing else. The canonical spelling does not give every value back: a host that writes a line with it reads the line back before it trusts it. And there is one reader for a new document and for one already signed: what an earlier version of this contract accepted and this one refuses is refused like text that was never a grant, so a host that keeps signed documents treats one it can no longer read as a fault of its record, never as a grant that is not there. Found by a third independent review, with execution, of this branch at 1d15c66. Decided by Rod on 2026-10-12: one release of the contract. --- src/Standing/Canonical.php | 53 ++++++++ src/Standing/StandingGrant.php | 11 +- ...WhatAThirdReviewFoundInTheContractTest.php | 120 ++++++++++++++++++ 3 files changed, 183 insertions(+), 1 deletion(-) create mode 100644 tests/Standing/WhatAThirdReviewFoundInTheContractTest.php diff --git a/src/Standing/Canonical.php b/src/Standing/Canonical.php index 70966ad..32c9774 100644 --- a/src/Standing/Canonical.php +++ b/src/Standing/Canonical.php @@ -24,6 +24,12 @@ * * 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. + * + * 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, and a structure nested deeper than JSON is read cannot be + * read back at all. 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 { @@ -37,6 +43,53 @@ 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. + * + * 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. + * + * @param string $json JSON that already parses: this reads its keys, it does not validate it + * + * @throws \InvalidArgumentException naming the key, and the object it is said twice in + */ + public static function saidOnce(string $json, string $where): void + { + // One frame per object or list that is open: the keys an object has said, and the name it is known by. + $open = []; + $text = null; + for ($at = 0, $length = \strlen($json); $at < $length; ++$at) { + $byte = $json[$at]; + if ($byte === '"') { + $from = $at; + for (++$at; $json[$at] !== '"'; ++$at) { + $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); + $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, + $key, + $open[$frame]['name'] === null ? '' : ', inside «' . $open[$frame]['name'] . '»', + )); + } + $open[$frame]['said']['«' . $key] = true; + $open[$frame]['last'] = $key; + } elseif ($byte === '{' || $byte === '[') { + $parent = $open === [] ? null : $open[\count($open) - 1]; + $open[] = ['said' => [], 'last' => null, 'name' => $parent === null ? null : ($parent['last'] ?? $parent['name'])]; + } elseif ($byte === '}' || $byte === ']') { + array_pop($open); + } + } + } + /** * Refuse a key this contract does not read. * diff --git a/src/Standing/StandingGrant.php b/src/Standing/StandingGrant.php index 8fa0bb3..88abe66 100644 --- a/src/Standing/StandingGrant.php +++ b/src/Standing/StandingGrant.php @@ -338,13 +338,22 @@ 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. + */ public static function fromJson(string $json): self { $data = json_decode($json, true); if (!\is_array($data) || array_is_list($data)) { throw new \InvalidArgumentException('a standing grant is one JSON object: {"id", "to", "covers", "ceiling", "conditions", "undo", "falsifier"}'); } + Canonical::saidOnce($json, 'a standing grant'); return self::fromArray($data); } diff --git a/tests/Standing/WhatAThirdReviewFoundInTheContractTest.php b/tests/Standing/WhatAThirdReviewFoundInTheContractTest.php new file mode 100644 index 0000000..f3f537f --- /dev/null +++ b/tests/Standing/WhatAThirdReviewFoundInTheContractTest.php @@ -0,0 +1,120 @@ + + * + * @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»']; + yield 'a key spelled two ways' => ['{"\\u0074o":["SOMEONE-ELSE"],' . substr($whole, 1), 'to', '']; + 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 «{$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()); + } +} From 07e302d3d360109662acfbe2b659492c806fc91e Mon Sep 17 00:00:00 2001 From: Rod Vince Date: Sun, 11 Oct 2026 20:08:42 -0600 Subject: [PATCH 03/11] fix: a name ends where it ends, the scan of keys costs what the text is long, and a refusal says back only what is safe to read (greenhouse decisions/0615) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A fourth independent review, with execution, of this branch at d7d438a: two findings of medium severity and nine of low. Each was red in this suite before it was changed; nine of the tests are the reviewer's own. A name ends where it ends. The pattern for a grant's id, for the name of an operation and for the operation a ceiling names as its inverse ended in `$`, which also matches before one last newline: a grant that said it superseded "release-line" and a newline was taken, and named no grant. The scan for a key said twice costs what the text is long. It kept each open object by a second hand, so every key copied every key said before it: 458 KB held the reader for 4.3 s, 927 KB for 28 s. It now answers the same on the reviewer's 15,000 texts, and 5 MB in 0.2 s. It also answers for any text: one that does not parse is refused before a key is read, where the scan used to walk it and never come back. The canonical order is the order of the bytes. Sorted the way PHP compares what looks like a number, seats "7" and "007" gave one grant two digests. A grant's ceiling is read as it is written, or refused: an inverse that is not the name of one operation, and an axis the lenient reader of history would change, used to be dropped or changed in silence. An expiry whose own spelling in UTC is not the one form is refused when it is read. A list written as an object with the keys "0", "1" is refused. A refusal says back what a drafter wrote as JSON spells it, in plain characters and at most a line of it: said back raw, a key could clear the line it was printed on and leave "OK: ... is in force" in its place. The id is looked at before a sentence is built around it. A standing that says nothing of the signature fails closed. The reader of a receipt says what a host owes a line it can no longer read. Two things are built as the narrowest option and wait for a person's decision: a red receipt is read open (a run that carries one argument more than the condition describes still says no), and an argument a grant fixes or leaves free is named in letters and underscores — the narrowest alphabet that refuses none of the 76 names measured on the published catalogue. --- README.md | 46 +- src/Effect/EffectProfile.php | 2 +- src/Standing/Canonical.php | 86 ++- src/Standing/ConditionReceipt.php | 11 +- src/Standing/Falsifier.php | 8 +- src/Standing/GrantCondition.php | 4 +- src/Standing/GrantStanding.php | 9 +- src/Standing/NamedCall.php | 32 +- src/Standing/StandingGrant.php | 92 ++- src/Standing/StandingJudge.php | 7 +- ...StandingGrantNeverCrossesItsFloorsTest.php | 18 +- tests/Standing/Grants.php | 5 +- .../TheStandingJudgeAsksSevenThingsTest.php | 20 +- ...hatAFourthReviewFoundInTheContractTest.php | 529 ++++++++++++++++++ ...WhatAThirdReviewFoundInTheContractTest.php | 12 +- 15 files changed, 808 insertions(+), 73 deletions(-) create mode 100644 tests/Standing/WhatAFourthReviewFoundInTheContractTest.php diff --git a/README.md b/README.md index 9fca0fc..60cd9f9 100644 --- a/README.md +++ b/README.md @@ -194,12 +194,12 @@ 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* | +| 2 | `Signature` | the grant's signature does not hold *now* — and a host that does not say it verified it (`new GrantStanding()`) is read as one that did not | | 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); @@ -225,6 +225,26 @@ 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. +**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"`; +- a name ends where it ends: an id, the name of an operation and the operation a ceiling names as + its inverse take nothing after them, not even a newline; +- 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 in words, 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. + +`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. + 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 +252,15 @@ 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** is read open: a run that +carries what the condition fixes and one argument more is still the instrument saying no of that +very content, and it is the one that speaks until the content is measured again. + +An argument — fixed or free — is named in **letters 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 +270,19 @@ 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. + **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/EffectProfile.php b/src/Effect/EffectProfile.php index ea84263..3b6c50b 100644 --- a/src/Effect/EffectProfile.php +++ b/src/Effect/EffectProfile.php @@ -170,7 +170,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; } /** diff --git a/src/Standing/Canonical.php b/src/Standing/Canonical.php index 32c9774..065a42a 100644 --- a/src/Standing/Canonical.php +++ b/src/Standing/Canonical.php @@ -25,11 +25,20 @@ * 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, and a structure nested deeper than JSON is read cannot be - * read back at all. 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. + * 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 { @@ -50,12 +59,18 @@ public static function json(array $value): string * 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. * - * @param string $json JSON that already parses: this reads its keys, it does not validate it + * 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 + * @throws \InvalidArgumentException naming the key, and the object it is said twice in — 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, and the name it is known by. $open = []; $text = null; @@ -73,23 +88,66 @@ public static function saidOnce(string $json, string $where): void $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', + '%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, - $key, - $open[$frame]['name'] === null ? '' : ', inside «' . $open[$frame]['name'] . '»', + self::shown($key), + $open[$frame]['name'] === null ? '' : ', inside ' . self::shown($open[$frame]['name']), )); } $open[$frame]['said']['«' . $key] = true; $open[$frame]['last'] = $key; } elseif ($byte === '{' || $byte === '[') { - $parent = $open === [] ? null : $open[\count($open) - 1]; - $open[] = ['said' => [], 'last' => null, 'name' => $parent === null ? null : ($parent['last'] ?? $parent['name'])]; + $last = \count($open) - 1; + $open[] = ['said' => [], 'last' => null, 'name' => $last < 0 ? null : ($open[$last]['last'] ?? $open[$last]['name'])]; } elseif ($byte === '}' || $byte === ']') { array_pop($open); } } } + /** + * 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. + */ + 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. + $spelled = (string) json_encode($written, \JSON_INVALID_UTF8_SUBSTITUTE | \JSON_PARTIAL_OUTPUT_ON_ERROR | \JSON_UNESCAPED_SLASHES); + + return \strlen($spelled) <= 96 ? $spelled : substr($spelled, 0, 96) . '… (' . \strlen($spelled) . ' bytes as written)'; + } + + /** + * Refuse a JSON text that writes a list as an object whose keys are "0", "1", "2"… + * + * Read into an array, such an object IS a list: 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, so it is told + * here, from the text as it parses. + * + * @param mixed $read the text, decoded with objects kept as objects + * + * @throws \InvalidArgumentException naming where the list is + */ + public static function listsAreLists(mixed $read, string $where, ?string $name = null): void + { + if (\is_object($read)) { + $keys = array_keys(get_object_vars($read)); + if ($keys !== [] && array_map(strval(...), $keys) === array_map(strval(...), array_keys($keys))) { + 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, $name === null ? '' : ', in ' . self::shown($name))); + } + } + if (\is_object($read) || \is_array($read)) { + foreach ((array) $read as $key => $item) { + self::listsAreLists($item, $where, \is_object($read) ? (string) $key : $name); + } + } + } + /** * Refuse a key this contract does not read. * @@ -105,9 +163,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), )); } @@ -137,7 +195,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..368f038 100644 --- a/src/Standing/ConditionReceipt.php +++ b/src/Standing/ConditionReceipt.php @@ -74,8 +74,15 @@ 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». The `at` of a receipt is for the reader of the + * record and is read loosely; the judge joins by content, not by clock. * * @param array $data */ diff --git a/src/Standing/Falsifier.php b/src/Standing/Falsifier.php index c4ca52f..6eb0446 100644 --- a/src/Standing/Falsifier.php +++ b/src/Standing/Falsifier.php @@ -34,7 +34,7 @@ public function __construct( throw new \InvalidArgumentException('a falsifier names itself and says what would show the grant wrong'); } if ($withinSeconds < 1) { - throw new \InvalidArgumentException("falsifier «{$name}» needs «within_seconds»: how recently its watcher must have given a sign of life, as a whole number of seconds above zero"); + 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 +61,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..5b9fb02 100644 --- a/src/Standing/GrantCondition.php +++ b/src/Standing/GrantCondition.php @@ -53,6 +53,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..1ba9e94 100644 --- a/src/Standing/GrantStanding.php +++ b/src/Standing/GrantStanding.php @@ -24,10 +24,15 @@ */ 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'; + /** * @param GrantState $state what the ledger derives for the grant now * @param string|null $because for a state other than in force: what put it there and who may lift it, in words - * @param string|null $signatureFault why the grant's signature does not hold NOW — or `null`: it was re-verified and it holds + * @param 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 $instruments operation (canonical id) => its instrument digest today @@ -35,7 +40,7 @@ public function __construct( public GrantState $state = GrantState::InForce, public ?string $because = null, - public ?string $signatureFault = null, + public ?string $signatureFault = self::NOT_VERIFIED, public ?\DateTimeImmutable $watcherSignalAt = null, public array $receipts = [], public array $instruments = [], diff --git a/src/Standing/NamedCall.php b/src/Standing/NamedCall.php index cec5aa1..63e72b8 100644 --- a/src/Standing/NamedCall.php +++ b/src/Standing/NamedCall.php @@ -37,6 +37,16 @@ */ final readonly class NamedCall { + /** + * How an argument is named: letters and underscores, starting with a letter, at most 64 of them. + * + * 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, this is + * the narrowest alphabet that refuses none of the 76 names its operations declare. + */ + private const ARGUMENT = '/^[A-Za-z][A-Za-z_]{0,63}$/D'; + public OperationId $operation; /** @var array */ @@ -53,39 +63,39 @@ public function __construct(OperationId|string $operation, array $arguments = [], array $free = []) { $id = $operation instanceof OperationId ? $operation : new OperationId($operation); - if (preg_match('/^[a-z0-9]+(?:-[a-z0-9]+)*(?:\.[a-z0-9]+(?:-[a-z0-9]+)*)*$/', $id->canonical) !== 1) { + if (preg_match('/^[a-z0-9]+(?:-[a-z0-9]+)*(?:\.[a-z0-9]+(?:-[a-z0-9]+)*)*$/D', $id->canonical) !== 1) { throw new \InvalidArgumentException(\sprintf( - '«%s» does not name one operation. A standing grant names each operation whole — no pattern, no wildcard: what is not named is not covered.', - $operation instanceof OperationId ? $operation->canonical : $operation, + '%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 instanceof OperationId ? $operation->canonical : $operation), )); } $fixed = []; foreach ($arguments as $name => $value) { - if (!\is_string($name) || trim($name) === '' || !(\is_string($value) || \is_int($value) || \is_bool($value))) { + 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 and one plain value — text, a whole number, or true/false; got %s', + 'an argument fixed for «%s» is a name — letters and underscores, starting with a letter — and one plain value: text, a whole number, or true/false; got %s', $id->canonical, - json_encode([$name => $value]) ?: get_debug_type($value), + Canonical::shown([$name => $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', + '«free» of «%s» lists the names of the arguments a call may carry with any value — each in letters and underscores, starting with a letter, and never one it already fixes; got %s', $id->canonical, - json_encode($name) ?: get_debug_type($name), + 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; diff --git a/src/Standing/StandingGrant.php b/src/Standing/StandingGrant.php index 88abe66..440c539 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,7 +83,7 @@ /** 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']; @@ -113,11 +116,9 @@ 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) === '')) { @@ -135,7 +136,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»"); @@ -277,7 +278,26 @@ private static function instant(string $text): ?\DateTimeImmutable // 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. - return $instant !== false && $instant->format('Y-m-d\TH:i:s') === substr($text, 0, 19) ? $instant : null; + 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. */ @@ -293,7 +313,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 */ @@ -301,6 +326,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; @@ -308,6 +335,7 @@ 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) ? self::instant($expires) : null; @@ -328,7 +356,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), @@ -354,10 +382,56 @@ public static function fromJson(string $json): self throw new \InvalidArgumentException('a standing grant is one JSON object: {"id", "to", "covers", "ceiling", "conditions", "undo", "falsifier"}'); } Canonical::saidOnce($json, 'a standing grant'); + // The shape of a list is decided from the text: read into an array, `{"0": …, "1": …}` is one. + Canonical::listsAreLists(json_decode($json), 'a standing grant'); return self::fromArray($data); } + /** + * 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) || trim($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)); + } + $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 $read; + } + /** * @param array $data * diff --git a/src/Standing/StandingJudge.php b/src/Standing/StandingJudge.php index 8dd2fb2..f5801a6 100644 --- a/src/Standing/StandingJudge.php +++ b/src/Standing/StandingJudge.php @@ -130,8 +130,13 @@ public static function judge(StandingGrant $grant, GrantStanding $standing, Stan foreach ($grant->conditions as $condition) { $measuring = $condition->by->operation->forCli(); $last = null; + // WHAT SPEAKS FOR A CONDITION IS READ CLOSED, AND WHAT SPEAKS AGAINST IT IS READ OPEN. A green receipt + // counts only from the run the condition describes — a lenient run that says yes measured something else. + // A red one from a run that carries those arguments AND one more is the instrument saying no of this very + // content: read closed, it was ignored, and the green before it went on speaking. foreach ($standing->receipts as $receipt) { - if ($receipt->subject === $call->subject && $condition->by->matchesExactly($receipt->by, $receipt->arguments)) { + if ($receipt->subject === $call->subject + && ($condition->by->matchesExactly($receipt->by, $receipt->arguments) || (!$receipt->ok && $condition->by->matches($receipt->by, $receipt->arguments)))) { $last = $receipt; } } diff --git a/tests/Standing/AStandingGrantNeverCrossesItsFloorsTest.php b/tests/Standing/AStandingGrantNeverCrossesItsFloorsTest.php index cd25e6b..00ce2ce 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']], @@ -154,7 +154,7 @@ 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('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(new \Milpa\Command\Consent\OperationId('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..0e6cc3b 100644 --- a/tests/Standing/Grants.php +++ b/tests/Standing/Grants.php @@ -93,14 +93,15 @@ public static function receipt(string $subject = self::CANDIDATE, bool $ok = tru } /** - * 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( + signatureFault: null, watcherSignalAt: $watcherSignalAt === null ? null : self::at($watcherSignalAt), receipts: $receipts ?? [self::receipt()], instruments: ['lab.scan' => self::INSTRUMENT], diff --git a/tests/Standing/TheStandingJudgeAsksSevenThingsTest.php b/tests/Standing/TheStandingJudgeAsksSevenThingsTest.php index 69f5948..46c10fc 100644 --- a/tests/Standing/TheStandingJudgeAsksSevenThingsTest.php +++ b/tests/Standing/TheStandingJudgeAsksSevenThingsTest.php @@ -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]); $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()]), 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(signatureFault: null, watcherSignalAt: Grants::at('2026-10-11T11:50:00Z'), receipts: [Grants::receipt()], instruments: []); 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(signatureFault: null, watcherSignalAt: $watched, receipts: [$scan], instruments: $instruments), Grants::call()); self::assertSame('not_measured', $missing->reason); self::assertStringContainsString('«builds»', $missing->sentence); self::assertSame([], $missing->cited); - $both = StandingJudge::judge($grant, new GrantStanding(watcherSignalAt: $watched, receipts: [$build, $scan], instruments: $instruments), Grants::call()); + $both = StandingJudge::judge($grant, new GrantStanding(signatureFault: null, watcherSignalAt: $watched, receipts: [$build, $scan], instruments: $instruments), 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); $verdict = StandingJudge::judge(Grants::grant(), $standing, Grants::call(subject: null, effect: Grants::effect(Reversibility::Irreversible))); self::assertSame(StandingCheck::InForce, $verdict->failed); @@ -259,7 +259,7 @@ 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]); } public function testAmongSeveralGrantsOneThatPassesIsEnough(): void @@ -337,8 +337,8 @@ 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)), + self::held('superseded', new GrantStanding(GrantState::Superseded, signatureFault: null)), self::held('expired', change: ['expires_at' => '2026-10-11T00:00:00Z']), ]; @@ -365,7 +365,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)), self::held('newer', change: ['expires_at' => '2026-10-11T00:00:00Z'])], Grants::call()); self::assertSame('newer', $answer['grant']->id ?? null); self::assertSame('expired', $answer['verdict']->reason ?? null); diff --git a/tests/Standing/WhatAFourthReviewFoundInTheContractTest.php b/tests/Standing/WhatAFourthReviewFoundInTheContractTest.php new file mode 100644 index 0000000..e9fe7a3 --- /dev/null +++ b/tests/Standing/WhatAFourthReviewFoundInTheContractTest.php @@ -0,0 +1,529 @@ + + * + * @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\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()); + } + } + self::assertSame('lab.scan', (new NamedCall(" lab:scan\n"))->operation->canonical, 'control: what is blank around a name was never part of 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); + } + + 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'); + $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 300 000 bytes more. + $forged = "\x1b[2K\rOK: standing grant «release-line» is in force.\x1b[8m\u{202E}" . str_repeat('x', 300_000); + + $refusal = self::refusal($document($forged)); + + self::assertNotNull($refusal, 'control: the document is refused'); + 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.\x1b[8m", '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'); + } + + // ── 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()); + } + + // ── two proposals, built as the narrowest option and waiting for a person's decision ───────────────────── + + /** 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 testARedReceiptSpeaksOnlyForTheConditionItsRunFitsAndTheContentItMeasured(): void + { + // Open is not «anything»: another content, another operation, or another value of an argument the condition + // FIXES is not this condition measured red. + $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::assertTrue($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 'a digit anywhere: the narrowest alphabet has none, because no published operation needs one' => ['sha256']; + 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 the way operations name theirs. */ + #[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 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. The alphabet is the narrowest that refuses none of them. + $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(['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(), Grants::call()); + + self::assertSame(StandingCheck::Signature, $verdict->failed, 'the default of «is its signature verified» was yes'); + // …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/WhatAThirdReviewFoundInTheContractTest.php b/tests/Standing/WhatAThirdReviewFoundInTheContractTest.php index f3f537f..6f02b9c 100644 --- a/tests/Standing/WhatAThirdReviewFoundInTheContractTest.php +++ b/tests/Standing/WhatAThirdReviewFoundInTheContractTest.php @@ -46,11 +46,11 @@ public static function whatIsSaidTwice(): iterable 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»']; + 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"']; yield 'a key spelled two ways' => ['{"\\u0074o":["SOMEONE-ELSE"],' . substr($whole, 1), 'to', '']; yield 'a key with a quote in it' => [substr($whole, 0, -1) . ',"a \\"b":1,"a \\"b":2}', 'a "b', '']; } @@ -63,7 +63,7 @@ public function testADocumentThatSaysAKeyTwiceIsRefused(string $text, string $ke try { $grant = StandingGrant::fromJson($text); } catch (\InvalidArgumentException $refused) { - self::assertStringContainsString("a standing grant says «{$key}» twice{$inside}: a document a person signs says each thing once", $refused->getMessage()); + self::assertStringContainsString('a standing grant says ' . json_encode($key) . " twice{$inside}: a document a person signs says each thing once", $refused->getMessage()); return; } From 755c3ef87b21e12be6251cc8b43e5ec8cb945ead Mon Sep 17 00:00:00 2001 From: Rod Vince Date: Sun, 11 Oct 2026 20:32:49 -0600 Subject: [PATCH 04/11] fix: every text a grant keeps is one line of plain text, and an argument is named in letters, digits and underscores (greenhouse decisions/0615) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three decisions of Rod on 2026-10-12, on what the fourth review left open. Every text a grant keeps is one line of plain text, and one that is not is refused when it is read: no line break, no tab, no other control character, none of the characters that turn or isolate the direction text is read in, no line or paragraph separator. It was the refusal that read as "OK", taken to a grant that is accepted: a condition named so that a judge's own sentence cleared the line a terminal printed it on. It holds for how an act is taken back, for the name and the statement of a condition and of the falsifier, for each seat, for the suspension a grant lifts, for the text an argument is fixed to and for the inverse a ceiling names — in a document and in a value built by hand. Measured on what the labs and the suites write in a grant: 432 texts, none refused. An argument is named in letters, digits and underscores, starting with a letter, at most 64 of them: the alphabet built before had no digits, and an argument named sha256 could not be fixed. A red receipt always counts: it is read open, as it was built. Nothing changes there but the words that called it a proposal. Saying text back in a refusal left one control character as it came — DEL, which JSON does not escape. It is written as its escape now. --- README.md | 17 +- src/Standing/Canonical.php | 37 +++- src/Standing/Falsifier.php | 5 + src/Standing/GrantCondition.php | 5 + src/Standing/NamedCall.php | 17 +- src/Standing/StandingGrant.php | 12 ++ ...hatAFourthReviewFoundInTheContractTest.php | 168 ++++++++++++++++-- 7 files changed, 235 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index 60cd9f9..6eaeeda 100644 --- a/README.md +++ b/README.md @@ -236,7 +236,14 @@ does not read** — a line the judge would ignore is a limit nobody keeps. (`rollback_contract`) is the name of one operation in words, 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. + own spelling in UTC is that form too; +- **every text it keeps is one line of plain text** — `undo`, the `name` and `statement` of a + condition and of the falsifier, each seat, `lifts`, the text an argument is fixed to and the + inverse a ceiling names. No line break, no tab, no other control character (C0, DEL, C1), no + character that turns or isolates the direction text is read in, no line or paragraph separator: + 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, and such a text would be read otherwise than its bytes. Letters written right to + left are text; what is refused is the control that turns them. `fromArray()` reads a document already in an array, and cannot see a key said twice: the array holds the last one only. @@ -256,10 +263,10 @@ That is for what speaks **for** a condition. A receipt that says **no** is read carries what the condition fixes and one argument more is still the instrument saying no of that very content, and it is the one that speaks until the content is measured again. -An argument — fixed or free — is named in **letters 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. +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", diff --git a/src/Standing/Canonical.php b/src/Standing/Canonical.php index 065a42a..36a3af4 100644 --- a/src/Standing/Canonical.php +++ b/src/Standing/Canonical.php @@ -116,8 +116,9 @@ public static function saidOnce(string $json, string $where): void */ 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. - $spelled = (string) json_encode($written, \JSON_INVALID_UTF8_SUBSTITUTE | \JSON_PARTIAL_OUTPUT_ON_ERROR | \JSON_UNESCAPED_SLASHES); + // 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)); return \strlen($spelled) <= 96 ? $spelled : substr($spelled, 0, 96) . '… (' . \strlen($spelled) . ' bytes as written)'; } @@ -173,7 +174,7 @@ 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, trimmed — and one line of plain text ({@see self::oneLine()}). * * @param array $data */ @@ -183,10 +184,40 @@ 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(trim($value))) { + throw new \InvalidArgumentException(self::notOneLine("{$where} gives «{$key}»", $value)); + } return trim($value); } + /** + * Whether a text is ONE LINE OF PLAIN TEXT: read as it is written (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, a tab, an escape or any other control character — or one of the + * characters that turn or isolate the direction text is read in, or a line or paragraph separator — 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. So every text a grant keeps is refused when it is read if it carries one: + * + * · the C0 controls, DEL and the C1 controls (U+0000–U+001F, U+007F–U+009F); + * · the direction controls (U+061C, U+200E, U+200F, U+202A–U+202E, U+2066–U+2069); + * · the line and the paragraph separator (U+2028, U+2029); + * · and bytes that are not text at all. + * + * Letters written right to left are text and are taken; what is refused is the control that turns them. + */ + public static function oneLine(string $text): bool + { + return preg_match('/[\x{0000}-\x{001F}\x{007F}-\x{009F}\x{061C}\x{200E}\x{200F}\x{2028}-\x{202E}\x{2066}-\x{2069}]/u', $text) === 0; + } + + /** 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 character, and nothing that turns the direction it is read in: what a person signs is read as it is written. Got %s', $what, self::shown($text)); + } + /** * @param array $value * diff --git a/src/Standing/Falsifier.php b/src/Standing/Falsifier.php index 6eb0446..792dc0d 100644 --- a/src/Standing/Falsifier.php +++ b/src/Standing/Falsifier.php @@ -33,6 +33,11 @@ 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 ' . 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'); } diff --git a/src/Standing/GrantCondition.php b/src/Standing/GrantCondition.php index 5b9fb02..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)); + } + } } /** diff --git a/src/Standing/NamedCall.php b/src/Standing/NamedCall.php index 63e72b8..c7fa9f1 100644 --- a/src/Standing/NamedCall.php +++ b/src/Standing/NamedCall.php @@ -38,14 +38,16 @@ final readonly class NamedCall { /** - * How an argument is named: letters and underscores, starting with a letter, at most 64 of them. + * 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, this is - * the narrowest alphabet that refuses none of the 76 names its operations declare. + * 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-z_]{0,63}$/D'; + private const ARGUMENT = '/^[A-Za-z][A-Za-z0-9_]{0,63}$/D'; public OperationId $operation; @@ -74,11 +76,14 @@ public function __construct(OperationId|string $operation, array $arguments = [] foreach ($arguments as $name => $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 and underscores, starting with a letter — and one plain value: text, a whole number, or true/false; got %s', + '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', $id->canonical, Canonical::shown([$name => $value]), )); } + if (\is_string($value) && !Canonical::oneLine($value)) { + throw new \InvalidArgumentException(Canonical::notOneLine("the argument «{$name}» fixed for «{$id->canonical}» gives its value", $value)); + } $fixed[$name] = $value; } ksort($fixed, \SORT_STRING); @@ -87,7 +92,7 @@ public function __construct(OperationId|string $operation, array $arguments = [] foreach ($free as $name) { 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 — each in letters and underscores, starting with a letter, and never one it already fixes; got %s', + '«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', $id->canonical, Canonical::shown($name), )); diff --git a/src/Standing/StandingGrant.php b/src/Standing/StandingGrant.php index 440c539..89f5a64 100644 --- a/src/Standing/StandingGrant.php +++ b/src/Standing/StandingGrant.php @@ -124,9 +124,15 @@ public function __construct( 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"); } @@ -192,6 +198,9 @@ 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)); + } $this->to = $seats; $this->covers = array_values($covers); @@ -419,6 +428,9 @@ private static function ceiling(array $written, string $where): EffectProfile if ($inverse !== null && (!\is_string($inverse) || trim($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"); diff --git a/tests/Standing/WhatAFourthReviewFoundInTheContractTest.php b/tests/Standing/WhatAFourthReviewFoundInTheContractTest.php index e9fe7a3..a970c78 100644 --- a/tests/Standing/WhatAFourthReviewFoundInTheContractTest.php +++ b/tests/Standing/WhatAFourthReviewFoundInTheContractTest.php @@ -316,6 +316,7 @@ public function testWhatIsSaidBackOfAValueThatIsNotTextIsBoundedToo(): void 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); @@ -353,15 +354,19 @@ public static function whereADrafterWrites(): iterable 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 300 000 bytes more. + // drafter's own, a right-to-left override, and 300 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. $forged = "\x1b[2K\rOK: standing grant «release-line» is in force.\x1b[8m\u{202E}" . str_repeat('x', 300_000); + $plain = 'release ' . str_repeat('x', 300_000); - $refusal = self::refusal($document($forged)); + foreach ([$forged, $plain] as $said) { + $refusal = self::refusal($document($said)); - self::assertNotNull($refusal, 'control: the document is refused'); - 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); + self::assertNotNull($refusal, 'control: the document is refused'); + 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 @@ -375,10 +380,14 @@ public function testWhatIsSaidBackIsStillWhatWasWritten(): void public function testTheIdIsCheckedBeforeItIsUsedToSayAnythingElse(): void { - $refusal = (string) self::refusal((string) json_encode(Grants::document(['id' => "x» is in force.\x1b[8m", 'ceiling' => null]))); + $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 ──────────────────────────────────────────────────────────────────── @@ -424,7 +433,139 @@ public function testTheReaderOfAReceiptSaysWhatAHostOwesALineItCanNoLongerRead() self::assertStringContainsString('cannot see a key said twice', (string) (new \ReflectionMethod(StandingGrant::class, 'fromArray'))->getDocComment()); } - // ── two proposals, built as the narrowest option and waiting for a person's decision ───────────────────── + // ── 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) { + self::assertStringContainsString('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 — and what is + // blank AROUND a text, which was never part of it. + $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}", + "a no-break\u{a0}space, a soft\u{ad}hyphen and a zero-width\u{200d}joiner", + '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::assertSame('take it back', Grants::grant(['undo' => " take it back\n"])->undo, 'what is blank around a text was never part of it'); + } + + 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) { + self::assertStringContainsString('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 @@ -472,14 +613,14 @@ public static function namesNoOperationGivesAnArgument(): iterable 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 'a digit anywhere: the narrowest alphabet has none, because no published operation needs one' => ['sha256']; + 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 the way operations name theirs. */ + /** 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 { @@ -490,7 +631,7 @@ public function testAnArgumentIsNamedInPlainLettersTheWayOperationsNameTheirs(st new NamedCall('lab:publish', ...$how); self::fail('a named call took an argument named ' . json_encode($name)); } catch (\InvalidArgumentException $refused) { - self::assertStringContainsString('letters and underscores', $refused->getMessage()); + self::assertStringContainsString('letters, digits and underscores', $refused->getMessage()); } } } @@ -498,7 +639,8 @@ public function testAnArgumentIsNamedInPlainLettersTheWayOperationsNameTheirs(st 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. The alphabet is the narrowest that refuses none of them. + // 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', @@ -514,6 +656,8 @@ public function testEveryArgumentNameThePublishedCatalogueDeclaresIsStillAName() 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'); } From e0bf655bad6c629aa2ef53be523519fe7d61a439 Mon Sep 17 00:00:00 2001 From: Rod Vince Date: Sun, 11 Oct 2026 21:59:09 -0600 Subject: [PATCH 05/11] fix: nothing is trimmed, what cannot be seen is refused, a red receipt counts from any run, and a host says its record reads whole (greenhouse decisions/0615) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit What a fifth review found in this branch, and three decisions of Rod on 2026-10-12 about what it left open. Decided by Rod: An argument is fixed to a value a person can read, and no other: text that is visible ASCII, single spaces inside, none around — or a whole number, or true or false. A fixed value is compared byte by byte with what a caller sends, so "strict" with a zero-width space after it was another run: the run a person read said no, and the look-alike said yes. It costs that an argument cannot be fixed to a value with an accent. A red receipt counts from any run. A "no" of that instrument on that content stops the act whatever arguments the run carried — one more, the fixed one missing or at another value, none at all — until the content is measured again by the run the condition describes. Green stays closed. Another content, or another operation, says nothing of the condition. A text a grant keeps carries nothing that is not seen. Besides controls and the direction controls: every format character, every space that is not U+0020, the variation selectors, the fillers that print as nothing, private use and unassigned places. Letters of every language, accents and punctuation are kept. The README lists exactly what is refused, as measured over every code point; nothing the lab's sixteen books hold is refused by it. Found by the review: Nothing is taken off the ends of a text. A reader that trims repairs: nine fields took a line break, a tab or a null byte at either end. A text that is not as it reads is refused, in all seventeen places a grant holds one — and so is the name of an operation with a separator at either end, which its identity took off the same way. What the judge says is bounded and plain. A condition, a falsifier and the content are named as JSON spells them, at most a line; a sentence the host hands in is repeated as it came when it is one line of plain text, up to 480 characters, and spelled out otherwise. The effect profile's refusals say back what they quote the same way. One place the review did not list: 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 — so a sentence says at most a line of it too. A line whose "by" names no operation is not a receipt. And a host now says its record of a grant reads whole (GrantStanding::$recordFault), or the judge refuses: skipping a line that cannot be read was the easy thing to do, and a red receipt skipped leaves the green before it speaking. Where the grant stands has no default any more. A text longer than 256 KB is refused before it is read, and one scan of the text says both a key said twice and a list written as an object. Scopes, rules and the parts of a certificate are sorted by their bytes wherever a digest depends on the order. A grant built in code is held to what a grant read from its text is held to: its inverse, its texts and its expiry. An inverse is the name of one operation under any ceiling. Three expectations this suite pinned change, by decision: a no-break space, a soft hyphen and a zero-width joiner were taken; what is blank around a text or a name was trimmed off; and a red receipt from a run in another mode than the one a condition fixes did not count. --- README.md | 93 ++- src/Effect/ContextFacts.php | 3 +- src/Effect/DeclaredAuthorityPolicy.php | 4 +- src/Effect/DescentCertificate.php | 8 +- src/Effect/EffectProfile.php | 13 +- src/Operation.php | 2 +- src/Standing/Canonical.php | 154 +++- src/Standing/ConditionReceipt.php | 18 +- src/Standing/GrantStanding.php | 17 +- src/Standing/NamedCall.php | 54 +- src/Standing/StandingCheck.php | 5 +- src/Standing/StandingGrant.php | 35 +- src/Standing/StandingJudge.php | 57 +- tests/Standing/Grants.php | 3 + .../TheStandingJudgeAsksSevenThingsTest.php | 24 +- ...WhatAFifthReviewFoundInTheContractTest.php | 697 ++++++++++++++++++ ...hatAFourthReviewFoundInTheContractTest.php | 56 +- 17 files changed, 1106 insertions(+), 137 deletions(-) create mode 100644 tests/Standing/WhatAFifthReviewFoundInTheContractTest.php diff --git a/README.md b/README.md index 6eaeeda..b7340d3 100644 --- a/README.md +++ b/README.md @@ -194,7 +194,7 @@ 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* — and a host that does not say it verified it (`new GrantStanding()`) is read as one that did not | +| 2 | `Signature` | the grant's signature does not hold *now*, or what the host keeps of the grant does not read whole — 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 | @@ -213,6 +213,11 @@ The judgment is pure: it reads a `StandingGrant`, the facts a host gathered abou (`GrantStanding`) and the call as facts (`StandingCall`). It never grants, widens or restores — growing back is a person's new signature, and it happens in the host. +**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 @@ -225,32 +230,71 @@ 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. + **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"`; -- a name ends where it ends: an id, the name of an operation and the operation a ceiling names as - its inverse take nothing after them, not even a newline; +- 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; +- **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 in words, and what the lenient reader of a - stored profile would drop or change is not taken; + (`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** — `undo`, the `name` and `statement` of a - condition and of the falsifier, each seat, `lifts`, the text an argument is fixed to and the - inverse a ceiling names. No line break, no tab, no other control character (C0, DEL, C1), no - character that turns or isolates the direction text is read in, no line or paragraph separator: - 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, and such a text would be read otherwise than its bytes. Letters written right to - left are text; what is refused is the control that turns them. +- **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 | +| fillers that print as nothing | U+115F, U+1160, U+3164, U+FFA0, U+2800 | +| 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. And it is a list, not a promise that nothing unseen is left: the +Khmer inherent vowels (U+17B4, U+17B5) are shown as nothing and are taken, and a letter of another +alphabet that looks the same is text. `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. +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 @@ -259,9 +303,21 @@ 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** is read open: a run that -carries what the condition fixes and one argument more is still the instrument saying no of that -very content, and it is the one that speaks until the content is measured again. +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 `~`, single spaces inside, none around — a whole number, or true or +false. 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 @@ -288,7 +344,10 @@ one already signed, and a later version of this contract may refuse what an earl 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. +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 among them — and `null` +is that fault, not "nothing was measured". **What it cannot say: money.** There is no effect axis for it, so no ceiling bounds what a call spends, and nothing in an operation's declaration says that it spends. "Do not grant over an diff --git a/src/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 3b6c50b..857df2f 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. @@ -151,12 +152,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), )); } } @@ -391,8 +392,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 +406,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 +457,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 36a3af4..35c22f4 100644 --- a/src/Standing/Canonical.php +++ b/src/Standing/Canonical.php @@ -42,6 +42,21 @@ */ 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 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. + */ + 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{115F}\x{1160}\x{3164}\x{FFA0}\x{2800}]|(?! )\p{Zs}/u'; + /** * The canonical bytes of a structure. * @@ -53,25 +68,32 @@ public static function json(array $value): string } /** - * Refuse a JSON text that says a key twice in one object. + * 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 — or saying the text is not JSON + * @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, and the name it is known by. + // 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; for ($at = 0, $length = \strlen($json); $at < $length; ++$at) { @@ -94,13 +116,18 @@ public static function saidOnce(string $json, string $where): void $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' => [], 'last' => null, 'name' => $last < 0 ? null : ($open[$last]['last'] ?? $open[$last]['name'])]; + $open[] = ['said' => [], 'places' => true, 'last' => null, 'name' => $last < 0 ? null : ($open[$last]['last'] ?? $open[$last]['name'])]; } elseif ($byte === '}' || $byte === ']') { - array_pop($open); + $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']))); + } } } } @@ -112,41 +139,56 @@ public static function saidOnce(string $json, string $where): void * 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. + * 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 \strlen($spelled) <= 96 ? $spelled : substr($spelled, 0, 96) . '… (' . \strlen($spelled) . ' bytes as written)'; + return $whole[0] . '… (' . \strlen($spelled) . ' bytes as written)'; } /** - * Refuse a JSON text that writes a list as an object whose keys are "0", "1", "2"… - * - * Read into an array, such an object IS a list: 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, so it is told - * here, from the text as it parses. + * 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. * - * @param mixed $read the text, decoded with objects kept as objects + * 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. * - * @throws \InvalidArgumentException naming where the list is + * 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 listsAreLists(mixed $read, string $where, ?string $name = null): void + public static function said(string $sentence): string { - if (\is_object($read)) { - $keys = array_keys(get_object_vars($read)); - if ($keys !== [] && array_map(strval(...), $keys) === array_map(strval(...), array_keys($keys))) { - 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, $name === null ? '' : ', in ' . self::shown($name))); - } + if (!self::oneLine($sentence)) { + return self::shown($sentence); } - if (\is_object($read) || \is_array($read)) { - foreach ((array) $read as $key => $item) { - self::listsAreLists($item, $where, \is_object($read) ? (string) $key : $name); - } + if (preg_match('/^.{' . self::SAID . '}(?=.)/us', $sentence, $start) !== 1) { + return $sentence; } + + return $start[0] . '… (' . \strlen($sentence) . ' bytes as written)'; } /** @@ -174,7 +216,12 @@ public static function only(array $data, array $known, string $where): void } /** - * A required, non-blank string of a document, trimmed — and one line of plain text ({@see self::oneLine()}). + * 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 */ @@ -184,38 +231,69 @@ 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(trim($value))) { + if (!self::oneLine($value)) { throw new \InvalidArgumentException(self::notOneLine("{$where} gives «{$key}»", $value)); } - return trim($value); + return $value; } /** - * Whether a text is ONE LINE OF PLAIN TEXT: read as it is written (decided by Rod, 2026-10-12). + * 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, a tab, an escape or any other control character — or one of the - * characters that turn or isolate the direction text is read in, or a line or paragraph separator — 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. So every text a grant keeps is refused when it is read if it carries one: + * 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: * - * · the C0 controls, DEL and the C1 controls (U+0000–U+001F, U+007F–U+009F); - * · the direction controls (U+061C, U+200E, U+200F, U+202A–U+202E, U+2066–U+2069); + * · 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); - * · and bytes that are not text at all. + * · 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 filler that prints as nothing: U+115F, U+1160, U+3164, U+FFA0, U+2800; + * · 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. + * + * 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. * - * Letters written right to left are text and are taken; what is refused is the control that turns them. + * WHAT THIS IS NOT: a promise that nothing unseen is left. It refuses what is listed. Two characters Unicode + * itself says are shown as nothing are not on the list and are taken — the Khmer inherent vowels, U+17B4 and + * U+17B5 — and a mark that stacks on a letter, or a letter of another alphabet that looks the same, is text. */ public static function oneLine(string $text): bool { - return preg_match('/[\x{0000}-\x{001F}\x{007F}-\x{009F}\x{061C}\x{200E}\x{200F}\x{2028}-\x{202E}\x{2066}-\x{2069}]/u', $text) === 0; + return preg_match(self::UNSEEN, $text) === 0 && !str_starts_with($text, ' ') && !str_ends_with($text, ' '); } /** 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 character, and nothing that turns the direction it is read in: what a person signs is read as it is written. Got %s', $what, self::shown($text)); + 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)); } /** diff --git a/src/Standing/ConditionReceipt.php b/src/Standing/ConditionReceipt.php index 368f038..2cc9288 100644 --- a/src/Standing/ConditionReceipt.php +++ b/src/Standing/ConditionReceipt.php @@ -51,6 +51,9 @@ public function __construct( 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'); } + if (NamedCall::named($by) === null) { + throw new \InvalidArgumentException('a condition receipt says which operation measured, by its name; got ' . Canonical::shown($by->canonical)); + } } /** @@ -81,8 +84,13 @@ public function toArray(): array * 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». The `at` of a receipt is for the reader of the - * record and is read loosely; the judge joins by content, not by clock. + * 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. + * + * 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 */ @@ -94,8 +102,12 @@ public static function fromArray(array $data): ?self || trim($data['subject']) === '' || trim($data['instrument']) === '') { return null; } + $by = NamedCall::named($data['by']); + if ($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($by, $data['arguments'], $data['subject'], $data['ok'], $data['instrument'], \is_string($who) ? $who : null, $at); } } diff --git a/src/Standing/GrantStanding.php b/src/Standing/GrantStanding.php index 1ba9e94..c1a2fc4 100644 --- a/src/Standing/GrantStanding.php +++ b/src/Standing/GrantStanding.php @@ -27,8 +27,14 @@ /** 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 * @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. * A host says which: the default is that nobody verified it, @@ -36,14 +42,21 @@ * @param \DateTimeImmutable|null $watcherSignalAt the watcher's last sign of life since the grant was given or last reinstated, or `null`: none * @param list $receipts every receipt the host holds, in ledger order — the last one about a content says what is known of it now * @param array $instruments operation (canonical id) => its instrument digest today + * @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 = 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 c7fa9f1..0b51936 100644 --- a/src/Standing/NamedCall.php +++ b/src/Standing/NamedCall.php @@ -34,6 +34,17 @@ * 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. 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 nothing and one space are + * never the same line on a screen. */ final readonly class NamedCall { @@ -49,6 +60,12 @@ */ 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 — or nothing at all. */ + 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 */ @@ -64,8 +81,8 @@ */ public function __construct(OperationId|string $operation, array $arguments = [], array $free = []) { - $id = $operation instanceof OperationId ? $operation : new OperationId($operation); - if (preg_match('/^[a-z0-9]+(?:-[a-z0-9]+)*(?:\.[a-z0-9]+(?:-[a-z0-9]+)*)*$/D', $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.', Canonical::shown($operation instanceof OperationId ? $operation->canonical : $operation), @@ -77,12 +94,17 @@ public function __construct(OperationId|string $operation, array $arguments = [] 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', - $id->canonical, + Canonical::short($id->canonical), Canonical::shown([$name => $value]), )); } - if (\is_string($value) && !Canonical::oneLine($value)) { - throw new \InvalidArgumentException(Canonical::notOneLine("the argument «{$name}» fixed for «{$id->canonical}» gives its value", $value)); + if (\is_string($value) && preg_match(self::VALUE, $value) !== 1) { + throw new \InvalidArgumentException(\sprintf( + 'the argument «%s» fixed for «%s» is fixed to text a person can read and no other — visible ASCII, 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; } @@ -93,7 +115,7 @@ public function __construct(OperationId|string $operation, array $arguments = [] 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 — each in letters, digits and underscores, starting with a letter, and never one it already fixes; got %s', - $id->canonical, + Canonical::short($id->canonical), Canonical::shown($name), )); } @@ -107,6 +129,26 @@ public function __construct(OperationId|string $operation, array $arguments = [] $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. + */ + public static function named(OperationId|string $operation): ?OperationId + { + $id = $operation instanceof OperationId ? $operation : new OperationId($operation); + if (\is_string($operation) && str_replace([':', '_'], '.', strtolower($operation)) !== $id->canonical) { + return null; + } + + return preg_match(self::OPERATION, $id->canonical) === 1 ? $id : null; + } + /** * Whether a call is the one this names: that operation, carrying every fixed argument with that value. * 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 89f5a64..3059313 100644 --- a/src/Standing/StandingGrant.php +++ b/src/Standing/StandingGrant.php @@ -87,6 +87,15 @@ 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; @@ -152,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), )); } } @@ -166,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.', @@ -201,6 +216,11 @@ public function __construct( 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); @@ -383,16 +403,21 @@ public static function fromArray(array $data): self * 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. + * + * A text longer than {@see self::LONGEST} bytes is refused before it is read. */ 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'); - // The shape of a list is decided from the text: read into an array, `{"0": …, "1": …}` is one. - Canonical::listsAreLists(json_decode($json), 'a standing grant'); return self::fromArray($data); } @@ -425,7 +450,7 @@ private static function ceiling(array $written, string $where): EffectProfile } } $inverse = $written['rollback_contract'] ?? null; - if ($inverse !== null && (!\is_string($inverse) || trim($inverse) === '')) { + 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)) { diff --git a/src/Standing/StandingJudge.php b/src/Standing/StandingJudge.php index f5801a6..f5bff13 100644 --- a/src/Standing/StandingJudge.php +++ b/src/Standing/StandingJudge.php @@ -56,13 +56,22 @@ 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), )); } @@ -71,7 +80,7 @@ public static function judge(StandingGrant $grant, GrantStanding $standing, Stan '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 +94,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,42 +137,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; - // WHAT SPEAKS FOR A CONDITION IS READ CLOSED, AND WHAT SPEAKS AGAINST IT IS READ OPEN. A green receipt - // counts only from the run the condition describes — a lenient run that says yes measured something else. - // A red one from a run that carries those arguments AND one more is the instrument saying no of this very - // content: read closed, it was ignored, and the green before it went on speaking. + // 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 ($standing->receipts as $receipt) { if ($receipt->subject === $call->subject - && ($condition->by->matchesExactly($receipt->by, $receipt->arguments) || (!$receipt->ok && $condition->by->matches($receipt->by, $receipt->arguments)))) { + && ($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, )); } @@ -174,7 +185,7 @@ 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); } diff --git a/tests/Standing/Grants.php b/tests/Standing/Grants.php index 0e6cc3b..d161b1d 100644 --- a/tests/Standing/Grants.php +++ b/tests/Standing/Grants.php @@ -13,6 +13,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; @@ -101,10 +102,12 @@ public static function receipt(string $subject = self::CANDIDATE, bool $ok = tru 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 46c10fc..4611864 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', signatureFault: null, 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, signatureFault: null, 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(signatureFault: null, 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(signatureFault: null, 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(signatureFault: null, 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', signatureFault: null); + $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,7 +259,7 @@ private static function held(string $id, ?GrantStanding $standing = null, array private static function suspended(): GrantStanding { - 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]); + 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 @@ -337,8 +337,8 @@ public function testASuspendedGrantThatDoesNotNameTheCallStopsNothing(): void public function testExpiredRevokedAndSupersededGrantsStopNobody(): void { $gone = [ - self::held('revoked', new GrantStanding(GrantState::Revoked, signatureFault: null)), - self::held('superseded', new GrantStanding(GrantState::Superseded, signatureFault: null)), + 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']), ]; @@ -365,7 +365,7 @@ public function testWhenNoneLendsTheRefusalThatGotFurthestSpeaksWhicheverWasGive public function testLevelWithAnotherTheLaterGrantSpeaks(): void { - $answer = StandingJudge::among([self::held('older', new GrantStanding(GrantState::Superseded, signatureFault: null)), 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()); self::assertSame('newer', $answer['grant']->id ?? null); self::assertSame('expired', $answer['verdict']->reason ?? null); diff --git a/tests/Standing/WhatAFifthReviewFoundInTheContractTest.php b/tests/Standing/WhatAFifthReviewFoundInTheContractTest.php new file mode 100644 index 0000000..5591333 --- /dev/null +++ b/tests/Standing/WhatAFifthReviewFoundInTheContractTest.php @@ -0,0 +1,697 @@ + + * + * @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\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']); + } + // 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) { + $verdict = $judged($said); + + 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(new OperationId($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(new OperationId('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(new OperationId("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 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)); + } + } + + 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 index a970c78..31a05f3 100644 --- a/tests/Standing/WhatAFourthReviewFoundInTheContractTest.php +++ b/tests/Standing/WhatAFourthReviewFoundInTheContractTest.php @@ -22,6 +22,7 @@ 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; @@ -102,7 +103,17 @@ public function testAnOperationNameEndsWhereItEnds(): void self::assertStringContainsString('does not name one operation', $refused->getMessage()); } } - self::assertSame('lab.scan', (new NamedCall(" lab:scan\n"))->operation->canonical, 'control: what is blank around a name was never part of it'); + // 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. */ @@ -251,6 +262,8 @@ public function testAnExpiryWhoseOwnSpellingTheReaderRefusesIsRefusedWhenItIsRea { // 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 @@ -354,15 +367,19 @@ public static function whereADrafterWrites(): iterable 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 300 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. - $forged = "\x1b[2K\rOK: standing grant «release-line» is in force.\x1b[8m\u{202E}" . str_repeat('x', 300_000); - $plain = 'release ' . str_repeat('x', 300_000); + // 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); @@ -491,7 +508,8 @@ public function testEveryTextAGrantKeepsIsOneLineThatIsReadAsItIsWritten(string try { $grant = StandingGrant::fromJson($document); } catch (\InvalidArgumentException $refused) { - self::assertStringContainsString('one line of plain text', $refused->getMessage()); + // 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; @@ -514,15 +532,15 @@ public function testTheRefusalOfTheJudgeReadAsOkWasBuiltFromAGrantThatIsNowRefus public function testTextThatIsOnlyTextIsStillTaken(): void { - // Controls: what a person writes in any language, with the marks and the spaces a sentence has — and what is - // blank AROUND a text, which was never part of it. + // 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}", - "a no-break\u{a0}space, a soft\u{ad}hyphen and a zero-width\u{200d}joiner", 'tabs are words: "tab", and so is \\n — written out, not typed', ]; foreach ($legitimate as $text) { @@ -530,7 +548,8 @@ public function testTextThatIsOnlyTextIsStillTaken(): void self::assertSame($text, $grant->undo); self::assertSame($text, $grant->conditions[0]->statement); } - self::assertSame('take it back', Grants::grant(['undo' => " take it back\n"])->undo, 'what is blank around a text was never part of it'); + 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 @@ -550,7 +569,8 @@ public function testTheValuesRefuseTheSameTextWhenBuiltByHand(): void $build($text); self::fail("{$what} took " . json_encode($text, \JSON_INVALID_UTF8_SUBSTITUTE)); } catch (\InvalidArgumentException $refused) { - self::assertStringContainsString('one line of plain text', $refused->getMessage(), $what); + // 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); @@ -591,10 +611,11 @@ public function testAGreenReceiptFromARunTheGrantDoesNotDescribeStillDoesNotCoun self::assertSame('measured_red', StandingJudge::judge(Grants::grant(), Grants::standing([$red, $lenient]), Grants::call())->reason); } - public function testARedReceiptSpeaksOnlyForTheConditionItsRunFitsAndTheContentItMeasured(): void + public function testARedReceiptSpeaksForItsInstrumentAndTheContentItMeasured(): void { - // Open is not «anything»: another content, another operation, or another value of an argument the condition - // FIXES is not this condition measured red. + // 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(); @@ -602,7 +623,7 @@ public function testARedReceiptSpeaksOnlyForTheConditionItsRunFitsAndTheContentI 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::assertTrue($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' => '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])])); } @@ -664,9 +685,10 @@ public function testEveryArgumentNameThePublishedCatalogueDeclaresIsStillAName() public function testAHostThatSaysNothingOfTheSignatureIsNotReadAsHavingVerifiedIt(): void { - $verdict = StandingJudge::judge(Grants::grant(), new GrantStanding(), Grants::call()); + $verdict = StandingJudge::judge(Grants::grant(), new GrantStanding(GrantState::InForce), Grants::call()); - self::assertSame(StandingCheck::Signature, $verdict->failed, 'the default of «is its signature verified» was yes'); + 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'); } From c7ad91f5e931cccf00f4e54e6d0bde21eeede2dd Mon Sep 17 00:00:00 2001 From: Rod Vince Date: Sun, 11 Oct 2026 22:30:08 -0600 Subject: [PATCH 06/11] fix: no code point Unicode says is shown as nothing is taken in a text a grant keeps (greenhouse decisions/0615) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Decided by Rod on 2026-10-12, as part of "what is not seen is refused": the rule is said by the property, not by a list. No code point of Unicode's Default_Ignorable_Code_Point set is taken. Two of that set were still taken — the Khmer inherent vowels, U+17B4 and U+17B5, the only two of it this reader read as text. They are refused now. A test asks every code point there is, through the reader, with the Unicode tables of the PCRE the suite runs on as a second instrument: a character added to that set turns it red the day the tables know it. Of the 1,112,064 code points, 957,522 are refused. The pattern names the two by code point and not the property by its name: a PCRE older than 10.40 does not know that name, and a pattern that does not compile refuses every text there is. --- README.md | 11 +++--- src/Standing/Canonical.php | 21 +++++++---- ...WhatAFifthReviewFoundInTheContractTest.php | 36 +++++++++++++++++++ 3 files changed, 57 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index b7340d3..dfaeb9b 100644 --- a/README.md +++ b/README.md @@ -269,7 +269,7 @@ punctuation and symbols, and the ordinary space (U+0020) inside it. **What it re | 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 | -| fillers that print as nothing | U+115F, U+1160, U+3164, U+FFA0, U+2800 | +| 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 | | 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 | @@ -278,9 +278,12 @@ What that costs: a no-break space, a soft hyphen, a zero-width joiner or non-joi 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. And it is a list, not a promise that nothing unseen is left: the -Khmer inherent vowels (U+17B4, U+17B5) are shown as nothing and are taken, and a letter of another -alphabet that looks the same is text. +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 1,112,064, this reader refuses 957,522. 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. `fromArray()` reads a document already in an array, and cannot see a key said twice: the array holds the last one only. diff --git a/src/Standing/Canonical.php b/src/Standing/Canonical.php index 35c22f4..e62e66c 100644 --- a/src/Standing/Canonical.php +++ b/src/Standing/Canonical.php @@ -52,10 +52,14 @@ final class Canonical * 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 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. + * 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. It is not written here as `\p{DI}` only because a PCRE older + * than 10.40 does not know that name, and a pattern that does not compile refuses every text there is. */ - 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{115F}\x{1160}\x{3164}\x{FFA0}\x{2800}]|(?! )\p{Zs}/u'; + 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}]|(?! )\p{Zs}/u'; /** * The canonical bytes of a structure. @@ -266,7 +270,8 @@ public static function text(array $data, string $key, string $where): string * 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 filler that prints as nothing: U+115F, U+1160, U+3164, U+FFA0, U+2800; + * · 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; * · 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; @@ -281,9 +286,11 @@ public static function text(array $data, string $key, string $where): string * newer one takes — a letter added to Unicode since — and a document that uses none of those is read the same * everywhere. * - * WHAT THIS IS NOT: a promise that nothing unseen is left. It refuses what is listed. Two characters Unicode - * itself says are shown as nothing are not on the list and are taken — the Khmer inherent vowels, U+17B4 and - * U+17B5 — and a mark that stacks on a letter, or a letter of another alphabet that looks the same, is text. + * 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. + * + * WHAT THIS IS NOT: a promise that two texts that look the same are one. A mark that stacks on a letter, or a + * letter of another alphabet that looks the same, is text. */ public static function oneLine(string $text): bool { diff --git a/tests/Standing/WhatAFifthReviewFoundInTheContractTest.php b/tests/Standing/WhatAFifthReviewFoundInTheContractTest.php index 5591333..4a27540 100644 --- a/tests/Standing/WhatAFifthReviewFoundInTheContractTest.php +++ b/tests/Standing/WhatAFifthReviewFoundInTheContractTest.php @@ -653,6 +653,7 @@ public static function whatCannotBeSeen(): iterable '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, @@ -681,6 +682,41 @@ public function testATextAGrantKeepsCarriesNothingThatCannotBeSeen(int $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 From 8d4e307a159217c8f8f41c33817338d31b877736 Mon Sep 17 00:00:00 2001 From: Rod Vince Date: Mon, 12 Oct 2026 00:06:10 -0600 Subject: [PATCH 07/11] fix: a stop that cannot be read stops every grant that names the call, and a record that does not read whole lends to no seat (greenhouse decisions/0615) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit What a sixth review found in this branch: one finding of medium severity and five of low. Two of the low ones are built as proposals, not decided. A stop that can no longer be read was not walked around by one grant — and was by two. Among several grants that name a call, the one whose record does not read whole was refused alone, and the call passed under another: the line that cannot be read may be the one that stopped it. It refuses the call now whatever another grant would lend, as a suspension does. And the other half of it: a document a host kept and can no longer read is no grant it can hand in, so there was nowhere to say it. among() asks a third thing, with no default: whether everything the host keeps of standing grants reads. When it does not, nothing is lent to any seat, whatever grants were handed in. The judge no longer skips what is not a receipt. A list built by mapping the reader over the lines holds a null for a line that cannot be read; the judge went on, with a warning of PHP, on the green before it. It refuses. Nothing is trimmed from the two digests of a receipt either: a subject with a line break after it was another text, and a red receipt spelled that way said nothing of the content it measured. An argument is not fixed to the empty text, which is no character at all. Four more fillers that are blank by their own definition are refused: U+13441, U+13442, U+16FE4, U+1D159. And where the PCRE knows Unicode's property for "shown as nothing" by name, 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. PROPOSED, NOT DECIDED — each a small change that can be taken out: A text does not begin with a combining mark, and carries no more than eight in a row. Three hundred marks on one letter are drawn over the lines around it; ordinary writing stacks at most four or five. What a person signs spells a plain character plainly: a \u escape that names a printable ASCII character other than the quote and the backslash is refused. An operation written entirely in escapes was shown as escapes and read as its name, with the digest of the plain text. Two expectations this suite pinned change: the empty text was a value an argument could be fixed to; and a key was "spelled two ways" with an escape of a plain letter, which the second proposal refuses first — it is spelled with a letter a writer does escape now, and holds either way. --- README.md | 43 ++- src/Standing/Canonical.php | 85 +++++- src/Standing/ConditionReceipt.php | 18 +- src/Standing/GrantStanding.php | 12 +- src/Standing/NamedCall.php | 18 +- src/Standing/StandingGrant.php | 4 +- src/Standing/StandingJudge.php | 68 ++++- .../TheStandingJudgeAsksSevenThingsTest.php | 24 +- .../TwoProposalsOfASixthReviewTest.php | 162 ++++++++++ ...WhatAFifthReviewFoundInTheContractTest.php | 17 +- ...WhatASixthReviewFoundInTheContractTest.php | 278 ++++++++++++++++++ ...WhatAThirdReviewFoundInTheContractTest.php | 5 +- 12 files changed, 683 insertions(+), 51 deletions(-) create mode 100644 tests/Standing/TwoProposalsOfASixthReviewTest.php create mode 100644 tests/Standing/WhatASixthReviewFoundInTheContractTest.php diff --git a/README.md b/README.md index dfaeb9b..5ad798c 100644 --- a/README.md +++ b/README.md @@ -194,7 +194,7 @@ 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*, or what the host keeps of the grant does not read whole — and a host that does not say it verified the one and read the other is read as one that did neither | +| 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 | @@ -221,8 +221,16 @@ 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** +(`recordFault`): the line that cannot be read may be the one that stopped it, and a stop that cannot +be read is not "no stop". Expired, revoked and superseded grants stop nobody. 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`. 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 @@ -239,6 +247,10 @@ a grant put together by hand reads back from its own spelling. - 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"`; +- **proposed, not decided** (the sixth review, L5): it spells a plain character plainly — a `\u` + escape that names a printable ASCII character other than the quote and the backslash 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; - 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; - **nothing is taken off the ends of a text.** A reader that trims repairs: the document a person @@ -269,7 +281,7 @@ punctuation and symbols, and the ordinary space (U+0020) inside it. **What it re | 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 | +| 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 | @@ -282,8 +294,16 @@ 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 1,112,064, this reader refuses 957,522. 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. +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. + +**Proposed, not decided** (the sixth review, L2): 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. @@ -316,8 +336,8 @@ That is for what speaks **for** a condition. A receipt that says **no** counts f | 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 `~`, single spaces inside, none around — a whole number, or true or -false. A fixed value is compared byte by byte with what a caller sends, so a value that reads +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()`. @@ -349,8 +369,11 @@ as a **fault of its record** — never as a grant that is not there, or a measur 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 among them — and `null` -is that fault, not "nothing was measured". +`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 diff --git a/src/Standing/Canonical.php b/src/Standing/Canonical.php index e62e66c..f4696ee 100644 --- a/src/Standing/Canonical.php +++ b/src/Standing/Canonical.php @@ -56,10 +56,24 @@ final class Canonical * 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. It is not written here as `\p{DI}` only because a PCRE older - * than 10.40 does not know that name, and a pattern that does not compile refuses every text there is. + * 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}'; + + /** + * PROPOSED, NOT DECIDED (the sixth review, L2; greenhouse decisions/0615): 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. To take the proposal out: this constant, and where it is used. */ - 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}]|(?! )\p{Zs}/u'; + private const MARKS = '^\p{M}|\p{M}{9}'; /** * The canonical bytes of a structure. @@ -105,6 +119,19 @@ public static function saidOnce(string $json, string $where): void if ($byte === '"') { $from = $at; for (++$at; $json[$at] !== '"'; ++$at) { + // PROPOSED, NOT DECIDED (the sixth review, L5; greenhouse decisions/0615): what a person signs + // spells a plain character plainly. 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. No text a grant keeps needs such an escape, and no writer makes one. To take the + // proposal out: this `if`, and {@see self::spellsAPlainCharacter()}. + if ($json[$at] === '\\' && $json[$at + 1] === 'u' && self::spellsAPlainCharacter(substr($json, $at + 2, 4))) { + throw new \InvalidArgumentException(\sprintf( + '%s writes %s as an escape (\\u%s): what a person signs spells a plain character plainly — 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)), + )); + } $at += $json[$at] === '\\' ? 1 : 0; } $text = substr($json, $from, $at - $from + 1); @@ -136,6 +163,18 @@ public static function saidOnce(string $json, string $where): void } } + /** + * PROPOSED, NOT DECIDED ({@see self::saidOnce()}): whether four hexadecimal digits name a printable ASCII + * character a text can say as itself — every one from the space to the tilde but the quote and the backslash, + * which a JSON text can only say as an escape. + */ + private static function spellsAPlainCharacter(string $hex): bool + { + $code = (int) hexdec($hex); + + return $code >= 0x20 && $code <= 0x7E && $code !== 0x22 && $code !== 0x5C; + } + /** * 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. @@ -271,12 +310,17 @@ public static function text(array $data, string $key, string $where): string * · 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; + * 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. * + * PROPOSED, NOT DECIDED ({@see self::MARKS}): 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. @@ -287,14 +331,39 @@ public static function text(array $data, string $key, string $where): string * 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. + * `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 mark that stacks on a letter, or a - * letter of another alphabet that looks the same, is text. + * 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 preg_match(self::unseen(), $text) === 0 && !str_starts_with($text, ' ') && !str_ends_with($text, ' '); + } + + /** + * 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»». */ diff --git a/src/Standing/ConditionReceipt.php b/src/Standing/ConditionReceipt.php index 2cc9288..d24b13f 100644 --- a/src/Standing/ConditionReceipt.php +++ b/src/Standing/ConditionReceipt.php @@ -51,6 +51,14 @@ public function __construct( 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)); + } + } if (NamedCall::named($by) === null) { throw new \InvalidArgumentException('a condition receipt says which operation measured, by its name; got ' . Canonical::shown($by->canonical)); } @@ -89,6 +97,13 @@ public function toArray(): array * 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. * @@ -99,7 +114,8 @@ 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; } $by = NamedCall::named($data['by']); diff --git a/src/Standing/GrantStanding.php b/src/Standing/GrantStanding.php index c1a2fc4..dc9c99e 100644 --- a/src/Standing/GrantStanding.php +++ b/src/Standing/GrantStanding.php @@ -34,13 +34,21 @@ * 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 + * @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. * 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 diff --git a/src/Standing/NamedCall.php b/src/Standing/NamedCall.php index 0b51936..2b28506 100644 --- a/src/Standing/NamedCall.php +++ b/src/Standing/NamedCall.php @@ -39,12 +39,13 @@ * 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. A value - * that is not text — a whole number, true or false — is as it always 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 nothing and one space are - * never the same line on a screen. + * 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 { @@ -60,8 +61,11 @@ */ 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 — or nothing at all. */ - private const VALUE = '/^(?:[\x21-\x7E]+(?: [\x21-\x7E]+)*)?$/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'; @@ -100,7 +104,7 @@ public function __construct(OperationId|string $operation, array $arguments = [] } if (\is_string($value) && preg_match(self::VALUE, $value) !== 1) { throw new \InvalidArgumentException(\sprintf( - 'the argument «%s» fixed for «%s» is fixed to text a person can read and no other — visible ASCII, single spaces inside, none around: a call is compared with it byte by byte; got %s', + '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), diff --git a/src/Standing/StandingGrant.php b/src/Standing/StandingGrant.php index 3059313..2f79247 100644 --- a/src/Standing/StandingGrant.php +++ b/src/Standing/StandingGrant.php @@ -402,7 +402,9 @@ public static function fromArray(array $data): self * 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. + * 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. */ diff --git a/src/Standing/StandingJudge.php b/src/Standing/StandingJudge.php index f5bff13..a5c3118 100644 --- a/src/Standing/StandingJudge.php +++ b/src/Standing/StandingJudge.php @@ -74,6 +74,21 @@ public static function judge(StandingGrant $grant, GrantStanding $standing, Stan 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( @@ -145,7 +160,7 @@ public static function judge(StandingGrant $grant, GrantStanding $standing, Stan // 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 ($standing->receipts as $receipt) { + 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; @@ -200,19 +215,42 @@ public static function judge(StandingGrant $grant, GrantStanding $standing, Stan * 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 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». A suspension that reads speaks before it. * · 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. * - * @return array{grant: StandingGrant, verdict: StandingVerdict}|null + * @param list $grants every grant the host holds that + * may concern the call, 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|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; $passed = null; $furthest = null; $naming = 0; @@ -225,6 +263,8 @@ public static function among(array $grants, StandingCall $call): ?array $answer = ['grant' => $grant, 'verdict' => $verdict]; if ($standing->state === GrantState::Suspended) { $suspended = $answer; + } elseif ($standing->recordFault !== null) { + $unread ??= $answer; } elseif ($verdict->passes()) { $passed ??= $answer; } elseif ($furthest === null || self::reached($verdict) >= self::reached($furthest['verdict'])) { @@ -245,7 +285,21 @@ 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, + ), + )]; + } + + return $suspended ?? $unread ?? $passed ?? $furthest; } /** How far through the seven checks a refusal got. */ diff --git a/tests/Standing/TheStandingJudgeAsksSevenThingsTest.php b/tests/Standing/TheStandingJudgeAsksSevenThingsTest.php index 4611864..ba023cc 100644 --- a/tests/Standing/TheStandingJudgeAsksSevenThingsTest.php +++ b/tests/Standing/TheStandingJudgeAsksSevenThingsTest.php @@ -265,7 +265,7 @@ private static function suspended(): GrantStanding public function testAmongSeveralGrantsOneThatPassesIsEnough(): void { $unmeasured = self::held('asks-more', change: ['conditions' => [['name' => 'builds', 'statement' => 'it builds', 'by' => ['operation' => 'lab:build', '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); @@ -318,7 +318,7 @@ 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()); + $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 +328,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); @@ -342,12 +342,12 @@ public function testExpiredRevokedAndSupersededGrantsStopNobody(): void 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 +356,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 +365,7 @@ public function testWhenNoneLendsTheRefusalThatGotFurthestSpeaksWhicheverWasGive public function testLevelWithAnotherTheLaterGrantSpeaks(): void { - $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()); + $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..9d188c8 --- /dev/null +++ b/tests/Standing/TwoProposalsOfASixthReviewTest.php @@ -0,0 +1,162 @@ + + * + * @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 PROPOSALS OF THE SIXTH REVIEW — BUILT, AND NOT DECIDED (greenhouse decisions/0615). Each is a small change that + * can be taken out: this file, and the lines of `Canonical` that carry its name. + * + * · L2 — a text does not begin with a combining mark, and carries no more than eight in a row. + * · L5 — the text a person signs spells a plain ASCII character plainly, never as a `\u` escape. + * + * The two tests the reviewer 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]); + } + + // ── L2 (proposed) · 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()); + } + + // ── L5 (proposed) · 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 'the last one, the tilde' => [$u . '007E', true]; + yield 'a digit' => [$u . '0037', true]; + 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]; + } + + #[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::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'); + } + } + } +} diff --git a/tests/Standing/WhatAFifthReviewFoundInTheContractTest.php b/tests/Standing/WhatAFifthReviewFoundInTheContractTest.php index 4a27540..9756703 100644 --- a/tests/Standing/WhatAFifthReviewFoundInTheContractTest.php +++ b/tests/Standing/WhatAFifthReviewFoundInTheContractTest.php @@ -130,9 +130,12 @@ public function testAFixedValueIsVisibleAsciiWithSingleSpacesInsideAndNoneAround public function testWhatAFixedValueMayStillBe(): void { - foreach (['strict', 'very strict', 'a b c', 'v1.2.3-rc.1', 'refs/heads/main', '~!@#$%^&*()_+{}|:"<>?', ''] as $value) { + 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']); @@ -298,7 +301,17 @@ public function testNoSentenceOfTheJudgeSaysBackRawOrAtAnyLength(\Closure $judge { // 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) { - $verdict = $judged($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); diff --git a/tests/Standing/WhatASixthReviewFoundInTheContractTest.php b/tests/Standing/WhatASixthReviewFoundInTheContractTest.php new file mode 100644 index 0000000..ebffac3 --- /dev/null +++ b/tests/Standing/WhatASixthReviewFoundInTheContractTest.php @@ -0,0 +1,278 @@ + + * + * @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\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: their neighbours draw something, and are text. + foreach ([0x13440, 0x13443, 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 (a proposal), 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(new OperationId('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 index 6f02b9c..7246e39 100644 --- a/tests/Standing/WhatAThirdReviewFoundInTheContractTest.php +++ b/tests/Standing/WhatAThirdReviewFoundInTheContractTest.php @@ -51,7 +51,10 @@ public static function whatIsSaidTwice(): iterable 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"']; - yield 'a key spelled two ways' => ['{"\\u0074o":["SOMEONE-ELSE"],' . substr($whole, 1), 'to', '']; + // It was «to» with its first letter written as an escape. A plain letter written so is now refused for that + // (a proposal of the sixth review, not decided), so the two spellings are of a letter a writer does escape: + // the question — one key, spelled two ways, said twice — holds with that proposal and without it. + 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', '']; } From 4177ab8c76045e9f54051d7e819673b076fa4b5d Mon Sep 17 00:00:00 2001 From: Rod Vince Date: Mon, 12 Oct 2026 00:08:41 -0600 Subject: [PATCH 08/11] test: the control of the fillers holds on a PCRE with older Unicode tables (greenhouse decisions/0615) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The test that four fillers are refused had a control beside it: their neighbours draw something, and are text. Two of those neighbours are hieroglyphs as new as the fillers themselves (Unicode 15). The checks of this repository run on a PCRE whose tables are older: for it those two places are not assigned yet, and a text that carries one is refused — as the contract says a host with older tables does. The control uses characters of the same scripts that every table has had for many versions. Nothing in src/ changes. --- tests/Standing/WhatASixthReviewFoundInTheContractTest.php | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tests/Standing/WhatASixthReviewFoundInTheContractTest.php b/tests/Standing/WhatASixthReviewFoundInTheContractTest.php index ebffac3..df4e4d3 100644 --- a/tests/Standing/WhatASixthReviewFoundInTheContractTest.php +++ b/tests/Standing/WhatASixthReviewFoundInTheContractTest.php @@ -158,8 +158,11 @@ public function testAFillerThatPrintsAsNothingIsNotTaken(): void 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: their neighbours draw something, and are text. - foreach ([0x13440, 0x13443, 0x16FE3, 0x1D158, 0x1D15A] as $point) { + // 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)); } } From baba41e8f250827cbc6bf5ba5cb81020a48b7f70 Mon Sep 17 00:00:00 2001 From: Rod Vince Date: Mon, 12 Oct 2026 01:23:18 -0600 Subject: [PATCH 09/11] fix: a line that was not read stops every grant that names the call, and a name is given as text, never as an identity built elsewhere (greenhouse decisions/0615) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit What a seventh review found in this branch: one finding of medium severity, which is a question for whoever decides, and two of low. A line that was not read, found in the list of receipts, refused its own grant and nobody else's. The judge said of a grant that what the house keeps of it does not read whole, and among several grants the call passed under another. It stops the others now, as a fault the host says does — 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. A name is given as text. An identity built elsewhere has already had its ends taken off, and nothing can ask for them back: a named call and a receipt could be built from one where the same text was refused. They are built from the text now, and so is a call, which took any text as its operation. PROPOSED, NOT DECIDED — each a small change that can be taken out: 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 an argument, a wider grant is in force, and the seat calls without that argument: the stopped grant "does not name" the call and the wider one lends it. With the proposal a stopped grant that lends to the seat and covers the operation refuses a call another grant would lend, unless the call says another value for something that entry fixes. Not another: the argument is not there, or is null; a number and the text of its digits; a boolean and the texts PHP and JSON write it as. Capitals, a space around a value and words for yes and no are not set aside. This is older than this branch: the published version has it. The proposal about escapes is narrowed, and its sentence corrected. It said no writer makes such an escape; writers that are safe for HTML do — the apostrophe, the angle brackets, the ampersand — and their documents were refused. It refuses now only the escape of what names are made of: letters, digits, the colon, the dot, the underscore, the dash and the space. The strict alternative is written beside it for whoever decides. The proposal about combining marks is as it was. One mutant is gone: the anchor of the pattern for an operation can no longer be reached by any input, now that a name is compared with its identity before the pattern is asked. --- README.md | 34 ++- src/Standing/Canonical.php | 35 ++- src/Standing/ConditionReceipt.php | 16 +- src/Standing/NamedCall.php | 14 +- src/Standing/StandingCall.php | 8 +- src/Standing/StandingJudge.php | 88 ++++++- ...onReceiptBindsContentAndInstrumentTest.php | 3 +- .../AProposalOfASeventhReviewTest.php | 214 ++++++++++++++++++ ...StandingGrantNeverCrossesItsFloorsTest.php | 2 +- tests/Standing/Grants.php | 3 +- .../TwoProposalsOfASixthReviewTest.php | 42 +++- ...WhatAFifthReviewFoundInTheContractTest.php | 11 +- ...atASeventhReviewFoundInTheContractTest.php | 152 +++++++++++++ ...WhatASixthReviewFoundInTheContractTest.php | 3 +- 14 files changed, 577 insertions(+), 48 deletions(-) create mode 100644 tests/Standing/AProposalOfASeventhReviewTest.php create mode 100644 tests/Standing/WhatASeventhReviewFoundInTheContractTest.php diff --git a/README.md b/README.md index 5ad798c..b0e3a97 100644 --- a/README.md +++ b/README.md @@ -222,8 +222,9 @@ A house may hold several grants that name the same call. `StandingJudge::among() 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. So does a grant that names the call and **whose record does not read whole** -(`recordFault`): the line that cannot be read may be the one that stopped it, and a stop that cannot -be read is not "no stop". Expired, revoked and superseded grants stop nobody. Otherwise one grant +— 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". Expired, revoked and superseded grants stop nobody. 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 @@ -232,6 +233,19 @@ be the one that names this call, stopped; so may a line it cannot attribute to o 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`. +**Proposed, not decided** (the seventh review, M1): **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`, a wider grant +is in force, and the seat calls with no `channel` at all — the operation's default — so the stopped +grant "does not name" the call and the wider one lends it. With the proposal, a stopped grant +(suspended, or its record not read whole) that lends to the seat and covers the operation refuses a +call another grant would lend, **unless the call says another value for something that entry +fixes** (`stopped_beside`). Not "another": the argument is not there, or is `null`; and the same +text once the type is set aside — a number and the text of its digits, a boolean and the texts PHP +and JSON write it as (`true`: `"1"`, `"true"`; `false`: `"0"`, `""`, `"false"`). Everything else +says another value: capitals, a space around the value, `07` for 7, "yes" for true, a list. Where no +other grant would lend, nothing changes. + Some documents cannot be written at all. `StandingGrant` refuses, at construction: a ceiling with an unclassified axis, a ceiling above `compensatable`, a ceiling whose **authority** is above `write_as_user` (an act above it itself gives authority, and no grant lends one), covering any @@ -240,17 +254,23 @@ 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. +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. **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"`; -- **proposed, not decided** (the sixth review, L5): it spells a plain character plainly — a `\u` - escape that names a printable ASCII character other than the quote and the backslash 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; +- **proposed, not decided** (the sixth review, L5, as the seventh adjusted it): 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. 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) — none of them can spell a name. The strict alternative, left for + whoever decides: refuse the escape of every printable ASCII character but the quote and the + backslash, which costs the documents 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; - **nothing is taken off the ends of a text.** A reader that trims repairs: the document a person diff --git a/src/Standing/Canonical.php b/src/Standing/Canonical.php index f4696ee..3905214 100644 --- a/src/Standing/Canonical.php +++ b/src/Standing/Canonical.php @@ -119,14 +119,14 @@ public static function saidOnce(string $json, string $where): void if ($byte === '"') { $from = $at; for (++$at; $json[$at] !== '"'; ++$at) { - // PROPOSED, NOT DECIDED (the sixth review, L5; greenhouse decisions/0615): what a person signs - // spells a plain character plainly. 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. No text a grant keeps needs such an escape, and no writer makes one. To take the - // proposal out: this `if`, and {@see self::spellsAPlainCharacter()}. - if ($json[$at] === '\\' && $json[$at + 1] === 'u' && self::spellsAPlainCharacter(substr($json, $at + 2, 4))) { + // PROPOSED, NOT DECIDED (the sixth review, L5, as the seventh adjusted it; greenhouse + // decisions/0615): what a person signs spells plainly what NAMES are made of. 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. No text a grant keeps needs such an escape. + // To take the proposal out: this `if`, and {@see self::spellsWhatNamesAreMadeOf()}. + if ($json[$at] === '\\' && $json[$at + 1] === 'u' && self::spellsWhatNamesAreMadeOf(substr($json, $at + 2, 4))) { throw new \InvalidArgumentException(\sprintf( - '%s writes %s as an escape (\\u%s): what a person signs spells a plain character plainly — a reader would show the escape and read the character', + '%s writes %s as an escape (\\u%s): what a person signs spells plainly what a name is made of — 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)), @@ -164,15 +164,26 @@ public static function saidOnce(string $json, string $where): void } /** - * PROPOSED, NOT DECIDED ({@see self::saidOnce()}): whether four hexadecimal digits name a printable ASCII - * character a text can say as itself — every one from the space to the tilde but the quote and the backslash, - * which a JSON text can only say as an escape. + * PROPOSED, NOT DECIDED ({@see self::saidOnce()}): whether four hexadecimal digits name a character 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 an argument, an axis and an expiry 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. None of those characters can spell a name, and a sentence that carries one is + * read. + * + * THE STRICT ALTERNATIVE, for whoever decides: refuse the escape of EVERY printable ASCII character but the quote + * and the backslash — 93 of them — which a JSON text can only say as escapes. It is one line, + * `return $code >= 0x20 && $code <= 0x7E && $code !== 0x22 && $code !== 0x5C;`, and it costs the documents of + * those writers: a sentence with an apostrophe or an ampersand, written by one of them, is then not read. */ - private static function spellsAPlainCharacter(string $hex): bool + private static function spellsWhatNamesAreMadeOf(string $hex): bool { $code = (int) hexdec($hex); - return $code >= 0x20 && $code <= 0x7E && $code !== 0x22 && $code !== 0x5C; + return $code <= 0x7F && preg_match('/^[A-Za-z0-9:._ -]$/D', \chr($code)) === 1; } /** diff --git a/src/Standing/ConditionReceipt.php b/src/Standing/ConditionReceipt.php index d24b13f..f94805c 100644 --- a/src/Standing/ConditionReceipt.php +++ b/src/Standing/ConditionReceipt.php @@ -36,11 +36,15 @@ */ final readonly class ConditionReceipt { + public OperationId $by; + /** + * @param string $by the operation that measured, by its name as it reads — never an identity + * built elsewhere, which has already had its ends taken off * @param array $arguments the arguments the measuring call carried */ public function __construct( - public OperationId $by, + string $by, public array $arguments, public string $subject, public bool $ok, @@ -59,9 +63,8 @@ public function __construct( throw new \InvalidArgumentException(Canonical::notOneLine("a condition receipt gives the digest of {$which}", $digest)); } } - if (NamedCall::named($by) === null) { - throw new \InvalidArgumentException('a condition receipt says which operation measured, by its name; got ' . Canonical::shown($by->canonical)); - } + $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)); } /** @@ -118,12 +121,11 @@ public static function fromArray(array $data): ?self || !Canonical::oneLine($data['subject']) || !Canonical::oneLine($data['instrument'])) { return null; } - $by = NamedCall::named($data['by']); - if ($by === null) { + if (NamedCall::named($data['by']) === null) { return null; } $who = $data['who'] ?? null; - return new self($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/NamedCall.php b/src/Standing/NamedCall.php index 2b28506..9751e46 100644 --- a/src/Standing/NamedCall.php +++ b/src/Standing/NamedCall.php @@ -83,13 +83,13 @@ * @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(string $operation, array $arguments = [], array $free = []) { $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.', - Canonical::shown($operation instanceof OperationId ? $operation->canonical : $operation), + Canonical::shown($operation), )); } @@ -142,11 +142,15 @@ public function __construct(OperationId|string $operation, array $arguments = [] * (`: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. */ - public static function named(OperationId|string $operation): ?OperationId + public static function named(string $operation): ?OperationId { - $id = $operation instanceof OperationId ? $operation : new OperationId($operation); - if (\is_string($operation) && str_replace([':', '_'], '.', strtolower($operation)) !== $id->canonical) { + $id = new OperationId($operation); + if (str_replace([':', '_'], '.', strtolower($operation)) !== $id->canonical) { return null; } diff --git a/src/Standing/StandingCall.php b/src/Standing/StandingCall.php index 674ac81..ad82c32 100644 --- a/src/Standing/StandingCall.php +++ b/src/Standing/StandingCall.php @@ -29,6 +29,9 @@ public OperationId $operation; /** + * @param string $operation the operation that is called, by its name 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 * @param array $arguments the arguments of the call, as the handler will receive them * @param string $seat who calls, by the key the grant would name it by * @param string|null $subject the digest of the content this call acts on, or `null` when the operation cannot say @@ -36,13 +39,14 @@ * @param \DateTimeImmutable $at the moment of the act */ public function __construct( - OperationId|string $operation, + string $operation, public array $arguments, public string $seat, public ?string $subject, public EffectProfile $effect, public \DateTimeImmutable $at, ) { - $this->operation = $operation instanceof OperationId ? $operation : new OperationId($operation); + $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/StandingJudge.php b/src/Standing/StandingJudge.php index a5c3118..af296be 100644 --- a/src/Standing/StandingJudge.php +++ b/src/Standing/StandingJudge.php @@ -251,19 +251,33 @@ public static function among(array $grants, StandingCall $call, ?string $recordF } $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()) { + // PROPOSED, NOT DECIDED (the seventh review, M1; greenhouse decisions/0615): 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", a wider one is in force, and the seat calls with no `channel` at all — the operation's + // default — so the stopped grant «does not name» the call and the wider one lends it. To take the + // proposal out: this `if`, the one before the last `return`, and the two functions it calls. + if ($beside === null && self::mayBeNaming($grant, $call) && ($standing->state === GrantState::Suspended || $standing->recordFault !== null || self::holdsALineNotRead($standing))) { + $beside = ['grant' => $grant, 'how' => $standing->state === GrantState::Suspended ? 'is suspended' : 'has a record that does not read whole']; + } + continue; } ++$naming; $answer = ['grant' => $grant, 'verdict' => $verdict]; if ($standing->state === GrantState::Suspended) { $suspended = $answer; - } elseif ($standing->recordFault !== null) { + } elseif ($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. $unread ??= $answer; } elseif ($verdict->passes()) { $passed ??= $answer; @@ -299,9 +313,81 @@ public static function among(array $grants, StandingCall $call, ?string $recordF )]; } + // PROPOSED, NOT DECIDED (the seventh review, M1): the call ANOTHER grant would lend, while a stopped grant + // may be the one that names it. Where no other grant would lend, the answer is what it was. + if ($beside !== null && $passed !== null) { + return ['grant' => $beside['grant'], 'verdict' => StandingVerdict::refuse(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 another value for what it fixes. Under doubt a stop stops: another grant lends nothing to this call until a person lifts that stop or gives a grant that supersedes «%1$s» by name — or the call says the value it means.', + $beside['grant']->id, + $beside['how'], + Canonical::short($call->operation->forCli()), + ))]; + } + 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; + } + + /** + * PROPOSED, NOT DECIDED ({@see self::among()}): whether a grant MAY be the one that names this call, spelled + * another way — it lends to the seat, it covers the operation, and the call does not SAY another value for + * anything that entry fixes. + */ + private 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; + } + + /** + * PROPOSED, NOT DECIDED ({@see self::mayBeNaming()}): whether what a call carries for an argument is certainly + * ANOTHER value than the one a grant fixes — by the least that ordinary PHP reads as one value, and no more. + * + * Not another: the argument is not there or is `null` (the operation's default may be the fixed value); and the + * same text once the type is set aside — a number and the text of its digits, a boolean and the texts PHP and + * JSON write it as (`true`: "1", "true"; `false`: "0", "", "false"). Everything else says another value: + * capitals, a space around the value, `07` for 7, «yes» for true, a list. An operation that reads those as one + * is not covered by this. + */ + private static function saysAnother(mixed $carried, string|int|bool $fixed): bool + { + if ($carried === null) { + return false; + } + if (!\is_scalar($carried)) { + return true; + } + $texts = static fn (string|int|float|bool $value): array => \is_bool($value) ? ($value ? ['1', 'true'] : ['0', '', 'false']) : [(string) $value]; + + return array_intersect($texts($carried), $texts($fixed)) === []; + } + /** How far through the seven checks a refusal got. */ private static function reached(StandingVerdict $verdict): int { 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..0bb933a --- /dev/null +++ b/tests/Standing/AProposalOfASeventhReviewTest.php @@ -0,0 +1,214 @@ + + * + * @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\StandingGrant; +use Milpa\Command\Standing\StandingJudge; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\TestCase; + +/** + * A PROPOSAL OF THE SEVENTH REVIEW — BUILT, AND NOT DECIDED (greenhouse decisions/0615): UNDER DOUBT, A STOP STOPS. + * + * A stopped grant — suspended, or its record not read whole — that lends to the seat and covers the operation refuses + * the call that another grant would lend, unless the call SAYS another value for something that entry fixes. It is a + * small change that can be taken out: this file, and the lines of `StandingJudge` that carry its name. + * + * The first test is the reviewer's own, moved here as it was 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(): StandingGrant + { + return Grants::grant(['id' => 'wide', '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: THE LEAST that + * ordinary PHP reads as one value — the argument not there, null, and a text standing for a number or a boolean. + * + * @return iterable}> what the stopped grant fixes for «it», and what the call carries + */ + public static function spellingsThatMayBeTheSameAct(): iterable + { + yield 'the argument is not there: the default of the operation' => ['stable', []]; + yield 'the argument is null' => ['stable', ['it' => null]]; + yield 'a number, sent as the text a terminal sends' => [7, ['it' => '7']]; + yield 'a text that is a number, sent as the number' => ['7', ['it' => 7]]; + yield 'a number, sent as a number with no fraction' => [7, ['it' => 7.0]]; + 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 '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 empty text' => [false, ['it' => '']]; + yield 'the word true, sent as the answer' => ['true', ['it' => true]]; + } + + /** @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'); + self::assertSame(['narrow', StandingCheck::InForce, '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 another value for what it fixes', $answer['verdict']->sentence); + } + } + } + + /** + * What SAYS another value: the stop is not this call's, and the wide grant lends. The last four are what the + * rule does NOT set aside, by decision of how little it reaches — capitals, a space around a value, and words + * for yes and no that are not PHP's own. + * + * @return iterable}> + */ + public static function spellingsThatSayAnotherValue(): iterable + { + yield 'another value, said' => ['stable', ['it' => 'beta']]; + yield 'another number' => [7, ['it' => 8]]; + yield 'another number, as text' => [7, ['it' => '8']]; + yield 'the opposite answer' => [true, ['it' => false]]; + yield 'the opposite answer, as zero' => [true, ['it' => 0]]; + yield 'the opposite answer, as the word' => [false, ['it' => 'true']]; + yield 'a list' => ['stable', ['it' => ['stable']]]; + yield 'NOT SET ASIDE: the value in capitals' => ['stable', ['it' => 'Stable']]; + yield 'NOT SET ASIDE: a space after the value' => ['stable', ['it' => 'stable ']]; + yield 'NOT SET ASIDE: a number written with a zero before it' => [7, ['it' => '07']]; + yield 'NOT SET ASIDE: yes, for true' => [true, ['it' => 'yes']]; + } + + /** @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]]))); + } + + public function testItChangesNothingWhereNoOtherGrantWouldLend(): void + { + // The stop is for the call ANOTHER grant would lend. With no other grant, or one that names the call and + // refuses it for its own reason, the answer is what it was: nobody's call, or that refusal. + $call = Grants::call(arguments: ['path' => 'candidate.tar']); + $stopped = ['grant' => self::narrow(['channel' => 'stable']), 'standing' => self::suspended()]; + + self::assertNull(StandingJudge::among([$stopped], $call, null), 'nobody names this call: it is judged as before any grant existed'); + self::assertNull(StandingJudge::among([$stopped, ['grant' => Grants::grant(['id' => 'also-narrow', 'covers' => [['operation' => 'lab:publish', 'arguments' => ['channel' => 'stable']]]]), 'standing' => self::standing()]], $call, null)); + $unmeasured = StandingJudge::among([$stopped, ['grant' => self::wide(), 'standing' => self::standing(['receipts' => []])]], $call, null); + self::assertSame(['wide', 'not_measured'], [$unmeasured['grant']->id ?? null, $unmeasured['verdict']->reason ?? 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); + } +} diff --git a/tests/Standing/AStandingGrantNeverCrossesItsFloorsTest.php b/tests/Standing/AStandingGrantNeverCrossesItsFloorsTest.php index 00ce2ce..9969eb0 100644 --- a/tests/Standing/AStandingGrantNeverCrossesItsFloorsTest.php +++ b/tests/Standing/AStandingGrantNeverCrossesItsFloorsTest.php @@ -155,7 +155,7 @@ public function testTheValuesRefuseTheSameThingsWhenBuiltByHand(): void 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('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(new \Milpa\Command\Consent\OperationId('lab scan')))); + 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 d161b1d..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; @@ -90,7 +89,7 @@ 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')); } /** diff --git a/tests/Standing/TwoProposalsOfASixthReviewTest.php b/tests/Standing/TwoProposalsOfASixthReviewTest.php index 9d188c8..5d807a5 100644 --- a/tests/Standing/TwoProposalsOfASixthReviewTest.php +++ b/tests/Standing/TwoProposalsOfASixthReviewTest.php @@ -27,7 +27,9 @@ * can be taken out: this file, and the lines of `Canonical` that carry its name. * * · L2 — a text does not begin with a combining mark, and carries no more than eight in a row. - * · L5 — the text a person signs spells a plain ASCII character plainly, never as a `\u` escape. + * · L5, as the seventh review 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. What a + * writer that is «safe for HTML» escapes — the apostrophe, `<`, `>`, `&` — is read. * * The two tests the reviewer wrote are here as they were written; every text is built from code points. * @@ -128,8 +130,30 @@ public static function escapesInATextAPersonSigns(): iterable 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 'the last one, the tilde' => [$u . '007E', 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]; @@ -139,6 +163,20 @@ public static function escapesInATextAPersonSigns(): iterable yield 'a backslash said twice and then the letters u0070' => [\chr(0x5C) . \chr(0x5C) . 'u0070', false]; } + public function testWhatAWriterThatIsSafeForHtmlWritesIsRead(): void + { + // The sentence this proposal carried 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 { diff --git a/tests/Standing/WhatAFifthReviewFoundInTheContractTest.php b/tests/Standing/WhatAFifthReviewFoundInTheContractTest.php index 9756703..683b2cb 100644 --- a/tests/Standing/WhatAFifthReviewFoundInTheContractTest.php +++ b/tests/Standing/WhatAFifthReviewFoundInTheContractTest.php @@ -14,7 +14,6 @@ namespace Milpa\Command\Tests\Standing; -use Milpa\Command\Consent\OperationId; use Milpa\Command\Effect\Authority; use Milpa\Command\Effect\ContextFacts; use Milpa\Command\Effect\DeclaredAuthorityPolicy; @@ -330,7 +329,7 @@ public function testTheNameOfAnOperationIsSaidBackAtMostALineOfItToo(): void ]); $asked = Grants::call(); $call = static fn (?string $subject): StandingCall => new StandingCall($long, $asked->arguments, $asked->seat, $subject, $asked->effect, $asked->at); - $red = new ConditionReceipt(new OperationId($long), ['path' => 'candidate.tar'], Grants::CANDIDATE, false, Grants::INSTRUMENT, null, Grants::at('2026-10-11T11:50:00Z')); + $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)), @@ -523,7 +522,7 @@ public function testWhatIsJudgedAndWhatJudgesHaveOneDigestWhateverOrderTheyWereW public function testAGrantBuiltByHandReadsBackFromItsOwnSpelling(): void { - $by = new NamedCall(new OperationId('lab:scan')); + $by = new NamedCall('lab:scan'); try { $grant = new StandingGrant( 'g', @@ -607,10 +606,12 @@ public function testASentenceOfTheHostIsCutAtItsLengthAndNotBeforeIt(): void public function testAnOperationHandedInAsAnIdentityEndsWhereItEndsToo(): void { - // An identity built elsewhere has already had its ends taken off — and what is left may still end in a line break. + // An identity built elsewhere has already had its ends taken off — and what is left may still end in a line + // break. This test handed one in and was refused for the line break; an identity is not taken at all now + // (the seventh review): the name is given as text, and that text is refused. $this->expectException(\InvalidArgumentException::class); $this->expectExceptionMessage('does not name one operation'); - new NamedCall(new OperationId("lab:scan\n.")); + new NamedCall("lab:scan\n."); } // ── D5, complete · a NO of that instrument on that content stops the act, from whatever run ───────────── diff --git a/tests/Standing/WhatASeventhReviewFoundInTheContractTest.php b/tests/Standing/WhatASeventhReviewFoundInTheContractTest.php new file mode 100644 index 0000000..40a0646 --- /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 is a question for whoever decides, built as a proposal in a file of its own. + * + * @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 index df4e4d3..96e4170 100644 --- a/tests/Standing/WhatASixthReviewFoundInTheContractTest.php +++ b/tests/Standing/WhatASixthReviewFoundInTheContractTest.php @@ -14,7 +14,6 @@ namespace Milpa\Command\Tests\Standing; -use Milpa\Command\Consent\OperationId; use Milpa\Command\Standing\Canonical; use Milpa\Command\Standing\ConditionReceipt; use Milpa\Command\Standing\GrantStanding; @@ -263,7 +262,7 @@ public function testTheDigestsOfAReceiptAreGivenAsTheyRead(string $digest): void foreach (['subject', 'instrument'] as $which) { self::assertNull(ConditionReceipt::fromArray([$which => $digest] + $line), "a receipt: «{$which}» given as " . json_encode($digest)); try { - new ConditionReceipt(new OperationId('lab.scan'), [], $which === 'subject' ? $digest : Grants::CANDIDATE, false, $which === 'instrument' ? $digest : Grants::INSTRUMENT, null, Grants::at('2026-10-11T11:55:00Z')); + 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())); From d5a62fa4a1d9b30a7dcc5d137cb44977335aace5 Mon Sep 17 00:00:00 2001 From: Rod Vince Date: Mon, 12 Oct 2026 02:49:41 -0600 Subject: [PATCH 10/11] =?UTF-8?q?fix:=20a=20refusal=20names=20the=20way=20?= =?UTF-8?q?out=20of=20its=20own=20kind=20of=20stop,=20a=20name=20is=20text?= =?UTF-8?q?=20for=20every=20caller,=20and=20a=20text=20is=20read=20only=20?= =?UTF-8?q?if=20its=20own=20spelling=20is=20=E2=80=94=20and=20the=20reach?= =?UTF-8?q?=20of=20a=20stop=20is=20proposed=20from=20the=20closed=20side?= =?UTF-8?q?=20(greenhouse=20decisions/0615)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit What an eighth review found in this branch: one finding of medium severity, which is the same question for whoever decides, and four of low. FIXED A stop beside a call named a way out that did not lead out. When the stop was a record that does not read, the refusal asked for a grant that supersedes the stopped one by name; a person gave it, and the same refusal came back. The way out is said by the kind of stop now: a record is out of the way when it reads again. The same held one line above, for a grant that names the call, suspended, whose record does not read either: it is told the same. And the README says that expired, revoked and superseded grants stop nobody when their record reads. A name is given as text for every caller. `strict_types` belongs to the caller's file: from PHP's default mode an OperationId, being Stringable, turned itself into text before the constructor looked. A named call, a receipt, a call and `named()` take anything and throw a TypeError for what is not a text. A text is read only if its own canonical spelling is. That spelling says `supersedes`, `lifts` and `expires_at` also when the text left them out, so a text 48 bytes under the limit was read and its spelling refused. PROPOSED, NOT DECIDED — each a change that can be taken out Under doubt, a stop stops — rebuilt. It listed what does not say another value, and the review found 146 of 355 spellings still walking around a stop: listing what PHP reads as one value never ends. The rule is said from the closed side now. A stopped grant that lends to the seat and covers the operation refuses the call, 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. Of the same 355, 23 stay open, each an operation's own reading of a value: capitals, a text PHP compares as the same number, and a zero that `?:` reads as nothing; 75 are refused that no reading takes for the fixed value. Its second half: the stop beside a call is answered whether or not another grant would lend it, where the answer used to be `null` and a host let the seat's own word decide; and `mayBeNaming()` is public, for a host to ask of a grant in force before that word decides. For lending nothing changes. `StandingCall` says what a host owes: the arguments as the operation reads them. Plain letters as escapes — adjusted. In a sentence it is as it was. Where a text is not a sentence — a seat, the expiry, the name and the value of an argument — the escape of every printable ASCII character is refused: a value made of what a sentence is forgiven was written whole in escapes. It costs a writer that escapes the plus an expiry with a positive offset; it writes the same instant with Z. Stacked marks: as it was. --- README.md | 84 ++++-- src/Standing/Canonical.php | 62 ++-- src/Standing/ConditionReceipt.php | 7 +- src/Standing/NamedCall.php | 27 +- src/Standing/StandingCall.php | 16 +- src/Standing/StandingGrant.php | 12 +- src/Standing/StandingJudge.php | 112 +++++--- .../AProposalOfASeventhReviewTest.php | 269 +++++++++++++++--- .../TheStandingJudgeAsksSevenThingsTest.php | 4 +- .../TwoProposalsOfASixthReviewTest.php | 136 ++++++++- ...atAnEighthReviewFoundInTheContractTest.php | 265 +++++++++++++++++ 11 files changed, 864 insertions(+), 130 deletions(-) create mode 100644 tests/Standing/WhatAnEighthReviewFoundInTheContractTest.php diff --git a/README.md b/README.md index b0e3a97..5e23f43 100644 --- a/README.md +++ b/README.md @@ -224,8 +224,9 @@ whatever another grant would lend — until a person lifts that suspension or gi 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". Expired, revoked and superseded grants stop nobody. Otherwise one grant -that passes is enough, and when none does, the refusal that got furthest speaks. +"no stop" — whatever state that grant is in. Expired, revoked and superseded grants whose record +reads stop nobody. 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 @@ -233,18 +234,42 @@ be the one that names this call, stopped; so may a line it cannot attribute to o 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`. -**Proposed, not decided** (the seventh review, M1): **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`, a wider grant -is in force, and the seat calls with no `channel` at all — the operation's default — so the stopped -grant "does not name" the call and the wider one lends it. With the proposal, a stopped grant -(suspended, or its record not read whole) that lends to the seat and covers the operation refuses a -call another grant would lend, **unless the call says another value for something that entry -fixes** (`stopped_beside`). Not "another": the argument is not there, or is `null`; and the same -text once the type is set aside — a number and the text of its digits, a boolean and the texts PHP -and JSON write it as (`true`: `"1"`, `"true"`; `false`: `"0"`, `""`, `"false"`). Everything else -says another value: capitals, a space around the value, `07` for 7, "yes" for true, a list. Where no -other grant would lend, nothing changes. +**Proposed, not decided** (the seventh review, M1, 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. 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. + +With the proposal, 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`). +- **What it costs**: while a grant is stopped, a call over that operation by that seat is refused + unless it says each fixed argument in the grant's own kind and spelling — also where the + operation's default, or its reading of `"7"`, is certainly another value than the fixed one. 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 @@ -256,23 +281,34 @@ does not read** — a line the judge would ignore is a limit nobody keeps. 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. +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"`; -- **proposed, not decided** (the sixth review, L5, as the seventh adjusted it): 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. 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) — none of them can spell a name. The strict alternative, left for - whoever decides: refuse the escape of every printable ASCII character but the quote and the - backslash, which costs the documents of those writers; +- **proposed, not decided** (the sixth review, L5, as the seventh and the eighth adjusted it): 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, left for whoever decides: that second rule everywhere, + which 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; + 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 diff --git a/src/Standing/Canonical.php b/src/Standing/Canonical.php index 3905214..7a0078c 100644 --- a/src/Standing/Canonical.php +++ b/src/Standing/Canonical.php @@ -114,22 +114,28 @@ public static function saidOnce(string $json, string $where): void // 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; + // PROPOSED, NOT DECIDED (the sixth review, L5, as the seventh and the eighth adjusted it; greenhouse + // decisions/0615): 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. To take the proposal out: `$valueOf`, this line, the `if` below, and + // {@see self::spellsWhatIsWrittenPlainly()}. + $exact = \in_array($open[\count($open) - 1]['name'] ?? null, self::EXACT, true) || $valueOf === self::EXACTLY; + $valueOf = null; for (++$at; $json[$at] !== '"'; ++$at) { - // PROPOSED, NOT DECIDED (the sixth review, L5, as the seventh adjusted it; greenhouse - // decisions/0615): what a person signs spells plainly what NAMES are made of. 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. No text a grant keeps needs such an escape. - // To take the proposal out: this `if`, and {@see self::spellsWhatNamesAreMadeOf()}. - if ($json[$at] === '\\' && $json[$at + 1] === 'u' && self::spellsWhatNamesAreMadeOf(substr($json, $at + 2, 4))) { + 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 what a name is made of — a reader would show the escape and read the character', + '%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; @@ -138,6 +144,7 @@ public static function saidOnce(string $json, string $where): void } 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( @@ -164,24 +171,43 @@ public static function saidOnce(string $json, string $where): void } /** - * PROPOSED, NOT DECIDED ({@see self::saidOnce()}): whether four hexadecimal digits name a character NAMES ARE MADE - * OF — a letter, a digit, the colon, the dot, the underscore, the dash, or the space. + * PROPOSED, NOT DECIDED ({@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']; + + /** PROPOSED, NOT DECIDED ({@see self::saidOnce()}): the one key whose own text is not a sentence either. */ + private const EXACTLY = 'expires_at'; + + /** + * PROPOSED, NOT DECIDED ({@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. * - * Those are what an id, an operation, the name of an argument, an axis and an expiry 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. None of those characters can spell a name, and 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, for whoever decides: refuse the escape of EVERY printable ASCII character but the quote - * and the backslash — 93 of them — which a JSON text can only say as escapes. It is one line, + * THE STRICT ALTERNATIVE, for whoever decides: the second rule everywhere — refuse the escape of every printable + * ASCII character but the quote and the backslash, 93 of them, in a sentence too. It is one line, * `return $code >= 0x20 && $code <= 0x7E && $code !== 0x22 && $code !== 0x5C;`, and it costs the documents of * those writers: a sentence with an apostrophe or an ampersand, written by one of them, is then not read. */ - private static function spellsWhatNamesAreMadeOf(string $hex): bool + 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; } diff --git a/src/Standing/ConditionReceipt.php b/src/Standing/ConditionReceipt.php index f94805c..dbe42c6 100644 --- a/src/Standing/ConditionReceipt.php +++ b/src/Standing/ConditionReceipt.php @@ -39,12 +39,13 @@ public OperationId $by; /** - * @param string $by the operation that measured, by its name as it reads — never an identity - * built elsewhere, which has already had its ends taken off + * @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 */ public function __construct( - string $by, + mixed $by, public array $arguments, public string $subject, public bool $ok, diff --git a/src/Standing/NamedCall.php b/src/Standing/NamedCall.php index 9751e46..1fad579 100644 --- a/src/Standing/NamedCall.php +++ b/src/Standing/NamedCall.php @@ -79,11 +79,13 @@ 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(string $operation, array $arguments = [], array $free = []) + public function __construct(mixed $operation, array $arguments = [], array $free = []) { $id = self::named($operation); if ($id === null) { @@ -146,9 +148,21 @@ public function __construct(string $operation, array $arguments = [], array $fre * 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(string $operation): ?OperationId + 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; @@ -157,6 +171,15 @@ public static function named(string $operation): ?OperationId 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 ad82c32..eabc301 100644 --- a/src/Standing/StandingCall.php +++ b/src/Standing/StandingCall.php @@ -29,17 +29,23 @@ public OperationId $operation; /** - * @param string $operation the operation that is called, by its name 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 - * @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( - string $operation, + mixed $operation, public array $arguments, public string $seat, public ?string $subject, diff --git a/src/Standing/StandingGrant.php b/src/Standing/StandingGrant.php index 2f79247..fcbf602 100644 --- a/src/Standing/StandingGrant.php +++ b/src/Standing/StandingGrant.php @@ -406,7 +406,10 @@ public static function fromArray(array $data): self * 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. + * 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 { @@ -421,7 +424,12 @@ public static function fromJson(string $json): self // object — read into an array, `{"0": …, "1": …}` is one. Canonical::saidOnce($json, 'a standing grant'); - return self::fromArray($data); + $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; } /** diff --git a/src/Standing/StandingJudge.php b/src/Standing/StandingJudge.php index af296be..d7b5f2b 100644 --- a/src/Standing/StandingJudge.php +++ b/src/Standing/StandingJudge.php @@ -214,10 +214,14 @@ public static function judge(StandingGrant $grant, GrantStanding $standing, Stan * 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. + * superseded grants WHOSE RECORD READS are not suspended: they lend nothing and stop nobody. * · 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». A suspension that reads speaks before it. + * 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. + * · PROPOSED, NOT DECIDED: 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. * · 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. @@ -258,27 +262,31 @@ public static function among(array $grants, StandingCall $call, ?string $recordF foreach ($grants as ['grant' => $grant, 'standing' => $standing]) { $verdict = self::judge($grant, $standing, $call); if (!$verdict->named()) { - // PROPOSED, NOT DECIDED (the seventh review, M1; greenhouse decisions/0615): 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", a wider one is in force, and the seat calls with no `channel` at all — the operation's - // default — so the stopped grant «does not name» the call and the wider one lends it. To take the - // proposal out: this `if`, the one before the last `return`, and the two functions it calls. - if ($beside === null && self::mayBeNaming($grant, $call) && ($standing->state === GrantState::Suspended || $standing->recordFault !== null || self::holdsALineNotRead($standing))) { - $beside = ['grant' => $grant, 'how' => $standing->state === GrantState::Suspended ? 'is suspended' : 'has a record that does not read whole']; + // PROPOSED, NOT DECIDED (the seventh review, M1, as the eighth rebuilt it; greenhouse decisions/0615): + // 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. To take the proposal out: this `if`, the one before the + // last `return`, and the two functions it calls. + $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) { - $suspended = $answer; - } elseif ($standing->recordFault !== null || self::holdsALineNotRead($standing)) { + 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. + // 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; } elseif ($furthest === null || self::reached($verdict) >= self::reached($furthest['verdict'])) { @@ -313,14 +321,24 @@ public static function among(array $grants, StandingCall $call, ?string $recordF )]; } - // PROPOSED, NOT DECIDED (the seventh review, M1): the call ANOTHER grant would lend, while a stopped grant - // may be the one that names it. Where no other grant would lend, the answer is what it was. - if ($beside !== null && $passed !== null) { + // PROPOSED, NOT DECIDED (the seventh review, M1, as the eighth rebuilt it): 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) { return ['grant' => $beside['grant'], 'verdict' => StandingVerdict::refuse(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 another value for what it fixes. Under doubt a stop stops: another grant lends nothing to this call until a person lifts that stop or gives a grant that supersedes «%1$s» by name — or the call says the value it means.', + '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, - $beside['how'], + 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), ))]; } @@ -340,11 +358,28 @@ private static function holdsALineNotRead(GrantStanding $standing): bool } /** - * PROPOSED, NOT DECIDED ({@see self::among()}): whether a grant MAY be the one that names this call, spelled - * another way — it lends to the seat, it covers the operation, and the call does not SAY another value for - * anything that entry fixes. + * PROPOSED, NOT DECIDED (greenhouse decisions/0615): 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}). */ - private static function mayBeNaming(StandingGrant $grant, StandingCall $call): bool + public static function mayBeNaming(StandingGrant $grant, StandingCall $call): bool { if (!$grant->lendsTo($call->seat)) { return false; @@ -366,26 +401,25 @@ private static function mayBeNaming(StandingGrant $grant, StandingCall $call): b } /** - * PROPOSED, NOT DECIDED ({@see self::mayBeNaming()}): whether what a call carries for an argument is certainly - * ANOTHER value than the one a grant fixes — by the least that ordinary PHP reads as one value, and no more. + * PROPOSED, NOT DECIDED ({@see self::mayBeNaming()}): whether what a call carries for an argument is, BEYOND + * DOUBT, another value than the one a grant fixes. * - * Not another: the argument is not there or is `null` (the operation's default may be the fixed value); and the - * same text once the type is set aside — a number and the text of its digits, a boolean and the texts PHP and - * JSON write it as (`true`: "1", "true"; `false`: "0", "", "false"). Everything else says another value: - * capitals, a space around the value, `07` for 7, «yes» for true, a list. An operation that reads those as one - * is not covered by this. + * 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 { - if ($carried === null) { - return false; - } - if (!\is_scalar($carried)) { - return true; - } - $texts = static fn (string|int|float|bool $value): array => \is_bool($value) ? ($value ? ['1', 'true'] : ['0', '', 'false']) : [(string) $value]; - - return array_intersect($texts($carried), $texts($fixed)) === []; + 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/tests/Standing/AProposalOfASeventhReviewTest.php b/tests/Standing/AProposalOfASeventhReviewTest.php index 0bb933a..0c16a31 100644 --- a/tests/Standing/AProposalOfASeventhReviewTest.php +++ b/tests/Standing/AProposalOfASeventhReviewTest.php @@ -17,19 +17,24 @@ 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; /** - * A PROPOSAL OF THE SEVENTH REVIEW — BUILT, AND NOT DECIDED (greenhouse decisions/0615): UNDER DOUBT, A STOP STOPS. + * A PROPOSAL OF THE SEVENTH REVIEW, REBUILT AS THE EIGHTH ASKED — BUILT, AND NOT DECIDED (greenhouse + * decisions/0615): UNDER DOUBT, A STOP STOPS. * * A stopped grant — suspended, or its record not read whole — that lends to the seat and covers the operation refuses - * the call that another grant would lend, unless the call SAYS another value for something that entry fixes. It is a - * small change that can be taken out: this file, and the lines of `StandingJudge` that carry its name. + * 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. It is a small change that can be taken out: this file, and the lines of `StandingJudge` + * that carry its name. * - * The first test is the reviewer's own, moved here as it was written. + * 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 */ @@ -52,9 +57,9 @@ private static function narrow(array $fixes): StandingGrant return Grants::grant(['id' => 'narrow', 'covers' => [['operation' => 'lab:publish', 'arguments' => $fixes]]]); } - private static function wide(): StandingGrant + private static function wide(string $id = 'wide'): StandingGrant { - return Grants::grant(['id' => 'wide', 'covers' => [['operation' => 'lab:publish']]]); + return Grants::grant(['id' => $id, 'covers' => [['operation' => 'lab:publish']]]); } /** @@ -95,26 +100,93 @@ public function testAStopIsNotWalkedAroundBySpellingTheCallAnotherWay(): void } /** - * What a call may carry and still be, for all the judge can tell, the act a stopped grant fixes: THE LEAST that - * ordinary PHP reads as one value — the argument not there, null, and a text standing for a number or a boolean. + * 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 text that is a number, sent as the number' => ['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 empty text' => [false, ['it' => '']]; - yield 'the word true, sent as the answer' => ['true', ['it' => true]]; + 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 */ @@ -134,31 +206,31 @@ public function testUnderDoubtAStopStops(string|int|bool $fixed, array $carried) self::assertFalse($answer['verdict']->passes() ?? true, 'the stopped grant may be the one that names this call, and the wide one lent it'); self::assertSame(['narrow', StandingCheck::InForce, '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 another value for what it fixes', $answer['verdict']->sentence); + self::assertStringContainsString('does not say, beyond doubt, another value for what it fixes', $answer['verdict']->sentence); } } } /** - * What SAYS another value: the stop is not this call's, and the wide grant lends. The last four are what the - * rule does NOT set aside, by decision of how little it reaches — capitals, a space around a value, and words - * for yes and no that are not PHP's own. + * 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 value, said' => ['stable', ['it' => 'beta']]; + 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 'another number, as text' => [7, ['it' => '8']]; + yield 'zero, where another number is fixed' => [7, ['it' => 0]]; yield 'the opposite answer' => [true, ['it' => false]]; - yield 'the opposite answer, as zero' => [true, ['it' => 0]]; - yield 'the opposite answer, as the word' => [false, ['it' => 'true']]; - yield 'a list' => ['stable', ['it' => ['stable']]]; - yield 'NOT SET ASIDE: the value in capitals' => ['stable', ['it' => 'Stable']]; - yield 'NOT SET ASIDE: a space after the value' => ['stable', ['it' => 'stable ']]; - yield 'NOT SET ASIDE: a number written with a zero before it' => [7, ['it' => '07']]; - yield 'NOT SET ASIDE: yes, for true' => [true, ['it' => 'yes']]; + 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 */ @@ -196,19 +268,152 @@ public function testAStopStopsOnlyWhatItMayBeNaming(): void self::assertFalse($lends(self::narrow(['channel' => 'stable']), self::standing(['receipts' => [null]]))); } - public function testItChangesNothingWhereNoOtherGrantWouldLend(): void + /** 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 proposal 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 { - // The stop is for the call ANOTHER grant would lend. With no other grant, or one that names the call and - // refuses it for its own reason, the answer is what it was: nobody's call, or that refusal. $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); - self::assertNull(StandingJudge::among([$stopped], $call, null), 'nobody names this call: it is judged as before any grant existed'); - self::assertNull(StandingJudge::among([$stopped, ['grant' => Grants::grant(['id' => 'also-narrow', 'covers' => [['operation' => 'lab:publish', 'arguments' => ['channel' => 'stable']]]]), 'standing' => self::standing()]], $call, null)); - $unmeasured = StandingJudge::among([$stopped, ['grant' => self::wide(), 'standing' => self::standing(['receipts' => []])]], $call, null); - self::assertSame(['wide', 'not_measured'], [$unmeasured['grant']->id ?? null, $unmeasured['verdict']->reason ?? null]); - // A stop that names the call as it is spelled still speaks first, as it always did. + // 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/TheStandingJudgeAsksSevenThingsTest.php b/tests/Standing/TheStandingJudgeAsksSevenThingsTest.php index ba023cc..6f9df71 100644 --- a/tests/Standing/TheStandingJudgeAsksSevenThingsTest.php +++ b/tests/Standing/TheStandingJudgeAsksSevenThingsTest.php @@ -317,7 +317,9 @@ 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'); + // 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); diff --git a/tests/Standing/TwoProposalsOfASixthReviewTest.php b/tests/Standing/TwoProposalsOfASixthReviewTest.php index 5d807a5..1ade9ec 100644 --- a/tests/Standing/TwoProposalsOfASixthReviewTest.php +++ b/tests/Standing/TwoProposalsOfASixthReviewTest.php @@ -27,11 +27,13 @@ * can be taken out: this file, and the lines of `Canonical` that carry its name. * * · L2 — a text does not begin with a combining mark, and carries no more than eight in a row. - * · L5, as the seventh review 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. What a - * writer that is «safe for HTML» escapes — the apostrophe, `<`, `>`, `&` — is read. + * · L5, 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 two tests the reviewer wrote are here as they were written; every text is built from code points. + * The tests the reviewers wrote are here as they were written; every text is built from code points. * * @subject-in milpa/command */ @@ -191,10 +193,136 @@ public function testOnlyAnEscapeThatSpellsAPlainCharacterIsRefused(string $escap } 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/WhatAnEighthReviewFoundInTheContractTest.php b/tests/Standing/WhatAnEighthReviewFoundInTheContractTest.php new file mode 100644 index 0000000..da4163b --- /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 is a proposal and has a file of its own + * ({@see AProposalOfASeventhReviewTest}), and so has what it found in the proposal about escapes + * ({@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]); + } + + /** «Expired, 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('Expired, 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); + } +} From eb5f513f45cc9b2974f45c7268794247a314d64d Mon Sep 17 00:00:00 2001 From: Rod Vince Date: Mon, 12 Oct 2026 04:03:31 -0600 Subject: [PATCH 11/11] =?UTF-8?q?fix:=20what=20a=20run=20answered=20is=20a?= =?UTF-8?q?sked=20for=20its=20type=20here,=20the=20words=20say=20which=20d?= =?UTF-8?q?oor=20answers=20a=20stop=20beside=20a=20call=20=E2=80=94=20and?= =?UTF-8?q?=20what=20were=20three=20proposals=20are=20decided=20(greenhous?= =?UTF-8?q?e=20decisions/0615)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit What a ninth review found in this branch. It passed: no finding of high or medium severity, three of low. What a run answered is asked for its type by the contract, as a name is. `ok` was typed `bool` in the signature, and `strict_types` belongs to the caller's file: from PHP's default mode the text "false" — or "no", "failed", the number 2 — built a GREEN receipt, and the call passed on it. A no that was said was kept as a yes. The constructor takes anything now and throws a TypeError for what is not a boolean. Its sibling: the inverse a ceiling names was taken from an identity built elsewhere, from such a caller. An effect profile asks whether its rollback contract is a text; it is the one change outside src/Standing, and every caller in the family hands it a text already. The words say which of the two doors answers a stop beside a call. For one stopped grant whose fixed argument the call leaves out, `among()` refuses and `judge()` says «not named», while the docblock of `among()` said it adds no criterion. `judge()` is unchanged. Its docblock, the one of `among()`, the verdict's and the first example of the README now say that a stop beside is `among()`'s to answer, and that a host asks `among()` also when it holds one grant, with every grant that lends to the seat and covers the operation. And a stop of the record beside the call is filed under the signature check, as every fault of a record is. Two sentences of the README say what is so. The cost of a stop: a call is refused unless it says another value for one thing each entry of the stopped grant over the operation fixes — not «each fixed argument». And the grants that are gone: revoked and superseded ones whose record reads stop nobody; one past its own date stops nobody unless it is still suspended. That behaviour is not changed here. DECIDED BY ROD, 2026-10-12 — D10 to D13. What were three proposals are rules, and no behaviour changes with it: the source, the README and the tests say «decided» where they said «proposed». D10. The reach of a stop: under doubt, a stop stops. Only a call that says, beyond doubt, another value for something the stopped grant fixes is outside it — of the same kind, in a grant's own spelling, and different. `among()` answers the stop whether or not another grant would lend, and `mayBeNaming()` is the host's to ask. Decided with its limit said — an operation's own reading of a value is not reached, and no rule that compares values closes it — and with its cost. A stop over the whole operation, and a stop exact by spelling as published, were not taken. D11. A text that begins with a combining mark, or carries more than eight in a row, is refused. D12. In a sentence the escape of what names are made of is refused; inside `arguments`, inside `to` and in `expires_at`, the escape of every printable ASCII character. D13. The grants that are gone, as they are: one past its own date that is still suspended keeps stopping until a new grant names it. --- README.md | 49 ++-- src/Effect/EffectProfile.php | 26 +- src/Standing/Canonical.php | 29 +- src/Standing/ConditionReceipt.php | 16 +- src/Standing/StandingJudge.php | 58 ++-- src/Standing/StandingVerdict.php | 15 +- .../AProposalOfASeventhReviewTest.php | 13 +- .../TwoProposalsOfASixthReviewTest.php | 14 +- ...WhatANinthReviewFoundInTheContractTest.php | 254 ++++++++++++++++++ ...atASeventhReviewFoundInTheContractTest.php | 2 +- ...WhatASixthReviewFoundInTheContractTest.php | 2 +- ...WhatAThirdReviewFoundInTheContractTest.php | 4 +- ...atAnEighthReviewFoundInTheContractTest.php | 10 +- 13 files changed, 413 insertions(+), 79 deletions(-) create mode 100644 tests/Standing/WhatANinthReviewFoundInTheContractTest.php diff --git a/README.md b/README.md index 5e23f43..fa89843 100644 --- a/README.md +++ b/README.md @@ -193,7 +193,7 @@ 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 | +| 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 | @@ -209,6 +209,20 @@ $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. @@ -224,9 +238,10 @@ whatever another grant would lend — until a person lifts that suspension or gi 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. Expired, revoked and superseded grants whose record -reads stop nobody. Otherwise one grant that passes is enough, and when none does, the refusal that -got furthest speaks. +"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 @@ -234,14 +249,13 @@ be the one that names this call, stopped; so may a line it cannot attribute to o 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`. -**Proposed, not decided** (the seventh review, M1, 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 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. -With the proposal, a stopped grant (suspended, or its record not read whole) that lends to the seat +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 @@ -266,10 +280,16 @@ reader, and the word `"false"` is true to `if`. 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 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 each fixed argument in the grant's own kind and spelling — also where the - operation's default, or its reading of `"7"`, is certainly another value than the fixed one. + 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 @@ -290,8 +310,7 @@ default mode an `OperationId` would turn itself into text before the constructor - 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"`; -- **proposed, not decided** (the sixth review, L5, as the seventh and the eighth adjusted it): it - spells plainly **what names are made of** — a `\u` escape that names a letter, a digit, the colon, +- **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 @@ -302,8 +321,8 @@ default mode an `OperationId` would turn itself into text before the constructor `<=`) 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, left for whoever decides: that second rule everywhere, - which costs the sentences of those writers; + 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 @@ -356,7 +375,7 @@ house runs on tables that know it. The fillers are not of that set, and are name 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. -**Proposed, not decided** (the sixth review, L2): a text that **begins with a combining mark**, or +**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. diff --git a/src/Effect/EffectProfile.php b/src/Effect/EffectProfile.php index 857df2f..b71bc9f 100644 --- a/src/Effect/EffectProfile.php +++ b/src/Effect/EffectProfile.php @@ -54,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 @@ -74,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). * @@ -92,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 diff --git a/src/Standing/Canonical.php b/src/Standing/Canonical.php index 7a0078c..eefe74c 100644 --- a/src/Standing/Canonical.php +++ b/src/Standing/Canonical.php @@ -65,13 +65,13 @@ final class Canonical 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}'; /** - * PROPOSED, NOT DECIDED (the sixth review, L2; greenhouse decisions/0615): a text does not begin with a combining - * mark, and carries no more than eight of them in a row. + * 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. To take the proposal out: this constant, and where it is used. + * two, Thai three, pointed Hebrew four or five. */ private const MARKS = '^\p{M}|\p{M}{9}'; @@ -119,13 +119,12 @@ public static function saidOnce(string $json, string $where): void $byte = $json[$at]; if ($byte === '"') { $from = $at; - // PROPOSED, NOT DECIDED (the sixth review, L5, as the seventh and the eighth adjusted it; greenhouse - // decisions/0615): what a person signs spells plainly what NAMES are made of — and, where the text is + // 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. To take the proposal out: `$valueOf`, this line, the `if` below, and - // {@see self::spellsWhatIsWrittenPlainly()}. + // forgiven. $exact = \in_array($open[\count($open) - 1]['name'] ?? null, self::EXACT, true) || $valueOf === self::EXACTLY; $valueOf = null; for (++$at; $json[$at] !== '"'; ++$at) { @@ -171,16 +170,16 @@ public static function saidOnce(string $json, string $where): void } /** - * PROPOSED, NOT DECIDED ({@see self::saidOnce()}): the frames whose texts are NOT SENTENCES — every text inside + * 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']; - /** PROPOSED, NOT DECIDED ({@see self::saidOnce()}): the one key whose own text is not a sentence either. */ + /** 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'; /** - * PROPOSED, NOT DECIDED ({@see self::saidOnce()}): whether four hexadecimal digits name a character this contract + * 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, @@ -197,10 +196,8 @@ public static function saidOnce(string $json, string $where): void * 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, for whoever decides: the second rule everywhere — refuse the escape of every printable - * ASCII character but the quote and the backslash, 93 of them, in a sentence too. It is one line, - * `return $code >= 0x20 && $code <= 0x7E && $code !== 0x22 && $code !== 0x5C;`, and it costs the documents of - * those writers: a sentence with an apostrophe or an ampersand, written by one of them, is then not read. + * 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 { @@ -355,8 +352,8 @@ public static function text(array $data, string $key, string $where): string * spell; * · and a SPACE AT EITHER END: a text is given as it reads, and nothing trims it. * - * PROPOSED, NOT DECIDED ({@see self::MARKS}): a text that begins with a combining mark, or carries more than - * eight of them in a row, is refused too. + * 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 diff --git a/src/Standing/ConditionReceipt.php b/src/Standing/ConditionReceipt.php index dbe42c6..efbf543 100644 --- a/src/Standing/ConditionReceipt.php +++ b/src/Standing/ConditionReceipt.php @@ -38,21 +38,35 @@ { 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( 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'); } diff --git a/src/Standing/StandingJudge.php b/src/Standing/StandingJudge.php index d7b5f2b..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; @@ -207,21 +219,25 @@ public static function judge(StandingGrant $grant, GrantStanding $standing, Stan /** * 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 WHOSE RECORD READS 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. - * · PROPOSED, NOT DECIDED: a stopped grant that only MAY be naming the call ({@see self::mayBeNaming()}) + * · 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. + * 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. @@ -236,8 +252,12 @@ public static function judge(StandingGrant $grant, GrantStanding $standing, Stan * 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 - * may concern the call, in the - * order they were given + * 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 @@ -262,13 +282,12 @@ public static function among(array $grants, StandingCall $call, ?string $recordF foreach ($grants as ['grant' => $grant, 'standing' => $standing]) { $verdict = self::judge($grant, $standing, $call); if (!$verdict->named()) { - // PROPOSED, NOT DECIDED (the seventh review, M1, as the eighth rebuilt it; greenhouse decisions/0615): - // UNDER DOUBT, A STOP STOPS. «The grant names the call» was made for LENDING, where being exact fails + // 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. To take the proposal out: this `if`, the one before the - // last `return`, and the two functions it calls. + // 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]; @@ -321,13 +340,14 @@ public static function among(array $grants, StandingCall $call, ?string $recordF )]; } - // PROPOSED, NOT DECIDED (the seventh review, M1, as the eighth rebuilt it): a stop BESIDE the call — a + // 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) { - return ['grant' => $beside['grant'], 'verdict' => StandingVerdict::refuse(StandingCheck::InForce, 'stopped_beside', \sprintf( + // 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) { @@ -358,7 +378,7 @@ private static function holdsALineNotRead(GrantStanding $standing): bool } /** - * PROPOSED, NOT DECIDED (greenhouse decisions/0615): WHETHER A GRANT MAY BE THE ONE THAT NAMES THIS CALL, said + * 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. * @@ -377,7 +397,9 @@ private static function holdsALineNotRead(GrantStanding $standing): bool * 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}). + * ({@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 { @@ -401,7 +423,7 @@ public static function mayBeNaming(StandingGrant $grant, StandingCall $call): bo } /** - * PROPOSED, NOT DECIDED ({@see self::mayBeNaming()}): whether what a call carries for an argument is, BEYOND + * 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 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/AProposalOfASeventhReviewTest.php b/tests/Standing/AProposalOfASeventhReviewTest.php index 0c16a31..e1280f2 100644 --- a/tests/Standing/AProposalOfASeventhReviewTest.php +++ b/tests/Standing/AProposalOfASeventhReviewTest.php @@ -25,14 +25,14 @@ use PHPUnit\Framework\TestCase; /** - * A PROPOSAL OF THE SEVENTH REVIEW, REBUILT AS THE EIGHTH ASKED — BUILT, AND NOT DECIDED (greenhouse - * decisions/0615): UNDER DOUBT, A STOP STOPS. + * 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. It is a small change that can be taken out: this file, and the lines of `StandingJudge` - * that carry its name. + * 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. * @@ -204,7 +204,8 @@ public function testUnderDoubtAStopStops(string|int|bool $fixed, array $carried) $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'); - self::assertSame(['narrow', StandingCheck::InForce, 'stopped_beside'], [$answer['grant']->id ?? null, $answer['verdict']->failed, $answer['verdict']->reason]); + // 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); } @@ -306,7 +307,7 @@ public function testOfTwoStopsBesideTheCallTheFirstGivenSpeaks(): void } /** - * What a grant can fix an argument to, asked on its own: the proposal says «another value» only of one of these. + * What a grant can fix an argument to, asked on its own: the rule says «another value» only of one of these. * * @return iterable */ diff --git a/tests/Standing/TwoProposalsOfASixthReviewTest.php b/tests/Standing/TwoProposalsOfASixthReviewTest.php index 1ade9ec..b37ad7f 100644 --- a/tests/Standing/TwoProposalsOfASixthReviewTest.php +++ b/tests/Standing/TwoProposalsOfASixthReviewTest.php @@ -23,11 +23,11 @@ use PHPUnit\Framework\TestCase; /** - * TWO PROPOSALS OF THE SIXTH REVIEW — BUILT, AND NOT DECIDED (greenhouse decisions/0615). Each is a small change that - * can be taken out: this file, and the lines of `Canonical` that carry its name. + * 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. * - * · L2 — a text does not begin with a combining mark, and carries no more than eight in a row. - * · L5, as the seventh and the eighth reviews adjusted it — the text a person signs spells plainly what NAMES + * · 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 @@ -57,7 +57,7 @@ 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]); } - // ── L2 (proposed) · a text is not a stack of marks, and does not begin with one ───────────────────────── + // ── D11 · a text is not a stack of marks, and does not begin with one ──────────────────────────────────── public function testATextIsNotAStackOfMarksAndDoesNotBeginWithOne(): void { @@ -107,7 +107,7 @@ public function testWhatALanguageStacksOnALetterIsStillRead(string $text): void self::assertSame($grant->digest(), StandingGrant::fromJson($grant->canonical())->digest()); } - // ── L5 (proposed) · what a person signs spells plain letters plainly ─────────────────────────────────── + // ── D12 · what a person signs spells plain letters plainly ─────────────────────────────────────────────── public function testWhatAPersonSignsSpellsPlainLettersPlainly(): void { @@ -167,7 +167,7 @@ public static function escapesInATextAPersonSigns(): iterable public function testWhatAWriterThatIsSafeForHtmlWritesIsRead(): void { - // The sentence this proposal carried said «no writer makes one». Writers do: PHP's own, asked to be safe for + // 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)); 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 index 40a0646..458ecf1 100644 --- a/tests/Standing/WhatASeventhReviewFoundInTheContractTest.php +++ b/tests/Standing/WhatASeventhReviewFoundInTheContractTest.php @@ -27,7 +27,7 @@ /** * 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 is a question for whoever decides, built as a proposal in a file of its own. + * 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 */ diff --git a/tests/Standing/WhatASixthReviewFoundInTheContractTest.php b/tests/Standing/WhatASixthReviewFoundInTheContractTest.php index 96e4170..738e556 100644 --- a/tests/Standing/WhatASixthReviewFoundInTheContractTest.php +++ b/tests/Standing/WhatASixthReviewFoundInTheContractTest.php @@ -187,7 +187,7 @@ public function testThePatternNamesThePropertyItselfWhereThisPcreKnowsIt(): void } 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 (a proposal), and + // 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( diff --git a/tests/Standing/WhatAThirdReviewFoundInTheContractTest.php b/tests/Standing/WhatAThirdReviewFoundInTheContractTest.php index 7246e39..581caec 100644 --- a/tests/Standing/WhatAThirdReviewFoundInTheContractTest.php +++ b/tests/Standing/WhatAThirdReviewFoundInTheContractTest.php @@ -52,8 +52,8 @@ public static function whatIsSaidTwice(): iterable 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 - // (a proposal of the sixth review, not decided), so the two spellings are of a letter a writer does escape: - // the question — one key, spelled two ways, said twice — holds with that proposal and without it. + // (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', '']; } diff --git a/tests/Standing/WhatAnEighthReviewFoundInTheContractTest.php b/tests/Standing/WhatAnEighthReviewFoundInTheContractTest.php index da4163b..60d7c3f 100644 --- a/tests/Standing/WhatAnEighthReviewFoundInTheContractTest.php +++ b/tests/Standing/WhatAnEighthReviewFoundInTheContractTest.php @@ -27,9 +27,9 @@ /** * 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 is a proposal and has a file of its own - * ({@see AProposalOfASeventhReviewTest}), and so has what it found in the proposal about escapes - * ({@see TwoProposalsOfASixthReviewTest}). + * 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. * @@ -135,7 +135,7 @@ public function testAStopThatNamesTheCallDoesNotNameAWayOutThatDoesNotLeadOutEit self::assertSame(['stopped', 'suspended'], [$two['grant']->id ?? null, $two['verdict']->reason ?? null]); } - /** «Expired, revoked and superseded grants whose record reads stop nobody» — and one whose record does not read stops. */ + /** «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']); @@ -148,7 +148,7 @@ public function testAGrantThatIsGoneStopsNobodyOnlyWhileItsRecordReads(): void 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('Expired, revoked and superseded grants whose record reads stop nobody', $readme); + 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 ────────────────────────────────────