Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 83 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,89 @@ authorisation at every link of the chain: ask "remove the old plugin" and a samp
with no fact saying why. With the contract declared, an unnamed target becomes a question instead.
The doubt belongs to the operation that discovers a concrete operation cannot exist yet.

## A standing grant, and the judgment of one call under it

Some acts repeat, and a person should not have to sign each one. The only way to let a seat repeat
an act used to be handing it the scope whole: no ceiling, no condition, nothing to take it back when
the evidence changes. A **standing grant** is narrower than that on every count. A person signs it
once; it names the seats, the operations, the widest effect a call may have, what must have been
**measured on the very content the act touches**, and what would show the grant was wrong to give.

An operation says what content a call acts on, as a digest derived from the bytes:

```php
new Operation(
name: 'release:publish',
// …
actsOn: static fn (array $arguments): ?string => is_file($arguments['archive'])
? 'sha256:' . hash_file('sha256', $arguments['archive'])
: null,
);
```

The operation that measures a condition declares the same thing, and a host joins the two **by
content, never by clock**: a measurement taken on another candidate, or after the act, is not this
one. `StandingJudge::judge()` then asks seven things, in order, and the first that fails is the
verdict:

| # | Check | Refuses when |
|---|-------|--------------|
| 1 | `Named` | the grant does not name this operation, these arguments and this seat — the call is then judged as it was before any grant existed |
| 2 | `Signature` | the grant's signature does not hold *now* |
| 3 | `InForce` | it is suspended, superseded, revoked, or past its own date |
| 4 | `Watched` | the operation watching its falsifier gave no sign of life within its window |
| 5 | `Subject` | the operation cannot say what content this call acts on |
| 6 | `Ceiling` | the effect of this call is wider than the grant's ceiling (`EffectProfile::isNoWiderThan`, the one comparator) |
| 7 | `Conditions` | a condition has no green receipt on this content, from the instrument as it is today |

```php
$verdict = StandingJudge::judge($grant, $standing, $call);

$verdict->named(); // false: not this grant's call
$verdict->passes(); // true: every check held; $verdict->cited are the receipts it ran on
$verdict->failed; // the StandingCheck that refused, with $verdict->sentence for the reader
```

The judgment is pure: it reads a `StandingGrant`, the facts a host gathered about it
(`GrantStanding`) and the call as facts (`StandingCall`). It never grants, widens or restores —
growing back is a person's new signature, and it happens in the host.

A house may hold several grants that name the same call. `StandingJudge::among()` says which
answer stands, and **shrinking wins**: a grant that names the call and is suspended refuses it,
whatever another grant would lend — until a person lifts that suspension or gives a grant that
supersedes it by name. Expired, revoked and superseded grants stop nobody. Otherwise one grant that
passes is enough, and when none does, the refusal that got furthest speaks.

Some documents cannot be written at all. `StandingGrant` refuses, at construction: a ceiling with an
unclassified axis, a ceiling above `compensatable`, a ceiling whose **authority** is above
`write_as_user` (an act above it itself gives authority, and no grant lends one), covering any
`standing.*` operation, a grant with no condition, a falsifier nothing watches, and **any key it
does not read** — a line the judge would ignore is a limit nobody keeps.

A grant names calls in two ways, and the difference is deliberate. What it **covers** is matched
openly: the call carries each fixed argument, and the rest is free — the candidate changes on every
call. The operation that **measures** a condition and the one that **watches** the falsifier are
matched closed (`NamedCall::matchesExactly()`): besides the fixed arguments a call may carry only
those listed in `free`, typically the one that says which candidate. An open match fails open
there — a later run with `--lenient` would speak for the condition.

```json
"conditions": [{"name": "no-host-names", "statement": "what is published names no machine",
"by": {"operation": "release:scan", "arguments": {"strict": true}, "free": ["archive"]}}]
```

A grant that supersedes a **suspended** one says which suspension it lifts (`lifts`, beside
`supersedes`): taking a suspended grant out of the way is growing back, and growing back names what
it lifts.

**What it cannot say: money.** There is no effect axis for it, so no ceiling bounds what a call
spends, and nothing in an operation's declaration says that it spends. "Do not grant over an
operation that spends" is a rule for whoever signs, not something this package can hold.

And one limit of the receipt: it pins the measuring operation by `Operation::instrumentDigest()` —
its declaration and the body of its handler. A handler that delegates changes behaviour without
changing that digest.

## Requirements

- PHP **≥ 8.3**
Expand Down
109 changes: 103 additions & 6 deletions src/Operation.php
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,27 @@ public function __construct(
* cannot both create its target and amend one that exists. Both are refused at declaration.
*/
public bool $amendsNamedTarget = false,
/**
* WHAT a call of this operation acts on, as the digest of its content, derived from the call's
* arguments — or `null` when this operation does not say (greenhouse decisions/0615).
*
* A condition measured on one candidate says nothing about another, and «the same candidate» cannot
* be a name, a path or a moment: a file is rewritten under its name, and a measurement taken after
* the act legalizes nothing. So the operation that measures and the operation that acts each declare
* how their arguments lead to the bytes they touch, and a host joins the two by that digest — by
* content, never by clock. The value is derived FROM THE BYTES (the sha256 of the candidate's
* archive, the id of a commit); it never arrives as an argument, because a digest the caller hands
* in is a statement, not a reading.
*
* Declaring it changes nothing on its own. A host that judges a standing grant reads it through
* {@see self::actsOnDigest()}; an operation that declares none answers `null` there, and not being
* able to look is not having looked: no grant applies to a call that cannot say what it touches.
*
* LAST on purpose, like every field added to this constructor: positional callers keep working.
*
* @var (\Closure(array<string, mixed>): ?string)|null
*/
public ?\Closure $actsOn = null,
) {
if ($this->amendsNamedTarget && $this->namedTarget === null) {
throw new \InvalidArgumentException(
Expand Down Expand Up @@ -298,16 +319,92 @@ public function handlerDigest(): ?string
return null;
}

$reflejo = new \ReflectionFunction(\Closure::fromCallable($this->handler));
$archivo = $reflejo->getFileName();
$lineas = $archivo === false ? false : file($archivo);
if ($lineas === false) {
return self::bodyDigest(\Closure::fromCallable($this->handler));
}

/**
* The digest of the content this call acts on, or `null` when this operation cannot say.
*
* Asked of the SAME declaration for the call that measures and for the call that acts, so the two
* meet only when both answers agree. `null` covers every way of not knowing — no declaration, a
* declaration that answered nothing for these arguments, one that threw because the candidate is
* not there — and all of them mean the same to a judge: nothing can be bound to this call.
*
* @param array<string, mixed> $arguments
*/
public function actsOnDigest(array $arguments): ?string
{
if ($this->actsOn === null) {
return null;
}

try {
$digest = ($this->actsOn)($arguments);
} catch (\Throwable) {
return null;
}

return \is_string($digest) && trim($digest) !== '' ? trim($digest) : null;
}

/**
* What makes this operation the INSTRUMENT it is today: its identity, its inputs, its declared
* effects, the body of its handler and the body of what says what it acts on — in one digest
* (greenhouse decisions/0615).
*
* A condition receipt pins it. A guard that did not see, hardened afterwards, is another
* instrument: what the old one measured green says nothing about what the new one would find, so a
* receipt taken by a different digest does not count. It carries the limit {@see self::handlerDigest()}
* declares — a handler that delegates changes behaviour without changing this — and says so rather
* than hide it.
*
* `null` when either body cannot be read, or when the operation does not say what it acts on: an
* instrument that cannot be pinned, or that measures nothing in particular, yields no receipt worth
* keeping.
*/
public function instrumentDigest(): ?string
{
$handler = $this->handlerDigest();
$subject = $this->actsOn === null ? null : self::bodyDigest($this->actsOn);
if ($handler === null || $subject === null) {
return null;
}

$effects = $this->effectCeiling();
$scopes = $this->scopes;
sort($scopes);

return 'sha256:' . hash('sha256', Standing\Canonical::json([
'acts_on' => $subject,
'effects' => [
'authority' => $effects->authority->value,
'externality' => $effects->externality->value,
'mutation' => $effects->mutation->value,
'reversibility' => $effects->reversibility->value,
'subject' => $effects->subject->value,
],
'handler' => $handler,
'inputs' => $this->inputSchema,
'mutating' => $this->mutating,
'operation' => (new Consent\OperationId($this->name))->canonical,
'permission' => $this->permission,
'scopes' => $scopes,
]));
}

/** The digest of a closure's source lines, or `null` when they cannot be read. */
private static function bodyDigest(\Closure $closure): ?string
{
$reflection = new \ReflectionFunction($closure);
$file = $reflection->getFileName();
$lines = $file === false ? false : file($file);
if ($lines === false) {
return null;
}

$cuerpo = \array_slice($lineas, $reflejo->getStartLine() - 1, $reflejo->getEndLine() - $reflejo->getStartLine() + 1);
$body = \array_slice($lines, $reflection->getStartLine() - 1, $reflection->getEndLine() - $reflection->getStartLine() + 1);

return 'sha256:' . hash('sha256', implode('', $cuerpo));
return 'sha256:' . hash('sha256', implode('', $body));
}

/**
Expand Down
97 changes: 97 additions & 0 deletions src/Standing/Canonical.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
<?php

/**
* This file is part of Milpa Command — the Command-as-atom core of the Milpa PHP framework.
*
* (c) Rodrigo Vicente - TeamX Agency — https://teamx.agency <hola@teamx.agency>
*
* @license Apache-2.0
*
* @link https://github.com/getmilpa/command
*/

declare(strict_types=1);

namespace Milpa\Command\Standing;

/**
* One spelling for a structure that gets signed or digested (greenhouse decisions/0615).
*
* JSON has many spellings of the same object and a signature is over bytes, not over meaning: a
* grant a person signed as `{"id":…,"to":…}` and a host rebuilt as `{"to":…,"id":…}` would be two
* documents. So every map is sorted at every depth and every list is left alone — in a map, key
* order carries nothing; in a list it is the meaning.
*
* Part of the contract, not a convenience: a host that keeps a record of grants and receipts digests
* its lines with the same spelling, so that a judge written anywhere reads the same bytes.
*/
final class Canonical
{
/**
* The canonical bytes of a structure.
*
* @param array<array-key, mixed> $value
*/
public static function json(array $value): string
{
return json_encode(self::sorted($value), \JSON_THROW_ON_ERROR | \JSON_UNESCAPED_SLASHES | \JSON_UNESCAPED_UNICODE);
}

/**
* Refuse a key this contract does not read.
*
* A document a person signs must not carry a line the judge ignores: someone who writes
* `"budget": 50` believes they bounded something, and a reader that skipped it in silence would
* let them keep believing it.
*
* @param array<array-key, mixed> $data
* @param list<string> $known
*/
public static function only(array $data, array $known, string $where): void
{
foreach (array_keys($data) as $key) {
if (!\in_array($key, $known, true)) {
throw new \InvalidArgumentException(\sprintf(
'%s carries «%s», which nothing reads (it reads: %s). A line the judge ignores is a limit nobody keeps — remove it.',
$where,
(string) $key,
implode(', ', $known),
));
}
}
}

/**
* A required, non-blank string of a document, trimmed.
*
* @param array<array-key, mixed> $data
*/
public static function text(array $data, string $key, string $where): string
{
$value = $data[$key] ?? null;
if (!\is_string($value) || trim($value) === '') {
throw new \InvalidArgumentException(\sprintf('%s needs «%s», in words', $where, $key));
}

return trim($value);
}

/**
* @param array<array-key, mixed> $value
*
* @return array<array-key, mixed>
*/
private static function sorted(array $value): array
{
if (!array_is_list($value)) {
ksort($value);
}
foreach ($value as $key => $item) {
if (\is_array($item)) {
$value[$key] = self::sorted($item);
}
}

return $value;
}
}
Loading
Loading