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

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

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

opened this

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_via column. It is page, api or tty, added by a forward-only migration in src/db.ts. It is cleared when words are regenerated, edited or withdrawn. The listing page shows it beside "approved by <name> <when>".
  • approveAsset in src/actions.ts takes the door. Over MCP, approved: true is refused with a new person_required failure. 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_asset description says the same. Withdrawing with approved: false still works, and get_listing still 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.json is updated for the new failure and field.
  • src/listings.test.ts and src/tty.test.ts cover MCP approve refused and leaving the asset unapproved, MCP withdraw, page approval storing page, and terminal approval storing tty. src/contract.test.ts was 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 test on src/listings.test.ts, src/contract.test.ts and src/tty.test.ts passes (35 tests).
  • Two checks did not run. scripts/gate.ts doesn't exist in this clone. bun x tsc --noEmit couldn't run because there is no tsconfig.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_asset over MCP with approved: true. It should be refused with person_required.

🤖 Generated with Claude Code

Closes #22

commented

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_via column. It is page, api or tty, added by a forward-only migration in src/db.ts. It is cleared when words are regenerated, edited or withdrawn. The listing page shows it beside "approved by <name> <when>".
  • approveAsset in src/actions.ts takes the door. Over MCP, approved: true is refused with a new person_required failure. 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_asset description says the same. Withdrawing with approved: false still works, and get_listing still 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.json is updated for the new failure and field.
  • src/listings.test.ts and src/tty.test.ts cover MCP approve refused and leaving the asset unapproved, MCP withdraw, page approval storing page, and terminal approval storing tty. src/contract.test.ts was 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 test on src/listings.test.ts, src/contract.test.ts and src/tty.test.ts passes (35 tests).
  • Two checks did not run. scripts/gate.ts doesn't exist in this clone. bun x tsc --noEmit couldn't run because there is no tsconfig.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_asset over MCP with approved: true. It should be refused with person_required.

🤖 Generated with Claude Code

Changes requested — before the latest push

The MCP door is closed, but the same approval is still open one door over, and it is the one an assistant would use.

  1. HIGH — PATCH /api/listings/:id with {kind, approved:true, seen} still approves, recorded as door: "api" (src/routes.ts, the approveAsset(... 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 with person_required, as on MCP, and keep withdraw. Drop api from Door, the via enum and the openapi schema. Then approved_via is only page or tty. Add a test that the API approve is refused and audited.
  1. 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.
  1. In approve (src/actions.ts), the person_required check runs before listingFor/permit. An anonymous or unauthorised MCP caller gets person_required instead of the unauthorized or forbidden answer. Run the permit check first, then the door check.
  1. Minor: the type Door import in src/actions.ts is out of order. The doc comment above approveAsset has an overlong line. contract.test.ts now 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.
commented

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.

  1. API approve. PATCH /api/listings/:id with approved: true is now refused with person_required (403) and audited; withdraw still works. approved_via is now only page or tty. api is gone from Door, the via enum, src/routes.ts and contract/openapi.json, and the PATCH summary now says the API only withdraws. A separate Caller type (Door plus api and mcp) is what the action takes, so those two doors can be named and refused.
  2. 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.
  3. 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, not person_required. A new assertion covers the anonymous MCP case.
  4. 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.

Approved

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 api entry 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.
Sign in to join this conversation.

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs