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-]{2,14}[a-z0-9](ends in alphanumeric)Agent-Machine: <one of agent_registry.machines>(free-form; registered values get extra trust signals)Task-Ref: (Issue#N|PR#N|none|round-X/<topic>|bot/<topic>)
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 revert PR. Label it
decision:auto-revert. - 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: L2 ships as detection + incident. On a real post-merge failure the bot opens a decision:post-merge-revalidation issue carrying the git revert <sha> command; it does not yet author the revert PR. Having the bot commit to a supervised repo is itself a Quadrant-D action that needs its own ADR — the same rationale that keeps mirror auto-cascade manual. The classifier_auto_revert rule + decision:auto-revert label are already in place for when a revert PR is opened (by you now, or by a future auto-revert feature). See STATUS.md.
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
- Revert PRs (with
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
If a decision:pending-owner Issue is open for more than 14 days with no activity, the bot pings the owner via a daily nudge comment. After 30 days, label decision:abandoned. No auto-action — only human deletes or revives.
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>. - 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.