diff --git a/docs/superpowers/specs/2026-08-29-intake-pipeline-design.md b/docs/superpowers/specs/2026-08-29-intake-pipeline-design.md index 672d272..60a3f16 100644 --- a/docs/superpowers/specs/2026-08-29-intake-pipeline-design.md +++ b/docs/superpowers/specs/2026-08-29-intake-pipeline-design.md @@ -6,7 +6,7 @@ ## Goal -Someone who is not the admin can send in photos of one item through a link they were given. A draft listing is written for it automatically. The admin is told by email, and the item sits unpublished until the admin has read the copy, typed a price and published it deliberately. +Someone who is not the admin can send in photos of one item through a link they were given. A draft listing is written for it automatically. The admin is told by email, and the item sits unpublished until the admin has read the copy, checked the price and published it deliberately. ## Where this starts from @@ -31,12 +31,22 @@ Settled in conversation before this was written, and recorded on the issue. | What the AI drafts | Name, marketing description, category and tags from the existing taxonomy, and a suggested price | | Where a submission lives pre-approval | An `items` row at `status='pending'`, not a separate submissions table | | How the admin acts on it | Signed one-click links in the email, plus a review queue in the admin | -| Whether one click can publish | No. Publishing always requires a typed price | +| Whether one click can publish | No. Publishing always happens from the review queue | +| What an item is priced at on arrival | The AI's suggestion, or 80.00 when it has none | | Model | Sonnet 5, in an env var | ## The invariant -**Nothing reaches the storefront at a price a model guessed.** Everything below follows from that: the suggested price lives on the draft and never on the item, the email has no publish button, and the publish path refuses an item with no price. +**Nothing reaches the storefront without the admin publishing it from the queue.** The item arrives `pending`, which is already invisible to every public query, and only `mark-available` moves it. The email carries no publish button, so the admin has necessarily seen the item and its price before anything ships. + +The price is the deliberate exception, and it is worth being precise about what was traded. An arriving item is priced immediately — the model's suggestion if it made one, otherwise 80.00 — so the review queue's price field is pre-filled rather than blank. The admin can change it, but is not forced to, and an unchanged field publishes at a number the admin did not choose. + +That is a real risk and it is accepted knowingly. It is bounded by the queue: publishing is a deliberate act on a screen showing the price, not something that can happen from an inbox or by a timeout. What is given up is the stronger guarantee that a machine-guessed price could never reach the storefront at all. + +Two consequences follow, and both are load-bearing: + +- **80.00 is a plausible price, not an obvious sentinel.** `0` would render as "$0.00" and read as a bug to anyone who saw it; 80.00 renders as a decision. A default that went unnoticed therefore sells the item rather than announcing itself. This is the argument for surfacing it loudly in the queue rather than for choosing a different number. +- **The queue must make the price's provenance visible.** A price field is not enough. The queue shows whether the number came from the model, from the 80.00 default, or from the admin, so "nobody has looked at this price" is legible at a glance instead of being indistinguishable from a considered one. ## Architecture @@ -75,8 +85,17 @@ CREATE TABLE item_drafts ( ai_description TEXT, ai_category_id INTEGER REFERENCES categories(id) ON DELETE SET NULL, ai_tag_names TEXT[], - -- Deliberately not on items. See the invariant. + -- Kept even though the suggestion is also copied onto the item, so what the + -- model proposed stays readable after the admin has edited the item's price. + -- Without it there is no way to ask later whether the model's numbers were + -- any good. ai_suggested_price_cents INTEGER, + -- ai | default | admin. What the item's current price actually came from. + -- Derivable by comparing three numbers, but only fragilely: a model that + -- happens to suggest exactly 8000, or an admin who deliberately types the + -- model's number, both collapse the comparison. Recorded rather than + -- inferred, because the queue uses it to say "nobody has chosen this price". + price_source TEXT NOT NULL DEFAULT 'default', ai_error TEXT, input_tokens INTEGER, output_tokens INTEGER, @@ -86,14 +105,19 @@ CREATE TABLE item_drafts ( created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); -ALTER TABLE items ALTER COLUMN price_cents DROP NOT NULL; +-- An arriving item is always priced, so the column stays NOT NULL and only +-- gains a fallback. 80.00 applies when the model declined to suggest anything; +-- a suggestion, when there is one, is written over it by the worker. +ALTER TABLE items ALTER COLUMN price_cents SET DEFAULT 8000; ``` -**On making `price_cents` nullable.** This is the most invasive line in the design: `price_cents` is referenced across fifteen files including the cart and the checkout. The alternative is to insert a submission at `0` and guard publishing on `price_cents > 0`, which changes no types and no other file. +**On `price_cents` staying NOT NULL.** The alternative considered was making it nullable so that "no price yet" was expressible, with the publish path refusing an unpriced item. That was rejected in favour of always having a price. -It is still the wrong choice. Zero is a real number that formats as a real price, and a sentinel that renders as "$0.00" in a shop is precisely the failure this codebase already has a scar from — `DEMO_MODE` meaning "demo unless the value is exactly false" quietly stopped the shop charging anyone. Null cannot be formatted by accident. And the blast radius is not hidden work but the opposite: making the column nullable makes the TypeScript type `number | null`, and the compiler then names every one of those fifteen sites and refuses to build until each has said what it does with an unpriced item. A sentinel gets the same reach with none of the enumeration. +The practical effect is that this design costs far less to build. `price_cents` is referenced across fifteen files including the cart and the checkout, and every one of them keeps working untouched — the column's type does not change, so nothing downstream has to learn about an item that has no price. The migration adds a default and nothing else. -The publish guard belongs in `mark-available` alongside the existing status checks, and is worth an integration test of its own. +What it costs is covered under the invariant above: the storefront can now be reached by a price the admin never chose, so the protection moves from the schema into the queue's presentation, where it is weaker. `price_source` exists to make that presentation possible. + +The number lives in the migration rather than in configuration. Changing a default price is a rare, deliberate act with a record attached, which is the right amount of friction; an env var would let it drift silently between environments, and a wrong default is not visible anywhere until something has already sold at it. ### Ingestion @@ -101,7 +125,7 @@ The publish guard belongs in `mark-available` alongside the existing status chec 1. Hash the token, look up a matching `upload_links` row that is not revoked. No match, revoked, or over its cap: `404`. Not `403` — whether a link exists is not something a stranger needs to be able to distinguish, which is the reasoning `uploads.ts` already applies. 2. Validate the photos through the existing `uploadTypes` checks. Caps: at most 10 photos, at most 10 MB each. -3. In one transaction: insert the `items` row (`status='pending'`, `price_cents=NULL`, a placeholder name — the submission timestamp — replaced by the draft or the admin), insert `item_images`, insert `item_drafts` at `state='queued'`, bump the link's counter. +3. In one transaction: insert the `items` row (`status='pending'`, no `price_cents` given so the 8000 default applies, a placeholder name — the submission timestamp — replaced by the draft or the admin), insert `item_images`, insert `item_drafts` at `state='queued'` and `price_source='default'`, bump the link's counter. 4. Respond immediately. The uploader is told it arrived and that a person will look at it. **The AI is not called here** — a slow or failing API call must not turn into a failed upload for someone who did nothing wrong. Rate limited by IP through a new limiter in `rateLimit.ts`. `keyByCallerAndEmail` does not fit — there is no email — so this needs its own key function over `ipKeyGenerator` alone, and the existing comment about a bare `ip:` bucket being a shared allowance applies and is accepted here: the link itself is the per-caller identity, and its counter is the per-caller cap. @@ -119,7 +143,9 @@ The request, via `@anthropic-ai/sdk` and `client.messages.parse()` with `zodOutp The note is untrusted input from an unauthenticated stranger. It is passed as data, and nothing the model returns is executed, interpolated into SQL, or rendered as HTML — the drafted description goes through the same markdown-with-`html: false` treatment as every other stored body, which is what makes "a model wrote this" and "a person wrote this" equally safe to render. -Result: `state='ready'`, the fields populated, token counts and computed cost recorded. Failure: `attempts` incremented, `ai_error` stored, and after three attempts `state='failed'` — which still leaves a perfectly good pending item with photos in the review queue, just with no draft copy. A failed AI call must never lose someone's submission. +Result: `state='ready'`, the fields populated, token counts and computed cost recorded. Where the model returned a price, it is written onto the item and `price_source='ai'`; where it did not, the item keeps the 8000 default and `price_source` stays `'default'`. The suggested price is recorded on the draft either way. + +Failure: `attempts` incremented, `ai_error` stored, and after three attempts `state='failed'` — which still leaves a perfectly good pending item with photos in the review queue, just with no draft copy and priced at the default. A failed AI call must never lose someone's submission. **Spend ceiling.** A monthly cap (`INTAKE_MONTHLY_BUDGET_USD`) summed from `cost_micros`. Past it, submissions still save and still notify; only the API call is skipped, with `state='failed'` and an error saying so. The ceiling protects the bill, and it must not be the thing that loses inventory. @@ -137,7 +163,9 @@ The two signed actions are deliberately the ones whose worst case is a wasted AP ### The review queue -A new admin screen listing drafts by state: photos, the submitter's note, which link it came from, the drafted copy in editable fields, the suggested price shown as a suggestion beside an empty price input, and Publish / Regenerate / Discard. Publish writes the edited values onto the item and calls the existing `mark-available`. +A new admin screen listing drafts by state: photos, the submitter's note, which link it came from, the drafted copy in editable fields, the price, and Publish / Regenerate / Discard. Publish writes the edited values onto the item and calls the existing `mark-available`. + +The price field carries the weight the schema no longer does, so it is not an ordinary input. It is pre-filled, and it is labelled with where the number came from — the model, or the 80.00 default, or the admin — with anything that is not `price_source='admin'` marked visibly as unconfirmed. Editing it sets `price_source='admin'`. Publishing something still marked unconfirmed is allowed, because that is the decision taken, but it says so plainly at the point of publishing rather than after. Reuses the existing admin item components where they fit rather than growing a second item editor. @@ -160,8 +188,8 @@ The through-line: **a submission is the only irreplaceable thing here.** Photos | Failure | Behaviour | | --- | --- | -| API call fails or times out | Retry to three attempts, then `state='failed'`. Item and photos intact, reviewable with no draft | -| Budget exhausted | Submission saved, draft skipped, admin still notified | +| API call fails or times out | Retry to three attempts, then `state='failed'`. Item and photos intact, reviewable with no draft, priced at the default | +| Budget exhausted | Submission saved, draft skipped, admin still notified, item priced at the default | | Malformed model output | Rejected by the schema, counts as a failed attempt | | SMTP down | Draft still `ready` and visible in the queue; the queue, not the email, is the source of truth | | Link revoked mid-upload | `404`. Already-submitted items are unaffected | @@ -169,8 +197,8 @@ The through-line: **a submission is the only irreplaceable thing here.** Photos ## Testing -- **Unit** — token hashing and constant-time verification, HMAC signing and expiry, the budget calculation, the prompt builder's handling of an empty note, and the publish guard's price rule. All pure, in the style of `keyByCallerAndEmail` and `isAllowedRecipient` being exported specifically to be tested directly. -- **Integration** — submission through a valid link creates a pending item with images and a queued draft; a revoked link gets `404`; publishing without a price is refused; a signed action link works once and an expired or tampered one does not. The Anthropic client is stubbed; no test spends money. +- **Unit** — token hashing and constant-time verification, HMAC signing and expiry, the budget calculation, the prompt builder's handling of an empty note, and the `price_source` transitions. All pure, in the style of `keyByCallerAndEmail` and `isAllowedRecipient` being exported specifically to be tested directly. +- **Integration** — submission through a valid link creates a pending item with images, a queued draft and the 8000 default; a model suggestion overwrites that price and sets `price_source='ai'`; a failed or budget-skipped draft leaves the default in place; a revoked link gets `404`; a signed action link works once and an expired or tampered one does not. The Anthropic client is stubbed; no test spends money. - **E2E** — the submission page, and the queue round trip from draft to published. Assertions scoped to the item under test rather than the whole grid, since the dev database never truncates. ## Out of scope