Documentation / guides
Approval gates
Resolve loop-owned and host-owned gates with durable-first semantics.
Responses
gate.ResponseRequest is {Action string; Values map[string]json.RawMessage}. gate.GateResponse adds {GateID gate.ID; Action string; Values map[string]json.RawMessage; Source gate.ResponseSource}. ResponseSource is {Kind ResponseSourceKind; Reason string}, where the exact ResponseSourceKind values are ResponseFromUser, ResponseFromPolicy, ResponseFromModel, and ResponseFromClassifier.
gate.Answer is the live-only host result: {GateID gate.ID; Action string; Values map[string]string; Source ResponseSource}. It has no JSON codec. Form values are present only in this live answer and in the bounded durable form audit; they are not returned as an unvalidated prompt projection.
Actions
Permission approval actions are exact, case-sensitive strings:
| Constant | String |
|---|---|
ApprovalApprove | "Approve" |
ApprovalApproveAlwaysWorkspace | "Approve always for this workspace" |
ApprovalDeny | "Deny" |
ParseApprovalAction accepts only those values. DecodeApprovalAction strictly rejects null, unknown fields, duplicate keys, trailing JSON, and unknown actions with *gate.ApprovalActionDecodeError. ApprovalControls returns exactly those three controls. There is no session-global approval, persistent deny action, or second prompt for the same prepared request.
Loop routing
Session.RespondGate(ctx, response) is the public response path. Permission and ask-user gates are loop-owned: the session validates the action against the gate’s controls, builds the corresponding command for the private gate.Route.LoopID, and delivers it only after the durable resolve has committed. A response with Source.Kind == gate.ResponseFromClassifier is rejected before locking; classifier responses can be produced only by the private review adapter and can only carry ApprovalApprove.
package main
import (
"context"
"github.com/looprig/harness/pkg/gate"
"github.com/looprig/harness/pkg/session"
)
func answerPermission(ctx context.Context, controller session.SessionController, id gate.ID) error {
return controller.RespondGate(ctx, gate.GateResponse{
GateID: id,
Action: string(gate.ApprovalApprove),
Source: gate.ResponseSource{Kind: gate.ResponseFromUser, Reason: "operator approved"},
})
}
The route is owned by the session. A client supplies only the gate ID, action, values, and source; it cannot supply a loop route or grant token.
Host routing
session.GateHost is the host-owned contract:
type GateHost interface {
OpenHostGate(context.Context, uuid.UUID, gate.Gate, gate.Payload) (gate.ID, error)
AwaitGateAnswer(context.Context, gate.ID) (gate.Answer, error)
CloseGate(context.Context, gate.ID, gate.CloseReason) error
}
Only KindForm and KindOpenURL with ResolverSession qualify. OpenHostGate validates the kind/payload pair, derives the trusted schema or origin onto Gate.Prompt, prepares and activates the gate, and returns a public ID. The opener must then await or close it. A canceled await frees the live slot but does not close durable state; the opener must call CloseGate if it gives up.
Durable-first
%%{init: {"theme":"dark"}}%%
sequenceDiagram
participant C as Client or host
participant S as Session
participant J as Journal
participant L as Loop or answer slot
C->>S: RespondGate(GateResponse)
S->>S: Claim open entry
S->>J: Append GateResolved
alt append succeeds
S->>S: Remove entry and stop timer
S->>L: Dispatch loop command or Answer
else append fails
S->>S: Revert claim
S-->>C: GateAppendFailed
end
GateNotFound means no directory entry exists; GateNotReady means it is preparing, claiming, or already closed; GateActionInvalid means the action or source is not legal for the envelope; GateKindMismatch means the payload/owner contract does not match; GateAppendFailed wraps the durable failure. These are *session.GateError values and should be classified with errors.As.
See pkg/gate/response.go, pkg/session/session.go, and the route proofs in internal/sessionruntime/gates_route_e2e_test.go.