A poster that retries is told it retried — the same event back, with a status and a flag that say nothing was created.
Today POST /api/events answers 201 always: answer(postEvent(...), 201). When dedupe matches an existing (source, dedupe) row the action returns the stored event and the door still says "created". Retrying is the whole point of dedupe — client/relay.ts retries, docs/adopting.md tells other crews to, and both integrations (#15, #16) dedupe on an order or card id — so the one thing a poster needs to know is whether its retry landed twice, and right now the answer is a lie by status code.
Done looks like
[ ] postEvent returns the fact it already knows: Result<{ event: Event; created: boolean }> (or an equivalent shape kept in the action, never re-derived at a door). The insert path is created: true; the path that catches the unique-index conflict and reads the existing row is created: false. One decision, in one place, so every door agrees.
[ ] POST /api/events answers 201 when it created and 200 when it matched. The body is the event either way, with the same fields as before plus nothing a caller has to parse differently.
[ ] MCP post_event says which happened in its result text, and a test asserts the two answers differ.
[ ] emit("event.posted") still fires only on a real insert: a test posts the same dedupe twice and asserts one row, one live event, and the same id returned both times.
[ ] The contract is written where a poster reads it: docs/adopting.md gets a line ("a retry with the same dedupe gets 200 and the id it got the first time"), client/relay.ts surfaces it to its caller without growing past the ten lines that make it copyable, and README's API section shows both statuses.
[ ] Tests in src/actions.test.ts and src/server.test.ts that fail without the change, including: a different source with the same dedupe creates a second event (the index is (source, dedupe)), and an event with no dedupe is always a creation.
Risks:postEvent's return type is used by the page, the API and MCP — changing it touches every caller in the same pull, which is the cost of not inventing a second way to ask. #8 rewrites this handler for the signed path; whoever lands second keeps both behaviours, and #8's 201 becomes 201/200 too. Do not make created a header instead: a header is invisible in the MCP door and in the terminal.
A poster that retries is told it retried — the same event back, with a status and a flag that say nothing was created.
Today
POST /api/eventsanswers201always:answer(postEvent(...), 201). Whendedupematches an existing(source, dedupe)row the action returns the stored event and the door still says "created". Retrying is the whole point ofdedupe—client/relay.tsretries,docs/adopting.mdtells other crews to, and both integrations (#15, #16) dedupe on an order or card id — so the one thing a poster needs to know is whether its retry landed twice, and right now the answer is a lie by status code.Done looks like
postEventreturns the fact it already knows:Result<{ event: Event; created: boolean }>(or an equivalent shape kept in the action, never re-derived at a door). The insert path iscreated: true; the path that catches the unique-index conflict and reads the existing row iscreated: false. One decision, in one place, so every door agrees.POST /api/eventsanswers201when it created and200when it matched. The body is the event either way, with the same fields as before plus nothing a caller has to parse differently.post_eventsays which happened in its result text, and a test asserts the two answers differ.emit("event.posted")still fires only on a real insert: a test posts the samededupetwice and asserts one row, one live event, and the same id returned both times.docs/adopting.mdgets a line ("a retry with the samededupegets200and the id it got the first time"),client/relay.tssurfaces it to its caller without growing past the ten lines that make it copyable, and README's API section shows both statuses.src/actions.test.tsandsrc/server.test.tsthat fail without the change, including: a differentsourcewith the samededupecreates a second event (the index is(source, dedupe)), and an event with nodedupeis always a creation.Files:
src/actions.ts,src/actions.test.ts,src/routes.ts,src/mcp.ts,src/server.test.ts,client/relay.ts,docs/adopting.md,README.mdRisks:
postEvent's return type is used by the page, the API and MCP — changing it touches every caller in the same pull, which is the cost of not inventing a second way to ask. #8 rewrites this handler for the signed path; whoever lands second keeps both behaviours, and #8's201becomes201/200too. Do not makecreateda header instead: a header is invisible in the MCP door and in the terminal.Suggested: Torque.