Documentation / guides
Gate and permission-review events
Track approval requests, decisions, forms, and permission review.
Gates are durable questions or control boundaries. A gate event tells a consumer what became answerable and how it closed; it does not expose every private payload used to construct the question. Permission review events are a separate Internal audit trail for automated classifiers reviewing an already open permission gate.
Gate lifecycle
type GatePrepared struct {
enduring
loopScoped
Header
Gate gate.Gate `json:"gate,omitzero"`
}
type GateOpened struct {
enduring
loopScoped
Header
Gate gate.Gate `json:"gate,omitzero"`
}
type GateResolved struct {
enduring
loopScoped
Header
GateID gate.ID `json:"gate_id,omitzero"`
Resolver gate.ResolverKind `json:"resolver,omitempty"`
Reason gate.CloseReason `json:"reason,omitempty"`
Action string `json:"action,omitempty"`
Source gate.ResponseSource `json:"source,omitzero"`
Audit gate.ResponseAudit `json:"-"`
}
GatePrepared is the private prepare projection. The journal stores it only
inside journal.GatePreparedRecord, together with the typed gate.OpenPayload.
It is not a normal EventRecord, is not sent by Hub.PublishEvent, and is
filtered from product event replay. GateOpened is the Public activation
projection. It carries the pure gate.Gate envelope and no private payload; it
is the event that makes the gate listable and answerable. GateResolved is the
single Enduring close-with-answer record.
| Event | Class | Scope | Visibility | Durable purpose |
|---|---|---|---|---|
GatePrepared | Enduring | ScopeLoop method, resolver-dependent coordinates | Private journal record | Validate the later open and preserve the typed open payload for restore |
GateOpened | Enduring | ScopeLoop method, resolver-dependent coordinates | Public | Announce the answerable gate envelope |
GateResolved | Enduring | ScopeLoop method, resolver-dependent coordinates | Public | Atomically record answered, abandoned, owner-closed, or restore-unavailable state |
The event method returns ScopeLoop for all three gate types because gates are
part of the event union’s loop delivery path. A host-owned gate can be
session-only, loop-attributed, or turn-attributed in its coordinates; a
loop-owned permission or ask-user gate must carry the full step quartet.
GateResolved.Resolver is retained on the record so a decoder can choose the
same identity profile without the prepared payload.
Open, answer, resolve
%%{init: {"theme":"dark"}}%%
sequenceDiagram
participant L as Loop or host resolver
participant J as journal
participant H as event hub
participant C as Consumer
participant U as User or policy
L->>J: private GatePreparedRecord{GatePrepared, OpenPayload}
L->>J: GateOpened
J-->>H: committed sequence
H-->>C: public GateOpened
C->>U: render gate.Gate
U->>L: Session.RespondGate(GateResponse)
L->>J: GateResolved{GateID, Action, Reason, Source, Audit}
H-->>C: public GateResolved
L->>L: deliver validated response to parked owner
For a permission gate, Action is one of the exact
gate.ApprovalAction values: Approve, Approve always for this workspace,
or Deny. A non-answer close such as CloseAbandoned or
CloseOwnerClosed has an empty action and a non-empty Reason. Audit is
projected through the typed gate audit codec. It can retain redaction-aware
requirement, candidate, form, or answer summaries, but never grant tokens,
raw tool arguments, or an open-url action target.
Host-owned versus loop-owned identity
gate.ResolverSession is used for host-owned form and open-url gates. Only a
SessionID is required, though a loop or turn may be included when the host
attributes the elicitation to one. gate.ResolverLoop is used for permission
and ask-user gates parked in a loop; SessionID, LoopID, TurnID, and StepID are
all required. An empty or unknown resolver on a decoded GateResolved uses the
strict loop profile, preserving the fail-closed behavior of older records.
This distinction is enforced by event.ValidateEvent, which returns
*event.InvalidEventError with FieldSessionID, FieldTurnID, or
FieldStepID rather than allowing a malformed gate to reach restore.
Permission review audit
type PermissionReviewStarted struct {
enduring
loopScoped
Header
GateID gate.ID `json:"gate_id,omitzero"`
ToolExecutionID uuid.UUID `json:"tool_execution_id,omitzero"`
Classifier hustle.Name `json:"classifier,omitzero"`
ClassifierRevision string `json:"classifier_revision,omitzero"`
}
type PermissionReviewCompleted struct {
enduring
loopScoped
Header
GateID gate.ID `json:"gate_id,omitzero"`
ToolExecutionID uuid.UUID `json:"tool_execution_id,omitzero"`
Classifier hustle.Name `json:"classifier,omitzero"`
ClassifierRevision string `json:"classifier_revision,omitzero"`
Status gate.ReviewStatus `json:"status,omitzero"`
Risk gate.ReviewRisk `json:"risk,omitzero"`
Authorization gate.ReviewAuthorization `json:"authorization,omitzero"`
Categories []gate.ReviewRiskCategory `json:"categories,omitzero"`
AutoApproved bool `json:"auto_approved,omitzero"`
}
Both review values are Enduring, loop-scoped, and Internal. They require a
gate ID, a tool execution ID, a valid classifier name, and a bounded non-blank
classifier revision. The completion status is one of allowed, needs_human,
not_applicable, timed_out, failed, cancelled, or stale.
For allowed and needs_human, risk, authorization, and distinct known
categories are required; allowed cannot carry critical risk, and
AutoApproved must agree with the status. For the other terminal statuses,
risk, authorization, categories, and AutoApproved must all be empty or
false. The durable review events deliberately omit classifier prompt,
evidence, model output, rationale, and gate candidate data.
Consume and answer a public gate
func answerPermission(ctx context.Context, live session.Session) error {
sub, err := live.SubscribeEvents(event.EventFilter{
Enduring: event.LoopScope{All: true},
})
if err != nil {
return err
}
defer sub.Close()
for {
select {
case <-ctx.Done():
return ctx.Err()
case delivery, ok := <-sub.Events():
if !ok {
return sub.Err()
}
switch e := delivery.Event.(type) {
case event.GateOpened:
if e.Gate.Kind != gate.KindPermission {
continue
}
return live.RespondGate(ctx, gate.GateResponse{
GateID: gate.ID(e.Gate.ID),
Action: string(gate.ApprovalDeny),
Source: gate.ResponseSource{Kind: gate.ResponseFromUser},
})
case event.GateResolved:
log.Printf("gate %s resolved with %q at journal %d", e.GateID, e.Action, delivery.JournalSeq)
}
}
}
}
The example answers only a Public gate. It cannot see the Internal review
events through this subscription, even with Enduring.All; classifier audit
records are available only to privileged restore or audit consumers. A host
that opens a form or open-url gate uses the separate session.GateHost contract
and receives its live gate.Answer; the durable public record is still
GateOpened followed by GateResolved.
Source and proofs
GatePrepared,GateOpened, andGateResolvedPermissionReviewStartedandPermissionReviewCompletedgateenvelope, resolver, and close vocabularies,responses, andreview domainsgate identity validationandevent wire projectiongate event round-trip and private payload tests,permission review tests, andprivileged publication tests
The gate is part of a turn and Step sequence; the surrounding tool events explain which tool request caused it.