Commit Graph
4 Commits
Author SHA1 Message Date
6c64dd2312 fix(whatsapp): make every silent disposition observable, stop pure silence for linked senders (#1563)
* fix(whatsapp): make every silent disposition observable, stop pure silence for linked senders (#1552)

Silence was a legitimate outcome in seven places and none left a trace a
support question could be answered from. Now:

- Unknown-sender declines (over quota, quota RPC failure, greeting
  throttle) persist content-free trace rows: wamid, phone hash, type,
  disposition. No body, media, raw payload, or profile name; capped at
  20 rows per hash and day; deleted by the existing 30-day retention.
  The wamid dedupe also stops redelivered bad-code/greeting messages
  from earning a second reply.
- Linked-sender deliberate silences (muted, stale tap, ignorable type)
  record their reason on the skipped row.
- Non-policy silences reply: a row missing its media reference sends
  M18 through the link's reply address, a link revoked between arrival
  and processing sends the M1 unlinked copy (greeting-throttled).
- Outbound rows keep WHY a send failed (Graph error detail), and Meta
  'failed' delivery statuses store their error code and title.
- The WhatsApp settings panel shows the last inbound event (closed
  enum, server-derived) and warns when the latest reply never left.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(whatsapp): include errorDetail in typed sendText mock results

SendTextResult gained errorDetail; vi.mocked call sites must match the
widened type or they raise fresh tsc errors over the repo baseline.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(whatsapp): review fixes: fail-closed greeting throttle, cap only declined traces

From CodeRabbit's pass on #1563:
- greetingThrottled fails closed when the throttle window cannot be
  read, matching the unknown-sender quota's stance.
- The decline-trace day cap applies only to 'skipped' rows (the one
  unbounded path); 'done' traces always insert so the wamid dedupe
  keeps preventing duplicate M1/M2 replies even past the cap. Their
  volume is already bounded upstream by the greeting throttle and the
  pre-binding quota.
- company-question test mocks match the widened SendTextResult.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 16:02:16 +02:00
576ed4d290 fix(agent): let the assistant see answers the user gave in WhatsApp (#1425)
Two field findings from the first live receipts.

1. The assistant re-asked for information the user had already given.
   The user answered the representation question in WhatsApp ("Elias
   Karlsson från Canguro Media, Jakob Wennberg från Arcim"), the answer
   was stored correctly on invoice_inbox_items.channel_context, and then
   the in-app assistant said it could see no participant names and asked
   for them again. The intent's inbox query selected only document_id and
   extracted_data, and nothing under lib/agent/ read channel_context at
   all. It is now selected, threaded onto each underlag as chat_answers,
   and rendered into the prompt as "uppgivna av användaren" with an
   explicit instruction that human answers outrank anything read off the
   image and must never be re-asked. Also backfilled by document_id: a
   receipt can reach the intent through the document paths without its
   inbox row being matched to the transaction.

2. The representation question accepted half an answer in silence.
   Naming participants but no purpose stored purpose=null and replied
   "Tack!", leaving the deduction undocumented while looking complete.
   Skatteverket wants both (BFL 5 kap 6-7 §). It now asks once, for the
   missing half only, and keeps the question open so the reply routes
   back to the same receipt. Anti-loop: the follow-up fires only when no
   representation block exists yet, so a second incomplete answer is
   taken as-is rather than nagging.

Tests cover the prompt half and the query half separately: the earlier
prompt tests injected chat_answers directly and would have stayed green
with the column still missing from the select, which is precisely how the
bug shipped. Both mutation-checked.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 20:08:15 +02:00
88760ae6f6 fix(whatsapp-inbox): harden against adversarial review findings (#1342)
* fix(whatsapp-inbox): erase the WhatsApp channel on account deletion

whatsapp_phone_links relied on the auth.users ON DELETE CASCADE, but
Accounted never deletes auth.users: account deletion is
anonymize_user_account plus a ~100-year ban that keeps the auth row as a
tombstone, so the cascade never fires and nothing revokes the link. After
erasure the link stayed active with a decryptable phone_enc,
lookupActiveLink kept resolving the number, and every further inbound
message was persisted with body_text and the verbatim raw_payload while
the bot kept replying: GDPR Art 17 plus continued collection with no
lawful basis.

The RPC is re-created verbatim from 20260724150000 with one added block
that revokes and crypto-shreds the link, resets its conversation, nulls
body_text/raw_payload on that link's messages and deletes outstanding
link codes, plus a guarded repair pass for tombstones anonymized before
this migration. Covered by a pg-real test that fails against the previous
definition.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(whatsapp-inbox): pepper the link-code hash and bound code minting

hashLinkCode stored a bare sha256 over CODE_ALPHABET^6 = 30^6 values
behind a fixed 'AC-' prefix. The module cited the invite-token pattern,
but invite tokens are 256-bit random; this space enumerates offline in
about a second, so hashing at rest protected nothing. The sibling
phone-crypto.ts already states the team's own threat model for a LARGER
space ("a plain sha256 would be brute-forceable ... hence the pepper"),
so link codes now hash through the same env-mandated pepper.

/link/start was also an authenticated unbounded INSERT that left every
earlier code valid. Minting now burns the caller's unused codes (the code
the panel shows is the only one that works) and is capped per TTL window,
with the route answering 429 instead of throwing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(whatsapp-inbox): harden the conversation layer against the review findings

Pre-merge hardening of the unshipped chat layer. Every change below has a
test that fails without it.

Lifecycle and races:
- conversation writes go through updateConversation(), an optimistic
  compare-and-set on updated_at (the trigger makes it a revision counter).
  The ack winner, the answer worker, the pin refresh and the sweep hold
  different claims, so blind whole-jsonb writes resurrected answered
  questions, wiped pending_question and dropped queue entries.
- terminal markStatus writes are guarded on processing_status='processing'
  so a losing worker cannot overwrite the winner's 'done' and null its
  inbox_item_id.
- the message -> inbox item path is idempotent: a pre-check plus a 23505
  fallback adopt the item a concurrent worker created, instead of throwing
  after the WORM document is already committed.
- PROCESSING_STUCK_MS 90s -> 5 min. The enforced step budget of one media
  row already exceeds 90s, so the sweep was re-claiming live workers.
- sweep 2b re-arms only when the conversation itself has been quiet, not
  just the rows: pending_ack=false plus unacked rows is also the state of a
  live finalize, which produced a duplicate combined ack.
- pin expiry re-checks against fresh state instead of writing back a stale
  whole context, which reverted company choices applied mid-pass.
- askNextQueuedQuestion claims the pop before sending, so two answer
  workers cannot ask the same question twice.

Company question:
- the state is rolled back when the M6 send fails, so the next receipt
  re-asks instead of parking receipts behind a question nobody received.
- applyCompanyChoice claims the open question (company_options) rather
  than the state: a double tap confirms once, a transient membership-query
  error is no longer read as "not a member", and a LATE answer still lands.
- at the 48h TTL the parked receipts are kept, not discarded: options and
  staged rows survive so a late digit or tap still files them, and only
  rows past Meta's ~30-day media window get the terminal marker.
- an out-of-range digit or a typed company name now gets the options
  repeated instead of silence or the "I cannot answer questions" reply.

Inline dispositions:
- stop/start/byt/company answers run their side effect BEFORE the terminal
  wamid row, with a SELECT pre-check for dedupe. Writing the row 'done'
  first made them at-most-once: a crash in between lost the action forever.

Copy and answers:
- acks state the extracted currency instead of labelling every total 'kr'.
- M17 stops promising "about 10 minutes" when the daily quota tripped.
- M18 is sent once per message tracked by the outbound row, so a file
  whose first attempt died still reaches the sender, including from the
  max-attempts path.
- M11 no longer claims the number is disconnected: 'stopp' pauses, and
  muted senders now persist no chat content at all.
- 'byt' is recognized in every state but awaiting_company (m6-confirm
  teaches the word, and it was being stored as answer data instead).
- text sent while a re-send question is open is kept as a note on THAT
  receipt with the question left open, instead of binding to another
  receipt's question.
- a quoted reply wins over the pending question and is appended when the
  quoted question is already answered, so corrections stop landing on the
  wrong receipt.
- context answers keep raw_answer + answered_at like representation does.
- finalizeBurst checks the send result: on failure it rolls the question
  back and leaves the rows unacked for the sweep.

PII:
- the sender's plaintext number is stripped from raw_payload before it is
  persisted; replies decrypt the link's phone_enc instead.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(whatsapp-inbox): record the erasure path and the hardening decisions

RoPA gains the account-deletion row (immediate, not via the cron: the
auth.users cascade never fires because the row is tombstoned) plus the
two new security measures, and its "never in the clear" phone claim is
now true of the stored payload. DECISIONS.md records the non-obvious
calls: revoke-not-delete on erasure, commit-then-roll-back for the
company question, keeping expired company choices answerable, the
compare-and-set conversation write, effect-before-terminal-row for inline
dispositions, honest M11 copy, and the raw_payload redaction.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(whatsapp-inbox): stop the answer re-claim from following a confirm with M16

A worker that died after applying an answer and sending its confirmation
leaves the row 'processing'. The sweep re-runs it, resolveAnswerTarget
finds the question already answered, and the user got "I did not
understand" immediately after the confirmation they had just received.
The fallback is now first-attempt only.

The catch comment claiming the sweep retries these rows is corrected
too: 'error' is terminal for the sweep, and nothing on the answer path
throws anyway (interpretChatAnswer degrades, sends never throw,
supabase-js returns errors), so the catch is a programming-error net.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(whatsapp-inbox): drop the amount floor on the representation question

The Swedish compliance review on #1340 caught a real error in the trigger
rules: the representation question only fired above 150 kr, but the duty to
document deltagare and syfte is what makes the expense deductible at all
(BFL 5 kap 6-7 §) and it is not conditioned on any amount. The 300 kr per
person figure I had in mind is the VAT-deduction base cap, a different rule.
A 120 kr business lunch would have been booked with no participant trail,
which is exactly the deduction Skatteverket denies later.

Noise stays bounded by the triggers that were already there: the question
fires only for receipt-shaped documents from restaurant, cafe or hotel
merchants, at most once per receipt, twice per burst and six times per
sender per day, and a single "nej" dismisses it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Jakob Wennberg <jakob.wennberg@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
2026-08-05 15:47:23 +02:00
629069e281 feat(whatsapp-inbox): conversation layer with clarifying questions (#1340)
PR4 of the WhatsApp intake track: turns the per-message PR3 pipeline into a
conversation. Media replies are burst-debounced into ONE combined ack (M4
single / M5 numbered list) sent by the single winner of the atomic
pending_ack claim; losers stay silent. Multi-company senders get the company
question (reply buttons <=3, list 4-10, numbered text >10) with an 8h
sliding pin ('byt' clears it); their receipts park as staged message rows
until the answer and then run through the normal intake path.

Clarifying questions are evaluated per receipt after extraction, max one per
receipt, priority unreadable > representation > partial, keyed on the
Phase-0 classification (legibility/documentKind/merchantCategory) with
heuristic fallbacks (compressed-chat-photo signal, extended meal regex).
Budgets: <=2 content questions per burst, <=6 per sender per Stockholm day;
over budget acks only and flags the item moved_to_app. Questions expire
after 48h (sweep, silent hand-off) and are asked exactly once.

Free-text answers route through the ONE new LLM call
(lib/interpret-answer.ts): Sonnet via Bedrock, max_tokens 600, no thinking,
forced tool call validated by Zod with hard caps, gated by
checkAgentRateLimit, reply framed as untrusted data. Any failure degrades to
storing the raw text as a note; exact 'nej' short-circuits without the LLM.
Answers land in invoice_inbox_items.channel_context
(representation/user_note/quality) with ChannelQuestionAsked/Answered
processing-history events. Late answers match by quoted wamid or the most
recent open question within 7 days.

New per-minute sweep cron (registry-gated physical route, 503
EXTENSION_DISABLED when off) re-claims stuck rows (max 3 attempts), rescues
crashed burst acks, expires questions and pins. One new migration
(20260802210000) adds whatsapp_messages.acked_at, the relational burst-
membership marker, with pg-real coverage for the single-winner claim.

Verified: full vitest suite (12270), pg-real against a migrated
supabase/postgres 15 (977), lint 0 errors, tsc at the 405 baseline,
check:guards green, crontabs regenerated. Mutation-checked the debounce
claim and the daily budget gate.

Co-authored-by: Jakob Wennberg <jakob.wennberg@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 15:27:00 +02:00