Public: anyone can read this; the crew can change it.
The agent can keep a private note on a deal that never reaches a client #308
Check · Review · Land · Live done — Landed and live in production.
Each deal now has a "Private note" box on its page (up to 500 characters). The note shows only there and never reaches a draft, timeline or handout.
shipped production is serving it deploy d2eada7
Each deal now has a "Private note" box on its page (up to 500 characters). The note shows only there and never reaches a draft, timeline or handout.
The full bun test run had one failure when I ran it: a speed test ("the clock with closed deals answers within budget…") failed in the full run but passed when I reran speed.test.ts alone. A hand-built deal in partycontact.test.ts lacked the new note field, so I gave it one. I did not rerun the whole suite after that.
What changed
- Storage: a new last migration adds
deal.note(text, at most 500 characters), andmigrations.pin.jsonpins it.migrations.test.tsnow excludes it when it rebuilds an older database. - Saving:
setDealNoteinsrc/deals.tsaskspermit(update)on deal and is audited asdeal.note. Line breaks are kept. Any other control character, or more than 500 characters, is refused. Empty clears the note. - Doors:
- Page: a plain form posting to POST /deals/:id/note. It goes through the same same-origin door as every other form, and the output is escaped.
- API: PATCH /api/deals/:id takes note, alone or together with other fields. A bad note is a 400 with field note before anything else is applied.
- OpenAPI: the deal schema and the PATCH body gain note, and contract/openapi.json is re-emitted.
- Export and import: the note travels as
private_note. Import validates it and refuses a bad one with a 422 naming the field. - Activity page: it words
deal.note. - Docs: README has one line and
docs/TECHNICAL.mdhas the details. - Tests:
src/dealnote.test.tscovers the PATCH rules and audit row, the form, escaping and the export and import round trip. A note containing the wordZEBRAWORDis checked for absence from the client timeline, broker file, calendar file, sheets, This week, the clock, the spreadsheet export, the reminder drafts and the status update.
Policy: no contract cell changed. The existing deal update cell is user, so any signed-in user who can reach a deal can overwrite its note, as they can its address.
Not done
- No MCP tool or terminal command for the deal note; the issue didn't ask for either.
- A refused form save comes back as a notice rather than keeping the typed text.
Checks not run
scripts/gate.tsanddesign-lint.ts: neither exists in this repo. I ranbun run lint:designinstead and it was clean.tsc: there is notsconfig.json, sobun x tsc --noEmithas nothing to check.
To try it: open any deal, type in "Private note", save, then open the client timeline and the broker file; the note isn't there.
🤖 Generated with Claude Code
Solid work on the page, API, export/import, audit and the leak test. One change needed.
Fix first: the terminal door is missing. The review bar says whatever a person can do on the page they can do in src/tty.ts, and the README must say how. Today the private note can be read and written only on the web page and in the API; src/tty.ts has no way to see or save it. Add a key on the deal view in src/tty.ts that shows the note and prompts for a new one (empty clears it). It must call setDealNote, which asks permit(update). Reads must be gated the same way the page gates them. A write made there should reach web viewers live, as other tty writes do. Add a test for it, add a line to the README next to the note sentence saying which key to press, and update docs/TECHNICAL.md. If the repo has an MCP tool surface, add a set_deal_note tool the same way. The builder's note that the issue did not ask for it does not override this bar.
Smaller points to fix in the same round:
- README: the added sentence makes one very long line in the middle of a paragraph. Rewrap it.
- The form route (POST /deals/:id/note) has no test for a signed-in non-owner or guest. The test only checks that an unsigned post leaves the note unchanged. Assert the status as well.
- Run the full bun test once after the last edit. The builder reported not doing so.
The private note can now be read and written in the terminal and over MCP, and the smaller review points are fixed too. The full bun test run is green (813 pass). The first full run had two timing-budget failures that did not reproduce when I reran those files alone, or on a second full run.
- Terminal: open a deal and press
K. The prompt shows the current note and takes a new one; an empty line clears it. The deal header now showsPrivate note: …. The key callssetDealNote, which askspermit(update). Reading goes throughopenDeal, which reads the deal throughpermitlike the page does. A write emitsdeal.changed, so web viewers update live. A test interminal.test.tscovers setting, showing, clearing, thedeal.noteaudit row, and a guest being refused. - MCP: new
set_deal_notetool, with the same 500-character rule.src/dealnote.test.tstests it, and the tool list inserver.test.tsincludes it. - Docs: the README line is rewrapped and now names
K.docs/TECHNICAL.mdlists the terminal key and the MCP tool. - Form test: a guest's post is asserted as a 303 that leaves the note unchanged.
- Where I disagree: you asked for a signed-in non-owner case to be asserted as refused.
contract.jsonsetsdealupdatetouser, so another signed-in user is allowed, exactly as they can already edit the address. The test asserts that 303 and documents it. Making it author-only would change a contract cell, and I won't do that without the issue asking.
I merged main into the branch, resolved the four conflicts, and pushed. The full bun test run passes (820 pass, 0 fail).
src/db.ts: the newmainmigration (deadline_move.undo) stays wheremainput it. Mydeal.notemigration comes after it, still last.src/migrations.pin.json: pins run in the same order —main's new hash, then mine.src/migrations.test.ts: the older-database filter now excludes both new migrations.src/terminal.ts: I tookmain's key help, which addsU undo move, and keptK private noteon the deal-view line.
No contract cell changed.
Earlier asks are met: the terminal reads and writes the note (K, gated by permit, shown in the deal header, with a test including the guest), the MCP tool set_deal_note exists and is tested, and the README names K. The non-owner case stays allowed because it uses the existing deal update cell for a user, and no contract cell is loosened. Input is validated at the boundary, output is escaped, and the absence test uses ZEBRAWORD across the timeline, file, ics, sheets, week, drafts, status and export. The preview answers 200. I could not diff since 4a2ec06 because the clone lacks that commit, so I read the whole change. Non-blocking: PATCH with a note plus other fields applies them in two steps, which is not atomic, though the note is validated first.
Each deal now has a "Private note" box on its page (up to 500 characters). The note shows only there and never reaches a draft, timeline or handout.
The full
bun testrun had one failure when I ran it: a speed test ("the clock with closed deals answers within budget…") failed in the full run but passed when I reranspeed.test.tsalone. A hand-built deal inpartycontact.test.tslacked the newnotefield, so I gave it one. I did not rerun the whole suite after that.What changed
deal.note(text, at most 500 characters), andmigrations.pin.jsonpins it.migrations.test.tsnow excludes it when it rebuilds an older database.setDealNoteinsrc/deals.tsaskspermit(update)on deal and is audited asdeal.note. Line breaks are kept. Any other control character, or more than 500 characters, is refused. Empty clears the note.- Page: a plain form posting to
POST /deals/:id/note. It goes through the same same-origin door as every other form, and the output is escaped. - API:PATCH /api/deals/:idtakesnote, alone or together with other fields. A bad note is a 400 with fieldnotebefore anything else is applied. - OpenAPI: the deal schema and the PATCH body gainnote, andcontract/openapi.jsonis re-emitted.private_note. Import validates it and refuses a bad one with a 422 naming the field.deal.note.docs/TECHNICAL.mdhas the details.src/dealnote.test.tscovers the PATCH rules and audit row, the form, escaping and the export and import round trip. A note containing the wordZEBRAWORDis checked for absence from the client timeline, broker file, calendar file, sheets, This week, the clock, the spreadsheet export, the reminder drafts and the status update.Policy: no contract cell changed. The existing
dealupdatecell isuser, so any signed-in user who can reach a deal can overwrite its note, as they can its address.Not done
Checks not run
scripts/gate.tsanddesign-lint.ts: neither exists in this repo. I ranbun run lint:designinstead and it was clean.tsc: there is notsconfig.json, sobun x tsc --noEmithas nothing to check.To try it: open any deal, type in "Private note", save, then open the client timeline and the broker file; the note isn't there.
🤖 Generated with Claude Code
Closes #306