Gateway — Per-Agent Binding & Delta Map (WIP)
Audience: an engineer who read the ADR-0022 design thread and wants to know where per-agent configuration is anchored and which of the "proposed / new" items actually shipped.
Scope of THIS doc: the narrow angle — where does "per-agent" get
configured, plus the delta map from the design conversation to current
main. It does not re-explain the two-hop auth model, the edge PEP, egress
guard, or onboarding — those live in the full picture:
Governed MCP & the Team Gateway.
Read that first if you want the end-to-end system.
Status legend (same as the sibling doc): 🟢 LIVE · 🟡 STUBBED · 🔵 DEV-ONLY · 🟠 PENDING/PARKED.
Changelog since 2026-07-06 (imported from Knowledge-Base Google Doc)
The original tab was a design conversation captured while ADR-0022 was still
awaiting-approval — it framed credential_mode, the deny-floor override, and
agent-scoped policy as proposed decisions. Since then the ADR was approved
(founder ruling 2026-07-10) and the whole model shipped. This doc replaces
the "proposed / next step: dispatch architect" framing with verified status.
- Resolved:
credential_modeon the binding,require_per_agentfail-closed, agent-level guardrail scope, deny-floor invariant, and per-agent narrow-only tool selection are all merged and LIVE (verified below). - Superseded: the "I'm dispatching the architect / PM, awaiting your sign-off" narrative — ADR-0022 (#1732) and PRD #1681 v1.1 are approved; the implementation issues (#1739/#1740/#1742/#1744/#1736) merged.
- Corrected: the delta-map "New" rows are no longer new.
Where does "per-agent" get configured? → At the binding, not at registration
This was the base's open question. Code confirms the design answer.
Registration declares a fact about the upstream (what auth it supports). The
choice of shared vs individual credentials is a per-team decision, so it lives
on the binding row — mcp_server_units, i.e. (server × team):
mcp_server_units.credential_mode—team_shared(default) |per_agent(migration 143;internal/mcpserver/binding.go).mcp_server_units.require_per_agent— the fail-closed switch.
Same server bound to two teams can run different modes — exactly the "some
teams per-agent, some per-team" requirement, with no server-level either/or. The
mode is editable after the fact via UpdateMCPServerBinding (SetCredentialMode
/ SetRequirePerAgent, ADR-0022 W2 #1739) — it is not frozen at
registration. 🟢 LIVE
Nuance vs the base:
RegisterMCPServerdoes accept an initialcredential_mode(it creates the first binding), but the secret itself no longer travels in the register form — the ref is left empty for a separateSetMCPServerCredentialwrite (service.go: "secret leaves the ref empty…"). So the base's "kill the register-time token" is done at the backend; the org-wide_defaultslot survives only as the final backward-compat fallback.
Credential resolution (most-specific-wins) — verified
Creds live in the vault keyed by the team_id column at three grains
(internal/mcpserver/credential/scope.go):
| Grain | Vault team_id | Written by |
|---|---|---|
| org | _default | legacy slot (both scope ids absent) |
| team | <unit_id> | scoped SetMCPServerCredential |
| agent | _agent_<agent_id> | scoped write; deleted on agent offboard (AgentScopeTeamID) |
Resolver precedence (credential/resolve.go, CredentialSlots):
require_per_agent = true→[_agent_<id>]only; emptyagent_id⇒ empty slice = hard denial (never a silent org-key fallback). 🟢credential_mode = per_agent→[_agent_<id>?, unit?, _default]. 🟢credential_mode = team_shared(default) →[unit?, _default]. 🟢
So: per-server/binding by default, optionally per-team, optionally per-agent — and per-agent can be made mandatory (fail-closed). The passthrough ban is untouched: resolution only selects which gateway-owned key is dereferenced, never the client's token.
Delta map — design conversation → shipped code
The base tagged four items New/proposed. All four are now LIVE.
| Piece | Base status | Verified current status |
|---|---|---|
| Auth-mode-only registration (secret out of register form) | "UI change (#530), backend RPC already separate" | 🟢 backend LIVE — secret written via scoped SetMCPServerCredential; register leaves ref empty. UI rework tracked in #530 🟡 |
| Team bindings + approval | Exists | 🟢 LIVE (binding.go, UpdateMCPServerBinding / decideBinding) |
| Org→team guardrail cascade | Exists | 🟢 LIVE (mcp_tool_policies, unit_id NULL = org-wide) |
| Agent-level guardrail scope | New (schema + resolver) | 🟢 LIVE — migration 142 adds nullable agent_id to mcp_tool_policies; resolveQuery gives agent ▸ team ▸ org precedence |
| Per-team credentials | Vault ready; write-RPC + UI new | 🟢 backend LIVE — three-grain scoped write (credential/scope.go, #1740) |
| Per-agent credentials + fail-closed mode | New (keying + credential_mode + lifecycle) | 🟢 LIVE — _agent_<id> keying + credential_mode + require_per_agent + offboard cascade (#1742/#1740) |
| Team-page gateway section (badge, matrix, guardrail editor) | Frontend new; reads existing tables | 🟢 backend/tables LIVE; SPA team-gateway/*.jsx present |
| Auto-derived team endpoint | mig-131 reusable; auto-derivation new | 🟢 LIVE — /mcp/team/{unit} is the PEP |
The three governance decisions the base flagged as "proposed" — now resolved
-
credential_modeplacement → on the binding. ✅ Shipped exactly as proposed: it is a column onmcp_server_units, per(server, team), editable per-team. Registration only declares the upstream auth fact. -
Deny-floor override semantics → un-softenable. ✅ Preserved. Migration 142's comment is explicit: within a severity, agent ▸ team ▸ org, but
(action = 'deny') DESCstays the first sort key — an agent-scopedescalatecan never soften an org/teamdeny. A child scope may only tighten. This upholds the T5 cascade-override invariant (migration 132). -
Agent-policy scoping mechanism →
agent_idcolumn (option a). ✅ Founder ruling 2026-07-10.agent_idisTEXTwith no FK (deliberately — mirrorsmember_api_keys.scoped_agent_id; the runtime agent id is an opaque string and the referent is ambiguous under the #969 schema-lie). Tenant isolation is preserved byorg_id+ FORCE RLS; the write RPC additionally validatesAgentInOrg.
Proposed decision 4 (per-agent MCP selection at creation) — also shipped:
AgentService.SetAgentTools repurposes agent_configurations.tools as a
narrow-only allowlist over the team's approved set (empty = inherit-all; the
wizard can only deselect). Effective set = team set ∩ allowlist ∩ agent
guardrails. Grants stay team-level (#1744, ADR-0022 W3/B8). 🟢 LIVE — enforced at
the edge as EdgeAgentAllowlist (gate 3.4 in the sibling doc).
What's still WIP / not settled here
- Prod enablement is parked —
MCP_GATEWAY_ENABLEDdefaults false and there is no prod platform signing key (#1864). Everything above is exercisable on beta-dev, dark for prod. See the sibling doc §8. 🟠 - #530 register-form UI rework — the backend split is done; the client SPA register form change is the remaining slice. 🟡
visibilitycolumn honored by the MCP resolver — flagged in the ADR housekeeping; confirm before relying onmcp_server_units.visibilityfor entitlement decisions. (Unverified in this pass — check the resolver.)
Source map (this angle only)
- Binding +
credential_mode/require_per_agent:internal/mcpserver/binding.go,internal/mcpserver/service.go - Three-grain credential write + IDOR/clearance:
internal/mcpserver/credential/scope.go - Most-specific-wins resolution + fail-closed:
internal/mcpserver/credential/resolve.go - Agent-scoped tool policy:
internal/context/store/migrations/142_agent_scoped_tool_policies.up.sql,internal/guardrails/store.go - Per-agent narrow-only allowlist:
internal/agent/service.go(SetAgentTools)
Full system, auth hops, egress, onboarding: Governed MCP & the Team Gateway. Related: ADR-0021/0022 (gateway + trust root), ADR-0024 (governance realignment), PRD #1681. Trust-root blocker: #1864.
Reflects upsquad-core@9858af5f (2026-07-24). If ADR-0022 items change or #1864
resolves, re-verify the delta map against internal/mcpserver/credential/.