Decision Gate (xema/decision-gate)

The decision gate is a built-in workflow action that pauses a run while one or more deciders (humans, agent sessions, or external endpoints) review pinned artifact versions and produce a binding verdict. Replies aggregate per a declared policy; downstream consumers (e.g. xema/publish-kb) read the gate's approvedArtifacts output directly so the bytes that get published are exactly the bytes the deciders approved.


When to use it

  • A multi-step workflow needs human sign-off before continuing.
  • A reviewer LLM should produce an automated verdict on a deliverable.
  • An external compliance endpoint must clear a change before it ships.
  • A combination of the above with a quorum policy (e.g. "2 of these 3 must approve").

If you only need a fixed delay (no decision), use xema/wait instead.


Quick example

Single human reviewer, 24-hour deadline, reject on timeout:

yaml
1jobs:
2  approve-requirements:
3    needs: [requirements]
4    matrixGather: [requirements]
5    uses: xema/decision-gate
6    with:
7      title: Approve requirements
8      timeoutSeconds: 86400
9      onTimeout: reject
10      subjectArtifacts: ${{ needs.requirements.outputs }}
11      recipients:
12        - kind: human
13          target: { userId: ${{ trigger.actorSubject }} }
14      policy:
15        kind: single
16    outputs:
17      outcome: ${{ job.outputs.outcome }}
18
19  publish:
20    needs: [approve-requirements, requirements]
21    if: ${{ needs.approve-requirements.outputs.outcome == 'approved' }}
22    uses: xema/publish-kb
23    with:
24      # The publish step reads the pinned (artifactId, versionId, version, hash)
25      # tuple from the gate output. It NEVER picks "latest" — the bytes the
26      # deciders approved are the bytes that get published.
27      artifactRef: ${{ needs.approve-requirements.outputs.approvedArtifacts[0] }}

Inputs

FieldTypeRequiredDescription
titlestringyesHuman-readable label shown on the Tasks page card.
timeoutSecondsintegeryesDeadline in seconds (1 to 604800 = 7 days).
onTimeoutapprove | rejectyesWhat happens if the policy isn't satisfied before the deadline.
correlationIdstringyesRun correlation id for audit. Typically ${{ trigger.correlationId }}.
inquiryIdstringoptionalStable id used as the inquiry key. Defaults to a deterministic value derived from the parent workflow + job key.
descriptionstringoptionalFree-form context shown to humans + included in agent decider prompts.
subjectArtifactsarrayoptionalUpstream job outputs carrying pinned (artifactId, versionId, version, hash) references — for an Agent job, its promoted structuredOutput. The gate flattens them into the inquiry. Empty/omitted means the gate is wrapping a non-deliverable concern (e.g. a release sign-off without a specific artifact).
recipientsarray (1+)yesWho decides. Each entry is a discriminated { kind, target } — see below.
policyobjectyesHow replies aggregate. See Reply policies below.

Recipient kinds

human — a user identified by their identity-system subject

yaml
1recipients:
2  - kind: human
3    target:
4      userId: [email protected]
5  - kind: human
6    target:
7      # Special token: resolves at runtime to the user who triggered the run.
8      # Not allowed when triggerKind=schedule (no triggering user exists).
9      userId: ${{ trigger.actorSubject }}

The recipient sees the inquiry on the Tasks page (sidebar entry) and clicks through to the focused decision page where they approve or reject.

agent — an LLM-driven decider

yaml
1recipients:
2  - kind: agent
3    target:
4      agentRef: senior-reviewer
5      invocationConfig:
6        # Optional per-recipient overrides — adapter-specific.
7        modelTier: precise

The agent runs as a peer activity, reviews the pinned subjects, and submits a structured verdict. Schema-violation in the agent's reply is treated as ABSTAIN.

endpoint — an external HTTP check

yaml
1recipients:
2  - kind: endpoint
3    target:
4      url: https://compliance.example.com/api/check
5      authRef: service-token-compliance
6      timeoutMs: 30000
7      verdictMapper:
8        # JSONPath into the response body, plus a value→verdict map.
9        jsonPath: $.decision
10        verdictMap:
11          allow: approve
12          deny: reject
13          unknown: abstain

The endpoint receives the pinned subjects + description as a JSON POST body and returns a verdict via the configured response shape.


Reply policies

KindWin conditionLose condition
singleFirst reply wins (any verdict).n/a
any_ofFirst non-abstain reply wins.n/a
all_ofAll recipients reply approve.Any recipient rejects.
m_of_nAt least m recipients reply approve.Remaining recipients can no longer reach m approvals.

m_of_n example — 2 of 3 reviewers must approve:

yaml
1policy:
2  kind: m_of_n
3  m: 2
4  # Optional: how agent/endpoint ABSTAIN replies count under the
5  # threshold. Default 'skip' treats abstain as a non-vote; the gate
6  # waits for the remaining recipients. 'reject_implicit' counts an
7  # abstain as a rejection (closes the slot in the no column).
8  abstainTreatment: skip

The gate short-circuits as soon as the outcome is provable:

  • m_of_n resolves APPROVE the moment approvedCount >= m.
  • m_of_n resolves REJECT the moment approvedCount + pendingCount < m (quorum is unreachable).
  • all_of resolves REJECT on the first reject.
  • any_of / single resolve on the first reply.

When the gate resolves, any in-flight non-human decider activities are cancelled and the corresponding recipient rows transition to skipped with a reason code (quorum_reached, quorum_unreachable, all_of_reject, first_reply_resolved).


Outputs

FieldTypeDescription
outcomeapproved | rejected | expired_approved | expired_rejected | cancelledTerminal outcome.
approvedArtifactsarray of pinned refsNon-empty iff outcome is approved or expired_approved. Each entry is {artifactId, versionId, version, hash}.
decisionTrailarrayPer-recipient decision records: {recipientId, verdict, reason, submittedBy, submittedAtIso}.
inquiryIdstringThe stable inquiry id (useful for audit cross-references).

Downstream consumers MUST read the pinned versionId from approvedArtifacts rather than picking a "latest" version — this is the guarantee that the bytes the deciders approved are the bytes that get published.


Cancellation and timeouts

If the parent run is cancelled (operator clicks "Cancel" on the run-detail page), the gate workflow is cancelled too: outstanding decider activities are cancelled, the inquiry is marked cancelled (visible on the Tasks page so reviewers know not to keep deliberating), and the gate's output is outcome: cancelled with an empty approvedArtifacts.

If the deadline passes before the policy is satisfied, the gate applies onTimeout (either approve or reject) and produces outcome: expired_approved or expired_rejected accordingly. There is no recursive escalation in v1 — to chain reviewers, model it as a multi-step workflow with separate gate jobs.


Common patterns

Single human approver (the simplest case)

yaml
1- kind: human
2  target: { userId: ${{ trigger.actorSubject }} }
3policy:
4  kind: single

Two humans, either can decide

yaml
1recipients:
2  - kind: human
3    target: { userId: [email protected] }
4  - kind: human
5    target: { userId: [email protected] }
6policy:
7  kind: any_of

Quorum (2-of-3)

yaml
1recipients:
2  - kind: human
3    target: { userId: [email protected] }
4  - kind: human
5    target: { userId: [email protected] }
6  - kind: human
7    target: { userId: [email protected] }
8policy:
9  kind: m_of_n
10  m: 2

Human + agent reviewer (any one approves)

yaml
1recipients:
2  - kind: human
3    target: { userId: ${{ trigger.actorSubject }} }
4  - kind: agent
5    target: { agentRef: senior-reviewer }
6policy:
7  kind: any_of

Compliance endpoint must clear, plus a human

yaml
1recipients:
2  - kind: endpoint
3    target:
4      url: https://compliance.example.com/check
5      authRef: service-token-compliance
6      verdictMapper:
7        jsonPath: $.decision
8        verdictMap:
9          allow: approve
10          deny: reject
11  - kind: human
12    target: { userId: ${{ trigger.actorSubject }} }
13policy:
14  kind: all_of

Previous: ← Troubleshooting Next: Review →