Skip to content
docsv0.19.0

EffectProfile

What an operation can do at WORST — the upper bound, declared by the operation itself. ── WHY AN UPPER BOUND AND NOT A DESCRIPTION ──────────────────────────────────────────────────── Most operations do not have one fixed effect. `file.write` can write a scratch file or a production artifact; `plugins.install` can pull a local path or a package from the internet. If the profile described the typical case, every classification would be a bet on the arguments nobody has seen yet. So the profile declares the CEILING: what this operation could do if its arguments turn out to be the worst plausible ones. Arguments can only ever narrow it — and only when they are actually resolved. An unresolved argument keeps the ceiling, which is GOV-05 applied at the call site: not knowing where the file goes is not permission to assume it goes somewhere harmless. ── WHY IT DOES NOT REPLACE `mutating` ────────────────────────────────────────────────────────── `Operation::$mutating` is read in eight places across four packages: the session gate, the CLI runner, the TUI projector, the MCP model, the HTTP policy. Replacing it would be a migration; and keeping both without a rule would be a second source of truth — the defect this repository has caught four times in a week, always the same way, always through a consumer that read the stale one. So there is one rule, enforced at construction: a profile cannot say `Mutation::None` for an operation that declares `mutating: true`. The contradiction is not resolved at read time; it is IMPOSSIBLE TO DECLARE. `mutating` stays the coarse, load-bearing signal, and this is the refinement that says what kind of mutation it is. ── WHY THE JOIN AND NOT AN AVERAGE ───────────────────────────────────────────────────────────── When two profiles combine — an ambiguity with several interpretations, a step that calls several operations — the result takes the HIGHEST of each dimension independently. Risks are not averaged (GOV-06). A local, reversible operation on highly sensitive data is not «medium»: it is an operation on highly sensitive data.

EffectProfile::__construct()

public function __construct(Milpa\Command\Effect\Mutation $mutation = Milpa\Command\Effect\Mutation::Unknown, Milpa\Command\Effect\Externality $externality = Milpa\Command\Effect\Externality::Unknown, Milpa\Command\Effect\Reversibility $reversibility = Milpa\Command\Effect\Reversibility::Unknown, Milpa\Command\Effect\Authority $authority = Milpa\Command\Effect\Authority::Unknown, array $escalatesOn = [], Milpa\Command\Effect\Subject $subject = Milpa\Command\Effect\Subject::Unknown, ?string $rollbackContract = null, array $descents = []):

Parameters

Parameters of __construct()
NameTypeDescription
$mutationMilpa\Command\Effect\Mutation
$externalityMilpa\Command\Effect\Externality
$reversibilityMilpa\Command\Effect\Reversibility
$authorityMilpa\Command\Effect\Authority
$escalatesOnlist<string>argument names whose value can RAISE this ceiling — declared so a caller knows which unresolved argument is the one that keeps the profile pinned at its worst
$subjectMilpa\Command\Effect\Subject
$rollbackContract?string
$descentsarray

EffectProfile::unclassified()

public static function unclassified(): self

The profile an operation gets when it never declared one: everything unknown, everything at its ceiling. This is the day-zero position for every operation in the catalogue, and it is deliberately unusable for anything sensitive. The pressure it creates is the point — the way out is to classify, never to add a permissive default (GOV-13).

EffectProfile::readOnly()

public static function readOnly(): self

Nothing at all: reads, leaves no trace, reaches nobody, spends no authority.

EffectProfile::isFullyClassified()

public function isFullyClassified(): bool

Whether somebody actually classified this, or it is four `unknown`s wearing a profile. The distinction matters to every caller: an unclassified ceiling is at its maximum, so it looks strict — but it is strict from ignorance, not from a decision anyone made.

EffectProfile::join()

public function join(self $other): self

The least-upper-bound of two profiles — the higher of each dimension, independently. Monotonic by construction: joining can only raise. That is what makes an additive adversarial enumerator safe (GOV-14) — a proposer that adds an interpretation can worsen the ceiling and can never improve it, so nothing has to trust that it enumerated honestly.

Parameters

Parameters of join()
NameTypeDescription
$otherself

EffectProfile::meet()

public function meet(self $other): self

The greatest-lower-bound of two profiles — the mirror of {@see self::join()}. Where join composes UPWARD (the more dangerous of each axis, so a ceiling covers both), meet composes DOWNWARD (the safer), so the result can only LOWER and never raise any axis. It is the primitive a STRUCTURAL counter rests on: when a human tightens the envelope of a gated call — «authorise it, but only if reversible / only Read» — the tightened ceiling is `meet($ceiling, $humanProfile)`, and it is safe precisely because a meet is `<=` both operands on every axis. That «never raises» is the whole safety claim.

Parameters

Parameters of meet()
NameTypeDescription
$otherself

EffectProfile::isNoWiderThan()

public function isNoWiderThan(self $other): bool

Whether this profile is `<=` `$other` on EVERY axis — the one comparator. It is what a gate asserts right after a meet (the never-widens tripwire: `meet($b, $p)` must be no wider than `$b`, and if it ever is not, nothing is granted) and what a policy uses to admit a call under a tightened envelope (the call's composed profile must be no wider than the envelope). One ordering, in one place: a second comparator elsewhere is the duplicate-judge defect, because the day the two disagree, whichever one a consumer happens to read decides authority. `Unknown` weighs as the top of its axis, so an unclassified axis is never «narrower» than a classified one — not knowing is not permission.

Parameters

Parameters of isNoWiderThan()
NameTypeDescription
$otherself

EffectProfile::fromPartial()

public static function fromPartial(array $axes): self

A human's tightening as a profile: the axes they named, `Unknown` on the ones they did not. `Unknown` is the TOP of every axis, so meeting this with a declared ceiling leaves the omitted axes exactly at the ceiling — naming one axis tightens one axis. The keys are the five axes and nothing else: an `amount`, a `path`, any value, is a change of TARGET, and that is not a tightening but a new proposal (greenhouse decisions/0065) — it is refused here so it can only travel the advisory route. `Guaranteed` is refused too: it buys less scrutiny and needs a producer's rollback contract, which nobody can supply by clicking.

Parameters

Parameters of fromPartial()
NameTypeDescription
$axesarray<string, mixed>e.g. `['reversibility' => 'compensatable', 'authority' => 'read']`

EffectProfile::fromArray()

public static function fromArray(array $data): self

The inverse of {@see self::toArray()}: a profile back from the array an event stored. A granted envelope lives in an event payload; the policy rehydrates it here to compare with {@see self::isNoWiderThan()}. All five axes are required — a partial array is a tightening ({@see self::fromPartial()}), not a stored profile, and reading one as the other would turn «axis not recorded» into «axis at its top» silently.

Parameters

Parameters of fromArray()
NameTypeDescription
$dataarray<string, mixed>as produced by toArray()

EffectProfile::forCall()

public function forCall(array $arguments, ?Milpa\Command\Effect\CallSubject $subject = null): self

The ceiling THIS CALL carries, once its arguments are known. Escalation is not resolved here and must not be: `unresolvedEscalators()` answers a different question — «is the ceiling still the ceiling?» — and while it returns anything the answer is yes. This only ever descends. A descent that does not hold is ignored in silence rather than raising, because a call that refuses to run because someone declared badly punishes the caller for the author's mistake. The one that stops is the ceiling: it simply does not come down. The subject of the call travels because greenhouse decisions/0050 made a descent depend on evidence bound to the code that will run, and decisions/0051 bound it to the operation too. A caller that cannot say what is about to run gets no descent: not being able to look is not the same as having looked and found nothing.

Parameters

Parameters of forCall()
NameTypeDescription
$argumentsarray<string, mixed>
$subject?Milpa\Command\Effect\CallSubject

EffectProfile::composeForCall()

public function composeForCall(array $arguments, ?Milpa\Command\Effect\CallSubject $subject = null): Milpa\Command\Effect\ProfileComposition

The effective ceiling AND the receipt of how it was reached (greenhouse decisions/0057). Where {@see forCall()} answers «what ceiling?», this answers «what ceiling, and who lowered each axis to get here?» — one AxisReduction per axis that came down, each naming its authorized producer. The composer is not a producer (MILPA-G002): it asks each descent to {@see Descent::explain()} what it lowered and records the answer; it never decides an axis itself.

Parameters

Parameters of composeForCall()
NameTypeDescription
$argumentsarray<string, mixed>
$subject?Milpa\Command\Effect\CallSubject

EffectProfile::unresolvedEscalators()

public function unresolvedEscalators(array $arguments): array

Which declared escalating arguments are still unresolved in this call. A caller uses it to answer the only question that matters before running: «is the ceiling still the ceiling?» While this returns anything, the answer is yes.

Parameters

Parameters of unresolvedEscalators()
NameTypeDescription
$argumentsarray<string, mixed>

EffectProfile::toArray()

public function toArray(): array

The profile as data, with `fully_classified` travelling inside it. The verdict ships in the payload so a consumer reading JSON cannot mistake four `unknown` values for a classification somebody made — which is the exact confusion GOV-11 is about, one layer down.