AGENTS.md@packages/chat-gateway/src/server/team · git:20260919.9482906 · 2026-09-19 · sha256 a054fae1bca9b0fa

AGENTS.md@packages/chat-gateway/src/server/team git:20260919.9482906A

Immutable. This exact content is served forever at /api/v1/blob/a054fae1bca9b0fa.

# DOX — packages/chat-gateway/src/server/team

Files in this directory. One row per source file. Team-controls layer — tiered authorization, workspace binding, output filter, append-only audit. See change: add-chat-gateway-team-controls.

| File | Purpose |
|------|---------|
| `tier.ts` | Verb→tier lookup read from `GENERATED_TOOLS` (`mcp-server-plugin/manifest`) — never re-declared. `NON_DELEGABLE` (`mint_device_token`, `set_providers`, `install_package`, `tunnel_connect`), curated `CHAT_COMMAND_ALLOWLIST` (includes the chat-ONLY `disarm`), `VERB_TIERS`, `CHAT_LOCAL_VERB_TIERS` (verbs the chat surface owns with NO MCP counterpart, so they have no shared row to read a tier from — `disarm`→`observe`; it must NEVER shadow a platform verb or chat and MCP would drift), `tierOfVerb` (shared table first, then local), `clampTier`/`maxTier`, `RoleTier`. See change: add-chat-gateway-team-controls. |
| `workspace.ts` | `resolveWorkspaceForCwd(cwd, workspaces)` — symlink-resolved, segment-boundary, longest-match (per-workspace best via `bestFolderIn`); returns `{workspaceId, folder}` or null. `isWithinWorkspace`/`isWithinFolder` = the scope-containment test. `WorkspaceView`. See change: add-chat-gateway-team-controls. |
| `authorize.ts` | The single chokepoint: `authorize(input) → Grant | Refusal`, layered under L1/L2; refuses `non_human_author` first, then `unbound_channel`, `no_principal_mapping`, `scope_violation`, `disarmed`, `non_delegable_verb`, `verb_not_allowlisted`, `verb_unknown_tier`, `insufficient_tier`. Identifier + role resolution (highest wins, roles cap at control), ceiling clamp (default `observe`). Pure; `verbTiers`/`allowlist` injectable for tests. `DisarmState` transitions. See change: add-chat-gateway-team-controls. |
| `team-config.ts` | `validateTeamControls(raw)` — TOTAL validation of per-binding principals/roles/ceiling/mirror levels, catch-all ceiling, disarm flag, retention, and the provisioning `guildId`; composes `parsePrincipals`/`parseRoles`/`parseAuditRetention`/`parseGuildId`/`parseBinding` over a shared `Checked<T>` result. Rejects a ROLE mapped to `operate` with `role_cannot_map_to_operate_requires_explicit_identifier`. Defaults: ceiling `observe`, mirror `names-only`, retention 10,000 (max 1,000,000). The GLOBAL ceiling is a HARD maximum, not merely a default: a binding ceiling is clamped with `minTier(binding, global)`, so a binding may only NARROW it. Otherwise an operator choosing the safest global `observe` could be silently defeated by one binding's stale `operate`, and the schema's "maximum tier any principal may resolve to" would be false. HARDENED (security pass): rejects RESERVED keys (`__proto__`/`constructor`/`prototype`) as workspace/principal/role ids, because `obj["__proto__"] = {}` reassigns a prototype instead of adding a key — reachable only via a PARSED payload (JSON.parse makes it an own key; an object literal does not) — and bounds cardinality via `MAX_BINDINGS`/`MAX_MAPPINGS_PER_BINDING`. Exports `FAIL_CLOSED_TEAM_CONFIG` — a rejected config degrades to it (nobody may act, nothing is provisioned) rather than to a live-but-unconfigured layer. Also `validateTeamControlsWrite(raw)`: the DASHBOARD-write variant that REFUSES an absent/malformed payload with `team_controls_payload_required` instead of reading it as "reset to defaults" — startup legitimately sees `undefined` and must default, but a live write that sees it would silently wipe every binding and deactivate every provisioned channel. See change: add-chat-gateway-team-controls. |
| `audit.ts` | `createCommandLog({filePath?,limit,now?})` — append-only ring-buffer command log (default limit from config). Exposes `append`/`entries`/`recent`/`size`/`load`; NO edit or delete operation. `onPersistFailure?` reports a failed write (the write runs on the MESSAGE path, so it must not throw into it and must not be silent). `isEntry` REQUIRES a valid `outcome` — anything not `refused` reads as permitted, so a truncated file must not manufacture authorizations. Atomic 0600 JSON persistence. See change: add-chat-gateway-team-controls. |
| `output-filter.ts` | `renderMirror(event, level) → string | null` for `names-only`/`names-and-diffs`/`full-transcript`; bounds STRUCTURED payloads only — assistant prose is mirrored verbatim, and that boundary lives in the module header rather than an exported constant. `elide(text, maxChars)` appends an explicit marker. See change: add-chat-gateway-team-controls. |
| `pacing.ts` | `createPacer` — Discord 5-msg/5s per-channel budget (derived from `RATE_WINDOW_MS`/`RATE_MAX_POSTS`), one post in flight per thread, coalescing with elision at a conservative `DEFAULT_MAX_CHARS` (1800). The char bound is deliberately BELOW Discord's 2000-char limit and is never read from the adapter: a payload between 1800 and 2000 chars is elided EARLY — lossy, but disclosed, and `elide` names the true withheld count so the reader can always tell (F7). `now`/`schedule` injectable for a stub clock. See change: add-chat-gateway-team-controls. |
| `controller.ts` | `createTeamController(deps)` — `initialDisarmed`/`onDisarmChange` persist the latch (11.3); ALL transitions go through one `setDisarmed`, so no call site can forget to persist. `createTeamController(deps)` — resolves channel→binding→policy (`bindingFor(channelId, parentChannelId?)` falls back to the PARENT, because a thread's messages carry the thread id while the operator binds the parent — without it every thread was refused `unbound_channel`), authorizes via the chokepoint, records every attempt in the command log, owns disarm (any observe+ disarms; only `rearmFromDashboard` re-arms; `rearmFromChat` always refused). Reads `deps.config()` LIVE rather than capturing it, so a dashboard rewrite takes effect without a restart. `syncDisarmFromConfig(flag)` adopts the dashboard's flag — the CALLER must invoke it only when the written flag differs from the LIVE latch (`shouldApplyDisarm`), which is what lets the dashboard re-arm a CHAT-initiated disarm: a chat disarm never writes config, so a caller comparing against config would see "no change" and never call. `noteMirror()` produces no entry. D5 trust failure: `reportTrustFailure(verb)` (sticky, first-cause-wins) + `trustHealth()`; `onTrustFailure` fires once on the healthy→unhealthy edge. See change: add-chat-gateway-team-controls. |
| `disarm-store.ts` | `createDisarmStore({filePath?})` — durable record of the disarm latch (11.3), DELIBERATELY not config: a chat disarm must not write config, so the latch gets its own file or a restart silently re-arms the layer. `load()` distinguishes `undefined` (never persisted → seed from config) from `false` (a real dashboard re-arm that a stale `disarmed: true` must not override). `save()` is best-effort and never throws: it runs on the MESSAGE path, where a full disk must not become a bot that stops answering. 0600 via atomic rename. |
| `health.ts` | `createTrustHealth()` + `TRUSTED_PRIORITY_MAX` (100) — D5 trust-failure state. `reportTrustFailure(verb)` names the verb and the missing trust level; snapshot is sticky (no clear path — only a restart re-derives trust). See change: add-chat-gateway-team-controls. |
| `provisioning-store.ts` | `createProvisioningStore({filePath?})` — the plugin-owned workspace↔channel store (D7), DELIBERATELY separate from `bindings.json` (that maps channel→session cwd for ROUTING; this records the channel the layer OWNS). One channel per workspace by construction (keyed by `workspaceId`); `ChannelBinding` carries `channelId`/`channelName`/`active`/`accessSignature`. Every read hands out a COPY so the store can never hold state that differs from disk. Atomic tmp-write+rename at 0600; a missing/corrupt/hand-edited file degrades to empty per-record rather than crashing startup; inactive records are RETAINED (deletion never propagates). See change: add-chat-gateway-team-controls. |
| `provisioner.ts` | `createProvisioner(deps)` — the ONLY module that creates, renames or re-permissions anything (D7). `reconcile()` converges the platform onto `config.bindings` × `listWorkspaces()`: provisions via `provisionChannel` (create-with-overwrites only — no create-then-patch path exists), reconciles access, renames on workspace rename, marks INACTIVE (never deletes) on workspace delete/unbind, reuses a retained channel on reactivation. CONVERGENCE, not event-diffing: `accessSignature(binding)` = the sorted SET of principal/role ids APPLIED (not their tiers — a tier/mirror change must cause no platform call), persisted only AFTER a successful call, so a failed reconcile leaves stored mapping consistent with real access and is retried (`fail()` edge-reports to health). Synchronous by contract: the platform call is awaited before the signature is written, so no interval retains a revoked principal's access. SERIALISED: `reconcile()` chains behind any run already in flight (each starts only after the previous finished and re-reads the config then), because convergence alone does not prevent two overlapping runs from BOTH provisioning an unprovisioned workspace — the second `upsert` would orphan the first, and since deletion never propagates the orphan is never revoked, leaving a later-removed principal with VIEW on a channel the layer no longer tracks. Callers CHAIN rather than join, because the dashboard write path REPLACES the config before calling: joining a run that already read the old config would report success without converging to the new one. `noteChannelGone(c)` drops the binding, touching no session. `grantsFor`, `accessSignature`, `ChannelProvisioningPort` exported. See change: add-chat-gateway-team-controls. |
| `surface.ts` | `buildTeamSurface(input)` — the configuration-surface PROJECTION (tasks 8.1-8.4). Server-side and PURE: config + workspaces + `allowedRoots` + command log + a `DelegationPort` in, one plain `TeamSurfaceView` out, so every display rule is testable with no socket or DOM and the panel stays a dumb renderer. Marks each workspace folder inert via `inertWorkspaceFolders`; orders the log newest-first (the log's own `recent()`, so the renderer cannot reorder it); reports the LIVE disarm state, never the config value (a chat disarm is runtime state); retains a binding whose workspace vanished, marked `bound: false`, rather than hiding policy drift. `DelegationPort.assignersForRoles(roleIds)` is BATCHED — one call per build covering every mapped role across every binding, because the platform read behind it (`guild.members.fetch()`) is expensive and rate-limited; a port that cannot answer returns `unavailable{missingPermission}` per role, and a role the port omits is read as an EMPTY ROSTER, never as "unknown". See change: add-chat-gateway-team-controls. |