feat(HT-126): customer-side create endpoint - #201
Conversation
The server-to-server surface an integrating product uses to create, list, read, and reply to conversations on behalf of one identified customer. Defines the three exclusion classes that separate it from the Agent Inbox contract: internal notes (already required by agent-inbox-v1 §5), unapproved and discarded drafts (stated here for the first time), and operator workflow metadata. Each attaches to the /api/v1/customer routing boundary and is acceptance-tested rather than asserted. Auth is the deployment service token plus an integrator-asserted customer email. The trust boundary and its two consequences are stated explicitly.
Threading: an API-created conversation carries no Helpthread token, so a customer email with no valid token opens a separate conversation. Stated as a documented seam with acceptance coverage rather than assumed away. Identity: drop the invented Customer entity — the store persists a customerEmail string and nothing else. Scope by a normalized expression of that column, index-backed, without assuming existing rows were normalized. customerEmail is immutable for the conversation's life. Visibility: an approved draft is not proof of sending. The predicate now requires inbound OR deliveryStatus='sent', hides third-party inbound threads whose sender never consented to disclosure, derives updatedAt from visible threads so notes cannot leak through list ordering, and hides rows in states the schema forbids rather than trusting its own writes. Spam: a reply must not reach appendThread, which reopens closed OR spam. Refusal happens before the append or the 404 leaks by side-effect. Attachments are all-or-nothing. Idempotency is defined for creation rather than inherited from a per-conversation send contract. authorKind is left open: collapsing an assistant to 'agent' contradicts the charter's actor model, and the choice among the alternatives is a project decision.
POST /api/v1/customer/conversations — the path an integrating product calls when its own user submits a help form (customer-conversations-v1 §6a). Ships create only; the read and reply endpoints stay specified and unbuilt. Identity comes from X-Helpthread-Customer-Email and never from the body: the service token can act for any customer, so a body-supplied address would be a second, forgeable source of truth. The Bearer token is checked first, so a bad token with a missing header is 401 rather than disclosing header validity. Attachments are all-or-nothing. Blobs are written before the transaction opens, mirroring the ingest path; a failed write returns 502 with no conversation, since a conversation claiming files it does not have is worse than a rejected request. createCustomerConversation is a new store method rather than an option on createConversation — the conversation row, its first thread, attachment references, and both outbox events commit together, and widening the existing method would hand every caller behavior it never asked for. The event pair matches what a mail-ingested conversation emits, so consumers cannot tell the two apart.
The email validator's control-character scan was redundant — LOCAL_PART and DOMAIN are strict allowlists that admit neither whitespace nor control characters — and had been written with literal control bytes in the pattern.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
📝 WalkthroughWalkthroughThe PR adds a customer-scoped Conversations API v1. It implements authenticated routes for conversation creation, listing, retrieval, and replies, with normalized email ownership, visibility filtering, attachments, pagination, status transitions, events, and integration tests. ChangesCustomer Conversations API
Estimated code review effort: 4 (Complex) | ~45 minutes Sequence Diagram(s)sequenceDiagram
participant CustomerClient
participant createInboxApi
participant customerConversationHandlers
participant ConversationStore
participant BlobStore
participant Outbox
CustomerClient->>createInboxApi: Send customer conversation request
createInboxApi->>customerConversationHandlers: Validate customer email and route
customerConversationHandlers->>BlobStore: Store creation attachments
customerConversationHandlers->>ConversationStore: Create, read, list, or append reply
ConversationStore->>Outbox: Emit conversation or message event
ConversationStore-->>customerConversationHandlers: Return customer-safe data
customerConversationHandlers-->>CustomerClient: Return HTTP response
Possibly related PRs
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches 💡 1📝 Generate docstrings 💡
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Adds §6b list, §6c get, and §6d reply, so an integrating product can show a user their own request history and let them answer in-product rather than only by mail. Visibility is one SQL predicate at the query layer, not response filtering: a thread reaches the customer only if it is not a note, is not an unapproved or discarded draft, is inbound or actually sent, and — when inbound — comes from the conversation's own customer. The third condition is the one that bites: the store persists an outbound row before the network send and approval writes 'approved' before delivery completes, so draft state alone would disclose text that never left. The fourth closes forwarded reply tokens, where routing authority is not disclosure consent. updatedAt is derived from visible threads rather than passed through. An internal note bumps the stored column, which would reorder the customer's list and disclose that unseen activity occurred. A reply to a spam conversation is refused before appendThread, which reopens closed OR spam — reaching it would leak the verdict by side-effect. Migration 034 indexes the normalized customer-email expression. Stored addresses are verbatim, so the match cannot be plain equality, and an unindexed expression scans every conversation on every read. authorKind reports the persisted author kind. Collapsing an assistant to 'agent' would tell a customer a human wrote an AI's reply, which the charter's actor model forbids; the individual actor id stays hidden.
A list row wants to label its excerpt ("You:" vs the organization) and the
summary carried the text without its author, which would force either a
per-row thread fetch or dropping the label.
Visibility predicate: condition 3 was `inbound OR sent`, which admits an inbound row carrying any delivery_status — including a combination migration 002's CHECK forbids. The doc comment claimed such a row was hidden; it was not. Spelled out per direction, with a regression test that drops the CHECK to seed the row, since the predicate is a whitelist over data this API does not exclusively write. Detail read: the summary and thread queries ran as two statements, so a conversation filed as spam between them still returned 200 with its threads. Both now share one transaction. Migration 034 claimed its trailing columns served the list's sort order. They cannot: the list sorts by a value derived from visible threads, not by conversations.updated_at. Reduced to the ownership filter it actually supports, and the gap is recorded rather than papered over. Spec: attachments are removed from the read surface rather than specified and unimplemented — serving them means minting bearer-equivalent signed URLs that must be provably unmintable for an excluded thread. A folded Authorization header is documented as 401, not 400; reporting 400 would confirm to an unauthenticated caller that its header parsed.
There was a problem hiding this comment.
Actionable comments posted: 8
🧹 Nitpick comments (3)
src/api/customer-conversations.ts (1)
30-39: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueConsider exporting the shared
parseJsonBodyhelper.The comment states this mirrors a private helper in
conversations.ts. Two copies of request-body parsing can drift. Export the existing one and import it here.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/api/customer-conversations.ts` around lines 30 - 39, Export the existing parseJsonBody helper from conversations.ts and remove the local duplicate in customer-conversations.ts. Import and reuse the shared helper there, preserving its current return behavior.src/api/customer-conversations.test.ts (1)
46-67: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueDrop the
as unknown as BlobStorecast.The double cast disables type checking on this fake. If
BlobStoregains a method or changesput's signature, this test keeps compiling while production code diverges. Implement the interface directly and let the compiler enforce it.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/api/customer-conversations.test.ts` around lines 46 - 67, Update createFakeBlobStore to construct a type-safe BlobStore implementation directly, removing the as unknown as BlobStore cast. Ensure the fake explicitly satisfies the current BlobStore interface while preserving its existing put, get, and signedUrl behavior.src/db/migrate.ts (1)
2243-2246: 🗄️ Data Integrity & Integration | 🔵 TrivialPlan the index build for a populated
conversationstable.
migrate()wraps migrations in a single transaction, soCREATE INDEX CONCURRENTLYcannot run here. A plain build can hold aSHARElock onconversationswhile the index is built.For larger deployments, create the index out-of-band with
CREATE INDEX CONCURRENTLYand leave this migration as a safe no-op viaIF NOT EXISTS. Otherwise document the expected build time against the production table size.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/db/migrate.ts` around lines 2243 - 2246, Update MIGRATION_034_CUSTOMER_EMAIL_LOOKUP and the migration flow to avoid building this index synchronously inside migrate()’s transaction: provision the index out-of-band with CREATE INDEX CONCURRENTLY, while retaining an IF NOT EXISTS migration no-op for already-provisioned deployments. Document the expected production-table build timing if the migration may still perform the plain index build.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@specs/api/customer-conversations-v1.md`:
- Line 279: Update the cross-reference in the customer-conversations
specification to point to the canonical specs/modules/substrate-v1.md document
instead of specs/plugins/substrate-v1.md, and use “Modules” terminology
consistently unless the phrase “plugin exception” is required.
In `@src/api/customer-conversations.test.ts`:
- Around line 629-657: Extend src/api/customer-conversations.test.ts:629-657 to
cover snoozed-pending wake and active-to-active transitions, asserting the
emitted conversation.message_received event and its reopened value for each.
Update src/api/customer-conversations.test.ts:212-226 with a new Headers
instance using two append calls for X-Helpthread-Customer-Email and an
empty-value case. In specs/api/customer-conversations-v1.md:458-524, either add
coverage for criteria 17-19, 21, and 22 or mark them pending because the
Idempotency-Key and mail-threading fixtures are unavailable.
- Around line 212-226: Update the invalid customer-header cases in the test at
`rejects a missing, multi-valued, or unsupported customer header` to include an
empty string and add a genuinely repeated header request, using the
request/header construction path that preserves duplicate values so
`Headers.get` joins them with “, ”. Assert both cases return status 400 with
`validation_failed`.
In `@src/api/customer-conversations.ts`:
- Around line 1-17: Update the stale documentation in
src/api/customer-conversations.ts lines 1-17 to describe all four shipped
customer-conversation endpoints, while retaining the note that Idempotency-Key
is not implemented. In specs/api/customer-conversations-v1.md lines 328-332,
remove the statement that §4d remains undecided and document its resolved
authorKind attribution.
- Around line 292-312: Update createCustomerConversation and the
customer-conversation creation handler to return number and createdAt directly
from the transaction result. Remove the subsequent getConversation call and its
fabricated fallbacks, and build the 201 response from the transaction’s
persisted fields while preserving the existing receipt shape.
- Around line 155-216: Update the attachment loop around boundedString and
decodeBase64 to validate each attachment’s encoded data length before decoding,
using the base64 4/3 expansion relationship and the existing
MAX_ATTACHMENTS_TOTAL_BYTES limit. Reject oversized encoded payloads before
decodeBase64 or any base64 re-encoding can allocate buffers, while preserving
the existing cumulative decoded-size check; also confirm the deployment target
has an upstream request-body size limit.
In `@src/store/conversations.ts`:
- Around line 1462-1477: Reconcile the conversation query’s documented behavior
with its implementation: make derived_updated_at, latest_body_text, and
latest_author_kind select from the same newest customer-visible thread using
identical (created_at, id) ordering. Prefer a single LATERAL subquery for the
preview fields, and remove the body_text IS NOT NULL filter unless the API
documentation is explicitly updated to define previews as skipping text-less
threads.
- Around line 1490-1530: Update getCustomerConversation to enforce a consistent
read across the summary and thread queries by configuring its transaction for
REPEATABLE READ isolation or locking the conversation row with FOR SHARE in the
initial summary query. Preserve the existing ownership, status filtering, and
thread retrieval behavior.
---
Nitpick comments:
In `@src/api/customer-conversations.test.ts`:
- Around line 46-67: Update createFakeBlobStore to construct a type-safe
BlobStore implementation directly, removing the as unknown as BlobStore cast.
Ensure the fake explicitly satisfies the current BlobStore interface while
preserving its existing put, get, and signedUrl behavior.
In `@src/api/customer-conversations.ts`:
- Around line 30-39: Export the existing parseJsonBody helper from
conversations.ts and remove the local duplicate in customer-conversations.ts.
Import and reuse the shared helper there, preserving its current return
behavior.
In `@src/db/migrate.ts`:
- Around line 2243-2246: Update MIGRATION_034_CUSTOMER_EMAIL_LOOKUP and the
migration flow to avoid building this index synchronously inside migrate()’s
transaction: provision the index out-of-band with CREATE INDEX CONCURRENTLY,
while retaining an IF NOT EXISTS migration no-op for already-provisioned
deployments. Document the expected production-table build timing if the
migration may still perform the plain index build.
🪄 Autofix
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro Plus
Run ID: 235add8d-d8c0-48d1-9bd8-baec0f858f14
📒 Files selected for processing (9)
docs/architecture/README.mdspecs/api/customer-conversations-v1.mdsrc/api/customer-conversations.test.tssrc/api/customer-conversations.tssrc/api/index.tssrc/api/router.tssrc/db/migrate.test.tssrc/db/migrate.tssrc/store/conversations.ts
| const rows = await db.query<CustomerSummaryRow>( | ||
| `SELECT * FROM ( | ||
| SELECT c.id, c.number, c.subject, c.status, c.created_at, | ||
| COALESCE( | ||
| (SELECT max(t.created_at) FROM threads t | ||
| WHERE t.conversation_id = c.id AND ${CUSTOMER_VISIBLE_THREAD}), | ||
| c.created_at | ||
| ) AS derived_updated_at, | ||
| (SELECT count(*) FROM threads t | ||
| WHERE t.conversation_id = c.id AND ${CUSTOMER_VISIBLE_THREAD})::int AS thread_count, | ||
| (SELECT t.body_text FROM threads t | ||
| WHERE t.conversation_id = c.id AND t.body_text IS NOT NULL AND ${CUSTOMER_VISIBLE_THREAD} | ||
| ORDER BY t.created_at DESC, t.id DESC LIMIT 1) AS latest_body_text, | ||
| (SELECT t.author_kind FROM threads t | ||
| WHERE t.conversation_id = c.id AND t.body_text IS NOT NULL AND ${CUSTOMER_VISIBLE_THREAD} | ||
| ORDER BY t.created_at DESC, t.id DESC LIMIT 1) AS latest_author_kind |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
derived_updated_at and preview can come from different threads.
derived_updated_at uses max(t.created_at) over all customer-visible threads. latest_body_text and latest_author_kind add t.body_text IS NOT NULL. If the newest visible thread carries no body_text, updatedAt comes from that thread while preview and previewAuthorKind come from an older one.
specs/api/customer-conversations-v1.md §2 line 44 defines preview as the newest visible thread's bodyText excerpt, '' when none. §4b line 202-205 requires the same (createdAt, id) ordering to select both values.
The code's behavior is defensible, and the doc comment at line 1012 already describes it. Reconcile the two: either drop the body_text IS NOT NULL filter so both derive from the same thread, or amend §2 and §4b to state that preview skips text-less threads. latest_body_text and latest_author_kind also duplicate the same ordered subquery twice; a single LATERAL join would return one row and remove the risk of the two diverging in a future edit.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/store/conversations.ts` around lines 1462 - 1477, Reconcile the
conversation query’s documented behavior with its implementation: make
derived_updated_at, latest_body_text, and latest_author_kind select from the
same newest customer-visible thread using identical (created_at, id) ordering.
Prefer a single LATERAL subquery for the preview fields, and remove the
body_text IS NOT NULL filter unless the API documentation is explicitly updated
to define previews as skipping text-less threads.
| async getCustomerConversation(conversationId, customerEmail) { | ||
| // Both statements run in ONE transaction. The summary query resolves | ||
| // ownership, existence, and status together, but on its own it only | ||
| // covers the first read: an operator filing the conversation as spam | ||
| // between the two queries would otherwise still return 200 with its | ||
| // threads, defeating §3c. A single snapshot closes that window. | ||
| return db.transaction(async (tx) => { | ||
| const rows = await tx.query<CustomerSummaryRow>( | ||
| `SELECT c.id, c.number, c.subject, c.status, c.created_at, | ||
| COALESCE( | ||
| (SELECT max(t.created_at) FROM threads t | ||
| WHERE t.conversation_id = c.id AND ${CUSTOMER_VISIBLE_THREAD}), | ||
| c.created_at | ||
| ) AS derived_updated_at, | ||
| (SELECT count(*) FROM threads t | ||
| WHERE t.conversation_id = c.id AND ${CUSTOMER_VISIBLE_THREAD})::int AS thread_count, | ||
| (SELECT t.body_text FROM threads t | ||
| WHERE t.conversation_id = c.id AND t.body_text IS NOT NULL AND ${CUSTOMER_VISIBLE_THREAD} | ||
| ORDER BY t.created_at DESC, t.id DESC LIMIT 1) AS latest_body_text, | ||
| (SELECT t.author_kind FROM threads t | ||
| WHERE t.conversation_id = c.id AND t.body_text IS NOT NULL AND ${CUSTOMER_VISIBLE_THREAD} | ||
| ORDER BY t.created_at DESC, t.id DESC LIMIT 1) AS latest_author_kind | ||
| FROM conversations c | ||
| WHERE c.id = $2 | ||
| AND ${NORMALIZED('c.customer_email')} = $1 | ||
| AND c.status IN ('active', 'pending', 'closed')`, | ||
| [customerEmail, conversationId], | ||
| ) | ||
| const row = rows[0] | ||
| if (row === undefined) return null | ||
|
|
||
| const threadRows = await tx.query<ThreadRow>( | ||
| `SELECT ${THREAD_COLUMNS_T} FROM threads t | ||
| WHERE t.conversation_id = $2 AND ${CUSTOMER_VISIBLE_THREAD} | ||
| ORDER BY t.created_at, t.id`, | ||
| [customerEmail, conversationId], | ||
| ) | ||
|
|
||
| return { ...toCustomerSummary(row), threads: threadRows.map(toStoredThread) } | ||
| }) | ||
| }, |
There was a problem hiding this comment.
🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Inspect how db.transaction opens its transaction and whether it sets an isolation level.
rg -n -C12 'BEGIN|ISOLATION LEVEL|async transaction' src/db/client.tsRepository: Helpthread/helpthread
Length of output: 958
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Locate relevant definitions without executing repo code.
rg -n "class Pglite|transaction\\(|BEGIN|READ COMMITTED|REPEATABLE READ|READ ONLY|BEGIN TRANSACTION|set transaction|transaction is" src/db/client.ts src -g '*.ts'
printf '\n--- src/db/client.ts wrapper ---\n'
sed -n '90,120p' src/db/client.ts | nl -ba -v90
printf '\n--- references to PgliteDb and PGlite transaction behavior ---\n'
rg -n "PgliteDb|new Pglite|pglite\\." src -g '*.ts'Repository: Helpthread/helpthread
Length of output: 8815
🌐 Web query:
pglite transaction default isolation level READ COMMITTED REPEATABLE READ
💡 Result:
PGlite inherits its transaction isolation behavior from PostgreSQL, as it is a WASM-compiled version of the PostgreSQL engine [1][2]. In PostgreSQL, the default transaction isolation level is READ COMMITTED [3]. PGlite operates in a single-user mode [1][4] and does not change this default. When you initiate a transaction using the PGlite .transaction() API [5][6], it uses the standard PostgreSQL transaction semantics. If you need a different isolation level, you can explicitly set it using standard SQL commands, such as SET TRANSACTION ISOLATION LEVEL REPEATABLE READ; [7], within the transaction block.
Citations:
- 1: https://github.com/electric-sql/pglite/
- 2: https://github.com/pglite/pglite/blob/main/README.md
- 3: https://www.postgresql.org/docs/current/transaction-iso.html
- 4: https://github.com/electric-sql/pglite/tree/d8243c03f04cebdff9e726630166fbd663291c00
- 5: https://pglite.dev/docs/api
- 6: https://github.com/electric-sql/pglite/blob/main/docs/docs/api.md
- 7: https://cdn.jsdelivr.net/npm/prisma-pglite@2.0.2/src/adapter/prisma-pglite-adapter/pglite.ts
🏁 Script executed:
#!/bin/bash
set -euo pipefail
printf '%s\n' '--- src/db/client.ts transaction wrapper ---'
sed -n '100,115p' src/db/client.ts
printf '%s\n' '--- src/db/postgres transaction isolation comment/default ---'
sed -n '35,48p' src/db/postgres.ts
printf '%s\n' '--- pglite package references ---'
rg -n '"pglite"|from ["'\''"]pglite|pglite' package.json pnpm-lock.yaml yarn.lock package-lock.json 2>/dev/null || trueRepository: Helpthread/helpthread
Length of output: 4720
Use one snapshot or a row lock for this customer read.
The comment says the wrapped transaction gives “a single snapshot” to stop an operator filing the conversation as spam between the summary query and thread query. PostgreSQL default isolation is READ COMMITTED, and each SELECT can take a fresh snapshot unless the transaction explicitly uses REPEATABLE READ. If the summary query returns active while a concurrent status change commits before the thread query runs, the response can still include threads.
Set the transaction to REPEATABLE READ, or lock the conversation row in the first query with FOR SHARE so status changes serialize with this read.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/store/conversations.ts` around lines 1490 - 1530, Update
getCustomerConversation to enforce a consistent read across the summary and
thread queries by configuring its transaction for REPEATABLE READ isolation or
locking the conversation row with FOR SHARE in the initial summary query.
Preserve the existing ownership, status filtering, and thread retrieval
behavior.
Create receipt: number and createdAt were re-read after the transaction and fell back to null and a fresh Date when the read missed, so a client could persist a fabricated timestamp against a contract with no nullable number. Both are now returned from inside the creating transaction. Attachments: the size cap was enforced after each entry was fully decoded, and verifying a decode allocates a second copy, so one oversized base64 string drove peak memory regardless of the cap. Rejected on encoded length first. Acceptance: §8 asserted every criterion was a test. Several were not. Each is now marked covered or outstanding, and the three that were both cheap and genuinely missing are written — the snoozed-pending wake, active to active, and a genuinely duplicated customer header, which a Record cannot express. The wake branch had no coverage at all. updatedAt and preview can legitimately come from different threads when the newest visible one carries no body text; §4b claimed they could not. Corrects a reference to specs/modules/substrate-v1.md and the module header, which still described a create-only surface.
|
@coderabbitai full review |
|
… idempotency Wrapping the two detail statements in a transaction did not close the window the comment claimed it did. READ COMMITTED gives every statement a fresh snapshot, so a conversation filed as spam between the summary and the thread read still returned 200 carrying its threads. The status is now re-checked after the threads read and the response discarded if it moved, and the reported status comes from that last observation. A change committing after the re-check is undetectable by any design and is no longer claimed otherwise. §6a specified Idempotency-Key transactional recording and replay semantics that the endpoint never implements — it does not read the header, so two identical keyed requests create two conversations. Removed from the contract rather than left as a promise an integrator could build on. §4a's formal predicate still read "inbound OR sent" while its prose and the SQL both require an inbound row to carry no delivery status. The formal statement is the load-bearing one; it now matches. Event assertions select by threadId. A conversation carries two message_received events and claimBatch orders by occurredAt alone, so a positional pick would tie-break nondeterministically.
🟢 SAFE TO MERGE
CI green. The AI-disclosure decision is answered. Codex (in place of a rate-limited
CodeRabbit): 7 findings, 4 real and fixed, 1 deferred, 2 accepted.
The rows still marked
INFERREDbelow are the author's engineering calls, not maintainerdecisions. They are labelled rather than quietly promoted; the maintainer authorized merging
with them in place on 2026-08-08, which is approval to ship, not evidence they were decided.
Decision provenance
One-way door: a public API surface under
/api/v1/customer. Once an integrator depends onit, the request and response shapes are a compatibility commitment.
The decision that needs you
authorKindreports the persisted author, so a customer sees'assistant'when an approvedAI draft was sent to them.
CHARTER.md's actor model forbids silently conflating an assistantwith human staff, which rules out reporting
'agent'; coarsening everything to'organization'is the same conflation at a lower resolution. But disclosing AI authorshipto customers by default is a product stance, not a code detail, and no maintainer decision
exists on it. Overrule and I'll add a policy layer instead.
What this adds
The customer-side conversations API: create (§6a), list (§6b), get (§6c), reply (§6d).
Visibility is one SQL predicate at the query layer, not response filtering. A thread reaches a
customer only if it is not a note, not an unapproved or discarded draft, and either inbound
with no delivery status or outbound and actually sent — and, when inbound, sent by the
conversation's own customer. Two of those are subtle:
approvedbefore delivery completes. Draft state alone would disclose text that never left.message. Routing authority is not disclosure consent.
updatedAtis derived from visible threads rather than passed through — the stored column isbumped by internal notes, which would reorder a customer's list and disclose unseen activity.
A reply to a spam conversation is refused before
appendThread, which reopensclosedORspam; reaching it would leak the verdict by side-effect.Review
Three adversarial passes. Two reviewers, disjoint findings — which is the argument for
running both.
Round 1 — Codex, standing in for a rate-limited CodeRabbit: 7 findings, 4 fixed, 1
deferred with the spec corrected, 2 accepted.
inbound OR sent, admitting an inbound row with any delivery status, while the comment claimed otherwiseAuthorizationreturned 401, spec said 400attachmentsspecified on read but not implementedCloses HT-126.
Round 2 — CodeRabbit (it did review this branch): 8 findings, all real, all addressed.
The two that mattered: the create receipt re-read
number/createdAtafter its transactionand fell back to
nulland a freshDate, so a client could persist a timestamp that neverexisted; and the attachment cap ran after each entry was fully decoded, letting one oversized
base64 string drive peak memory regardless of the limit. Also: §8 claimed every criterion was
a test when several were not — including the snoozed-pending wake, which had no coverage at
all. Criteria are now marked covered or outstanding, and the cheap missing ones are written.
Round 3 — Codex, standing in again: 5 findings, 3 real and fixed, 2 noted. CodeRabbit's
only review landed on
dd4fb76, no longer the head; on the fix-ups it reports "Reviewskipped: incremental reviews are disabled". Its green check therefore covers neither commit,
so Codex reviewed that exact delta instead.
Idempotency-Keysemantics the endpoint never implementsthreadId, not position.