Architecture
Architecture
This document defines the 4-module bot that is the heart of multiagent-protocol. The bot is deliberately split into four modules — not five layers — because the consolidation reduces inter-module API surface area by about a third without losing any of the L1–L5 enforcement.
Each module is described below with its inputs, outputs, and the test fixtures it owns.
Module map
┌─────────────────────────────────────────────────────────────────┐
│ bot/main.py — cron entry point (5-min tick) │
└────────┬───────────────────────────────────────────────┬────────┘
│ │
▼ ▼
┌────────────────────┐ ┌────────────────────┐
│ pr_validator.py │ │ branch_supervisor │
│ │ │ │
│ Per open PR: │ │ Per main branch: │
│ L1 pre-merge gate │ │ L2 post-merge │
│ L3 race guard │ │ re-validate │
│ L4 identity gate │ │ L5 break-glass │
│ │ │ audit │
└─────────┬──────────┘ └─────────┬──────────┘
│ │
├────────────────┐ │
▼ ▼ ▼
┌────────────────┐ ┌──────────────────┐ ┌──────────────────────┐
│ classifier.py │ │ decision_inbox.py│ │ drift_check.py │
│ │ │ │ │ │
│ A/B/C/D verdict│ │ Quadrant D → Issue│ │ Canonical mirror │
│ + audit log │ │ + owner approval │ │ SHA-256 cross-check │
└────────────────┘ └──────────────────┘ └──────────────────────┘
▲ ▲ ▲
│ │ │
└────────────────┴──────────────┬───────────────┘
│
┌───────┴────────┐
│ skills loader │
│ + plugins │
└────────────────┘
Module 1: pr_validator.py (L1 + L3 + L4)
Runs once per open PR per cron tick. The three layers it consolidates are tightly coupled (all read PR head SHA, base SHA, check-runs, and commit metadata) so a single module is more honest than three thin wrappers.
Inputs
pr_numbergithub_apiclient (authenticated as the bot’s App installation token)- The loaded
AppConfig(in particularconfig.skillsfor severity/enable toggles andconfig.agent_registryfor L4 trailer registry lookup)
L1 — Pre-merge gate (5 conditions)
A PR merges only if all five of:
| # | Name | Pass condition |
|---|---|---|
| C1 | ready-to-merge |
Label ready-to-merge present, applied by an allowlisted actor (owner only). |
| C2 | CI green | All required status checks completed with conclusion == "success". |
| C3 | Approval | Either (a) owner reaction/comment on the PR, OR (b) classifier returns A/B/C. |
| C4 | Base up-to-date | PR’s base SHA equals current main HEAD (no rebase needed). |
| C5 | Identity trailers | Every PR commit has all 5 Agent-* + Task-Ref trailers, well-formed. |
Failure produces a diagnostic comment listing every failing condition (not just the first). Bot re-evaluates on the next tick.
L3 — Race guard
After L1 passes and just before the merge API call, re-fetch main HEAD. If it has advanced since C4 was checked, abort the merge, auto-rebase the PR branch, and let the next tick re-evaluate. This prevents “merged a PR against a stale base” race.
L4 — Identity gate
Every commit must have:
Agent-Tool: <one of agent_registry.tools>Agent-Model: <one of agent_registry.models[Agent-Tool]>(orn/aformanual/github-actions)Agent-Session: s_[a-z0-9][a-z0-9-]{2,14}[a-z0-9](4-16 characters afters_; begins and ends in alphanumeric)Agent-Machine: <one of agent_registry.machines>(free-form; registered values get extra trust signals)Task-Ref: (Issue#N|issue#N|PR#N|none|round-X/<topic>|bot/<topic>)(Issue#Nis canonical for new commits; lowercaseissue#Nremains valid for historical compatibility.)
L4 has a 60-day burn-in window before promotion from advisory to hard-block (see docs/concepts/four-quadrants.md § “L4 burn-in”).
Outputs
- Either: a merge (via GitHub API, recording the head SHA as merge precondition to defeat TOCTOU).
- Or: a diagnostic comment + repeat next tick.
- Or: a
merge-gate-failurelabel +decision:pending-ownerIssue (Quadrant D path; seedecision_inbox.py).
Module 2: branch_supervisor.py (L2 + L5)
Runs once per supervised repo per cron tick. Operates on main HEAD, not on open PRs.
L2 — Post-merge re-validation
For each commit on main newer than the last branch_supervisor watermark:
- Re-run the L1 required-checks set against the merged commit’s SHA.
- If all pass: advance watermark, no action.
- If some fail and the failure is real (see “infra-failure differentiation”
below): open a
decision:post-merge-revalidationincident with parent-aware manual recovery guidance. - If the default-off
auto_revert_proption is enabled: attempt a revert PR only after the shallow clone proves the target has at most one parent. If the target has multiple parents, or its parent structure cannot be proved, do not rungit revert; append the fail-closed reason to the incident instead. - If failures are all infra-failure: record
infra-failurestate in the tick metrics, do not revert, retry on next tick.
Infra-failure differentiation
A failed check is considered infra-failure (not a real failure) when:
conclusion == "cancelled"(workflow killed mid-run — e.g., Actions minutes exhaustion), ORstarted_at == completed_at(zero-duration → never executed, runner queue rejected).
conclusion == "skipped" is not infra-failure: it means the workflow’s own if: condition evaluated false (intentional protocol skip). Treating skipped as infra was a known false-negative in earlier designs.
Shipping status: Detection + incident is always enabled. Automatic revert-PR
creation ships as a default-off opt-in (env.yml auto_revert_pr: true; see
ADR 0002). Before running git revert,
the bot inspects the raw commit object so a merge at the shallow-clone boundary
cannot masquerade as a root commit. It refuses every multi-parent target
fail-closed and does not guess -m: the incident tells the operator to inspect
the parents with git show --format=%P <sha>, validate the mainline parent, and
only then perform a manual git revert -m N <sha>. Automatic revert PRs still
go through the normal gate and are never auto-labelled ready-to-merge.
L5 — Break-glass auditor
For each commit on main whose subject matches ^\[break-glass-[a-z0-9-]+\]:
- Verify the commit author is in
config/owner.ymlallowlisted_actors. - Verify an ADR (Architecture Decision Record) was filed in
docs/decisions/within 24h of the commit timestamp, referencing the break-glass commit’s SHA. - If either fails, open a
decision:break-glass-unauditedIssue tagging the owner.
L5 runs across all registered repos including the bot’s own repo. The bot does not gate its own PRs (L1-L4 skip the bot repo by design) but its main commits are still audited.
Outputs
- Optional automatic revert PRs for proven non-merge targets (the operator may
apply the verified
decision:auto-revertlabel). - Break-glass audit Issues.
- Watermarks recorded in repo state (a single file in the bot’s own repo).
Module 3: decision_inbox.py
Runs once per cron tick across all supervised repos. Owns the Quadrant D → owner → resume loop.
Quadrant D issue lifecycle
- Open: When
classifier.pyreturns Quadrant D for a PR, the bot opens an Issue in<governance_repo>labelleddecision:pending-ownerwith the PR link and the 4-option ballot (A: approve, B: alternate, C: defer, /reject). - Poll: Every tick, the inbox checks open
decision:pending-ownerIssues for new owner reactions (👍/👎) or comments (/approve A,/approve B,/approve C,/reject). - Resolve: An owner verdict triggers:
- Approve → bot returns to PR, re-runs L1 (C3 will now pass), merges if everything else green.
- Reject → bot closes the PR with a “rejected per Decision Inbox” comment.
- Close: Inbox issue auto-closes when the linked PR merges or closes.
Stale handling
The framework default has no timer (see
decision-inbox.md, which is authoritative here). An
installation may explicitly enable the optional, notification-only lifecycle:
one reminder, one escalation, outing-window clock suspension, and one return
digest per issue, all restart-idempotent through bot marker comments. It never
approves, abandons, or closes an issue; the inbox therefore remains
asynchronous. /approve C marks the PR decision:deferred (needs more info),
and an approval whose PR head SHA has since changed is superseded with
decision:stale-approval. The deprecated decision_inbox.thresholds keys are
still accepted but ignored and cannot activate the lifecycle.
Outputs
- Issues opened/closed in
<governance_repo>. - PR labels (
decision:approved-A,decision:approved-B,decision:approved-C,decision:rejected). - Tick metrics: open count, average age, oldest age.
Module 4: drift_check.py
Runs once per cron tick. Enforces that canonical files in <governance_repo> match byte-for-byte across all adopter repos (mirror cascade).
Mechanism
- Read
config.mirror_paths(list of file paths under<governance_repo>that are canonical-of-canonical). - For each adopter repo: compare the git blob SHA of each canonical path against the
<governance_repo>source-of-truth blob SHA (equal blob SHA ⇔ byte-identical content). The SHAs come from one recursive-tree fetch per repo per tick — the governance tree is fetched once and reused across all adopters — with a per-path lookup fallback when a tree is unavailable. The governance repo itself is skipped (comparing the canonical to itself is always clean). - Mismatch →
decision:mirror-drift-incidentIssue with diff summary. - Missing (canonical-required file absent in adopter) → same Issue, with
missing=truefield.
Drift is detected, not auto-fixed. Auto-fix would require opening a PR in each adopter, which is its own classifier path — currently the operator handles drift by re-running the cascade workflow manually. (Auto-cascade PRs are a planned post-v1.0 feature, gated on an ADR in docs/decisions/ that explicitly authorizes the bot to open critical-path PRs in adopters.)
Outputs
decision:mirror-drift-incidentIssues.- Tick metrics: drift count per adopter, missing-file count per adopter.
Stateless across ticks
The bot is stateless across cron ticks. All state lives in GitHub:
- PR state: GitHub PR object.
- Decision Inbox: Issues in
<governance_repo>. - Optional inbox lifecycle state: authenticated marker comments on those Issues (reminder, escalation, and return-digest window metadata); no local timer database.
- Watermarks: a single file
bot-state/branch_supervisor_watermarks.jsonpersisted to a dedicatedbot-statebranch of the governance repo via the App token (the only commits the bot makes to its own repo). Deliberately notmain: the bot’s own L2/L5/unauthorized-push scanners read onlymain, so state commits can never self-trigger an incident. The tick loads this file at start (creating the branch on first run), persists incrementally after each repo and again in afinallyguard, so a timed-out tick still banks its progress. A repo seen for the first time bootstraps its watermark to the currentmainHEAD and scans nothing older — pre-activation history is out of scope, which is what prevents a cold-start incident flood. A corrupt persisted state fails the tick closed (non-zero) instead of silently re-walking history. - Audit log: GitHub Actions workflow artifacts (90-day retention) + commit history.
This means each tick re-evaluates from scratch. The drawback is chatty comments on long-running PR failures; the upside is no local DB to corrupt, no migration on bot version upgrade, and disaster recovery is “redeploy the bot” with no state to restore.
Per-tick cost (rate-limit budget)
At 6 supervised repos × ~5 open PRs × ~10 API calls per PR + the bounded L2/L5 main scans (≤100 commits per repo per tick; a handful of calls when idle) + drift_check (one recursive-tree call per adopter per tick, the governance tree cached) ≈ ~370 calls per tick worst case, ~4,400/hour at 12 ticks/hour. The GitHub App installation rate limit on a personal account is 5,000 requests/hour (the often-quoted 15,000/hour applies only to installations on GitHub Enterprise Cloud organizations), so the margin is real but thin. The bot therefore watches X-RateLimit-Remaining on every response and ends a tick early — after persisting watermarks — when fewer than a reserve threshold of calls remain; a secondary-rate-limit 403/429 backs off (honouring Retry-After, bounded) and then skips the affected repo for the tick instead of crashing and replaying.
Plug-in points
The 4 modules call into the skills loader (src/multiagent_protocol/skills/) for extensible behavior:
pr_validator.pycallsValidator.check(pr_context)for each registered validator (built-in C1-C5 + user-added).classifier.pycallsClassifierRule.evaluate(pr_context)for each registered rule (returns A/B/C/D vote; the engine takes the maximum quadrant across all rules — seefour-quadrants.md§ “Classifier rule composition”).branch_supervisor.pycallsBranchHook.on_commit(commit)for each registered hook (built-in: L5 audit; user-added: e.g., changelog enforcer).decision_inbox.pyanddrift_check.pydo not currently call skills, but the interface is reserved.
See docs/concepts/skills-plugin.md for the plugin interface specification.