@pithy-sh/support 0.1.2 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (170) hide show
  1. package/dist/ai/classify.d.ts +92 -0
  2. package/dist/ai/classify.d.ts.map +1 -0
  3. package/dist/ai/classify.js +153 -0
  4. package/dist/attachment/store.d.ts +55 -0
  5. package/dist/attachment/store.d.ts.map +1 -0
  6. package/dist/attachment/store.js +69 -0
  7. package/dist/audit/actions.d.ts +70 -0
  8. package/dist/audit/actions.d.ts.map +1 -0
  9. package/dist/audit/actions.js +70 -0
  10. package/dist/capability.d.ts +95 -0
  11. package/dist/capability.d.ts.map +1 -0
  12. package/dist/capability.js +189 -0
  13. package/dist/client/projection.d.ts +58 -0
  14. package/dist/client/projection.d.ts.map +1 -0
  15. package/dist/client/projection.js +2 -0
  16. package/dist/config/config.d.ts +217 -0
  17. package/dist/config/config.d.ts.map +1 -0
  18. package/dist/config/config.js +171 -0
  19. package/dist/data/attachment.d.ts +32 -0
  20. package/dist/data/attachment.d.ts.map +1 -0
  21. package/dist/data/attachment.js +34 -0
  22. package/dist/data/billingScope.d.ts +32 -0
  23. package/dist/data/billingScope.d.ts.map +1 -0
  24. package/dist/data/billingScope.js +34 -0
  25. package/dist/data/categories.d.ts +56 -0
  26. package/dist/data/categories.d.ts.map +1 -0
  27. package/dist/data/categories.js +99 -0
  28. package/dist/data/classification.d.ts +36 -0
  29. package/dist/data/classification.d.ts.map +1 -0
  30. package/dist/data/classification.js +30 -0
  31. package/dist/data/enums.d.ts +84 -0
  32. package/dist/data/enums.d.ts.map +1 -0
  33. package/dist/data/enums.js +72 -0
  34. package/dist/data/flag.d.ts +27 -0
  35. package/dist/data/flag.d.ts.map +1 -0
  36. package/dist/data/flag.js +29 -0
  37. package/dist/data/message.d.ts +83 -0
  38. package/dist/data/message.d.ts.map +1 -0
  39. package/dist/data/message.js +86 -0
  40. package/dist/data/tables.d.ts +162 -0
  41. package/dist/data/tables.d.ts.map +1 -0
  42. package/dist/data/tables.js +46 -0
  43. package/dist/data/thread.d.ts +79 -0
  44. package/dist/data/thread.d.ts.map +1 -0
  45. package/dist/data/thread.js +67 -0
  46. package/dist/error/errors.d.ts +66 -0
  47. package/dist/error/errors.d.ts.map +1 -0
  48. package/dist/error/errors.js +84 -0
  49. package/dist/http/guards.d.ts +46 -0
  50. package/dist/http/guards.d.ts.map +1 -0
  51. package/dist/http/guards.js +55 -0
  52. package/dist/http/handlers.d.ts +91 -0
  53. package/dist/http/handlers.d.ts.map +1 -0
  54. package/dist/http/handlers.js +250 -0
  55. package/dist/http/resolve.d.ts +46 -0
  56. package/dist/http/resolve.d.ts.map +1 -0
  57. package/dist/http/resolve.js +74 -0
  58. package/dist/http/responses.d.ts +831 -0
  59. package/dist/http/responses.d.ts.map +1 -0
  60. package/dist/http/responses.js +305 -0
  61. package/dist/http/routes.d.ts +111 -0
  62. package/dist/http/routes.d.ts.map +1 -0
  63. package/dist/http/routes.js +64 -0
  64. package/dist/http/schemas.d.ts +123 -0
  65. package/dist/http/schemas.d.ts.map +1 -0
  66. package/dist/http/schemas.js +91 -0
  67. package/dist/http/scopes.d.ts +58 -0
  68. package/dist/http/scopes.d.ts.map +1 -0
  69. package/dist/http/scopes.js +109 -0
  70. package/dist/http/views.d.ts +38 -0
  71. package/dist/http/views.d.ts.map +1 -0
  72. package/dist/http/views.js +146 -0
  73. package/dist/inbound/authenticity.d.ts +83 -0
  74. package/dist/inbound/authenticity.d.ts.map +1 -0
  75. package/dist/inbound/authenticity.js +63 -0
  76. package/dist/inbound/guard.d.ts +76 -0
  77. package/dist/inbound/guard.d.ts.map +1 -0
  78. package/dist/inbound/guard.js +80 -0
  79. package/dist/inbound/handler.d.ts +30 -0
  80. package/dist/inbound/handler.d.ts.map +1 -0
  81. package/dist/inbound/handler.js +84 -0
  82. package/dist/inbound/ingest.d.ts +106 -0
  83. package/dist/inbound/ingest.d.ts.map +1 -0
  84. package/dist/inbound/ingest.js +328 -0
  85. package/dist/inbound/recipient.d.ts +18 -0
  86. package/dist/inbound/recipient.d.ts.map +1 -0
  87. package/dist/inbound/recipient.js +48 -0
  88. package/dist/index.d.ts +22 -0
  89. package/dist/index.d.ts.map +1 -0
  90. package/dist/index.js +16 -0
  91. package/dist/link/sender.d.ts +162 -0
  92. package/dist/link/sender.d.ts.map +1 -0
  93. package/dist/link/sender.js +202 -0
  94. package/dist/migrations/0001_threads.d.ts +28 -0
  95. package/dist/migrations/0001_threads.d.ts.map +1 -0
  96. package/dist/migrations/0001_threads.js +105 -0
  97. package/dist/mime/address.d.ts +24 -0
  98. package/dist/mime/address.d.ts.map +1 -0
  99. package/dist/mime/address.js +30 -0
  100. package/dist/mime/parse.d.ts +98 -0
  101. package/dist/mime/parse.d.ts.map +1 -0
  102. package/dist/mime/parse.js +148 -0
  103. package/dist/mime/sanitize.d.ts +27 -0
  104. package/dist/mime/sanitize.d.ts.map +1 -0
  105. package/dist/mime/sanitize.js +214 -0
  106. package/dist/mime/threading.d.ts +81 -0
  107. package/dist/mime/threading.d.ts.map +1 -0
  108. package/dist/mime/threading.js +113 -0
  109. package/dist/mime/truncate.d.ts +19 -0
  110. package/dist/mime/truncate.d.ts.map +1 -0
  111. package/dist/mime/truncate.js +49 -0
  112. package/dist/provision/provisionSupport.d.ts +160 -0
  113. package/dist/provision/provisionSupport.d.ts.map +1 -0
  114. package/dist/provision/provisionSupport.js +112 -0
  115. package/dist/provision/resolveSupportConfig.d.ts +47 -0
  116. package/dist/provision/resolveSupportConfig.d.ts.map +1 -0
  117. package/dist/provision/resolveSupportConfig.js +24 -0
  118. package/dist/reply/send.d.ts +125 -0
  119. package/dist/reply/send.d.ts.map +1 -0
  120. package/dist/reply/send.js +155 -0
  121. package/dist/reply/snippets.d.ts +81 -0
  122. package/dist/reply/snippets.d.ts.map +1 -0
  123. package/dist/reply/snippets.js +137 -0
  124. package/dist/secret/registry.d.ts +20 -0
  125. package/dist/secret/registry.d.ts.map +1 -0
  126. package/dist/secret/registry.js +23 -0
  127. package/dist/seeds/example.d.ts +5 -0
  128. package/dist/seeds/example.d.ts.map +1 -0
  129. package/dist/seeds/example.js +352 -0
  130. package/dist/store/paging.d.ts +20 -0
  131. package/dist/store/paging.d.ts.map +1 -0
  132. package/dist/store/paging.js +22 -0
  133. package/dist/store/search.d.ts +69 -0
  134. package/dist/store/search.d.ts.map +1 -0
  135. package/dist/store/search.js +162 -0
  136. package/dist/store/searchIndex.d.ts +55 -0
  137. package/dist/store/searchIndex.d.ts.map +1 -0
  138. package/dist/store/searchIndex.js +68 -0
  139. package/dist/store/threads.d.ts +149 -0
  140. package/dist/store/threads.d.ts.map +1 -0
  141. package/dist/store/threads.js +199 -0
  142. package/dist/submission/encoding.d.ts +23 -0
  143. package/dist/submission/encoding.d.ts.map +1 -0
  144. package/dist/submission/encoding.js +57 -0
  145. package/dist/submission/guard.d.ts +73 -0
  146. package/dist/submission/guard.d.ts.map +1 -0
  147. package/dist/submission/guard.js +48 -0
  148. package/dist/submission/submit.d.ts +154 -0
  149. package/dist/submission/submit.d.ts.map +1 -0
  150. package/dist/submission/submit.js +321 -0
  151. package/dist/version.generated.d.ts +7 -0
  152. package/dist/version.generated.d.ts.map +1 -0
  153. package/dist/version.generated.js +9 -0
  154. package/dist/workflows/classify.d.ts +65 -0
  155. package/dist/workflows/classify.d.ts.map +1 -0
  156. package/dist/workflows/classify.js +70 -0
  157. package/dist/workflows/retryPolicy.d.ts +41 -0
  158. package/dist/workflows/retryPolicy.d.ts.map +1 -0
  159. package/dist/workflows/retryPolicy.js +45 -0
  160. package/dist/workflows/specs.d.ts +45 -0
  161. package/dist/workflows/specs.d.ts.map +1 -0
  162. package/dist/workflows/specs.js +48 -0
  163. package/dist/workflows/worker.d.ts +50 -0
  164. package/dist/workflows/worker.d.ts.map +1 -0
  165. package/dist/workflows/worker.js +39 -0
  166. package/package.json +24 -15
  167. package/src/http/handlers.ts +1 -1
  168. package/src/submission/encoding.ts +1 -1
  169. package/src/submission/submit.ts +1 -1
  170. package/src/version.generated.ts +1 -1
@@ -0,0 +1,171 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+ import { SupportReplySnippet } from "../reply/snippets.js";
4
+ import { normalizeAddress, parseAddress } from "@pithy-sh/core/src/address/address";
5
+ import { z } from "zod";
6
+ //#region src/config/config.ts
7
+ /**
8
+ * The support capability's configuration — the thin, user-owned surface in `pithy.config.ts`.
9
+ *
10
+ * The one field with no sensible default is `inboundAddresses`, and the reason is worth stating
11
+ * where an adopter will read it: **Cloudflare Email Routing takes over a zone's MX.** An address on
12
+ * the apex would move the adopter's real mail off their existing provider, so the address is theirs
13
+ * to choose deliberately — almost always on a subdomain (`support@help.theirdomain.com`) — and this
14
+ * capability will not guess one. Until it is set the inbox is inert: every message is ignored, and
15
+ * the handler says so once in the log rather than quietly storing mail from an address nobody
16
+ * configured.
17
+ */
18
+ /**
19
+ * The default Workers AI model for classification.
20
+ *
21
+ * An instruct model rather than a classifier: the taxonomy is federated, so the valid label set is
22
+ * not known until an adopter composes the capability, and a fine-tuned classifier cannot be given new
23
+ * classes in a config file. Small on purpose — this runs once per inbound message on the adopter's
24
+ * own bill, and the judgment is "which of these eight sentences fits", not a reasoning task.
25
+ */
26
+ const DEFAULT_CLASSIFY_MODEL = "@cf/meta/llama-3.1-8b-instruct";
27
+ /** How the inbound message is classified, and by what. */
28
+ const SupportAiConfig = z.object({
29
+ enabled: z.boolean().default(true).describe("Classify each inbound message with Workers AI. Off means every thread stays `uncategorized` and the inbox is chronological — still useful, and still free."),
30
+ model: z.string().default(DEFAULT_CLASSIFY_MODEL).describe("The Workers AI model classification runs on, over your own `AI` binding. Recorded on every classification, so a model change is visible in the data rather than inferred. Override to swap models with no code edits."),
31
+ maxChars: z.number().int().positive().default(4e3).describe("How much of a message body the model sees. A support request states its point in the first paragraph; the rest is usually a quoted thread, and paying to embed somebody's own signature block twice a day is the kind of cost that creeps."),
32
+ temperature: z.number().min(0).max(2).default(0).describe("Sampling temperature. Zero by default: classification wants the same answer for the same message, and a reclassification pass that disagreed with itself would be unreadable.")
33
+ }).describe("Workers AI classification settings — the model, how much it reads, and how deterministic it is.");
34
+ /** What happens to files that arrive attached to a message. */
35
+ const SupportAttachmentsConfig = z.object({
36
+ enabled: z.boolean().default(true).describe("Store attachments in your own R2. Off drops them at ingest — the message is still stored and the attachment metadata is not, which is the right setting for an inbox that never needs a screenshot."),
37
+ maxBytes: z.number().int().positive().default(10485760).describe("The largest single attachment stored. A larger one is skipped; the message it arrived on is kept."),
38
+ maxCount: z.number().int().positive().default(10).describe("How many attachments one message may contribute. Beyond this the extras are skipped — a bound on a number an attacker chooses."),
39
+ retainRaw: z.boolean().default(true).describe("Keep each message's raw MIME in R2, unchanged. Separate from `enabled` on purpose: the raw form is what makes the parse and the sanitize re-runnable, so a project that drops attachments still wants it, and folding the two together would silently turn off re-parsing for anyone who only meant to stop storing screenshots. Off means `rawKey` is null on every message and a sanitizer improvement can never be applied retroactively.")
40
+ }).describe("Attachment and raw-message handling — what bytes are kept, and the bounds on what one message may store.");
41
+ /**
42
+ * The spam and volume guard.
43
+ *
44
+ * A public address is a public write endpoint into the adopter's D1, and that is the honest way to
45
+ * think about it. These bounds are what stop a mail flood from being a storage bill.
46
+ */
47
+ const SupportGuardConfig = z.object({
48
+ maxRawBytes: z.number().int().positive().default(2097152).describe("The largest raw message accepted. Anything bigger is refused before it is parsed, because parsing is the expensive step and the size is known first."),
49
+ maxPerSenderPerHour: z.number().int().positive().default(20).describe("How many messages one address may land in an hour. The per-sender bound catches the common case: one broken auto-responder in a loop with your inbox."),
50
+ maxPerHour: z.number().int().positive().default(500).describe("How many messages the whole inbox may accept in an hour, across every sender. The bound that matters under a distributed flood, where no single address trips the per-sender limit."),
51
+ trustAuthenticationResults: z.boolean().default(false).describe("Treat the `Authentication-Results` header in the received message as a verdict you can rely on. **Off by default, and the reason is that under Cloudflare Email Routing there is no trust anchor for it.** Cloudflare evaluates DMARC — its `reply()` API requires a valid result — but does not reliably hand the verdict to a Worker (`Authentication-Results`, `Received`, and `DKIM-Signature` are all reported missing from `message.headers`), and a header a sender wrote is indistinguishable from one an MTA wrote once the MTA's copy is absent. Turn this on only if your receiving MTA both **stamps** the header and **strips** any inbound copy of it, and set `authservId` alongside. With it off the inbox still matches a sender to an account — it just never claims that match was verified, and withholds their billing history."),
52
+ authservId: z.string().min(1).optional().describe("The `authserv-id` your receiving MTA stamps its `Authentication-Results` header with — the field before the first `;`. Read only when `trustAuthenticationResults` is on. **This is a public hostname, not a credential**: an attacker who knows it can put it in a header they write, so it narrows a mistake rather than stopping an attacker. It is worth setting anyway, because with the wrong MTA in front of you it is the difference between reading somebody else's verdict and reading none."),
53
+ archiveSpam: z.boolean().default(true).describe("Archive a thread the moment the classifier calls it `spam`, so it never appears in the open inbox. **Archived, never deleted** — a classifier that silently destroyed mail would be untrustworthy the first time it was wrong, and it is wrong sometimes. The thread stays readable under the archived filter and unarchiving is one click, which is what makes this a filter rather than a bin. Off leaves spam in the open inbox for you to sort by hand.")
54
+ }).describe("Inbound bounds — size, per-sender rate, global rate, and what happens to spam.");
55
+ /**
56
+ * What a signed-in user may attach to an in-app submission.
57
+ *
58
+ * **Stated here rather than inherited from `attachments`, and that is the point.** The mail path's
59
+ * bounds were written for parts of a MIME message somebody sent to a public address: they cap size and
60
+ * count, and they say nothing at all about type, because there is no useful type restriction on mail —
61
+ * refusing a `.docx` a customer attached to a bug report would lose the report. A direct upload from a
62
+ * browser is a different surface with a different answer: the client is authenticated but untrusted,
63
+ * the useful payload is a screenshot, and an allowlist is both possible and worth having. Inheriting
64
+ * the email numbers would have meant a 10 MB any-type upload endpoint arriving as a side effect of a
65
+ * setting an adopter tuned for their inbox.
66
+ *
67
+ * Bytes are stored exactly as the mail path stores them — server-derived opaque key,
68
+ * `application/octet-stream` on the object whatever was declared — so nothing here re-opens the
69
+ * stored-XSS question `attachment/store.ts` already closed.
70
+ */
71
+ /**
72
+ * The hard ceiling on how many attachments one submission may declare, whatever
73
+ * `submission.attachments.maxCount` is set to.
74
+ *
75
+ * On the schema as well as in the handler because the two refuse at different moments and the earlier
76
+ * one is free: the validator rejects a thousand-element array before a handler runs, without reading
77
+ * the resolved config. The configured bound is what an adopter tunes; this is what is true regardless.
78
+ */
79
+ const MAX_SUBMISSION_ATTACHMENTS = 10;
80
+ const SupportSubmissionAttachmentsConfig = z.object({
81
+ enabled: z.boolean().default(true).describe("Accept attachments on an in-app submission. Off refuses any, which is the right setting for a project that wants feedback but no upload surface at all — the report is still stored, without the file."),
82
+ maxBytes: z.number().int().positive().default(5242880).describe("The largest single attachment accepted, measured on the **decoded** bytes. Deliberately smaller than the mail path's bound: a phone screenshot is under a megabyte, and this is a limit on what a signed-in client may push into your R2 in one request rather than on what a stranger's mail client happened to send."),
83
+ maxCount: z.number().int().positive().max(10).default(3).describe("How many attachments one submission may carry. Low on purpose — a bug report is a screenshot or two, and every extra slot is another `maxBytes` a client may spend per request. Checked before any payload is decoded, so the cheapest refusal in the path does not sit behind its most expensive step."),
84
+ allowedContentTypes: z.array(z.string().min(3).describe("One exact MIME type, lowercased, e.g. `image/png`. No wildcards.")).default([
85
+ "image/png",
86
+ "image/jpeg",
87
+ "image/gif",
88
+ "image/webp",
89
+ "application/pdf",
90
+ "text/plain"
91
+ ]).describe("The exact types a submission may carry — **an allowlist, not a denylist**, so a type nobody considered is refused rather than accepted. The default is what a bug report is actually made of: screenshots, a PDF, a log paste. The declared type is recorded and never honored when the bytes are served, so this bounds what lands in your bucket rather than standing in for that protection.")
92
+ }).describe("Bounds on a direct upload from a signed-in client — size, count, and an allowlist of types. Stated explicitly rather than inherited from the mail path, which answers a different question.");
93
+ /**
94
+ * The ceilings a configured submission bound may not exceed — the route's own schema is written to
95
+ * these, so a payload beyond one is refused by the validator before a handler runs.
96
+ *
97
+ * Two layers, and both earn their place. The **config** bound is the adopter's number and produces the
98
+ * message their user reads; the **schema** ceiling is what stops a megabyte of text reaching a handler
99
+ * at all, and it cannot be config-derived because a request schema is built once at module load while
100
+ * config is resolved per project. Bounding the config field by the same constant is what keeps them
101
+ * from disagreeing: a setting above the ceiling would be a limit the route silently refused to honor.
102
+ */
103
+ const MAX_SUBMISSION_SUBJECT_CHARS = 500;
104
+ /** The hard ceiling on a submission body, whatever `submission.maxBodyChars` is set to. */
105
+ const MAX_SUBMISSION_BODY_CHARS = 1e5;
106
+ /**
107
+ * The in-app submission channel: a signed-in user of the adopter's own app opening a support thread
108
+ * without leaving it.
109
+ *
110
+ * **The hardest problem in the mail path does not exist here.** `inbound/authenticity.ts` spends two
111
+ * hundred lines earning a customer link from a `From:` header that anybody can write; a submission
112
+ * arrives on a request `requireAuth()` already proved, so the account is the identity rather than a
113
+ * guess about it. Everything below is therefore a bound on *volume and shape*, never on identity.
114
+ *
115
+ * Bounded separately from `guard` for the same reason it is counted separately: that block exists
116
+ * because a public address is a public write endpoint, and this surface is neither public nor
117
+ * anonymous. Abuse here is attributable to an account and revocable with it, which is a materially
118
+ * different threat model and deserves its own numbers rather than a share of somebody else's.
119
+ */
120
+ const SupportSubmissionConfig = z.object({
121
+ enabled: z.boolean().default(true).describe("Serve the in-app submission routes. On by default because the capability is inert without an address either way, and a project that composes support almost always wants its signed-in users able to write in. Off removes the routes entirely — they answer 404, not 403, because a route that is not served has nothing to say about who was asking."),
122
+ maxSubjectChars: z.number().int().positive().max(500).default(200).describe("The longest subject a submission may carry. It becomes the thread's name in the inbox and the subject line of every reply, so it is bounded to something that renders in a list."),
123
+ maxBodyChars: z.number().int().positive().max(MAX_SUBMISSION_BODY_CHARS).default(1e4).describe("The longest report body accepted. Generous — somebody describing a bug properly is exactly who this channel is for — but bounded, because it is a string a client chose and it lands in a D1 row and a model prompt."),
124
+ maxPerAccountPerHour: z.number().int().positive().default(10).describe("How many submissions one account may land in an hour. **Session-bound abuse is attributable and revocable in a way mail is not**, which is why this number is smaller than the mail path's and still safe: a real person filing a genuine report does not file eleven, and an account that does is one an adopter can act on. Counted from the messages table over the same sliding hour the mail guard uses, and counted **only against app submissions** — a busy inbox must never stop a user reporting the outage."),
125
+ attachments: SupportSubmissionAttachmentsConfig.prefault({}).describe("What a signed-in client may upload with a report.")
126
+ }).describe("The in-app submission channel — whether it is served, what one submission may contain, and how many one account may send.");
127
+ /** How a reply leaves the building. */
128
+ const SupportReplyConfig = z.object({
129
+ enabled: z.boolean().default(true).describe("Allow replies from the control-plane route. Off makes the inbox read-only, which is a reasonable setting for a project that answers support from its own mail client."),
130
+ replyToAddress: z.string().optional().transform((address) => address === void 0 ? void 0 : parseAddress(address) ?? normalizeAddress(address)).describe("The `Reply-To` a reply carries — the address the customer's answer comes back to, which must be one of `inboundAddresses` or the conversation ends there. Defaults to the inbox address the thread arrived on, which is almost always what you want."),
131
+ deliverInApp: z.boolean().default(false).describe("Answer an `app` thread by storing the reply for the submitter to read in the app, instead of mailing it. **A choice, not a fallback.** Turning on Email Routing takes over the zone's MX, so a project already running mail on that domain cannot receive support replies without consequences everywhere else on it — and for a submitter who is a signed-in user sitting on the screen they wrote from, the answer is better placed there anyway. In-app delivery also happens automatically when there is no address to reply from and no email capability to send with, whatever this says; the setting is what makes it reachable for a project whose mail works fine. It never applies to an `email` thread: a mail thread's sender has no read-back, so a stored answer there is one nobody would ever see."),
132
+ snippets: z.record(z.string(), SupportReplySnippet).default({}).describe("Your own canned replies, merged over the ones Pithy ships and winning on a key collision. These are *starting points a human picks and edits* in the dashboard, not automatic replies — nothing here is ever sent without somebody pressing send. Author them with `defineSupportReplies` so a malformed one fails where it is written.")
133
+ }).describe("Reply settings — whether replies are allowed, where the answer goes, and what to offer as a starting point.");
134
+ /**
135
+ * How text search is answered.
136
+ *
137
+ * The one setting here has a cost an adopter must opt into knowingly, which is why it is a setting
138
+ * at all rather than a decision this package made for them.
139
+ */
140
+ const SupportSearchConfig = z.object({ fts: z.boolean().default(false).describe("Build a SQLite FTS5 index over message subjects and bodies. **Off by default, and the reason is not performance.** `wrangler d1 export` refuses to dump any database containing an FTS5 virtual table — it fails outright rather than skipping the table — and the check runs server-side across the whole database before `--table` filtering, so turning this on takes your **entire app database's** export with it. A failed attempt has also been reported to leave the database inaccessible until it clears. If you do not use `wrangler d1 export`, none of that reaches you and FTS5 is the better search. Safe to toggle either way: the index is derived rather than migrated, so `pithy support provision` creates or drops it to match — and until you re-provision, search falls back to the `LIKE` scan rather than failing.") }).describe("Text search settings — the `LIKE` scan by default, FTS5 as a deliberate opt-in.");
141
+ /** The full support configuration. */
142
+ const SupportConfig = z.object({
143
+ inboundAddresses: z.array(z.string().min(3).describe("One address this inbox accepts mail for, e.g. `support@help.example.com`.").transform((address) => parseAddress(address) ?? normalizeAddress(address))).default([]).describe("The addresses this capability claims. Every capability's `email()` handler sees every message the Worker receives, so this list is how support tells its mail apart from the bounce handler's — and it is a claim, not a route: the routing rule that delivers to this Worker is created by `pithy support provision`. **Use a subdomain, never your apex**: Email Routing takes over the zone's MX, and enabling it on the apex moves your real mail off your provider. Empty means the inbox is inert."),
144
+ categories: z.record(z.string(), z.string()).default({}).describe("Your own categories, merged over the eight Pithy ships and winning on a key collision. A key is `snake_case`; the value is the instruction a model reads when deciding whether this is the one, and it lands in the prompt verbatim. Author it with `defineSupportCategories` so a typo fails where it is written."),
145
+ ai: SupportAiConfig.prefault({}).describe("Classification settings."),
146
+ attachments: SupportAttachmentsConfig.prefault({}).describe("Attachment handling."),
147
+ guard: SupportGuardConfig.prefault({}).describe("Inbound mail size and rate bounds."),
148
+ submission: SupportSubmissionConfig.prefault({}).describe("The in-app submission channel."),
149
+ reply: SupportReplyConfig.prefault({}).describe("Reply settings."),
150
+ search: SupportSearchConfig.prefault({}).describe("How text search is answered.")
151
+ }).describe("Configuration for the support capability — which addresses it claims, and how it classifies and bounds them.");
152
+ /**
153
+ * Would this configuration ever put a byte in `SUPPORT_BUCKET`?
154
+ *
155
+ * **The one place the question is answered, because three independent settings answer it and each has
156
+ * its own writer.** `attachments.enabled` gates stored mail attachments (`inbound/ingest.ts:200`),
157
+ * `attachments.retainRaw` gates the raw MIME copy (`inbound/ingest.ts:346`), and
158
+ * `submission.attachments.enabled` gates an in-app submission's files (`submission/submit.ts:268`).
159
+ * All three default `true`, so the ordinary project is unaffected by this predicate existing.
160
+ *
161
+ * Two callers, and they must agree: `capability.ts` declares the binding only when this is true, and
162
+ * `pithy support provision` creates the bucket only when this is true. They disagreed before #440 —
163
+ * the provisioner asked about `enabled` and `retainRaw` and never about submissions, so a project that
164
+ * wanted uploads but no mail attachments got a binding pointing at a bucket nothing had created, and
165
+ * every submitted file was dropped with a warning. One predicate is what makes that unrepresentable.
166
+ */
167
+ function supportNeedsBucket(config) {
168
+ return config.attachments.enabled || config.attachments.retainRaw || config.submission.attachments.enabled;
169
+ }
170
+ //#endregion
171
+ export { DEFAULT_CLASSIFY_MODEL, MAX_SUBMISSION_ATTACHMENTS, MAX_SUBMISSION_BODY_CHARS, MAX_SUBMISSION_SUBJECT_CHARS, SupportAiConfig, SupportAttachmentsConfig, SupportConfig, SupportGuardConfig, SupportReplyConfig, SupportSearchConfig, SupportSubmissionAttachmentsConfig, SupportSubmissionConfig, supportNeedsBucket };
@@ -0,0 +1,32 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+ import { z } from "zod";
4
+ /**
5
+ * One attachment in `pithy_support_attachments` — the metadata row for bytes that live in R2.
6
+ *
7
+ * **The bytes are never proxied.** A dashboard gets a short-lived signed URL and fetches from R2
8
+ * directly, because proxying would put a Pithy-operated surface in the data path, which is the exact
9
+ * thing principle 1 exists to prevent. The key is server-derived and opaque, so holding a thread id
10
+ * never lets a caller name or guess an object.
11
+ *
12
+ * `contentType` and `filename` are recorded **as the sender declared them** and are never trusted on
13
+ * the way out. This is the most attacker-controlled field pair in the capability: a message can claim
14
+ * `text/html` for a script, or a filename with a path separator or a right-to-left override in it.
15
+ * Whatever serves these bytes re-derives its own `Content-Type` and `Content-Disposition`, the same
16
+ * rule `@pithy-sh/storage` applies to uploads.
17
+ */
18
+ export declare const SupportAttachment: z.ZodObject<{
19
+ id: z.ZodString;
20
+ messageId: z.ZodString;
21
+ threadId: z.ZodString;
22
+ filename: z.ZodString;
23
+ contentType: z.ZodString;
24
+ size: z.ZodNumber;
25
+ sha256: z.ZodString;
26
+ storageKey: z.ZodString;
27
+ contentId: z.ZodOptional<z.ZodNullable<z.ZodString>>;
28
+ inline: z.ZodCodec<z.ZodUnion<readonly [z.ZodLiteral<0>, z.ZodLiteral<1>, z.ZodBoolean, z.ZodString]>, z.ZodBoolean>;
29
+ createdAt: z.ZodCodec<z.ZodUnion<readonly [z.ZodNumber, z.ZodString, z.ZodDate]>, z.ZodDate>;
30
+ }, z.core.$strip>;
31
+ export type SupportAttachment = z.output<typeof SupportAttachment>;
32
+ //# sourceMappingURL=attachment.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"attachment.d.ts","sourceRoot":"","sources":["../../src/data/attachment.ts"],"names":[],"mappings":"AAIA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAExB;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,iBAAiB;;;;;;;;;;;;iBA2CmF,CAAC;AAClH,MAAM,MAAM,iBAAiB,GAAG,CAAC,CAAC,MAAM,CAAC,OAAO,iBAAiB,CAAC,CAAC"}
@@ -0,0 +1,34 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+ import { z } from "zod";
4
+ import { SQLiteBoolean, SQLiteDate } from "@pithy-sh/core/src/data/codecs";
5
+ //#region src/data/attachment.ts
6
+ /**
7
+ * One attachment in `pithy_support_attachments` — the metadata row for bytes that live in R2.
8
+ *
9
+ * **The bytes are never proxied.** A dashboard gets a short-lived signed URL and fetches from R2
10
+ * directly, because proxying would put a Pithy-operated surface in the data path, which is the exact
11
+ * thing principle 1 exists to prevent. The key is server-derived and opaque, so holding a thread id
12
+ * never lets a caller name or guess an object.
13
+ *
14
+ * `contentType` and `filename` are recorded **as the sender declared them** and are never trusted on
15
+ * the way out. This is the most attacker-controlled field pair in the capability: a message can claim
16
+ * `text/html` for a script, or a filename with a path separator or a right-to-left override in it.
17
+ * Whatever serves these bytes re-derives its own `Content-Type` and `Content-Disposition`, the same
18
+ * rule `@pithy-sh/storage` applies to uploads.
19
+ */
20
+ const SupportAttachment = z.object({
21
+ id: z.string().describe("UUID primary key."),
22
+ messageId: z.string().describe("The `pithy_support_messages.id` this attachment arrived on."),
23
+ threadId: z.string().describe("The `pithy_support_threads.id`, denormalized from the message. Carried so the thread view lists every attachment in one indexed read instead of a join across the thread's messages."),
24
+ filename: z.string().describe("The filename as the sender declared it, after stripping path separators and control characters. Display only — it never becomes part of a storage key."),
25
+ contentType: z.string().describe("The MIME type as the sender declared it. Recorded, never honored — a serve path derives its own."),
26
+ size: z.number().int().nonnegative().describe("The attachment's size in bytes, measured from the decoded bytes rather than claimed."),
27
+ sha256: z.string().describe("Lowercase hex SHA-256 of the decoded bytes. Makes a re-delivered attachment recognizable and gives an operator something to check a download against."),
28
+ storageKey: z.string().describe("The R2 object key, server-derived and opaque (`support/<thread>/<uuid>`). Never sent to a client — a signed URL is."),
29
+ contentId: z.string().nullish().describe("The `Content-ID`, when the part carried one. What an inline image in the HTML body refers to by `cid:`."),
30
+ inline: SQLiteBoolean.describe("Whether the part was `Content-Disposition: inline` — a signature image rather than the document somebody meant to send. A dashboard hides these by default."),
31
+ createdAt: SQLiteDate.describe("When the attachment row was written.")
32
+ }).describe("One attachment in `pithy_support_attachments` — metadata for bytes stored in the adopter's own R2.");
33
+ //#endregion
34
+ export { SupportAttachment };
@@ -0,0 +1,32 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+ /**
4
+ * The one payments subject kind support reads.
5
+ *
6
+ * `@pithy-sh/payments` keys a purchase on a **subject pair** — a user or an organization, plus an id —
7
+ * and which of the two a project uses is its `billingSubject` config. Support cannot honor that choice.
8
+ * It starts from a `From:` header, resolves it to a *person*, and does its billing lookup at thread-read
9
+ * time, where there is no Hono `Context` to hand the adopter's subject resolver: the seam's whole job is
10
+ * to answer "which organization is *this caller* acting for", and a support thread has no caller. So the
11
+ * lookups in `link/sender.ts` filter on `user`, and `http/responses.ts` declares that on every response.
12
+ *
13
+ * ## Why this is its own module and not a constant in either of them
14
+ *
15
+ * It was `link/sender.ts`'s, and #418 imported it from `http/responses.ts` to build `SenderBillingScope`
16
+ * off it — one line, one string, and the correct instinct: the value the `WHERE` clause filters on and
17
+ * the value a console is told must be the same value. What came with it was `sender.ts`'s import list,
18
+ * which is the server data layer: `@pithy-sh/auth`'s Kysely builder, `@pithy-sh/payments`' table map,
19
+ * D1. A response schema is a module a browser imports — that is the whole reason §HTTP makes a response
20
+ * a Zod object rather than an interface — and the adopter's client program stopped compiling on
21
+ * `Cannot find name 'D1Database'` in a file it had never heard of (#419).
22
+ *
23
+ * So the constant lives where neither half owns it, and imports nothing. Both halves name it, neither
24
+ * reaches the other, and widening it is still the single edit it was: one literal, breaking every
25
+ * consumer that has not decided what an organization's panel should render, which is the correct amount
26
+ * of friction for that change.
27
+ *
28
+ * `tooling/browser-scopes` holds the rule this module is an instance of: a module a browser may import
29
+ * reaches no module that needs the Workers runtime.
30
+ */
31
+ export declare const SUPPORT_BILLING_SCOPE: "user";
32
+ //# sourceMappingURL=billingScope.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"billingScope.d.ts","sourceRoot":"","sources":["../../src/data/billingScope.ts"],"names":[],"mappings":"AAGA;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AACH,eAAO,MAAM,qBAAqB,EAAG,MAAe,CAAC"}
@@ -0,0 +1,34 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+ //#region src/data/billingScope.ts
4
+ /**
5
+ * The one payments subject kind support reads.
6
+ *
7
+ * `@pithy-sh/payments` keys a purchase on a **subject pair** — a user or an organization, plus an id —
8
+ * and which of the two a project uses is its `billingSubject` config. Support cannot honor that choice.
9
+ * It starts from a `From:` header, resolves it to a *person*, and does its billing lookup at thread-read
10
+ * time, where there is no Hono `Context` to hand the adopter's subject resolver: the seam's whole job is
11
+ * to answer "which organization is *this caller* acting for", and a support thread has no caller. So the
12
+ * lookups in `link/sender.ts` filter on `user`, and `http/responses.ts` declares that on every response.
13
+ *
14
+ * ## Why this is its own module and not a constant in either of them
15
+ *
16
+ * It was `link/sender.ts`'s, and #418 imported it from `http/responses.ts` to build `SenderBillingScope`
17
+ * off it — one line, one string, and the correct instinct: the value the `WHERE` clause filters on and
18
+ * the value a console is told must be the same value. What came with it was `sender.ts`'s import list,
19
+ * which is the server data layer: `@pithy-sh/auth`'s Kysely builder, `@pithy-sh/payments`' table map,
20
+ * D1. A response schema is a module a browser imports — that is the whole reason §HTTP makes a response
21
+ * a Zod object rather than an interface — and the adopter's client program stopped compiling on
22
+ * `Cannot find name 'D1Database'` in a file it had never heard of (#419).
23
+ *
24
+ * So the constant lives where neither half owns it, and imports nothing. Both halves name it, neither
25
+ * reaches the other, and widening it is still the single edit it was: one literal, breaking every
26
+ * consumer that has not decided what an organization's panel should render, which is the correct amount
27
+ * of friction for that change.
28
+ *
29
+ * `tooling/browser-scopes` holds the rule this module is an instance of: a module a browser may import
30
+ * reaches no module that needs the Workers runtime.
31
+ */
32
+ const SUPPORT_BILLING_SCOPE = "user";
33
+ //#endregion
34
+ export { SUPPORT_BILLING_SCOPE };
@@ -0,0 +1,56 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+ import { z } from "zod";
4
+ /** A category map: key → the instruction a model reads when deciding whether this is the one. */
5
+ export type SupportCategories = Record<string, string>;
6
+ /**
7
+ * The eight Pithy ships. Each earns its slot by naming something a developer would *do differently*,
8
+ * not something a message is *about*.
9
+ */
10
+ export declare const DEFAULT_SUPPORT_CATEGORIES: {
11
+ readonly billing: "A question or complaint about money: a refund, a double charge, a failed renewal, or 'I paid and did not get it'.";
12
+ readonly account_access: "The sender cannot get in: a magic link that never arrived, a sign-in that fails, a lost or changed email address.";
13
+ readonly bug_report: "The sender is reporting that something is broken, wrong, or behaving differently than it should.";
14
+ readonly feature_request: "The sender is asking for something that does not exist yet, or for an existing thing to change.";
15
+ readonly abuse_report: "The sender is reporting another user's behavior — harassment, cheating, spam, or impersonation.";
16
+ readonly privacy_request: "The sender is exercising a data right: deletion, export, access, or correction of their personal data.";
17
+ readonly spam: "Unsolicited bulk mail, marketing, or an obvious phishing or scam attempt. Not a real support request.";
18
+ readonly uncategorized: "Use this when no other category clearly applies, or when the message is too ambiguous to place with confidence.";
19
+ };
20
+ /**
21
+ * Declare an adopter's own categories, validated at author time.
22
+ *
23
+ * The same shape `defineAuditActions` uses, for the same reason: a typo in a key is a filter that
24
+ * silently matches nothing, and it should fail where the constant is written rather than the first
25
+ * time a model happens to return it. Returns the same object, typed, so `MyCategories.tournament_dispute`
26
+ * narrows to a literal.
27
+ *
28
+ * ```ts
29
+ * export const GameCategories = defineSupportCategories({
30
+ * tournament_dispute: "The sender is contesting a tournament result, a disqualification, or a prize.",
31
+ * });
32
+ * ```
33
+ */
34
+ export declare function defineSupportCategories<const T extends SupportCategories>(categories: T): T;
35
+ /**
36
+ * The taxonomy a capability actually classifies against: the defaults, plus the adopter's, with the
37
+ * adopter's winning on a key collision so a shipped description can be reworded for their product.
38
+ */
39
+ export declare function resolveCategories(extra?: SupportCategories): SupportCategories;
40
+ /**
41
+ * The Zod enum a model's answer is checked against.
42
+ *
43
+ * Built from the *effective* taxonomy at capability-construction time rather than declared as a
44
+ * literal, because the whole point of federation is that the valid set is not known until an adopter
45
+ * composes the capability. This is the schema `classifyMessage` parses against before anything is
46
+ * written — the single place an invented label is caught.
47
+ */
48
+ export declare function categoryEnum(categories: SupportCategories): z.ZodEnum<Record<string, string>>;
49
+ /**
50
+ * The taxonomy rendered for a prompt: one `key — instruction` line per category, in declaration order.
51
+ *
52
+ * Deliberately not JSON. A bulleted list is what these models are trained to follow, and the format
53
+ * is stable enough that a category added by an adopter reads exactly like a shipped one.
54
+ */
55
+ export declare function describeCategories(categories: SupportCategories): string;
56
+ //# sourceMappingURL=categories.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"categories.d.ts","sourceRoot":"","sources":["../../src/data/categories.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAwBxB,iGAAiG;AACjG,MAAM,MAAM,iBAAiB,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;AAEvD;;;GAGG;AACH,eAAO,MAAM,0BAA0B;sBAEnC,mHAAmH;6BAEnH,mHAAmH;yBACzG,kGAAkG;8BAC7F,iGAAiG;2BACpG,iGAAiG;8BAE7G,wGAAwG;mBACpG,uGAAuG;4BAE3G,iHAAiH;CAC/E,CAAC;AAEvC;;;;;;;;;;;;;GAaG;AACH,wBAAgB,uBAAuB,CAAC,KAAK,CAAC,CAAC,SAAS,iBAAiB,EAAE,UAAU,EAAE,CAAC,GAAG,CAAC,CAkB3F;AAED;;;GAGG;AACH,wBAAgB,iBAAiB,CAAC,KAAK,GAAE,iBAAsB,GAAG,iBAAiB,CAElF;AAED;;;;;;;GAOG;AACH,wBAAgB,YAAY,CAAC,UAAU,EAAE,iBAAiB,GAAG,CAAC,CAAC,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAK7F;AAED;;;;;GAKG;AACH,wBAAgB,kBAAkB,CAAC,UAAU,EAAE,iBAAiB,GAAG,MAAM,CAIxE"}
@@ -0,0 +1,99 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+ import { SupportInvalidCategoryError } from "../error/errors.js";
4
+ import { UNCATEGORIZED } from "./enums.js";
5
+ import { z } from "zod";
6
+ //#region src/data/categories.ts
7
+ /**
8
+ * The support taxonomy — federated, the way audit actions and migration namespaces already are.
9
+ *
10
+ * Pithy ships eight categories chosen to map to **action rather than topic**, because an inbox sorted
11
+ * by topic is a filing cabinet and an inbox sorted by action is a work queue. An adopter adds their
12
+ * own with {@link defineSupportCategories} — a game adds `tournament_dispute`, a marketplace adds
13
+ * `seller_payout` — and core never learns about it.
14
+ *
15
+ * A category is a `snake_case` key plus the sentence that tells a model when to pick it. The
16
+ * description is not documentation for humans: it is **prompt input**, interpolated into the
17
+ * classification prompt verbatim, which is why a bad one shows up as bad classifications rather than
18
+ * as a bad doc. Write it as an instruction.
19
+ */
20
+ /** The shape a category key must take: lowercase `snake_case`, so it is safe in a prompt and a filter. */
21
+ const CATEGORY_KEY = /^[a-z][a-z0-9]*(_[a-z0-9]+)*$/;
22
+ /** How long a category description may be. Bounded because every one of them lands in every prompt. */
23
+ const MAX_DESCRIPTION = 200;
24
+ /**
25
+ * The eight Pithy ships. Each earns its slot by naming something a developer would *do differently*,
26
+ * not something a message is *about*.
27
+ */
28
+ const DEFAULT_SUPPORT_CATEGORIES = {
29
+ billing: "A question or complaint about money: a refund, a double charge, a failed renewal, or 'I paid and did not get it'.",
30
+ account_access: "The sender cannot get in: a magic link that never arrived, a sign-in that fails, a lost or changed email address.",
31
+ bug_report: "The sender is reporting that something is broken, wrong, or behaving differently than it should.",
32
+ feature_request: "The sender is asking for something that does not exist yet, or for an existing thing to change.",
33
+ abuse_report: "The sender is reporting another user's behavior — harassment, cheating, spam, or impersonation.",
34
+ privacy_request: "The sender is exercising a data right: deletion, export, access, or correction of their personal data.",
35
+ spam: "Unsolicited bulk mail, marketing, or an obvious phishing or scam attempt. Not a real support request.",
36
+ [UNCATEGORIZED]: "Use this when no other category clearly applies, or when the message is too ambiguous to place with confidence."
37
+ };
38
+ /**
39
+ * Declare an adopter's own categories, validated at author time.
40
+ *
41
+ * The same shape `defineAuditActions` uses, for the same reason: a typo in a key is a filter that
42
+ * silently matches nothing, and it should fail where the constant is written rather than the first
43
+ * time a model happens to return it. Returns the same object, typed, so `MyCategories.tournament_dispute`
44
+ * narrows to a literal.
45
+ *
46
+ * ```ts
47
+ * export const GameCategories = defineSupportCategories({
48
+ * tournament_dispute: "The sender is contesting a tournament result, a disqualification, or a prize.",
49
+ * });
50
+ * ```
51
+ */
52
+ function defineSupportCategories(categories) {
53
+ for (const [key, description] of Object.entries(categories)) {
54
+ if (!CATEGORY_KEY.test(key)) throw new SupportInvalidCategoryError({
55
+ message: `Invalid support category key: ${key}`,
56
+ action: "Use a lowercase snake_case key, e.g. `tournament_dispute`.",
57
+ detail: `key ${JSON.stringify(key)} does not match ${CATEGORY_KEY}`
58
+ });
59
+ if (description.trim().length === 0 || description.length > MAX_DESCRIPTION) throw new SupportInvalidCategoryError({
60
+ message: `Invalid description for support category "${key}".`,
61
+ action: `Write one instructional sentence, at most ${MAX_DESCRIPTION} characters, telling a model when to pick it.`,
62
+ detail: `description length ${description.length} is outside 1..${MAX_DESCRIPTION}`
63
+ });
64
+ }
65
+ return categories;
66
+ }
67
+ /**
68
+ * The taxonomy a capability actually classifies against: the defaults, plus the adopter's, with the
69
+ * adopter's winning on a key collision so a shipped description can be reworded for their product.
70
+ */
71
+ function resolveCategories(extra = {}) {
72
+ return {
73
+ ...DEFAULT_SUPPORT_CATEGORIES,
74
+ ...defineSupportCategories(extra)
75
+ };
76
+ }
77
+ /**
78
+ * The Zod enum a model's answer is checked against.
79
+ *
80
+ * Built from the *effective* taxonomy at capability-construction time rather than declared as a
81
+ * literal, because the whole point of federation is that the valid set is not known until an adopter
82
+ * composes the capability. This is the schema `classifyMessage` parses against before anything is
83
+ * written — the single place an invented label is caught.
84
+ */
85
+ function categoryEnum(categories) {
86
+ const keys = Object.keys(categories);
87
+ return z.enum(Object.fromEntries(keys.map((key) => [key, key]))).describe("One of this project's effective support categories — the shipped defaults plus any the adopter added.");
88
+ }
89
+ /**
90
+ * The taxonomy rendered for a prompt: one `key — instruction` line per category, in declaration order.
91
+ *
92
+ * Deliberately not JSON. A bulleted list is what these models are trained to follow, and the format
93
+ * is stable enough that a category added by an adopter reads exactly like a shipped one.
94
+ */
95
+ function describeCategories(categories) {
96
+ return Object.entries(categories).map(([key, description]) => `- ${key}: ${description}`).join("\n");
97
+ }
98
+ //#endregion
99
+ export { DEFAULT_SUPPORT_CATEGORIES, categoryEnum, defineSupportCategories, describeCategories, resolveCategories };
@@ -0,0 +1,36 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+ import { z } from "zod";
4
+ /**
5
+ * One classification in `pithy_support_classifications` — append-only, one row per model run.
6
+ *
7
+ * The thread carries the *current* answer because that is what the inbox query needs; this carries
8
+ * *every* answer, because a reclassification pass after a model upgrade has to be able to say which
9
+ * rows came from which model and what changed. Append-only is what makes that a fact rather than a
10
+ * reconstruction: nothing here is ever updated, and a wrong classification is superseded, not fixed.
11
+ *
12
+ * Confidence is the model's own self-report, which is worth exactly what a language model's
13
+ * self-report is worth — useful for sorting a review queue, never for a gate.
14
+ */
15
+ export declare const SupportClassification: z.ZodObject<{
16
+ id: z.ZodString;
17
+ threadId: z.ZodString;
18
+ messageId: z.ZodString;
19
+ category: z.ZodString;
20
+ priority: z.ZodEnum<{
21
+ low: "low";
22
+ normal: "normal";
23
+ urgent: "urgent";
24
+ }>;
25
+ sentiment: z.ZodEnum<{
26
+ angry: "angry";
27
+ frustrated: "frustrated";
28
+ neutral: "neutral";
29
+ positive: "positive";
30
+ }>;
31
+ confidence: z.ZodNumber;
32
+ model: z.ZodString;
33
+ createdAt: z.ZodCodec<z.ZodUnion<readonly [z.ZodNumber, z.ZodString, z.ZodDate]>, z.ZodDate>;
34
+ }, z.core.$strip>;
35
+ export type SupportClassification = z.output<typeof SupportClassification>;
36
+ //# sourceMappingURL=classification.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"classification.d.ts","sourceRoot":"","sources":["../../src/data/classification.ts"],"names":[],"mappings":"AAIA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAGxB;;;;;;;;;;GAUG;AACH,eAAO,MAAM,qBAAqB;;;;;;;;;;;;;;;;;;;iBA8BoF,CAAC;AACvH,MAAM,MAAM,qBAAqB,GAAG,CAAC,CAAC,MAAM,CAAC,OAAO,qBAAqB,CAAC,CAAC"}
@@ -0,0 +1,30 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+ import { SupportPriority, SupportSentiment } from "./enums.js";
4
+ import { z } from "zod";
5
+ import { SQLiteDate } from "@pithy-sh/core/src/data/codecs";
6
+ //#region src/data/classification.ts
7
+ /**
8
+ * One classification in `pithy_support_classifications` — append-only, one row per model run.
9
+ *
10
+ * The thread carries the *current* answer because that is what the inbox query needs; this carries
11
+ * *every* answer, because a reclassification pass after a model upgrade has to be able to say which
12
+ * rows came from which model and what changed. Append-only is what makes that a fact rather than a
13
+ * reconstruction: nothing here is ever updated, and a wrong classification is superseded, not fixed.
14
+ *
15
+ * Confidence is the model's own self-report, which is worth exactly what a language model's
16
+ * self-report is worth — useful for sorting a review queue, never for a gate.
17
+ */
18
+ const SupportClassification = z.object({
19
+ id: z.string().describe("UUID primary key."),
20
+ threadId: z.string().describe("The `pithy_support_threads.id` this classification was written against."),
21
+ messageId: z.string().describe("The `pithy_support_messages.id` the model actually read. Recorded because a thread's classification is a judgment about one message, and knowing which one is what makes a disagreement between two runs legible."),
22
+ category: z.string().describe("The category key the model chose, already validated against the effective taxonomy — an out-of-taxonomy answer never reaches this table, it becomes `uncategorized` first."),
23
+ priority: SupportPriority.describe("The priority the model assigned."),
24
+ sentiment: SupportSentiment.describe("The sentiment the model assigned."),
25
+ confidence: z.number().min(0).max(1).describe("The model's self-reported confidence, 0..1. Bounded here rather than by a CHECK constraint: the schema is the table definition, and a model's answer is the one value in this table that originates outside our own code, so the bound belongs on the way in."),
26
+ model: z.string().describe("The Workers AI model id that produced this row. The column the whole table exists for — without it a model upgrade is a silent rewrite of history."),
27
+ createdAt: SQLiteDate.describe("When this classification was written.")
28
+ }).describe("One append-only classification in `pithy_support_classifications` — what a model said, and which model.");
29
+ //#endregion
30
+ export { SupportClassification };