hostexit

package
v0.0.21 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 18, 2026 License: MIT Imports: 2 Imported by: 0

Documentation

Overview

Package hostexit decides what the pasture lifecycle hook tells its host.

It is the SOLE exit authority of the hook command: every path out of that command, including a panic, produces one Outcome, and the process exits with that Outcome and nothing else. Before this package existed the command printed the error and returned nil, so a hook that could not evaluate a gate exited 0 with empty stdout, which every host reads as "proceed". A refusal that reads as a proceed is worse than no hook at all.

The package is pure: it reads no environment, no clock, no store and no harness table, so the whole decision table is testable as a table.

Two paths lead here and they must never be confused.

  • ForFault maps an INTERNAL fault: a storage error, a validation refusal, a deadline expiry or a recovered panic. Pasture could not evaluate the event. The default is to fail OPEN, so a broken hook does not stop the user working. Only an explicit fail-closed policy turns a fault into a block, and only for a row that cites host evidence for its blocking exit code.
  • ForDecision carries an evaluated policy decision, including a Deny. A Deny is an ANSWER, not a fault, so it never passes through ForFault and is never weakened by the fault policy or by missing evidence.

Failing open means emitting the host's CONTINUE BYTES

"Exit 0 with nothing on stdout" is an exit-code-shaped idea, and it is a proceed only on a host that reads the exit code. It is NOT a proceed on OpenCode: the pasture-generated plugin parses stdout, and an empty body is not an answer it can read.

The plugin THIS BUILD generates no longer throws on an empty body. It reports "did not evaluate" on the console and lets the callback continue. THAT IS NOT A REASON TO STOP EMITTING THE CONTINUE BYTES, and the reason it is not is permanent: PASTURE CANNOT KNOW WHICH PLUGIN IS INSTALLED. The installed plugin is a file on the user's disk that this build did not write, an ALREADY-INSTALLED OLDER ONE STILL THROWS on an empty body and stops the user's tool call, and nothing in a hook invocation can tell the two apart. So the continue bytes are what makes the fail-open promise true for every installation, not only for a freshly regenerated one.

The same reasoning applies to Codex, whose command-hook ABI reads a JSON continuation object.

A fail-open fault therefore emits the CONTINUE BYTES of the host and the event class, supplied by the caller as a Continuation. This package does not know which bytes those are: the harness table belongs to the encoder boundary (internal/lifecycle/nativeresponse), so hostexit stays pure and the table stays testable without a harness. A Continuation that was never set is refused rather than defaulted, because defaulting it to "no bytes" is exactly the defect above.

THE RULE THIS REPLACES, so that nobody restores it as a fix. The earlier rule was "a fault writes NO native continuation, on every harness". It was written when a proceed was thought to be an exit code everywhere. It is the defect: on OpenCode an empty body at exit 0 MADE the generated plugin throw and stop the user's tool call, and on Codex the command-hook ABI reads a JSON continuation object. The OpenCode plugin generated by this build no longer throws, and that changes nothing here, because PASTURE CANNOT KNOW WHICH PLUGIN IS INSTALLED and an ALREADY-INSTALLED OLDER ONE STILL THROWS. Removing this arm because the current generator is safe would reopen the defect for every user who has not regenerated. The rule now is: A FAIL-OPEN FAULT EMITS THAT HARNESS'S CONTINUE BYTES AND A DIAGNOSTIC; NEVER NOTHING. On Claude Code those continue bytes ARE the empty body, so only the two byte-shaped hosts changed. A later change that makes a fault write nothing again reopens the defect.

Emitting continue bytes does NOT make a fault a decision. The event was not evaluated; the bytes only say "do not stop on our account". The durable fault record keeps the fault classification, and ForDecision remains the only door an evaluated answer passes through.

What PASTURE_HOOK_FAIL_CLOSED can reach

Fail-closed refuses the operation through the process EXIT CODE, so it reaches the hosts that refuse by exit code: Claude Code, and the Codex strict command-hook contract. It does NOT reach the OpenCode named callbacks: fail-closed has no channel on OpenCode named callbacks until the typed refusal object exists, so such an invocation continues. A non-zero exit is not a substitute, because the generated plugin reads one as a broken pasture installation and would present a policy refusal to the user as "verify PASTURE_BIN".

The limit is stated in four places so a reader meets it wherever they look: here, in the description of PASTURE_HOOK_FAIL_CLOSED, in the fault diagnostic itself, and in TestFailClosedHasNoChannelOnTheThrowingHost, which turns RED the day the typed refusal object exists so that somebody has to decide deliberately whether an unevaluated event may use it.

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

This section is empty.

Types

type Continuation

type Continuation struct {
	// contains filtered or unexported fields
}

Continuation is the exact bytes a host reads on standard output as "you may continue", for ONE harness and ONE event class.

It is a value with a SET flag rather than a plain byte slice because one legitimate continuation is the EMPTY body (Claude Code reads empty stdout as proceed), and "empty because the host wants nothing" must not be confused with "empty because the caller forgot". ForFault refuses an unset Continuation, so a forgotten one is a loud programming error at the call site instead of a silent regression to the defect this package exists to remove.

Construct one with EmptyContinuation or ContinuationOf. The zero value is unset and is refused.

func ContinuationOf

func ContinuationOf(bytes []byte) Continuation

ContinuationOf is the continuation of a host that reads a native object as "proceed". Empty bytes are accepted and mean the same as EmptyContinuation.

func EmptyContinuation

func EmptyContinuation() Continuation

EmptyContinuation is the continuation of a host that reads an EMPTY standard output as "proceed" — Claude Code, whose hook contract asks for no bytes when the hook has nothing to say.

func (Continuation) Bytes

func (c Continuation) Bytes() []byte

Bytes returns a copy of the continuation bytes, which may be empty.

func (Continuation) IsSet

func (c Continuation) IsSet() bool

IsSet reports whether the caller supplied a continuation at all.

type ExitStatus

type ExitStatus uint8

ExitStatus is the closed set of process exit statuses the hook may leave with. The zero value is unset and has no process exit code, so a caller that forgets to decide cannot silently exit 0 and be read as a proceed.

const (
	// ExitStatusUnset is the zero value. It is not a status and has no code.
	ExitStatusUnset ExitStatus = iota
	// ExitContinue leaves the host free to continue. Process exit code 0.
	ExitContinue
	// ExitNonBlockingError reports a hook problem that must not stop the host.
	// Process exit code 1.
	//
	// TWO paths in the command produce this status, and BOTH are refusals to
	// exit 0 in silence. ForFault itself never answers it — failing open is
	// defined as exit 0 plus the host's continue bytes, and blocking is exit 2
	// — so neither path is a classified fault.
	//
	// FIRST, the arm in lifecycleFault that meets a false result from ForFault:
	// a fault that could not be classified at all. SECOND, the arm in
	// emitLifecycleOutcome that meets an outcome naming no exit status at all.
	// The second arm was invisible from here for as long as it named its exit
	// with a bare literal 1 rather than this status; that is why this comment
	// once said there was only one, and it is why the arm now goes through
	// Code() like every other exit of the command.
	//
	// BOTH ARMS ARE UNREACHABLE TODAY, and by construction rather than by luck.
	// ForFault refuses only on inputs the command always supplies, so an unknown
	// event, an unknown host version, an unknown harness and a malformed payload
	// all fail open at exit 0 instead; and every outcome the command builds
	// names a status. The first arm becomes reachable the moment a caller builds
	// a Fault from a value it did not set — a new fault stage, a new policy, or
	// a continuation taken from a host table that has no entry for the event.
	// The second becomes reachable the moment a path returns the zero Outcome.
	//
	// IT IS NOT A HARMLESS STATUS ON EVERY ROW. The pasture-generated OpenCode
	// plugin reads ANY non-zero exit as a broken pasture installation and
	// throws. On a GATE callback nothing catches that throw, so the user's tool
	// call is stopped and this exit is not the non-blocking report its name
	// promises. On an OBSERVATION callback the generated code CATCHES the throw
	// and only logs, so nothing is stopped there and the name holds. The
	// diagnostic on that arm names the gate callbacks rather than claiming
	// either that the host is never blocked or that every row is stopped.
	//
	// One question stays open: whether a fail-open fault should use exit 1 on a
	// host that is documented to show the standard error of a failing hook to
	// the user. Answering that needs a citation from the host documentation, so
	// it is not answered here.
	ExitNonBlockingError
	// ExitBlock tells the host to refuse the operation. Process exit code 2.
	ExitBlock
)

func (ExitStatus) Code

func (s ExitStatus) Code() (int, bool)

Code returns the process exit code of the status. The second result is false for the unset zero value, which has no code.

func (ExitStatus) IsValid

func (s ExitStatus) IsValid() bool

IsValid reports whether the status is one of the three declared statuses.

func (ExitStatus) String

func (s ExitStatus) String() string

String names the status for a diagnostic. The unset value has no name.

type Fault

type Fault struct {
	// Mode is the EFFECTIVE native failure behaviour of the event, after the
	// failure-evidence rule of the runtime profile. It is the mode this package
	// obeys, and it is the ONLY mode that may reach an exit status.
	Mode pastureruntime.FailureMode
	// DeclaredMode is the mode the host contract DECLARES for the row, before
	// that rule demotes an uncited blocking row to report-and-continue. It
	// equals Mode on every row the rule does not demote.
	//
	// It is required, not optional, and ForFault refuses a Fault without it.
	// The reason is the defect it was added to remove: while only the effective
	// mode was carried, the diagnostic called the DEMOTED value "the declared
	// failure mode", so a blocking gate with no citation was told that it can
	// never refuse through an exit code. That is false about the declaration,
	// and it hides the one action the operator has — supply the citation. A
	// defaulted DeclaredMode would restore exactly that untruth in silence, so a
	// caller that forgets it gets a loud refusal instead.
	DeclaredMode pastureruntime.FailureMode
	// Undeclared reports that NO ROW of the host contract declares the
	// coordinate this fault was raised on: the harness is not supported, or the
	// event is not in this build's registration. DeclaredMode then carries the
	// mode the command TREATS the coordinate as — observe-only, the weakest
	// claim it can make — and it is still required, because the exit table
	// needs a mode to obey; what changes is what the reader is told. Without
	// this member the diagnostic and the durable record called that treated-as
	// mode "declared", two clauses away from a cause saying nothing declares
	// the row: a parser grouping the record by declaration counted a stale or
	// mismatched hook among the rows that really declare observe-only, and an
	// operator concluded the registration knew the event. A bool has no
	// unusable state, so UnusableInputs and its six conditions are unchanged.
	Undeclared bool
	// Evidence is the citation for the blocking exit code of that mode.
	Evidence pastureruntime.FailureEvidence
	// Policy is the user's fault policy.
	Policy FaultPolicy
	// Stage is what pasture knows about the durable state of the event.
	Stage FaultStage
	// Continuation is the host's proceed bytes for this event class, used when
	// the fault fails open.
	Continuation Continuation
	// Cause is the error that stopped the evaluation. It is preserved verbatim.
	Cause error
}

Fault is everything the exit authority needs to map ONE internal fault. It is a struct rather than a parameter list because every member is required and three of them are closed enums whose zero value is refused: naming them at the call site is what stops a caller passing the wrong one by position.

func (Fault) UnusableInputs

func (f Fault) UnusableInputs() []string

UnusableInputs names EVERY member of the fault that ForFault cannot use, in the order the members are declared. An empty result means ForFault can map the fault.

THE RESULT IS NEVER NIL. The empty result is an EMPTY SLICE, because the caller writes it straight into a durable JSON record: a nil slice marshals to `null` there, and `null` cannot be told apart from a member the writer forgot, while `[]` says "asked, and nothing was unusable". That is the same ambiguity recordedFailureMode exists to remove one member above it, and the mappable arm is essentially every line that file will ever hold, because the refusal arm is unreachable by construction.

It exists so that a caller which meets the false result can SAY WHICH INPUT WAS WRONG. Six separate conditions produce that result, and a message that names one of them — or names the whole struct — sends the reader to the wrong field. ForFault itself asks this function, so the refusal and the explanation can never describe different conditions.

WHY THE RESULT IS A SENTENCE AND NOT A TYPED VALUE. A typed input with an Explain method was considered and is not taken. Everything this result reports is a PROGRAMMING ERROR at the call site rather than a host condition, and the action is the same for all six: report it with the cause. There is one caller, it prints, and no caller wants to branch on which member was wrong. A typed value would add a second thing to keep in step with the six conditions and buy no behaviour today.

The trigger to revisit is a CONSUMER, not a preference, and it is NOT restated here. It is written once, in the "The lifecycle fault record" section of AGENTS.md, where the person it is addressed to meets it: an author of a parser for lifecycle-faults.jsonl opens that section, not this function. It stood in both places with nothing holding them together, which is duplication that drifts rather than a reader served twice. TestTheUnusableInputTriggerIsWrittenWhereAParserAuthorMeetsIt keeps that one statement in place, so removing it from AGENTS.md turns this package RED.

type FaultPolicy

type FaultPolicy uint8

FaultPolicy says what an internal fault does to the host. The zero value is unset and is refused, so the policy is always a deliberate choice made where the environment is parsed.

const (
	// FaultPolicyUnset is the zero value. It is not a policy.
	FaultPolicyUnset FaultPolicy = iota
	// FaultFailOpen lets the host continue when pasture cannot evaluate the
	// event. This is the default: a broken hook must not stop a user working.
	FaultFailOpen
	// FaultFailClosed blocks the host when pasture cannot evaluate an event
	// whose row cites host evidence for a blocking exit code. It is opt-in,
	// for a user who would rather stop than proceed unevaluated. It reaches
	// only the hosts that refuse by exit code; see the package doc.
	FaultFailClosed
)

func (FaultPolicy) IsValid

func (p FaultPolicy) IsValid() bool

IsValid reports whether the policy is one of the two declared policies.

func (FaultPolicy) String

func (p FaultPolicy) String() string

String names the policy for a diagnostic. The unset value has no name.

type FaultStage

type FaultStage uint8

FaultStage says what pasture KNOWS about the durable state of the event when the fault stopped it. The zero value is unset and is refused, so a caller cannot inherit a claim it never made.

It exists because one sentence used to be told to every reader: "this lifecycle event is not evaluated". That is true of a fault raised before any write, and it is NOT true of a hook abandoned at its deadline, whose durable receipt may already be committed. Telling a maintainer that nothing was recorded, and leaving an occurrence in the journal, is the class of untruth this whole epic exists to remove.

const (
	// FaultStageUnset is the zero value. It is not a stage.
	FaultStageUnset FaultStage = iota
	// FaultStageNotRecorded means pasture knows no occurrence was committed:
	// the fault stopped the invocation before the durable write, or the durable
	// write itself returned an error, which commits no occurrence.
	FaultStageNotRecorded
	// FaultStageRecordUnknown means pasture stopped without learning whether
	// the occurrence was committed. The abandoned deadline is the case that
	// matters: the work runs in a goroutine and the receipt commits before the
	// native bytes are produced, so an expiry can land on either side of the
	// commit.
	//
	// A HAZARD THIS ARM CARRIES INTO THE FUTURE, stated here because this is
	// where a reader meets the arm. Today the only thing that commit may hold
	// is an occurrence, so an unknown record state is harmless. Once an
	// EVALUATED DECISION can be part of the same commit, the same interleaving
	// can land AFTER a Deny is durably recorded, and the fail-open continuation
	// would then tell the host to PROCEED PAST A REFUSAL that the record says
	// was made. That direction is the dangerous one, so the change that adds
	// decisions must choose deliberately between making the decision and the
	// continuation one atomic step and recording the abandoned gate under its
	// own evaluation-fault reason; it must not inherit this arm unexamined.
	FaultStageRecordUnknown
	// FaultStageRecorded means pasture KNOWS the occurrence was committed. It
	// is the third answer to the one question this type asks — was the delivery
	// written? — and it existed as a fact before it existed as a stage, which
	// is how two shipped sentences came to be false.
	//
	// WHO USES IT, AND WHY NEITHER OTHER STAGE WAS HONEST FOR THEM:
	//
	//   - A DELIVERY THAT COULD NOT BE BOUND. The host payload does not carry
	//     the identity fields the event declares, so the delivery is REFUSED
	//     rather than reinterpreted. The row is written with the disposition
	//     that refused it and an empty interpreted set. FaultStageNotRecorded
	//     said "no occurrence was recorded for it", and the row was there.
	//   - A RECEIPT COMMITTED WITHOUT A CONTINUATION. The durable write
	//     succeeded and the host never received its bytes.
	//     FaultStageRecordUnknown said pasture "stopped before it learned
	//     whether this event was recorded", and pasture had learned — the error
	//     that marks this case is named for the commit it observed.
	//
	// THE STAGE SAYS NOTHING ABOUT WHETHER THE EVENT WAS EVALUATED, and that is
	// deliberate rather than an omission: those two users differ on exactly
	// that point — the first was not evaluated, the second was — and a stage
	// that answered both questions at once could not have been true for both.
	// Whether pasture could evaluate the event is stated once, in the
	// diagnostic's What, and the cause carries the particulars.
	FaultStageRecorded
)

func (FaultStage) IsValid

func (s FaultStage) IsValid() bool

IsValid reports whether the stage is one of the declared stages.

func (FaultStage) String

func (s FaultStage) String() string

String names the stage for a diagnostic and for the durable fault record. The unset value has no name.

type Outcome

type Outcome struct {
	Exit   ExitStatus
	Stdout []byte
	Stderr string
}

Outcome is everything the hook process gives its host: the exit status, the bytes written to stdout, and the diagnostic written to stderr.

On a fail-open fault Stdout carries the host's CONTINUE BYTES, so the host proceeds instead of choking on a body it cannot parse. On a block it carries nothing: a host that is being refused is not also told to continue.

func ForDecision

func ForDecision(native []byte, exit ExitStatus, stderr string) Outcome

ForDecision carries an evaluated decision to the host. The caller supplies the native bytes the host contract asks for, the exit status the decision maps to, and the text the user reads.

A decision is never re-judged here: the fault policy and the failure evidence do not appear in the signature, so an evaluated Deny cannot be turned into a proceed by a missing citation or by the fail-open default. That is a STRUCTURAL guarantee of the signature, not a behaviour a table can exercise; TestForDecisionCarriesTheDecisionVerbatim proves the pass-through and TestForDecisionSignatureCannotSeeThePolicyOrTheEvidence pins the structure.

func ForFault

func ForFault(fault Fault) (Outcome, bool)

ForFault maps an internal fault to the Outcome the host receives.

The table is small and total:

  • fail-open, any mode: continue, with the host's CONTINUE BYTES on stdout and the diagnostic on stderr.
  • fail-closed, a mode that blocks by exit code, WITH evidence: block, with nothing on stdout.
  • fail-closed, anything else: continue, exactly as fail-open.

The second result is false when there is nothing to map: a nil cause, an unset or invalid effective failure mode, an unset or invalid DECLARED failure mode, an unset or invalid policy, an unset or invalid stage, or a Continuation the caller never set. A false result is a programming error at the call site, never a host outcome, so the caller must treat it as one and must not fall through to a silent exit 0.

ForFault never encodes a policy Deny. A Deny is an evaluated answer and goes through ForDecision, where neither the fault policy nor missing evidence can weaken it.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL