Public: anyone can read this; the crew can change it.
Only a person approves: an AI agent over MCP can draft and withdraw, never sign off #24
Check · Review · Land · Live done — Landed and live in production.
Issue #22 is built and committed on the branch, but I can't confirm the push landed. `git push` said "Everything up-to-date", yet `git ls-remote origin` doesn't list the branch. It lists `main` and the pull refs, and none of them match the branch's commit `c2e96c2`. The forge's check should show whether it arrived.
shipped production is serving it deploy 957b843
Issue #22 is built and committed on the branch, but I can't confirm the push landed. git push said "Everything up-to-date", yet git ls-remote origin doesn't list the branch. It lists main and the pull refs, and none of them match the branch's commit c2e96c2. The forge's check should show whether it arrived.
What changed
- Each approval now records the door it came through in a new
asset.approved_viacolumn. It ispage,apiortty, added by a forward-only migration insrc/db.ts. It is cleared when words are regenerated, edited or withdrawn. The listing page shows it beside "approved by <name> <when>". approveAssetinsrc/actions.tstakes the door. Over MCP,approved: trueis refused with a newperson_requiredfailure. Its message says a person approves on the listing page. The refusal is audited like any refused change, and the asset stays unapproved.- The MCP
approve_assetdescription says the same. Withdrawing withapproved: falsestill works, andget_listingstill reports each asset's approval. - The contract table is unchanged. The door rule lives in the action, not in a role comparison.
- The README's "What is real" now says assistants may draft and edit and only you approve.
contract/openapi.jsonis updated for the new failure and field. src/listings.test.tsandsrc/tty.test.tscover MCP approve refused and leaving the asset unapproved, MCP withdraw, page approval storingpage, and terminal approval storingtty.src/contract.test.tswas also edited.
What I chose not to do
- The JSON API still approves, because a person's browser form posts there. A script that holds a person's session can therefore still approve. I left that alone rather than guess.
Checks
bun testonsrc/listings.test.ts,src/contract.test.tsandsrc/tty.test.tspasses (35 tests).- Two checks did not run.
scripts/gate.tsdoesn't exist in this clone.bun x tsc --noEmitcouldn't run because there is notsconfig.json, so the type check is unverified.
To try it
- Open a listing and approve a flyer. The page should show "approved by … via page".
- Call
approve_assetover MCP withapproved: true. It should be refused withperson_required.
🤖 Generated with Claude Code
The MCP door is closed, but the same approval is still open one door over, and it is the one an assistant would use.
- HIGH —
PATCH /api/listings/:idwith{kind, approved:true, seen}still approves, recorded asdoor: "api"(src/routes.ts, theapproveAsset(... door: "api")call). An assistant holding the session can call it directly and sign off, so "only a person approves" is not enforced. Nothing in the app's pages posts approvals to the JSON API: the page form posts to/listings/:id/assets/:kind/approve. Fix: refuse approving on the API door withperson_required, as on MCP, and keep withdraw. DropapifromDoor, theviaenum and the openapi schema. Thenapproved_viais onlypageortty. Add a test that the API approve is refused and audited.
- README says approval comes from "the JSON API a browser form posts to". That is false: no browser form posts there. Correct it once item 1 is done.
- In
approve(src/actions.ts), theperson_requiredcheck runs beforelistingFor/permit. An anonymous or unauthorised MCP caller getsperson_requiredinstead of the unauthorized or forbidden answer. Run the permit check first, then the door check.
- Minor: the
type Doorimport in src/actions.ts is out of order. The doc comment aboveapproveAssethas an overlong line.contract.test.tsnow probes the MCP cell only with withdraw, so the approve path of that cell is probed only through the action. That is acceptable, but say so in the test name or comment.
All four review points are addressed and pushed as 38a5d19 on the same branch. The five touched test files pass (50 tests). I couldn't run a type check or the design lint here, because the clone has no tsconfig.json and no scripts/design-lint.ts. scripts/gate.ts is also absent.
- API approve.
PATCH /api/listings/:idwithapproved: trueis now refused withperson_required(403) and audited; withdraw still works.approved_viais now onlypageortty.apiis gone fromDoor, theviaenum,src/routes.tsandcontract/openapi.json, and the PATCH summary now says the API only withdraws. A separateCallertype (Doorplusapiandmcp) is what the action takes, so those two doors can be named and refused. - README. It no longer claims a browser form posts to the JSON API. It says approval comes from the listing page or the terminal, and that the API and the MCP tool only withdraw.
- Permit order. In
approve,listingFor(permit) and the anonymous check now run before the door check. An anonymous or unauthorised caller gets the unauthorized or forbidden answer, notperson_required. A new assertion covers the anonymous MCP case. - Minor.
- Import and doc comment. The type Door import is gone from src/actions.ts, replaced by type Caller in its sorted place, and the long doc-comment line is reflowed.
- Contract test. src/contract.test.ts has a comment saying the MCP cell is probed with a withdraw and the approve path through the action's page door.
- Existing tests. Tests that approved over the JSON API now post the page form. One of them moved to a fresh test user so it doesn't exhaust the 20-write rate limit that user shares with the other tests.
I also added person_required: 403 to the status map in src/routes.ts. Without it the API returned 200 on the refusal, which the new test caught.
I changed no contract cell.
Earlier asks are met. The JSON API now refuses approve with person_required (403, audited) and still withdraws. approved_via stores only page or tty. The README no longer claims a browser form posts to the API. The permit and anon checks run before the door check, with a test for the anon MCP case. The contract test is annotated and no contract.json cell is loosened. MCP and API approve are refused, page and tty store their door, and the tty write goes through the same action and emits listing.changed. I judged this from the diff and the builder's report; I did not run the tests or fetch the preview.
Non-blocking:
- src/db.ts: the migration comment still says "(page, api, tty)"; api is never stored.
- src/pages/listing.ts: DOORS has an
apientry that can never be stored. Drop it. - README: the reflowed approval paragraphs have a few overlong lines and a slightly tangled sentence ("can withdraw, and refuse to approve, as does the JSON API"). Tidy them.
Issue #22 is built and committed on the branch, but I can't confirm the push landed.
git pushsaid "Everything up-to-date", yetgit ls-remote origindoesn't list the branch. It listsmainand the pull refs, and none of them match the branch's commitc2e96c2. The forge's check should show whether it arrived.What changed
asset.approved_viacolumn. It ispage,apiortty, added by a forward-only migration insrc/db.ts. It is cleared when words are regenerated, edited or withdrawn. The listing page shows it beside "approved by <name> <when>".approveAssetinsrc/actions.tstakes the door. Over MCP,approved: trueis refused with a newperson_requiredfailure. Its message says a person approves on the listing page. The refusal is audited like any refused change, and the asset stays unapproved.approve_assetdescription says the same. Withdrawing withapproved: falsestill works, andget_listingstill reports each asset's approval.contract/openapi.jsonis updated for the new failure and field.src/listings.test.tsandsrc/tty.test.tscover MCP approve refused and leaving the asset unapproved, MCP withdraw, page approval storingpage, and terminal approval storingtty.src/contract.test.tswas also edited.What I chose not to do
Checks
bun testonsrc/listings.test.ts,src/contract.test.tsandsrc/tty.test.tspasses (35 tests).scripts/gate.tsdoesn't exist in this clone.bun x tsc --noEmitcouldn't run because there is notsconfig.json, so the type check is unverified.To try it
approve_assetover MCP withapproved: true. It should be refused withperson_required.🤖 Generated with Claude Code
Closes #22