"The page, the terminal and MCP are one implementation" is the direction's promise, and it is drifting quietly. addMember, removeMember and listMembers have been in src/actions.ts since #27 with no route, no MCP tool and no terminal key — a whole capability with no door, which nobody noticed until a planning pass read the file. exportSnapshot has two API routes and no tool, which may be right, but nothing says so. Make the doors a thing that can be checked instead of a thing that is remembered.
What done looks like
[ ] src/doors.ts: one exported table, one row per capability the app has — the action's name, and for each of the four doors (page, API, MCP, terminal) either the address that reaches it (POST /api/proposals, tool propose, key n) or the sentence saying why it deliberately has none ("a backup is a file download; an agent reads the record through list_proposals"). Plain data, no imports from the doors themselves.
[ ] A test asserts the table against reality, in both directions: every entry claiming an API route matches a real route in routes, every entry claiming a tool matches a real tool in src/mcp.ts's list, and — the half that catches drift — every route and every tool that exists appears somewhere in the table. A door added without a row fails the test; a row naming a door that was removed fails it too.
[ ] Every exported action in src/actions.ts has a row. The test reads the module's exports so a new action with no row fails, rather than trusting a hand-written list. Helpers that are not capabilities (canWithdraw, the *_MAX constants) are excluded by an explicit, short deny-list in the table's own file, not by a clever heuristic.
[ ] The README.md "Any interface" section is generated from — or checked against — the same table, so the documentation cannot claim a door that does not exist. One or the other, whichever is simpler; say which in the pull.
[ ] The rows for the roster name #28 as the issue that will fill them, and the rows for voting name #45. This issue adds no doors: it writes down what is there and what is missing, and makes the gap fail a test.
[ ] bun test is green, and the pull lists every gap the table exposed on the day it landed.
Files I expect to change:src/doors.ts (new), src/doors.test.ts (new), README.md.
Risks: a table that must be hand-updated is only worth having if forgetting it fails the build — the bidirectional check is the whole point, so write that test first and watch it fail before filling the table in. Keep it to names and addresses: the moment it starts describing arguments or shapes it becomes a schema nobody maintains. It must import nothing from src/mcp.ts or src/routes.ts at module scope beyond the lists it compares, or a circular import will make the test the reason the app will not boot.
Fit: Ledger — the map of the app's own surface, kept honest by a test rather than by memory. A person assigns; I do not.
"The page, the terminal and MCP are one implementation" is the direction's promise, and it is drifting quietly.
addMember,removeMemberandlistMembershave been insrc/actions.tssince #27 with no route, no MCP tool and no terminal key — a whole capability with no door, which nobody noticed until a planning pass read the file.exportSnapshothas two API routes and no tool, which may be right, but nothing says so. Make the doors a thing that can be checked instead of a thing that is remembered.What done looks like
src/doors.ts: one exported table, one row per capability the app has — the action's name, and for each of the four doors (page, API, MCP, terminal) either the address that reaches it (POST /api/proposals, toolpropose, keyn) or the sentence saying why it deliberately has none ("a backup is a file download; an agent reads the record throughlist_proposals"). Plain data, no imports from the doors themselves.routes, every entry claiming a tool matches a real tool insrc/mcp.ts's list, and — the half that catches drift — every route and every tool that exists appears somewhere in the table. A door added without a row fails the test; a row naming a door that was removed fails it too.src/actions.tshas a row. The test reads the module's exports so a new action with no row fails, rather than trusting a hand-written list. Helpers that are not capabilities (canWithdraw, the*_MAXconstants) are excluded by an explicit, short deny-list in the table's own file, not by a clever heuristic.README.md"Any interface" section is generated from — or checked against — the same table, so the documentation cannot claim a door that does not exist. One or the other, whichever is simpler; say which in the pull.bun testis green, and the pull lists every gap the table exposed on the day it landed.Files I expect to change:
src/doors.ts(new),src/doors.test.ts(new),README.md.Risks: a table that must be hand-updated is only worth having if forgetting it fails the build — the bidirectional check is the whole point, so write that test first and watch it fail before filling the table in. Keep it to names and addresses: the moment it starts describing arguments or shapes it becomes a schema nobody maintains. It must import nothing from
src/mcp.tsorsrc/routes.tsat module scope beyond the lists it compares, or a circular import will make the test the reason the app will not boot.Fit: Ledger — the map of the app's own surface, kept honest by a test rather than by memory. A person assigns; I do not.