Spec: Handler protocol
Status: stable (frozen at v1.0.0) · Protocol version: 1 · Owner: Layer 1
The engine never integrates with a vendor service (DR-0014). Where a core judgment must become a delivery — a sync PR, a watch or migration work item, an API-verified platform fact — the engine composes a JSON intent document and hands it to a configured executable: a handler. Handlers own the vendor API, its credentials, and the idempotency obligations this protocol assigns them. The framework ships reference handlers for GitHub and Azure DevOps; any executable honouring this protocol replaces them without an engine change.
The boundary rule (DR-0015): core may understand vendor file formats (pipeline YAML dialects, CODEOWNERS syntax, output conventions); it must not call vendor services. Handlers are where services live.
1. Invocation contract
- The engine runs the handler command with the intent document as JSON on stdin (UTF-8, one document).
- The handler prints
key=valuefact lines on stdout (same convention as the CLI). Unknown keys are ignored; prose on stdout that is notkey=value-shaped is ignored. - Exit 0 = delivered (facts are trusted). Nonzero = infrastructure failure — the engine raises it loudly (CLI exit ≥ 4, red pipeline). Handlers never encode judgment in their exit code: every decision (is-newer, retraction, changed-ness, rule pass/fail) happened in core before the handler was invoked.
- Handlers inherit the engine's environment (that is how wrapper-provided
credentials like
VENDKIT_TOKEN_OPEN_PRreach them) and run with the consumer root as working directory.
2. Envelope
Every intent document carries:
{
"vendkit_handler_protocol": 1,
"kind": "pr" | "handoff" | "fact-verify" | "push-hint",
...kind-specific fields...
}
A handler must reject (nonzero) a protocol version or kind it does not understand — silent misdelivery is worse than a red run.
3. Kinds
pr — deliver the sync PR
Sent by sync-pipeline after the branch is committed and pushed (the engine
does all git work itself; the handler only speaks to the PR API).
{
"kind": "pr",
"head_branch": "vendkit/docs/sync-v1.4.2-to-v1.5.0",
"base_branch": "main",
"title": "sync(docs): v1.4.2 → v1.5.0",
"body_md": "…",
"slice": "docs",
"repo": "optional explicit target; else the handler uses its platform's
ambient coordinates (GITHUB_REPOSITORY / SYSTEM_TEAMPROJECT…)"
}
Facts: url=…, number=….
Idempotency obligation: the deterministic head_branch is the key. If
an open PR with that head exists the handler must update it (title/body),
never open a duplicate. The handler must never merge, approve, or bypass
review (INV-10) — a handler with merge capability is non-conforming.
handoff — deliver a finding as a work item
Sent by watch (and migration handoff) per actionable finding.
{
"kind": "handoff",
"dedup_key": "vendkit-watch-docs",
"title": "vendkit(docs): update available v1.4.2 → v1.5.0",
"body_md": "…the rendered report…",
"slice": "docs"
}
Facts: url=….
Idempotency obligation: at most one open item per dedup_key. If one
exists (GitHub: open issue labelled with the key; ADO: active work item
tagged with it), append the report as a comment — a chronological trail —
rather than creating a sibling.
fact-verify — verify an attested platform fact
Sent by conformance --verify-attestations for each attested rule result.
{ "kind": "fact-verify", "fact": "required_check_enforced", "slice": "docs",
"repo": "octo/demo", "branch": "main" }
The intent carries a stable machine fact key (never the human detail
prose) plus the coordinate the handler needs to query the platform: repo
(else the handler's own env — GITHUB_REPOSITORY / SYSTEM_TEAMPROJECT),
branch (the protected branch; when omitted, GitHub handlers resolve the
repo's default branch), and any fact-specific parameters (e.g. event for the
_enforcement facts, check for a named required status check). The core
composes these from the conformance rule and CLI --repo / --base-branch;
core itself calls no vendor API (conformance spec §4).
Facts: verdict=true|false|unknown. true promotes the rule to pass,
false makes it a fail (the attestation was wrong — that is a finding,
not an error), unknown leaves it attested. unknown is the non-failing
outcome the handler emits when the token lacks scope (a 401/403 → unknown,
never false), the endpoint is unavailable, a required coordinate is
missing, or the fact key is one the handler does not verify
(forward-compatible). Exit 0 for all three verdicts.
The reference handlers verify these facts against the platform API:
fact key |
GitHub (handler github) |
Azure DevOps (handler ado) |
|---|---|---|
required_check_enforced |
GET /repos/{repo}/branches/{branch}/protection → required_status_checks present (member check if named) |
GET .../_apis/policy/configurations → an enabled blocking Build-validation policy |
pull_request_enforcement |
— (tree-decidable on GitHub) | an enabled Build-validation policy (the gate runs on PRs) |
required_reviewers_policy |
— (CODEOWNERS is tree-decidable) | an enabled Required-reviewers policy |
Any other key (e.g. branch_protection_enabled) is unknown. Verification
needs a read-scoped token: VENDKIT_TOKEN_FACT_VERIFY (GitHub falls back to
GITHUB_TOKEN/GH_TOKEN with repo/administration:read; ADO falls back to
SYSTEM_ACCESSTOKEN/ADO_PAT with policy read).
push-hint — nudge a subscriber's sync pipeline
Sent by push-hint (the publisher-side dispatch step) once per github-actions
subscriber after a release is published (platform-integration spec §4, DR-0006).
{
"kind": "push-hint",
"repo": "acme/leaf-consumer",
"event_type": "vendkit-release",
"client_payload": { "version": "v1.5.0", "tag": "v1.5.0", "publisher": "acme/framework" }
}
GitHub: POST /repos/{repo}/dispatches with body
{"event_type": …, "client_payload": …}, authenticated by the dispatch-scoped
VENDKIT_TOKEN_PUSH_HINT (distinct from the PR token; the reference handler
refuses to run without it). Facts: dispatched=true, event_type=…,
repo=…. Azure DevOps: a deliberate no-op — the push hint there is the
consumer's own resources.pipelines trigger, so the publisher takes no action
(dispatched=false, skipped=ado-pull-trigger).
Push is a hint, not the reconciler: a lost dispatch costs latency, not correctness. The engine's dispatch step therefore treats a nonzero handler exit for one subscriber as a warning and continues — but the handler itself still obeys the protocol (a genuine API failure is a nonzero exit; the engine decides it is non-fatal here because push is best-effort).
4. Configuration and resolution
Handlers are wired per slice in the consumer config (onboarding spec §1):
handlers:
pr: { exec: [vendkit, handler, github] }
handoff: { exec: [vendkit, handler, github],
dedup_key: vendkit-watch-docs }
fact-verify: { exec: [vendkit, handler, github] }
vendkit handler <scm> is the built-in reference handler (same static
binary, DR-0016): it reads the intent on stdin and dispatches on kind.
<scm> is github or ado. Any other protocol-honouring executable can
replace it.
Resolution order per kind: VENDKIT_HANDLER_<KIND> environment override
(shell-split; - → _), then the slice config, else unwired. Unwired
behaviour is defined per producer and always visible, never silent:
prunwired → sync stops green after the push withpr-delivered=falseand the full intent onpr-intent=— the manual-orchestration workflow (ci: none), not an error.handoffunwired → findings are reported (stdout, summary) withhandoff=unwired; exit 0.fact-verifyunwired →--verify-attestationsis a usage error; without the flag, attestation degradation applies as normal.
5. Upstream reads are NOT handler territory
Listing a publisher's release tags and reading a file at a tag are plain
git protocol operations (internal/core/upstream.go): identical against GitHub,
Azure Repos, or a local path, authenticated by ordinary git credentials. No
handler, no vendor API. The only vendor knowledge is the clone-URL template
that expands an owner/repo shorthand (github) or org/project/repo
(azure-repos); a full URL or filesystem path is used verbatim.
6. Reference handlers
Shipped in-tree, released and conformance-tested with the framework:
| Command | Serves | Notes |
|---|---|---|
vendkit handler github |
pr, handoff, fact-verify, push-hint | PR credential refuses GITHUB_TOKEN fallback (differences ledger #2); push-hint uses VENDKIT_TOKEN_PUSH_HINT; fact-verify reads branch protection (required_check_enforced) with VENDKIT_TOKEN_FACT_VERIFY/GITHUB_TOKEN |
vendkit handler ado |
pr, handoff, fact-verify, push-hint | needs VENDKIT_ADO_ORG_URL; Basic-auth PAT / SYSTEM_ACCESSTOKEN; push-hint is a no-op (pull trigger); fact-verify reads branch policies (required_reviewers_policy, pull_request_enforcement, required_check_enforced) |
github / ado are built into the engine binary (DR-0016) — no separate
install, no interpreter. The neutral journal handler used by the scenario
kit (internal/e2e/journalhandler) records intents to
VENDKIT_NEUTRAL_JOURNAL and is the template for new handlers.
Writing a third handler (Jira, Slack, GitLab…) = one executable + a
handlers: edit in the slice config. The scenario kit's handler tests run
against the journal handler; a new handler can be smoke-tested by pointing
VENDKIT_HANDLER_HANDOFF at it and running vendkit watch.
7. Security posture
Handlers run with the credentials the consumer's pipeline grants them — least scope per purpose (security model §4). The engine passes no secrets in the intent document; credentials travel by environment only. A handler is consumer-configured, consumer-audited code: it lives in the consumer's trust boundary like any other pipeline step, and the reference handlers are manifest-tracked, gate-verified files when the machinery slice is vendored.