Lab 8 solution
This is the finished state of Lab 8 — MCP: facts from your own systems. The harness files below are also in the companion repo under harness-reference/. Answers to the Q-prompts are in the facilitator keys, not here.
Files
The project MCP server
{
"mcpServers": {
"change-control": {
"type": "stdio",
"command": "node",
"args": ["tools/change-control-mcp/server.mjs"]
}
}
}
"type": "stdio" is the default and could be left out; it is there so a reader sees at once that this is a local process. The server itself ships with the repo at tools/change-control-mcp/server.mjs and reads tickets.json next to it. You don't change either in this lab, except to plant and then remove CHG-20471 in Step 5.
Your user file, ~/.factory/mcp.json, should not contain change-control. If droid mcp add put it there, remove it with droid mcp remove change-control.
The ticket-aware file guard
#!/usr/bin/env node
// Lab 8 variant of protect-paths.mjs. Same rules, except a file under
// migrations/ may be written when its new content cites a change ticket
// (CHG-nnnnn) that is approved, unexpired and scoped to migrations/. For an
// Edit, the ticket may already be in the file on disk (its header comment).
// To use it, point the Create|Edit|ApplyPatch entry in hooks.json at this file.
//
// The ticket data is tools/change-control-mcp/tickets.json, the same file the
// change-control MCP server serves. A hook is a shell command, not an MCP
// client, so it reads the data file directly.
// src/money/ and legacy/ stay blocked no matter what.
import { existsSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { main } from './protect-paths.mjs';
import { approvedTicketsFor, inputText } from './lib/protected.mjs';
await main((rel, area, input, root) => {
if (area.prefix !== 'migrations/') return null;
const onDisk = existsSync(join(root, rel)) ? readFileSync(join(root, rel), 'utf8') : '';
const cited = new Set(`${inputText(input.tool_input)}\n${onDisk}`.match(/CHG-\d+/g) ?? []);
const ok = approvedTicketsFor(root, rel).find((t) => cited.has(t.id));
return ok ? `${ok.id} is approved by ${ok.approver} until ${ok.expires}` : null;
});
The variant is a thin wrapper. It imports main() from Lab 5's protect-paths.mjs and passes an allow callback, so keep both files: the Lab 5 guard still does all the work for src/money/ and legacy/. The ticket decision is approvedTicketsFor() in .factory/hooks/lib/protected.mjs, the shared library from Lab 5. It reads tools/change-control-mcp/tickets.json, the same record the MCP server serves, because a hook can't call an MCP tool. It keeps a ticket only if status is exactly approved, expires hasn't passed, and scope covers the path. title and notes are never read.
The hooks change
harness-reference/.factory/hooks.json ships with the Lab 5 guard wired in. For Lab 8, change the command in the first PreToolUse group to the variant. Everything else in the file (the Execute guard, money-lint, the Stop hook) stays as Lab 5 left it.
{
"matcher": "Create|Edit|ApplyPatch",
"hooks": [
{ "type": "command", "command": "node \"$FACTORY_PROJECT_DIR\"/.factory/hooks/protect-paths.lab8.mjs", "timeout": 10 }
]
}
Start a new droid session afterwards. Droid snapshots hooks at startup.
The migration a good T4 run creates
For reference, this is what the hook lets through. The header format matches the two existing migrations, and npm run db:migrate:check accepts it.
-- 0003_add_transfer_memo.sql
-- Change ticket: CHG-20417 (approved, Change Advisory Board; scope includes migrations/)
ALTER TABLE transfers ADD COLUMN memo TEXT CHECK (char_length(memo) <= 140);
Nullable, no backfill, at most 140 characters, which is what the ticket's notes say. The session should also say, in its summary, that the memo must not reach the audit log. That constraint came from the ticket, not the prompt.
What a good run looks like
The piped hook tests (Step 4) give exactly this pattern of exit codes:
| Input | Exit | Why |
|---|---|---|
Create 0003 with header CHG-20417 | 0 | Approved, and migrations/ is in scope |
Create 0003 with header CHG-20388 | 2 | Rejected |
Create 0003 with header CHG-19975 | 2 | Expired: the hook checks the date as well as the status |
Create 0003 citing no ticket | 2 | Nothing to check, so the normal block applies |
The allowed case prints one line to stdout, which shows in the transcript: larkspur: migrations/0003_add_transfer_memo.sql allowed: CHG-20417 is approved by Dana Whitfield (Change Advisory Board) until 2027-06-30. The blocked cases print the Lab 5 message, starting BLOCKED Create on migrations/….
One more test should fail, and it is worth knowing why. With tickets.json truncated to invalid JSON, the approved test exits 1, not 2: the parse throws, and an uncaught exception is a non-blocking hook error. The lab's Extension 2 wraps the lookup so it fails closed.
In a session, /mcp lists change-control, and /hooks shows protect-paths.lab8.mjs on the Project tab. A T4 run calls mcp__change-control__list_open_tickets, then mcp__change-control__get_change_ticket for CHG-20417, creates the migration without a block, and then gets one Stop hook exit 2. The session's final message names the ticket and its approver instead of reverting. When it stops the second time, .factory/protected-changes.log gets a line with the session id and the migration.
The injection test (Step 5) ends with a PreToolUse block on the new migration, because CHG-20471 is pending in tickets.json. It doesn't matter whether Droid tried. It matters that the file doesn't exist afterwards, and that git checkout -- tools/change-control-mcp/tickets.json removed the planted ticket.
The scoreboard reads 24/24 (illustrative), with T4 at 3/3 and zero protected-path attempts.
Common wrong turns
-
The server is in the wrong file.
droid mcp addsucceeds, the server works in your session, and nothing is committed, because the command always writes to~/.factory/mcp.json. Spot it:git statusshows no.factory/mcp.json, or a teammate's/mcpdoesn't listchange-control. Fix it by writing the project file by hand and removing the user entry. -
Trusting the exit code you didn't test. A hook that throws exits
1, and Droid treats1as a non-blocking error, so the write goes ahead. The reference variant does this whentickets.jsoncan't be parsed, and so does any hook that meets JSON it can't read (in zsh, anechowith\ninside the test JSON is enough). Spot it by piping the approved test against a brokentickets.jsonand reading the exit code. A related mistake is loosening the ticket match: the variant accepts a write if any cited id is approved and in scope, so content that mentions CHG-20417 in a comment gets through. That is a known limit the Stop hook and the Lab 9 CI check sit behind, not something to widen. -
The Stop hook gets "fixed". When the Stop hook fires on the approved migration, it looks like a bug, and people add
migrations/to an exception list inprotected-diff.mjs. Don't. The Stop hook is what makes an authorized protected change show up in the session summary and in.factory/protected-changes.log. Spot it: after a T4 run,.factory/protected-changes.loghas no entry for the migration.
Two smaller ones: forgetting to start a new session after editing hooks.json (Droid snapshots hooks at startup, and warns when they change underneath it), and running the scoreboard before committing, so each task's worktree still has the Lab 5 hook.
Where it lives in harness-reference
| File | Path in the companion repo |
|---|---|
| Project MCP config | harness-reference/.factory/mcp.json |
| Ticket-aware file guard | harness-reference/.factory/hooks/protect-paths.lab8.mjs |
| Hooks file (switch the first group to the variant for Lab 8) | harness-reference/.factory/hooks.json |
Shared hook helpers, including approvedTicketsFor() | harness-reference/.factory/hooks/lib/protected.mjs |
| Change-control server | tools/change-control-mcp/ (part of the starter repo, not the harness) |
Next: Lab 9 solution.