From b5ff539e90b09f94398c6660b8643cff6696062c Mon Sep 17 00:00:00 2001 From: Rod Vince Date: Wed, 7 Oct 2026 14:58:47 -0600 Subject: [PATCH] feat: an operation says it amends the target it names (greenhouse decisions/0596) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The intent contract's line was drawn by the verb: an operation that creates its named target is not asked «you did not name it», one that selects an existing target is. The record drew it elsewhere: of 34 times a house asked a person to confirm an edit, 28 were about a class that same session had brought into the house. An operation can now declare that it amends what its target names. The session's floor reads from that session's own record where the target came from, and asks about everything the record does not say. The declaration defaults to false, needs a named target, and cannot sit beside createsNamedTarget: both contradictions are refused where they are written. --- src/Operation.php | 33 +++++++ ...perationSaysItAmendsItsNamedTargetTest.php | 92 +++++++++++++++++++ 2 files changed, 125 insertions(+) create mode 100644 tests/AnOperationSaysItAmendsItsNamedTargetTest.php diff --git a/src/Operation.php b/src/Operation.php index f804e82..cf18fcd 100644 --- a/src/Operation.php +++ b/src/Operation.php @@ -177,7 +177,40 @@ public function __construct( * @var list */ public array $citesReceiptsOf = [], + /** + * Whether this operation AMENDS the entity {@see $namedTarget} names — changes, in place, one that + * exists — so that a session's own record can say where that entity came from (greenhouse decisions/0596). + * + * {@see $createsNamedTarget} drew the intent contract's line by the verb: `implement` materialises and is + * not asked, `edit` selects what exists and is. The record drew it elsewhere. Of 34 times a house asked a + * person to confirm an `edit`, 28 were about a class that same session had brought into the house a moment + * before: nothing the person already had was being selected. Correcting what one has just materialised is + * still materialising — the model's interpretive domain; touching what was already there is selecting, and + * stays the human's. + * + * So the target of an operation that declares this is NAMED when the session's record says where it came + * from: the session brought it into the house and it is still what the session left, or the call repairs a + * proposal of that same session. Who decides that is not this class: it is the session's floor, reading + * facts, with no model in the circuit. What the record does not say keeps being asked. + * + * Defaults to `false` — fail-closed. The declaration speaks of a target, so it needs one; and an operation + * cannot both create its target and amend one that exists. Both are refused at declaration. + */ + public bool $amendsNamedTarget = false, ) { + if ($this->amendsNamedTarget && $this->namedTarget === null) { + throw new \InvalidArgumentException( + "Operation '{$this->name}' declares that it amends its named target and names none. The declaration " + . 'speaks of a target: declare namedTarget, or drop it.' + ); + } + if ($this->amendsNamedTarget && $this->createsNamedTarget) { + throw new \InvalidArgumentException( + "Operation '{$this->name}' declares that it creates its named target AND that it amends it. One that " + . 'creates its target is never asked about it; one that amends an existing target is asked unless the ' + . "session's record says where the target came from. Declare the one this operation does." + ); + } if ($this->citesReceiptsOf !== [] && $this->continues === null) { throw new \InvalidArgumentException( "Operation '{$this->name}' names receipts it may cite (" . implode(', ', $this->citesReceiptsOf) . ') ' diff --git a/tests/AnOperationSaysItAmendsItsNamedTargetTest.php b/tests/AnOperationSaysItAmendsItsNamedTargetTest.php new file mode 100644 index 0000000..d44a83b --- /dev/null +++ b/tests/AnOperationSaysItAmendsItsNamedTargetTest.php @@ -0,0 +1,92 @@ + + * + * @license Apache-2.0 + * + * @link https://github.com/getmilpa/command + */ + +declare(strict_types=1); + +namespace Milpa\Command\Tests; + +use Milpa\Command\Operation; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\TestCase; + +/** + * An operation says it AMENDS the target it names — and says nothing it cannot mean (greenhouse decisions/0596). + * + * The intent contract's line was drawn by the verb: an operation that creates its named target is not asked «you did + * not name it», one that selects an existing target is. Of 34 times a house asked a person to confirm an `edit`, 28 + * were about a class that same session had brought into the house. So an operation can declare that it amends what + * its target names, and the session's floor reads from that session's own record where the target came from. + * + * The declaration is a statement about a target: it needs one, and it cannot sit beside the statement that the + * operation creates that same target. Both contradictions are refused where they are written, not reconciled by + * whoever reads them later. + */ +#[CoversClass(Operation::class)] +final class AnOperationSaysItAmendsItsNamedTargetTest extends TestCase +{ + public function testAnOperationDoesNotAmendUnlessItSaysSo(): void + { + $operation = new Operation('edit', 'Edits a class.', static fn (): array => [], namedTarget: 'class'); + + self::assertFalse($operation->amendsNamedTarget, 'fail-closed: silence keeps the question'); + } + + public function testAnOperationThatNamesNoTargetAndSaysNothingIsDeclaredAsBefore(): void + { + $operation = new Operation('list', 'Lists things.', static fn (): array => []); + + self::assertNull($operation->namedTarget, 'the control: most operations name no target, and say nothing about one'); + self::assertFalse($operation->amendsNamedTarget); + } + + public function testAnOperationThatSaysSoCarriesIt(): void + { + $operation = new Operation('edit', 'Edits a class.', static fn (): array => [], namedTarget: 'class', amendsNamedTarget: true); + + self::assertTrue($operation->amendsNamedTarget); + self::assertSame('class', $operation->namedTarget); + self::assertFalse($operation->createsNamedTarget, 'amending is not creating'); + } + + public function testItSurvivesACopyOfTheContract(): void + { + $operation = new Operation('edit', 'Edits a class.', static fn (): array => [], namedTarget: 'class', amendsNamedTarget: true); + + $copy = new Operation(...array_replace(get_object_vars($operation), ['description' => 'Edits a class, and says more.'])); + + self::assertTrue($copy->amendsNamedTarget, 'a host that rebuilds the contract keeps what it declared'); + } + + public function testAmendingNeedsATargetToSpeakOf(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage("Operation 'edit' declares that it amends its named target and names none"); + + new Operation('edit', 'Edits a class.', static fn (): array => [], amendsNamedTarget: true); + } + + public function testAnOperationCannotBothCreateItsTargetAndAmendOneThatExists(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage("Operation 'edit' declares that it creates its named target AND that it amends it"); + + new Operation('edit', 'Edits a class.', static fn (): array => [], namedTarget: 'class', createsNamedTarget: true, amendsNamedTarget: true); + } + + public function testCreatingAloneIsStillWhatItWas(): void + { + $operation = new Operation('implement', 'Fills a class.', static fn (): array => [], namedTarget: 'class', createsNamedTarget: true); + + self::assertTrue($operation->createsNamedTarget); + self::assertFalse($operation->amendsNamedTarget); + } +}