diff --git a/research/GATE-MAP.md b/research/GATE-MAP.md
new file mode 100644
index 0000000..f52508f
--- /dev/null
+++ b/research/GATE-MAP.md
@@ -0,0 +1,189 @@
+# GATE-MAP — Mission Agent Tool Call Gate, End to End
+
+> Research phase finding. No source files were modified.
+> Traced from code; every claim cites a file and function.
+
+---
+
+## Overview
+
+There are **two distinct gating paths** in this repository, depending on whether
+the agent is a *chat agent* (ZeroClaw, tool-free, MCP door) or a *mission agent*
+(`claude -p` inside a container or microVM). This document traces the mission
+path exclusively, as scoped by the task.
+
+The mission path has three layers that execute in order:
+
+```
+[GUEST] PreToolUse hook (tool-gate.sh) — blocks inside the container
+[HOST] Phase runner drain — reads gate records + tap after phase ends
+[HOST] mission_events INSERT — writes to the `mission_events` DB table
+```
+
+---
+
+## Hop 1 — The PreToolUse Hook Inside the Guest
+
+**File:** `crates/cm-api/src/vm_tool_gate.rs`
+**Executes in:** the guest (container or microVM)
+
+### Installation
+
+The host generates a shell script (`hook_script_with`, line 562) and installs it
+via `install_command_with` (line 819). For container-tier missions the installer
+is run by `container_tool_hooks::install_with` (`crates/cm-api/src/container_tool_hooks.rs`,
+line 53), which calls `container_exec::exec_as_root` to run the install script
+inside the container. The installed file is `/root/toolhooks/tool-gate.sh`
+(constant `HOOK_DIR = "/root/toolhooks"`). For microVM missions the same script
+is placed at `/root/toolgate/tool-gate.sh` (constant `GUEST_DIR = "/root/toolgate"`).
+
+The settings document written to `/root/toolhooks/settings.json` (or
+`/root/guest-settings.json` for the microVM tier) registers the hook via:
+
+```json
+[{ "hooks": [{ "type": "command", "command": "
/tool-gate.sh" }] }]
+```
+
+(`settings_hook` function, line 809.)
+
+### What the hook does
+
+When `claude -p` is about to execute a tool it fires the `PreToolUse` event.
+The generated shell script:
+
+1. Reads the hook JSON payload from stdin via `node` (the `NODE_EXTRACT`
+ constant, line 789) — extracts `tool_name`, `tool_input.command`,
+ `tool_input.file_path`, and `agent_type` (the subagent role).
+2. Optionally checks the **task-permission policy** (`TaskPolicy`, line 297):
+ if `enforce = true`, any tool not in `allowed` is denied; in shadow mode the
+ denial is written to `would-deny.jsonl` and the call is allowed.
+3. Checks **role policies** (`ROLE_POLICIES`, line 261): the `verifier` role is
+ denied write tools (`Write`, `Edit`, `MultiEdit`, `NotebookEdit`).
+4. Checks **write-tool path rules**: write tools targeting protected paths
+ (`PROTECTED_PATHS`, line 225 — `/root/toolgate`, `/root/toolhooks`,
+ `/root/tap/`, `/root/guest-settings.json`, `/root/.claude/settings`,
+ `.git/hooks/`) are denied.
+5. For `Bash` only, iterates over command segments split on `;|&\n` and applies
+ the `RULES` deny list (line 127): `rm -rf /`, force-push, hard-reset onto
+ remote, outbound POST (`curl`/`wget` with body flags),
+ `--dangerously-skip-permissions`, and reads/writes of the hook files
+ themselves.
+
+**On denial:** the script writes one JSONL line
+`{"rule":"","payload":}` to `denied.jsonl` (or
+`would-deny.jsonl` for shadow task-permission), prints the human reason to
+stderr, and **exits 2**. Exit 2 is the Claude Code contract for "block this
+tool call and return the stderr text to the model."
+
+**On allow:** exits 0 — the tool call proceeds.
+
+**Inert gate:** if `node` is absent, the script writes to `inert` and exits 0
+(never fail-closed), making every call allowed while leaving a marker the host
+can check.
+
+---
+
+## Hop 2 — PostToolUse Tap (Guest, Telemetry Only)
+
+**File:** `crates/cm-api/src/vm_tool_tap.rs`
+**Executes in:** the guest (container or microVM)
+
+A second hook (`PostToolUse`) appends one JSON record per completed tool call to
+`/root/tap/tools.jsonl` (container tier: `/root/toolhooks/tap/`). It **always
+exits 0** (line 13) — it is an observer, not a participant. A non-zero exit here
+would feed its stderr back to the model. The tap records tool name, path,
+session id, bounded response (for command tools only), and the subagent's
+`agent_type`/`agent_id`.
+
+---
+
+## Hop 3 — Host Drains the Gate and Tap Records
+
+**File:** `crates/cm-api/src/phase_runner.rs`, function
+`drain_finished_container_phases` (line ~601)
+**Executes in:** the host (server process)
+
+After a container-tier phase reaches `completed` or `failed`, the host's sweep
+(run on a tick) connects to Docker and reads the guest files:
+
+1. **Inert marker** (`drain_inert`): if the file exists the host logs the count
+ and records a `gate.inert` `MissionEvent`.
+2. **Denials** (`drain_denied`, line ~657): each `denied.jsonl` line becomes a
+ `gate.denied` `MissionEvent`. The detail is parsed by
+ `vm_tool_gate::denial_detail` (line 386) so the `rule` field is alongside
+ the hook payload.
+3. **Would-deny shadow** (`drain_would_deny`, line ~672): each `would-deny.jsonl`
+ line becomes a `gate.would_deny` `MissionEvent`.
+4. **Tool tap** (`drain`, line ~707): the tap file is read-then-cleared. Each
+ parsed `Observed` record is passed to `record_vm_tools`.
+
+For microVM phases the tap and gate records are read from inside the VM over SSH
+before the VM is destroyed (`phase_runner.rs`, line ~1852 and ~1855).
+
+---
+
+## Hop 4 — DB Write: `mission_events` Table
+
+**File:** `crates/cm-api/src/mission_events.rs`, function `record` / `record_all`
+(line ~126)
+**Executes in:** the host
+
+`record_vm_tools` (phase_runner.rs line 2423) builds `MissionEvent` structs
+and calls `mission_events::record_all`. Each event is inserted into the
+**`mission_events`** Postgres table:
+
+```sql
+INSERT INTO mission_events
+ (mission_id, phase_id, run_id, agent_id, kind, target, detail)
+VALUES ($1, $2, $3, $4, $5, $6, $7)
+-- subject to a per-phase cap of 400 events for TOOL_CALL / FILE_TOUCH kinds
+```
+
+Event kinds recorded for tool calls:
+- `tool.call` — one per tool invocation (constant `TOOL_CALL`, line 23)
+- `file.touch` — one per tool that touched a path (constant `FILE_TOUCH`, line 24)
+- `gate.denied` — one per gate denial
+- `gate.would_deny` — one per shadow-mode would-have-denied
+- `gate.inert` — when the gate ran without `node`
+
+---
+
+## Summary Table
+
+| Step | Where | File | Function | What happens |
+|------|-------|------|----------|-------------|
+| 1 | **Guest** | `vm_tool_gate.rs` | `hook_script_with` → `tool-gate.sh` | PreToolUse hook evaluates deny rules; exits 2 (block) or 0 (allow) |
+| 2 | **Guest** | `vm_tool_tap.rs` | PostToolUse tap script | Appends tool call record to `tools.jsonl`; always exits 0 |
+| 3a | **Host** | `container_tool_hooks.rs` | `drain_denied` / `drain_would_deny` / `drain_inert` / `drain` | Reads gate denial + tap files out of the container via Docker exec |
+| 3b | **Host** | `phase_runner.rs` | `drain_finished_container_phases` | Calls drain functions; calls `record_vm_tools` with parsed `Observed` |
+| 4 | **Host** | `mission_events.rs` | `record_all` | INSERTs `tool.call`, `file.touch`, `gate.denied`, … into `mission_events` |
+
+---
+
+## Key Design Notes
+
+- **Pre-execution gate is not the §15 human-approval gate.** The comment at the
+ top of `vm_tool_gate.rs` is explicit: the §15 `GatePolicy` (chat path, keys on
+ `session_id`/`message_id`) has no mission-shaped form, and a hook that blocked
+ the agent's process while waiting for a human would wedge the turn. The gate is
+ a deterministic deny list only.
+- **The hook is installed by the host, baked at install time.** The task policy
+ is compiled into the script at install time, not re-read from a file at call
+ time, so the guest cannot persuade itself to use a different policy file
+ (`hook_script_with`, line 562 comment).
+- **Container and microVM tiers share the same gate/tap code** but differ in how
+ the host drains: containers via `container_exec::connect` (DOCKER_HOST-aware);
+ microVMs by SSH before the VM is destroyed.
+- **DB table is `mission_events`, not `run_events` / `audit_log`.** Chat-path
+ tool calls land in `run_events` (written by `Runtime::emit`,
+ `cm-runtime/src/runtime.rs:624`). Mission-path tool calls land in
+ `mission_events`. The `audit_log` table is only for the MCP door path (chat
+ agents using the ZeroClaw MCP door), not for mission agents.
+
+---
+
+## Follow-up Items
+
+TASK: INT-01 — Verify that `record_vm_tools` attribution logic correctly maps tap session IDs to agent IDs under multi-agent phases
+TASK: INT-02 — Document the MCP door path (chat agents) in a parallel DOOR-MAP.md for comparison
+TASK: INT-03 — Confirm `CLAWMATES_TASK_PERMISSION=enforce` flag is set (or note it is still shadow) in production config