Skip to content

v0.5.0: vendor-neutral HITL protocol, Teams without a bot, and the gate an agent cannot route around - #7

Merged
renezander030 merged 4 commits into
masterfrom
feat/hitl-protocol-relay
Aug 2, 2026
Merged

v0.5.0: vendor-neutral HITL protocol, Teams without a bot, and the gate an agent cannot route around#7
renezander030 merged 4 commits into
masterfrom
feat/hitl-protocol-relay

Conversation

@renezander030

Copy link
Copy Markdown
Owner

Closes #5.

The six highest-ranked opportunities from a fresh mining pass, plus a working Microsoft Teams path.

What's here

Item What shipped
hitl/v0 protocol + relay channel internal/relay, relay_channel.go, spec, conformance command
Risk tiers + approval_policy risk: low | normal | high, audited policy_approve rows
Run-correlated approvals run_id minted per run, on the envelope and on action_approvals
Tool-call gate POST /gate/tool-call, default-deny, bound to an args hash
Spend at the decision budget line in the prompt and on the envelope
Escalation before timeout escalate_after / escalate_to

On #5: the approach in that issue is no longer available

Researched fresh, and it changes the answer rather than just the estimate:

  • Teams incoming webhooks / O365 connectors — retired; connector webhooks fully disabled May 2026
  • Graph chatMessage — send-only; card actions other than OpenUrl need a bot
  • Graph Approvals API — beta, documented as not supported in production
  • Bot Framework SDK (Python) — archived, support ended 2025-12-31

So teams.go plus a webhook URL cannot work at all. What remains in-binary is an Azure app registration and tenant admin consent, per vendor, forever.

The design

Split the gate from the presenter. draftcat keeps what must not be delegated: policy, quorum, expiry, the payload hash, approver membership, the audit trail. A relay owns only presentation.

The relay is untrusted. Five checks stand between it and a forged approval: body-bound HMAC, ±5min clock window, single-use nonce, payload-hash echo, and approver membership checked against draftcat's own config. A compromised relay can deny by staying silent, which times the gate out and fires nothing, but can never authorise.

Teams then needs one Power Automate flow in the customer's own tenant: HTTP trigger, Post adaptive card and wait for a response, HTTP POST back. No Azure app registration, no bot, no admin consent for draftcat. Slack, email, PagerDuty and a shell script are the same three steps.

relay earns its place in internal/channels on the same terms as Telegram: a complete OperatorChannel round trip including the callback server ships in the binary.

Verification

  • Full suite green, go vet clean, zero new lint issues (CI is only-new-issues)
  • ~60 new tests: 21 protocol, 9 relay round-trip, 12 config policy, 17 tool-gate/budget/escalation, 17 validator
  • Real binaries end-to-end: draftcat hitl verify against the built contrib/teams-relay, human taps Approve, signed decision returns, gate accepts. 12/12, exit 0
  • Negative proof: a deliberately forging relay fails the suite (exit 1) and the gate refuses its forged approval

Three calls worth a look

  1. policy_approve is its own decision value, not approve with a flag, so an auditor scanning decision sees immediately that no human tapped. High-risk steps are excluded inside AutoApproveRule.Match so no call site can route around it, and an unscoped rule is a validate error — that is how a policy tier silently becomes "no gate".
  2. Escalation notifies, never authorises. Adding escalate_to to the permitted approvers would let a slow operator promote someone the config never approved.
  3. run_id is a correlation column outside the receipt signature. Adding it to approval.Fields would invalidate the HMAC on every row signed before this release, so existing stores would fail audit-verify after upgrading.

Not in scope

Telegram is still required at boot in a relay-only deployment: draftcat runs one operator channel and Telegram still serves the command surface. Making it fully optional is a larger refactor than this release warranted.

…vals

Adds a vendor-neutral wire protocol between draftcat and any external
presenter, so reaching a new operator surface no longer means draftcat
taking on that vendor's bot lifecycle.

draftcat stays the gate: policy, quorum, expiry, payload hash, approver
membership and the audit trail. A relay owns only presentation. The relay
is untrusted -- five checks (body-bound HMAC, clock-skew window, single-use
nonce, payload-hash echo, approver membership) mean a compromised relay can
deny by staying silent, which times the gate out and fires nothing, but can
never authorise.

Microsoft Teams is reached with one Power Automate flow in the customer's
own tenant: HTTP trigger, "Post adaptive card and wait for a response",
HTTP POST back. No Azure app registration, no bot, no tenant admin consent.
The same three steps serve Slack, email or a shell script. Closes #5.

- internal/relay: hitl/v0 envelopes, signing, and the verification rules,
  reusing the existing t=<unix>,v1=<hex> webhook signature scheme rather
  than adding a second one
- relay_channel.go: complete OperatorChannel round trip including the
  callback server, with distinct-approver quorum counting. This is what
  qualifies `relay` for internal/channels, which admits a name only when a
  working implementation ships
- run_context.go + state: one run id minted per pipeline run, carried on
  the envelope and recorded on action_approvals, so the trail can answer
  which run an approval released. Added as a correlation column outside the
  receipt signature, so receipts written before this release still verify
- draftcat hitl verify <relay-url>: conformance suite that dispatches a
  real approval and checks the decision, including that a replay, a mutated
  payload hash and an out-of-scope approver are all refused
- validate: relay config is checked at boot on the same terms as the
  Telegram channel, and quorum now counts against the operator pool of the
  channel the step actually names

docs/hitl-protocol.md carries the specification and the Teams setup.
Completes the v0.5.0 set on top of the hitl/v0 relay.

Risk tiers + approval_policy. A step declares risk: low|normal|high, and an
operator can decide in advance that a declared class needs no fresh tap. The
reason is that a gate people switch off protects nothing: the observed
workaround for approval fatigue is disabling approval wholesale, which trades
a narrow audited exemption for a total one. High risk is never eligible,
enforced inside AutoApproveRule.Match so no call site can route around it, and
an unscoped rule is a validate error rather than a silent removal of the gate.
Every exemption is written as decision "policy_approve" with the rule that
fired, so the trail never conflates a policy release with a human decision.

Tool-call gate at POST /gate/tool-call. The gate covered declared pipeline
steps; a harness calling MCP or SDK tools mid-run went around it. A harness now
asks permission for one call and gets allow or deny back, with the same
allowlist, risk tiers, human approval and audit trail. Denies by default, so an
unconfigured tool fails closed, and the decision is bound to a hash of the exact
arguments proposed rather than to the tool name alone. Arguments are hashed,
never stored.

Spend at the decision. Cost caps were enforced between calls but never shown to
the person releasing the action. The approval prompt and the hitl/v0 envelope
now carry what the run has spent against its caps.

Escalation. A pending gate ran silently to timeout, so a run could die waiting
on an operator who never saw the prompt. escalate_after re-notifies while the
gate is still open and escalate_to names who else to tell. It widens who is
told, never who may decide -- authority is a config decision, not a timer.

contrib/teams-relay ships a conformant relay plus the Adaptive Card, the
Power Automate request schema and the setup both ways round. Verified
end-to-end: draftcat hitl verify against the built relay passes 12/12 with a
real approve round trip, and a deliberately forging relay fails the suite.
log.Fatalf on a relay that failed to start ran after `defer state.Close()`
and `defer vbridge.Shutdown()` were already registered, so a misconfigured
relay exited leaving the SQLite WAL unclosed and the voice bridge running.

The relay only needs cfg.Relay, so it is now constructed right after startup
validation, before either defer exists. A relay that cannot start still
refuses the boot, but now fails while there is nothing to unwind.
Keeps the top block to the latest two releases, so v0.3.1 drops off as
v0.5.0 lands. Folds the v0.5.0 governance entries into the existing list
rather than appending to it: spend-at-decision joins the cost-budget line,
run correlation joins durable gates, and the channel entry leads with what
an operator does about Teams instead of restating the protocol.

Adds `draftcat hitl verify` to the command list. Net shorter than before
despite six new features.
@renezander030
renezander030 merged commit 27ffac7 into master Aug 2, 2026
1 check passed
@renezander030
renezander030 deleted the feat/hitl-protocol-relay branch August 2, 2026 08:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add Microsoft Teams notification channel

1 participant