Documentation
¶
Overview ¶
Package approval is the write gate: the seam where a mutating tool call stops and waits for an operator (docs/v0.3-plan.md W2, hitl_policy.on_mutation).
It is deliberately two halves that meet here and nowhere else:
- pkg/permissions decides POLICY — may this call proceed without asking, must it ask, or is it refused outright. That package is ADK-independent and stays that way.
- ADK's tool-confirmation flow performs the PAUSE. It is durable: the request is a function call in the session event log, so the turn survives a process death and resumes from the log. mast's own permissions.Prompter is a synchronous in-process ask and by construction cannot survive a restart (scoreboard row 5), which is why the pause is not built on it.
The seam itself is an ADK runner plugin's BeforeToolCallback, which is the only place that sees every tool call — builtin, MCP, or specialist-scoped — before it runs. Registration order matters and is settled: the pkg/effects outbox plugin goes first, this one second, so a replayed call is answered from the outbox without asking an operator to approve a mutation that already happened (resolved-decision 144).
The substrate facts this package is built on are pinned by adkseam_test.go rather than assumed; each is load-bearing and none is documented by ADK.
Index ¶
- Constants
- func CallKey(name string, args map[string]any) string
- func ConfirmationResponse(v Verdict) map[string]any
- func EditStateKey(functionCallID string) string
- func New(cfg Config) (*plugin.Plugin, error)
- func VerdictSchema() *jsonschema.Schema
- type AppliedEdit
- type Config
- type OnMutation
- type Outcome
- type Parked
- type Request
- type Scope
- type Verdict
Constants ¶
const EditStateKeyPrefix = "mast_approval_edit_"
EditStateKeyPrefix namespaces the durable record of an applied edit in the session's state, one key per function call.
The record exists because the event log alone cannot answer "what executed?" after an edit. ADK re-fires the parked call verbatim, so the durable FunctionCall part still carries the arguments the *model* proposed, and the FunctionResponse next to it is the result of running the *operator's*. Reading the pair without this record gives an operator a confident, wrong answer about what mast did to their cluster (docs/v0.3-plan.md W2.5, "an audit gap the workstream has to close, not inherit").
const PluginName = "mast-write-gate"
PluginName is the registered name of the write gate's runner plugin.
Variables ¶
This section is empty.
Functions ¶
func CallKey ¶
CallKey renders a tool call as the one-line description an operator approves and the deny policy matches against. Keys are sorted so the same call always renders the same way — an approval record that depended on Go's map iteration order would be useless as an audit trail and unmatchable as a policy pattern.
Values are rendered as compact JSON and long ones are elided: the key is for a human and a glob, and a 40KB manifest argument in a log line helps neither. The full arguments travel in the confirmation payload, which is what the operator actually inspects.
func ConfirmationResponse ¶
ConfirmationResponse builds the FunctionResponse payload that answers a parked mutating call. It is the exact wire shape ADK's RequestConfirmationRequestProcessor looks for before re-dispatching the original call: `confirmed` is the boolean it reads, `payload` carries the verdict it cannot express.
It lives here, next to DecodeVerdict, so the writer and the reader of that shape stay in one place — the daemon's /resume handler and the eval rig both build it from this rather than each deriving it, which is what makes the rig's result evidence about mast.
An edit is confirmed=true: the operator is authorizing a call, just not the one the model proposed. The gate decides which arguments run.
func EditStateKey ¶
EditStateKey is the state key holding the AppliedEdit record for one function call.
func New ¶
New builds the write gate as an ADK runner plugin.
Register it AFTER the pkg/effects outbox plugin at every runner construction site. ADK runs before-tool callbacks in registration order and the first non-nil response wins, so outbox-first is what makes a replayed effect skip the gate: the mutation already happened, and asking an operator to approve it again would invite them to approve doing it twice (resolved-decision row 144).
func VerdictSchema ¶
func VerdictSchema() *jsonschema.Schema
VerdictSchema is the JSON schema a resume payload answering a parked mutating call must satisfy. It has been three-valued since W2.1 — before `edit` was executable — because the schema is written into the durable log at pause time, and a session paused under a two-valued schema and resumed after an upgrade is a migration across exactly the restart boundary the write gate exists to survive.
Approver is deliberately absent: it is not the client's to state. The resume boundary overwrites it with the authenticated caller.
Types ¶
type AppliedEdit ¶
type AppliedEdit struct {
Tool string `json:"tool"`
Approver string `json:"approver"`
ProposedKey string `json:"proposed_key"`
ExecutedKey string `json:"executed_key"`
ProposedArgs map[string]any `json:"proposed_args"`
ExecutedArgs map[string]any `json:"executed_args"`
Note string `json:"note,omitempty"`
}
AppliedEdit is the durable record of an operator's edit being applied: what the model asked for, what actually ran, and who authorized the substitution. Stored as a JSON string so it survives every session backend's state encoding unchanged.
func DecodeAppliedEdit ¶
func DecodeAppliedEdit(v any) (AppliedEdit, error)
DecodeAppliedEdit parses a record written under an EditStateKey. The value is whatever the session backend handed back: the JSON string that was written, or — for a backend that decodes state values — the map it decodes to.
func (AppliedEdit) String ¶
func (e AppliedEdit) String() string
String renders the record for an operator-facing listing.
type Config ¶
type Config struct {
// Policy is the workload's hitl_policy.on_mutation. Required.
Policy OnMutation
// Mutating classifies a tool by name. Required. mast passes
// effects.Predicate's classification so the outbox and the write
// gate can never disagree about what counts as a mutation — a tool
// that is recorded as an effect is a tool that needs approval.
Mutating func(toolName string) bool
// Gate adjudicates policy for a parked call and validates the
// operator's verdict. Required when Policy is
// OnMutationRequireApproval; unused otherwise.
Gate *permissions.Gate
// Logger receives the audit trail. Defaults to slog.Default().
Logger *slog.Logger
}
Config wires the write gate.
type OnMutation ¶
type OnMutation string
OnMutation is a workload's policy for what happens when a specialist calls a mutating tool (docs/orchestration-design.md, hitl_policy.on_mutation).
const ( // OnMutationRequireApproval parks the call and waits for an // operator. The default, and the only value that is safe when the // roster's remediation tools reach a live cluster. OnMutationRequireApproval OnMutation = "require_approval" // OnMutationApply executes mutating calls without asking. Policy // still applies — a configured deny still denies — but no human is // consulted. For workloads whose "mutations" are confined to a test // fixture, and for reproducing an approved change set unattended. OnMutationApply OnMutation = "apply" // OnMutationDryRun never executes a mutating call and reports the // call it would have made. The agent keeps working with a truthful // "this did not happen" instead of a fabricated success. OnMutationDryRun OnMutation = "dry_run" )
func (OnMutation) Valid ¶
func (p OnMutation) Valid() bool
Valid reports whether p is a policy this package implements. The empty string is not valid: callers default it explicitly so that a typo in a bundle cannot silently mean "require approval" in one code path and "apply" in another.
type Outcome ¶
type Outcome string
Outcome is the operator's answer to a parked mutating call.
const ( // OutcomeApprove runs the call as the agent proposed it. OutcomeApprove Outcome = "approve" // OutcomeReject refuses the call. The agent is told not to retry and // not to look for another way to achieve the same effect. OutcomeReject Outcome = "reject" // OutcomeEdit runs the call with arguments the operator supplied in // place of the agent's. The operator's arguments are validated against // the tool's declared input schema, re-adjudicated against policy // under their own call key, and recorded durably as an AppliedEdit — // an edit that fails any of those is refused rather than narrowed // (edit.go). OutcomeEdit Outcome = "edit" )
type Parked ¶
type Parked struct {
// Hint is the one-line question the gate wrote.
Hint string
// Tool is the parked call's tool name, "" if it could not be read.
Tool string
// Args are the arguments the agent proposed.
Args map[string]any
// Request is the gate's own payload (an approval.Request), as the
// map the event log round-trips it to. Nil when the confirmation
// came from somewhere other than mast's write gate — a tool that
// calls RequestConfirmation itself, for instance.
Request map[string]any
}
Parked describes a mutating call waiting on an operator, read back out of the durable session log.
The gate writes the question; this reads it. The two are in the same package because the args of an adk_request_confirmation call are an ADK-internal shape that mast happens to be able to read, not a contract — a reader that lived somewhere else would drift.
func DescribeConfirmation ¶
DescribeConfirmation reads a parked mutating call out of the Args of an adk_request_confirmation function call (internal/llminternal/functions.go builds them: {"originalFunctionCall": *genai.FunctionCall, "toolConfirmation": toolconfirmation.ToolConfirmation}).
Both value shapes are handled. In-process — an attach client tailing live events — the args hold the typed structs; read back from the event log they are the maps JSON leaves behind. Anything unreadable is left zero rather than guessed at: this feeds an operator deciding whether to change a cluster, and a plausible-looking reconstruction is worse than a blank.
type Request ¶
type Request struct {
Tool string `json:"tool"`
Args map[string]any `json:"args"`
Key string `json:"key"`
Policy string `json:"policy"`
Agent string `json:"agent"`
Verdict map[string]any `json:"verdict_format"`
}
Request is the payload of the parked confirmation: everything an operator needs to answer without reconstructing the call from the transcript, plus a description of the answer mast accepts.
func DecodeRequest ¶
DecodeRequest reads the parked confirmation's payload back into the typed Request. The value is whatever the caller got out of the durable log — Parked.Request, or a transcript projection's Payload — which is the map JSON leaves behind rather than the struct the gate wrote.
type Scope ¶
type Scope string
Scope is how far an approval reaches. It is on the wire so that a client asking for more than one call gets an explicit refusal instead of a silent narrowing (docs/v0.3-plan.md W2.3): only ScopeOnce is admissible for a mutation, and the refusal is issued by permissions.Gate.RecordMutationVerdict, not here, so that the rule lives with the rest of the grant policy.
const ( // ScopeOnce authorizes exactly this call. The default, and the only // scope a mutating call accepts. ScopeOnce Scope = "once" // ScopeSession asks to authorize this exact request for the session. ScopeSession Scope = "session" // ScopeSessionTool asks to authorize every call to this tool for the // session. ScopeSessionTool Scope = "session_tool" // ScopeAlways asks to persist a standing allowlist entry. ScopeAlways Scope = "always" )
type Verdict ¶
type Verdict struct {
Verdict Outcome `json:"verdict"`
Scope Scope `json:"scope,omitempty"`
Args map[string]any `json:"args,omitempty"`
Note string `json:"note,omitempty"`
Approver string `json:"approver,omitempty"`
}
Verdict is the operator's answer, carried in the Payload of the ADK tool confirmation that resumes a parked call (docs/orchestration-design.md, "Mutation approval").
Approver is not client-supplied trust. Whatever a client puts there is overwritten at the resume boundary with the authenticated principal that presented the verdict (cmd/mast's verdictFor), so every verdict mast itself produces carries one. An OutcomeEdit that nonetheless reaches the gate without an approver is refused: an edit executes arguments no model proposed and no policy pattern vetted, so the attribution is the only record of where they came from.
func DecodeVerdict ¶
func DecodeVerdict(c *toolconfirmation.ToolConfirmation) (Verdict, error)
DecodeVerdict reads mast's verdict record out of an ADK tool confirmation.
ADK's own field is the boolean Confirmed, which cannot express an edit; mast's record rides in Payload. A client that speaks only ADK still works — a payload with no verdict field falls back to the boolean — and a client that speaks both must not contradict itself: a payload saying "approve" under Confirmed:false is a bug somewhere in the caller's serialization, and guessing which half meant it would be guessing about whether to mutate a production cluster.
Everything unrecognized is an error rather than a default. The failure mode of a permissive decoder here is executing a mutation the operator did not authorize.