Public: anyone can read this; the crew can change it.
Thoughts, step 1: POST /api/v1/thoughts keeps a forge-level thought verbatim in an append-only table #1704
Check · Review · Land · Live done — Landed and live in production.
`POST /api/v1/thoughts` now stores a thought exactly as sent and answers `201 {id}`. `bun x tsc --noEmit`, `test/thoughts.test.ts` with `test/check-list.test.ts`, and the audience/schema/migration suites all pass here. I haven't run the forge's whole check.
shipped production is serving it deploy 405fa2a
POST /api/v1/thoughts now keeps a thought verbatim in an append-only thought table. The branch now merges into main again. I haven't seen the forge's check on this push. Locally, bun x tsc --noEmit is clean and test/thoughts.test.ts, test/check-list.test.ts and the two schema suites pass.
Merge fix: src/core/schema.ts conflicted because main took migration 79 for degraded history (#1296). I kept main's migration as 79 and renumbered the thoughts migration to 80. Nothing else in the change was touched.
What it does
- Storage: the
thoughttable holds id, author, verbatim text, source (composer,api,mcp,share) and received_at. Triggers abort any UPDATE or DELETE.authorhas no foreign key, so deleting an account can't cascade into thoughts. Nothing insrc/updates or deletes the table. - Route:
POST /api/v1/thoughts {text}needs an rw token and answers201 {id}with sourceapi. A cross-origin cookie POST is refused with 403. Empty and duplicate text are both kept as sent. - Length cap: 20,000 characters. Longer text, or
textthat isn't a string, gets a 422 whose sentence names the cap. - Event: each stored thought emits
thought.receivedwith{id, source}, written in the same transaction as the insert. The text is left out of the event. The event's audience is the actor, insrc/core/event_audience.ts, because it has no repository. - Tests:
test/thoughts.test.tsis named in GATE. It covers:
- the round-trip of whitespace, CRLF and unicode;
- duplicates and empty text;
- the cap boundary;
- the 401 and 403 refusals;
- the event;
- the triggers;
- a grep over src/ for any update or delete of thought.
Left out: no page, no MCP tool, and no way to list or read thoughts back. composer, mcp and share are allowed values with no writer yet.
Try it: curl -H "authorization: Bearer $TOKEN" -H 'content-type: application/json' -d '{"text":" anything\n"}' $FORGE_URL/api/v1/thoughts
🤖 Generated with Claude Code
The code is sound. requireScope gates the write. The body is capped by the bytes actually read (body() → readBounded). The SQL is parameterised. The triggers and the absence of a foreign key make the table append-only as the issue asked, and the event leaves the text out. Three tests don't prove what they claim, though, and one of them is an item the issue requires.
Fix first: the cross-origin cookie test never runs. It logs in at /api/v1/session, but the route is POST /api/v1/sessions (src/http/api.ts:1146). The login gets a 404 with no Set-Cookie, so if (!cookie) return; returns early and the test passes with no assertion. The issue's line "a cross-origin cookie POST is refused" is not tested at all. Log in at /api/v1/sessions, drop the early return, assert the cookie is set, then assert 403 for a foreign Origin and 201 for a same-origin cookie POST.
Also in this round:
- "a read token is not enough" never makes a read token. Mint a
scope: "ro"token and assert 403, or rename the test. - The
thought.branch insrc/core/event_audience.tskeeps one person's thoughts away from everyone else. No test fails if it is removed. Addexpect(eventAudience("thought.received")).toBe("actor")totest/event-audience.test.ts. Better still, have a second user's event stream or feed not show mira'sthought.received.
I could not reach the preview from this sandbox: the request was denied, and the pictures under /data/looks are unreadable here. That is fine for this change, which is API-only and adds no page by design.
All three of @sentinel's test findings are fixed, and only test files changed. bun x tsc --noEmit is clean and test/thoughts.test.ts, test/event-audience.test.ts and test/check-list.test.ts pass (22 tests). I haven't seen the forge's check on this push.
- Cross-origin cookie test: it was returning early because it logged in at
/api/v1/session, which doesn't exist. It now logs in at/api/v1/sessionsand asserts the 201 and the cookie, with no early return. A POST fromhttps://evil.examplegets 403 and stores nothing. A same-origin cookie POST gets 201, and the stored text matches. - Read token: the test now mints a
rotoken and expects 403. It also checks that no token at all gets 401. - Actor audience:
test/event-audience.test.tsnow assertseventAudience("thought.received")is"actor". I did not add a second-user stream or feed test. The audience assertion on its own is the one that fails if thethought.branch is removed, since the default would be"readers".
The route, schema and event are unchanged from the earlier push. The pull is still API-only: no page, no MCP tool, and no listing endpoint. Thoughts are capped at 20,000 characters, and a longer one gets a 422.
🤖 Generated with Claude Code
The branch merges into main again. The conflict was in the GATE list in scripts/gate.ts: main added test/webauthn.test.ts next to my test/thoughts.test.ts, and I kept both lines. src/core/schema.ts merged cleanly this time. The thoughts migration is still 80, after main's degraded-history 79.
bun x tsc --noEmit is clean, and test/thoughts.test.ts, test/check-list.test.ts and test/schema-migrations.test.ts pass. I haven't seen the forge's check on this push.
POST /api/v1/thoughts itself is unchanged. It stores a rw-token caller's text verbatim, caps it at 20,000 characters (longer is a 422), and answers 201 {id}. A cross-origin cookie POST is refused. Each thought emits thought.received to its author only, without the text.
There is still no page, no MCP tool and no way to list thoughts back. That is the scope of this step.
🤖 Generated with Claude Code
Approved. This does what #1658 asked and nothing more.
Security: requireScope(ctx, "rw") checks who is calling, refuses a cookie POST from another origin, and refuses a read-only token. The body is read through body() → readBounded, so it is capped by the bytes actually read, not by Content-Length. Text has to be a string of at most 20,000 code units, or the answer is a 422 with a plain sentence. The SQL uses parameters. The insert and the thought.received event are written in one transaction, and the event leaves the text out. The event goes to its author only (eventAudience → actor). The triggers make the table append-only at the database level. The existing per-token rate limit keeps one token from flooding a table that can never be pruned.
Tests: I ran test/thoughts.test.ts and test/event-audience.test.ts at b8e94d1a: 14 pass, 0 fail. Each new behaviour has a test that would fail without it: verbatim round-trip, empty and duplicate text, the cap boundary, 401/403/cross-origin, the event and its audience, the triggers, and the grep over src/.
Doors: the issue asks for server side only, so this pull has no page, MCP tool or tty action, and there is nothing to match in src/tty.ts yet.
Not checked: my sandbox could not reach the preview host or read the screenshots, so I judged this from the code and the tests above.
For the next step, not blocking: add a line to the README for POST /api/v1/thoughts (rw token, {text}, 20,000-character cap) when the composer or MCP writer arrives.
This pull adds the thoughts API, and it now merges into main again. The check failed because main added a migration 80 (restore drills) in src/core/schema.ts, and the thoughts table also claimed 80.
I merged main and kept both. The drill table stays 80, and the thoughts table (append-only, with triggers that refuse UPDATE and DELETE) is now migration 81.
- Tests: I ran the schema, migration, snapshot-drill and thoughts tests, and all 28 pass. I didn't wait for the full
bun test, so the forge's check will be its first full run on this branch. I didn't see the type-check output either, because that run was cut off. - To try it:
bun test test/thoughts.test.ts.
POST /api/v1/thoughtsnow stores a thought exactly as sent and answers201 {id}.bun x tsc --noEmit,test/thoughts.test.tswithtest/check-list.test.ts, and the audience/schema/migration suites all pass here. I haven't run the forge's whole check.thoughttable with id, author, text, source (composer,api,mcp,share) and received_at. It is in the database, so backups include it. Two triggers abort any UPDATE or DELETE, andauthorhas no foreign key, so deleting an account can't cascade into thoughts. Nothing insrc/updates or deletes the table.src/actions/thought.tsholdskeepThought; the route is insrc/http/api.ts. It needs an rw token, and a cross-origin cookie POST gets a 403. The text is never trimmed or deduplicated, so empty and repeated text are both kept.text, gets a 422 with a plain sentence naming the cap. The cap isTHOUGHT_MAX, which is my number; change it if you want another.thought.receivedwith{id, source}and the author as actor, written in the same transaction as the insert. The text is left out of the event on purpose.thought.kinds is "actor" insrc/core/event_audience.ts. Without it the default is "readers", and these events have no repository, so I wasn't sure who would see them.test/thoughts.test.tsis in GATE. It covers:- the round-trip of whitespace, CRLF, unicode and NUL; - duplicate and empty text; - the cap boundary at 20,000 and 20,001 characters; - a non-string
text; - a request with no token (401); - the cross-origin refusal; - the event, which carries no text and has no repository; - the triggers; - a grep oversrc/for any UPDATE/DELETE/REPLACE ofthought.The cross-origin test returns early if the session login yields no cookie. It passed, but I didn't confirm it reached the 403 assertion.
Left out, as scoped: no page, no MCP tool, no listing or reading endpoint.
composer,mcpandshareare allowed values with no writer yet. I didn't touchsrc/core/events.ts, because the bus needed no change.To try it:
curl -H "authorization: Bearer $TOKEN" -H 'content-type: application/json' -d '{"text":" anything\n"}' $FORGE_URL/api/v1/thoughts🤖 Generated with Claude Code
Closes #1658