approval

package
v0.4.0 Latest Latest
Warning

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

Go to latest
Published: Aug 17, 2026 License: Apache-2.0 Imports: 16 Imported by: 0

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

View Source
const ChangeSetField = "proposed_change"

ChangeSetField is the report property a change set travels in. It is a property of the *workload's* report schema (gke-triage's schemas/finding.json declares it), not something mast injects — mast only recognizes the name.

View Source
const ChangeSetStateKeyPrefix = "mast_change_set_"

ChangeSetStateKeyPrefix namespaces the durable record of the change set one specialist proposed, one key per specialist.

View Source
const DecisionSchema = "mast.decision/v1"

DecisionSchema names the record shape in an export's provenance header, so a consumer reading a file written by an older mast can tell what it is holding rather than inferring it from the fields that happen to be present.

View Source
const DecisionStateKeyPrefix = "mast_decision_"

DecisionStateKeyPrefix namespaces the durable record of one adjudication in the session's state, one key per function call.

It exists because the three answers an operator can give leave three different traces, and none of them is the whole thing (v0.4 W8):

  • approve leaves a FunctionCall and a FunctionResponse that look exactly like an ungated call;
  • reject leaves a FunctionResponse carrying a refusal string, which is indistinguishable from a tool that happened to fail;
  • edit leaves an AppliedEdit, which is the only one of the three that is already a record — and only because W2.5 needed it to answer "what ran?".

A fleet's adjudications are worth more than that. Read together they are the closest thing an operator has to labelled data about their own judgement — which calls a human waves through, which they refuse, and which they correct and how. That is only harvestable if all three land in the same shape, so the write gate records one Decision per adjudication whatever the answer was, and pkg/transcript exports them.

AppliedEdit is deliberately not replaced by it. It is a shipped surface — `mast sessions show` prints it, transcript.Detail projects it, the uat asserts on it — and folding it into this record would break all three to save a duplicated field pair.

View Source
const DefaultGrantTTL = 10 * time.Minute

DefaultGrantTTL is how long a change-set grant lives when the bundle does not say.

Ten minutes is chosen against the two failure modes rather than from a threat model: it is far longer than the seconds an approved executor takes to run its calls, so it never fires during normal operation; and it is far shorter than the hours over which an operator forgets what they approved. A daemon that crashes and comes back inside the window still fires the set, which is the crash behaviour W7 is for; one that comes back after it asks again.

View Source
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").

View Source
const FinishTaskToolName = "finish_task"

FinishTaskToolName is the completion tool ADK auto-installs on every Task-mode agent, and the one place a specialist's structured report exists as arguments mast can inspect before it becomes a result.

Hard-coded rather than imported: ADK keeps the constant in internal/workflowinternal. TestFinishTaskIsTheReportSeam pins the name against a live Task agent, so a rename upstream fails a test here instead of quietly disabling the producer contract.

View Source
const GrantStateKeyPrefix = "mast_change_grant_"

GrantStateKeyPrefix namespaces the durable record of one minted change-set grant. One key per authorized call.

Per signature rather than one key per set, because the calls in a set are executed independently and each one's consumption has to be recorded independently. A single key holding the whole set would make two calls in the same turn race to rewrite it, and the loser's consumption mark would vanish.

View Source
const MachineApproverPrefix = "mast:"

MachineApproverPrefix marks an identity that names a mechanism rather than a person: mast:internal for an in-process caller (the timed-pause scheduler, boot-time auto-resume), mast:scheduler, and any future sibling. cmd/mast mints these itself; no authenticated caller can present one, because the daemon overwrites the payload's approver with the authenticated principal at the resume boundary.

View Source
const PluginName = "mast-write-gate"

PluginName is the registered name of the write gate's runner plugin.

View Source
const RedactedApproverPrefix = "sha256:"

RedactedApproverPrefix marks a digested identity in an export, so a consumer can never mistake one for a login name.

Variables

This section is empty.

Functions

func CallKey

func CallKey(name string, args map[string]any) string

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 ChangeSetStateKey added in v0.4.0

func ChangeSetStateKey(specialist string) string

ChangeSetStateKey is the state key holding a specialist's proposed change set.

Durable, because the predicate that routes a finding to the change executor has to survive the approval pause. Under graph dispatch a confirmation resume re-enters at START and upstream nodes genuinely re-execute (docs/spike-findings.md, W2.1's asymmetry), so a decision held in a Go variable from the first pass is not there on the second.

func ConfirmationResponse

func ConfirmationResponse(v Verdict) map[string]any

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 DecisionStateKey added in v0.4.0

func DecisionStateKey(functionCallID string) string

DecisionStateKey is the state key holding the Decision record for one function call.

func DescribeChangeSet added in v0.4.0

func DescribeChangeSet(changes []ProposedChange) string

DescribeChangeSet renders a change set for a prompt or an operator message: one canonical signature per line, in list order.

func EditStateKey

func EditStateKey(functionCallID string) string

EditStateKey is the state key holding the AppliedEdit record for one function call.

func EncodeChangeSet added in v0.4.0

func EncodeChangeSet(changes []ProposedChange) (string, error)

EncodeChangeSet renders a change set for durable state. Stored as a JSON string so it survives every session backend's state encoding unchanged — the same reason AppliedEdit is.

func EncodeDecision added in v0.4.0

func EncodeDecision(d Decision) (string, error)

EncodeDecision renders the record for storage.

func EncodeGrant added in v0.4.0

func EncodeGrant(g Grant) (string, error)

EncodeGrant renders a grant for durable state, as a JSON string, for the same reason every other record here is one: it survives every session backend's state encoding unchanged.

func GrantStateKey added in v0.4.0

func GrantStateKey(signature string) string

GrantStateKey is the state key holding the grant for one call signature.

The signature is hashed rather than embedded: it contains the call's full arguments, which are arbitrary JSON of arbitrary length, and a state key is not the place for a 40KB manifest. The record carries the signature in full, and lookup compares it, so a hash collision produces "no grant" rather than the wrong grant.

func InputSchema added in v0.4.0

func InputSchema(t tool.Tool) (*jsonschema.Schema, error)

InputSchema reads a tool's declared parameters as a JSON Schema.

ADK carries the schema in two mutually exclusive fields: MCP tools and function tools set ParametersJsonSchema (a *jsonschema.Schema), while a declaration built by hand may use the genai.Schema in Parameters. Both are re-marshalled rather than type-asserted, because the field is typed `any` and its dynamic type is the provider's business.

func Legible added in v0.4.0

func Legible(changes []ProposedChange) error

Legible reports whether a change set is one an operator can approve as a set.

The rule is derived, not declared: every argument of every call must render into the call key in full. CallKey elides values over maxValueLen because a 40KB manifest in a log line helps nobody — but an elided value in the question is a value the operator did not read, and minting grants from it would convert "yes to what I can see" into "yes to what I cannot". A set with one of those is still approvable call by call, where the full arguments travel in each parked confirmation's payload and the operator can inspect them one at a time. This is the legibility rule of docs/v0.3-plan.md W7: narrow named tools, not manifest blobs.

func New

func New(cfg Config) (*plugin.Plugin, error)

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 NormalizeArgs added in v0.4.0

func NormalizeArgs(toolName string, schema *jsonschema.Schema, args map[string]any) (map[string]any, error)

NormalizeArgs validates one call's arguments against the tool's declared input schema and returns them in the shape the tool will receive.

Tool-instance-free on purpose. W2.5 only ever needed to check an operator's edit against the tool ADK was about to run, so the check took a live tool.Tool and read the schema off it. W7.0 needs the same check one step earlier — keyed by tool name, at the moment a specialist returns a finding, with no instance in hand — and the one thing that must not happen is a second implementation of "schema-valid arguments" that the two paths can disagree about. So the schema is a parameter and the caller says where it came from: InputSchema for a live tool, a catalog lookup for a proposed change.

Empty arguments are legal here (a tool may declare none, and the schema's own `required` is what says otherwise). The edit path refuses them before calling in, because an edit verdict carrying no arguments is a different thing: an operator who meant to approve.

func RedactApprover added in v0.4.0

func RedactApprover(approver string) string

RedactApprover replaces an approver identity with a stable digest of it.

The point of the digest, rather than dropping the field: a decision dataset needs to be able to answer "did the same person approve both of these?" — inter-approver consistency is most of what makes fleet adjudications interesting — and that question does not need anybody's name. Two exports taken a month apart digest the same person the same way, so the answer survives across files.

Machine identities pass through in the clear. Digesting mast:internal would hide nothing (there is exactly one of it, and the digest is a constant anyone can compute) while destroying the one distinction a consumer actually needs: whether a change was waved through by a human or by mast's own scheduler. A dataset that cannot separate those is not a dataset about human judgement.

The whole string is digested, including cmd/mast's "alice@corp (asserted by svc-proxy)" spelling. Splitting it to keep the proxy in the clear would leak the shape of who proxies for whom, and the pair is one principal for the purpose of "same approver?".

The truncation length follows grant.go's digestResult: 16 hex characters, 64 bits, which is far past collision risk for the number of distinct operators any fleet has.

func Signature added in v0.4.0

func Signature(toolName string, args map[string]any) (string, error)

Signature renders a change as a byte-stable identity for the exact call it proposes.

This is what "the operator approves the object that fires" reduces to: the call parked at the write gate has to render to the same bytes as the change the operator approved, or the claim is about intent rather than about the call. CallKey cannot serve — it elides values over 120 characters for legibility, so two different manifests share a key — and neither can Go's map iteration order, which is why this goes through encoding/json (which sorts object keys, at every depth).

Arguments must be JSON-encodable. NormalizeArgs guarantees that by construction; the error return is what stops a caller who skipped it from getting a signature that quietly compares equal to something else.

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 Authority added in v0.4.0

type Authority string

Authority is where the authorization for this call came from.

const (
	// AuthorityVerdict is the ordinary case: a person was asked about
	// this exact call and answered it.
	AuthorityVerdict Authority = "operator_verdict"

	// AuthorityChangeSetGrant is a call that fired on an answer given
	// earlier about a different call in the same change set (W7).
	//
	// These are recorded too, and the reason is the whole point of the
	// workstream: an export that held only the calls a human was asked
	// about would show one approved scale_deployment where four ran, and
	// would be a quietly false description of what the operator
	// authorized.
	AuthorityChangeSetGrant Authority = "change_set_grant"
)

type ChangeSetChecker added in v0.4.0

type ChangeSetChecker struct {
	// Declares reports whether the workload's tool_catalog names this
	// tool.
	Declares func(toolName string) bool

	// Schema resolves the tool's declared input schema by name. It is
	// consulted only for tools Declares accepted.
	Schema func(toolName string) (*jsonschema.Schema, error)
}

ChangeSetChecker validates a proposed change set against the workload's catalog and the named tools' declared input schemas.

Both fields are required, and they answer different questions. Declares is "may this workload call this tool at all" — the catalog is the workload's whole reachable surface (W2.2/W2.4), so a change naming something outside it is a change nothing could execute. Schema is "are these the arguments that tool takes" — which needs the live tool, because the catalog carries names and a mutating flag and deliberately no schemas.

func (ChangeSetChecker) Check added in v0.4.0

func (c ChangeSetChecker) Check(changes []ProposedChange) ([]ProposedChange, error)

Check validates every entry and returns the change set with each entry's arguments normalized into the shape the tool will receive.

The first failure wins and names the entry. A specialist that gets this back has to fix one thing and re-report; handing it a list of every problem at once invites it to rewrite the whole finding.

type ChangeSetContext added in v0.4.0

type ChangeSetContext struct {
	// Specialist proposed the set; Changes are its calls in order.
	Specialist string           `json:"specialist"`
	Changes    []ProposedChange `json:"changes"`

	// Grantable reports whether `scope: change_set` is admissible for
	// this set, and Ungrantable says why it is not.
	Grantable   bool   `json:"grantable"`
	Ungrantable string `json:"ungrantable,omitempty"`

	// TTLSeconds is how long an approval of the whole set would
	// authorize its remaining calls for.
	TTLSeconds int `json:"ttl_seconds,omitempty"`

	// Preconditions names, per tool, the read each granted call will be
	// re-checked against before it fires — and says plainly where there
	// is none, because "approved for ten minutes" and "approved while
	// the Deployment still has 3 replicas" are very different promises.
	Preconditions map[string]string `json:"preconditions,omitempty"`
}

ChangeSetContext is what the parked question says about the set the call belongs to, so an operator answering one call can see the rest and authorize them together.

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

	// ChangeSet, when non-nil, enforces the change-set producer
	// contract (W7.0): a specialist's report may only carry a
	// proposed_change naming a tool this workload declares, with
	// arguments that satisfy that tool's declared input schema.
	//
	// It rides the write gate rather than being a plugin of its own
	// for the reason W2.4 paid for: every runner construction path
	// already registers this one, and a check that only some paths
	// install is a check with a hole in it. Nil is "this composition
	// has no catalog to check against" — a library embed with no
	// bundle — and leaves reports untouched.
	ChangeSet *ChangeSetChecker

	// Grants, when non-nil, lets one operator answer authorize a whole
	// change set (W7): approving a call that belongs to a recorded set
	// with `scope: change_set` mints a grant for each of the set's
	// other calls, bound to that call's exact signature, and this gate
	// consumes them instead of parking again.
	//
	// The value configures how long such an approval speaks for and
	// how mast re-checks the world before each granted call fires. Nil
	// is W7.0 behaviour: every mutating call is parked on its own, and
	// `scope: change_set` is refused rather than quietly treated as
	// `once` — see grant.go.
	Grants *Freshness

	// Workload names the workload whose bundle composed this gate, and
	// is stamped onto every Decision record so an exported adjudication
	// is legible without the session it came from (v0.4 W8). Optional:
	// a library embed that registers this plugin itself has no bundle to
	// name, and an unnamed workload is a thinner row rather than a
	// missing one.
	Workload string

	// Logger receives the audit trail. Defaults to slog.Default().
	Logger *slog.Logger
}

Config wires the write gate.

type Decision added in v0.4.0

type Decision struct {
	// DecidedAt is when the gate adjudicated, not when the operator
	// answered — mast does not see the latter.
	DecidedAt time.Time `json:"decided_at"`

	// Session, Workload, Specialist and Invocation are what makes a row
	// legible without the session it came from. A dataset of calls with
	// no context is a dataset of trivia: "someone rejected
	// scale_deployment(api, 10)" is only a label if you can also say
	// which workload was running and which specialist proposed it.
	Session    string `json:"session"`
	Workload   string `json:"workload,omitempty"`
	Specialist string `json:"specialist,omitempty"`
	Invocation string `json:"invocation,omitempty"`

	// FunctionCallID keys the record and is the join back to the
	// transcript's FunctionCall/FunctionResponse pair.
	FunctionCallID string `json:"function_call_id,omitempty"`

	Tool string `json:"tool"`

	// Outcome is the operator's answer. Empty when there was not a
	// readable one — a malformed verdict is recorded rather than
	// dropped, because "clients keep sending mast payloads it cannot
	// read" is itself a finding.
	Outcome Outcome `json:"outcome,omitempty"`
	Scope   Scope   `json:"scope,omitempty"`

	Authority   Authority   `json:"authority"`
	Disposition Disposition `json:"disposition"`

	// Refusal is the machine-readable code the model was told, when the
	// call did not run: denied_by_operator, edit_refused,
	// denied_by_policy, and so on.
	Refusal string `json:"refusal,omitempty"`

	// ChangeSet names the approved set a granted call fired under
	// (Grant.Origin). Empty for an ordinary verdict.
	ChangeSet string `json:"change_set,omitempty"`

	// ProposedKey/ProposedArgs are the call as the model asked for it.
	ProposedKey  string         `json:"proposed_key"`
	ProposedArgs map[string]any `json:"proposed_args,omitempty"`

	// ExecutedKey/ExecutedArgs are set only when they differ from the
	// proposal — that is, on an edit. The proposed→executed pair is the
	// densest signal in the whole record: it is a human writing down
	// what the model should have said.
	ExecutedKey  string         `json:"executed_key,omitempty"`
	ExecutedArgs map[string]any `json:"executed_args,omitempty"`

	Approver string `json:"approver,omitempty"`
	Note     string `json:"note,omitempty"`
}

Decision is the durable record of one adjudication of one mutating call: what was proposed, what the operator answered, what actually ran, and who decided.

Stored as a JSON string in the session state, like AppliedEdit and Grant, so it survives every session backend's state encoding unchanged.

Approver is stored raw. Redaction is an export-time decision (RedactApprover) rather than a storage-time one, because the operator surface an on-call engineer reads — `mast sessions show`, the daemon's own audit log — must be able to name the person who approved a change to their cluster. What must not leak is the *export*, which travels.

func DecodeDecision added in v0.4.0

func DecodeDecision(v any) (Decision, error)

DecodeDecision parses a record written under a DecisionStateKey. 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 (Decision) Edited added in v0.4.0

func (d Decision) Edited() bool

Edited reports whether the operator substituted their own arguments.

func (Decision) Redacted added in v0.4.0

func (d Decision) Redacted() Decision

Redacted returns a copy of the record with the approver digested. Argument values are untouched, deliberately — see the note on transcript.ExportOptions.

type Disposition added in v0.4.0

type Disposition string

Disposition is what the gate did with the call, as distinct from what the operator asked for. The two come apart more often than they look like they should: an approved call can still be refused by the permissions policy, and an edit can be refused for arguments the tool does not declare. A dataset that recorded only the operator's answer would be labelling the wrong variable.

const (
	// DispositionAuthorized means the gate let the call through. It says
	// nothing about whether the tool then succeeded — that is the
	// FunctionResponse's business, not the gate's.
	DispositionAuthorized Disposition = "authorized"

	// DispositionRefusedByOperator means a person said no.
	DispositionRefusedByOperator Disposition = "refused_by_operator"

	// DispositionRefusedByMast means mast refused a verdict a person
	// gave: a malformed payload, an edit it could not attribute or
	// validate, a scope it does not grant, a configured deny the
	// operator's edit walked into. Refusal carries which.
	DispositionRefusedByMast Disposition = "refused_by_mast"
)

type Freshness added in v0.4.0

type Freshness struct {
	// TTL is how long a minted grant lives. Zero means DefaultGrantTTL.
	TTL time.Duration

	// Now is the clock, for tests. Nil means time.Now.
	Now func() time.Time

	// Precondition returns the freshness declaration for a tool, or
	// nil if it declares none. An error is fail-closed: no grant is
	// minted, and the call parks as it would have before W7.
	Precondition func(toolName string) (*Precondition, error)

	// Read runs a read-only tool and returns its result. Required when
	// any tool declares a precondition; without it a declared
	// precondition cannot be evaluated and no grant is minted.
	Read func(ctx agent.Context, toolName string, args map[string]any) (map[string]any, error)
}

Freshness bounds how long an approval speaks for.

Two clocks matter and neither one is enough alone. Wall time is the one mast can always measure: an approval answered from a phone at 02:00 and executed when the daemon comes back at 09:00 is not an approval of anything anyone looked at. Cluster state is the one that actually matters, and it can move in seconds — a change set is even self-invalidating by construction, since calls 1..k mutate the world calls k+1..N were reasoned about.

So the TTL is a backstop, not the check. Set it long enough that a legitimate approve→execute round trip never trips it (a short TTL that fires routinely trains operators to re-approve without reading), and let the precondition carry the real question. A tool with no declared precondition is bounded by the TTL alone, and mast says so in the parked question rather than implying a check it is not making.

type Grant added in v0.4.0

type Grant struct {
	// Signature is the exact call this grant authorizes, in the form
	// Signature renders. The whole grant hangs off this string being
	// byte-identical to the call that later arrives at the gate.
	Signature string `json:"signature"`

	Tool      string         `json:"tool"`
	Arguments map[string]any `json:"arguments"`

	// Origin is the call key of the parked call the operator actually
	// answered, and Approver is who answered it. Together they are the
	// audit answer to "who authorized this, and what were they looking
	// at when they did".
	Origin   string `json:"origin"`
	Approver string `json:"approver,omitempty"`
	Note     string `json:"note,omitempty"`

	MintedAt  time.Time `json:"minted_at"`
	ExpiresAt time.Time `json:"expires_at"`

	// Precondition is the snapshot of the world this grant was issued
	// against, or nil when the tool declares no precondition. Re-read
	// before the call fires.
	Precondition *PreconditionSnapshot `json:"precondition,omitempty"`

	// ConsumedBy is the function call id that spent this grant, and
	// VoidedBy is why it can never be spent (a failed freshness check).
	// Both empty on a live grant; a record is rewritten rather than
	// deleted so the audit trail keeps the whole life of the approval.
	ConsumedBy string `json:"consumed_by,omitempty"`
	VoidedBy   string `json:"voided_by,omitempty"`
}

Grant is the durable record of one authorized call.

func DecodeGrant added in v0.4.0

func DecodeGrant(v any) (Grant, error)

DecodeGrant reads a grant back out of durable state.

func (Grant) Spent added in v0.4.0

func (g Grant) Spent(functionCallID string) bool

Spent reports whether this grant can still authorize a call. The function call id is the caller's own: ADK re-dispatches a call after a confirmation, and a grant spent by *this* call is not spent as far as this call is concerned.

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

func DescribeConfirmation(args map[string]any) Parked

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.

func (Parked) Summary

func (p Parked) Summary() string

Summary is the operator-facing one-liner for a parked call: the hint when the gate wrote one, the rendered call otherwise, and an honest admission when neither could be read.

type Precondition added in v0.4.0

type Precondition struct {
	// Read names a read-only tool in the same catalog. Refusing a
	// mutating one is the caller's job (internal/compose does it): a
	// freshness check that changes the cluster is not a check.
	Read string `json:"read"`

	// Args are literal arguments for the read.
	Args map[string]any `json:"args,omitempty"`

	// ArgsFrom maps a read argument name to the *change's* argument to
	// take it from, so one declaration covers every call to the tool:
	// {namespace: namespace, name: deployment} turns
	// scale_deployment(deployment=api, namespace=prod, replicas=5) into
	// get_deployment(name=api, namespace=prod).
	//
	// A named argument the change does not carry is an error, not an
	// omission: it means the declaration does not describe this call,
	// and guessing would produce a check against the wrong object.
	ArgsFrom map[string]string `json:"args_from,omitempty"`

	// Fields are dot-separated paths into the read's result, compared
	// individually so an operator is told what moved rather than that
	// something did.
	//
	// Empty means compare the whole result. That is the blunt option
	// and it is the default on purpose: a narrow read
	// (`get_deployment -o jsonpath={.spec.replicas}`) needs no paths,
	// and a broad one that changes on every heartbeat should be
	// narrowed at the source rather than filtered here.
	Fields []string `json:"fields,omitempty"`
}

Precondition is a workload's declaration of what a change assumed about the cluster, expressed as a read this deployment can make.

mast cannot derive this. It is deliberately Kubernetes-agnostic and an MCP tool's arguments are opaque to it, so "re-read the object this call is about" is not a thing mast can synthesize — it does not know which tool reads that object, or which of the write call's arguments names it. The bundle knows both, so the bundle says (tool_catalog.tools[].precondition).

A tool that declares none gets a TTL and nothing else, which is the honest default rather than a safe one: see Freshness.

type PreconditionSnapshot added in v0.4.0

type PreconditionSnapshot struct {
	Read string         `json:"read"`
	Args map[string]any `json:"args,omitempty"`

	// Digest is a hash of the whole result, always recorded. The result
	// itself is not: it is cluster state, it can be large, and a
	// session log is not where an operator expects to find it.
	Digest string `json:"digest"`

	// Fields are the declared paths and their rendered values, which
	// are recorded in full — they are small, and they are the
	// difference between "something changed" and "replicas went 3 → 5".
	Fields map[string]string `json:"fields,omitempty"`
}

PreconditionSnapshot is what the read returned at approval time.

type ProposedChange added in v0.4.0

type ProposedChange struct {
	Tool      string         `json:"tool"`
	Arguments map[string]any `json:"arguments"`
}

ProposedChange is one machine-executable remediation: a tool the workload declares, and the arguments to call it with.

func DecodeChangeSet added in v0.4.0

func DecodeChangeSet(v any) ([]ProposedChange, error)

DecodeChangeSet reads a change set back out of durable state. The value is whatever the session backend handed back: the JSON string mast wrote, or the decoded shape a backend that decodes state values returns.

func ParseChangeSet added in v0.4.0

func ParseChangeSet(report map[string]any) ([]ProposedChange, error)

ParseChangeSet reads the change set out of a specialist's structured report.

A report with no ChangeSetField carries no change set — that is the common case and not an error, since only a roster whose report schema declares the field can ever produce one. A field that is present but is not a list of changes IS an error: the specialist tried to say something about remediation and mast could not read it, and passing that on as "no change proposed" would silently drop a proposal.

func (ProposedChange) Signature added in v0.4.0

func (c ProposedChange) Signature() (string, error)

Signature is the change's call signature. See Signature.

func (*ProposedChange) UnmarshalJSON added in v0.4.0

func (c *ProposedChange) UnmarshalJSON(raw []byte) error

UnmarshalJSON accepts `arguments` as either a JSON object or a JSON string holding one.

The string spelling is not a convenience, it is forced. A model's report is validated by ADK against the declared output schema, and that validation walks nested objects refusing any key the schema did not declare (adk/v2 internal/utils.ValidateMapOnSchema via matchType — see TestADKRefusesUndeclaredKeysInNestedObjects). An arguments object is free-form by definition: its keys are whichever tool the finding names. There is no schema that admits it, so the wire form is a string and mast parses it and checks it against the real tool's real schema — which is a stronger check than the report schema could have made anyway. mast's own roster loader agrees from the other direction: pkg/specialists.checkSchema refuses an object property with no declared properties, because it would accept anything.

The object spelling is accepted because that is what mast writes: durable records, operator payloads and grants all carry the parsed arguments, and a round trip through those must not have to re-encode.

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"`

	// ChangeSet describes the approved-as-a-unit set this call belongs
	// to, when it belongs to one (W7). Present means `scope:
	// change_set` is on the table: the operator is being asked about
	// one call and can authorize the rest with the same answer, and
	// they can only make that trade if they are shown what the rest
	// are.
	ChangeSet *ChangeSetContext `json:"change_set,omitempty"`

	// Stale is why an approval this operator already gave no longer
	// covers this call. Its presence is the difference between "please
	// approve this" and "you approved this, and mast is asking again
	// because the ground moved" — which is the whole point of checking
	// freshness rather than trusting a clock.
	Stale string `json:"stale,omitempty"`
}

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

func DecodeRequest(v any) (Request, error)

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"

	// ScopeChangeSet authorizes this call and the other calls in the
	// change set it belongs to — the set the parked question showed
	// (W7). It is not a broader grant in the sense the scopes above
	// are: it names no tool and no pattern, only a finite list of exact
	// (tool, arguments) signatures the operator was shown, each of
	// which expires and is re-checked against the cluster before it
	// fires. Every one of them still passes the deny policy.
	//
	// mast refuses it rather than narrowing it when the set cannot be
	// granted — an unrecorded set, an edited verdict, an argument too
	// large to have been read. See writeGate.planGrants.
	ScopeChangeSet Scope = "change_set"
)

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.

func (Verdict) Decision

func (v Verdict) Decision() (permissions.Decision, error)

Decision maps the verdict's scope onto the permissions decision the gate adjudicates. A reject is DecisionDeny regardless of scope — there is no such thing as denying more broadly than the call in hand.

Jump to

Keyboard shortcuts

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