Skip to content

fix(cat): name every forget fact the call never attempted - #838

Merged
github-actions[bot] merged 1 commit into
mainfrom
fix/forget-report-dropped-facts
Aug 29, 2026
Merged

fix(cat): name every forget fact the call never attempted#838
github-actions[bot] merged 1 commit into
mainfrom
fix/forget-report-dropped-facts

Conversation

@catomean

@catomean catomean commented Aug 29, 2026

Copy link
Copy Markdown
Collaborator

Closes two findings from the #563 audit that share one root cause: three places
decided independently which of the user's facts a forget call would act on, and
they disagreed.

The bug

forget_memories narrowed its own input three ways and told the user about
none of them:

  • forgetMemoriesMatching trimmed, dropped facts under 4 characters, and
    capped at 10.
  • removeFromEconomicProfile trimmed, dropped under 4 characters, and had
    no cap at all — so a 12-fact request cleared 12 profile entries but only
    10 memories, silently.
  • the handler built its "no stored match" list by filtering the caller's
    raw strings against the trimmed facts the stores reported back. A
    padded " photography " could therefore never appear in that list, however
    plainly it had missed.

Every one of these produces the same failure: 🧹 Removed X. while something
the user named was never looked at. That is finding 8's lie in a different
costume — the user believes data is gone that is still sitting there.

The fix

One exported selector, selectForgetFacts(facts) → { wanted, tooShort, overCap },
used by both stores and the handler, so all three agree by construction and the
leftovers have names instead of vanishing. The reply now says what it did not
attempt:

  • over the cap → "Only the first 10 were processed — not yet attempted: … Ask again for those."
  • too short to match safely → "Too short to match safely (they would hit unrelated memories): …"
  • both are reported on the nothing-was-removed path too, where they were the
    most misleading.

Counting the cap in usable facts also fixes a smaller bug: ten short
fragments used to consume the whole budget and starve a real fact behind them.

Verification

9 new tests in forget-reports-dropped-facts.test.ts. Mutation-proved — reverting
stillUnknown to filter raw facts fails exactly the padded-fact test; removing the
over-cap clause fails exactly the over-cap test. Full Cat suite green (52 suites,
727 tests) and a full non-incremental type-check clean. The box was too
loaded to finish one npm run verify, so it was verified by parts instead:
eslint on the changed files, and check:sizes / audit:routes / duplication /
dead-fields / client-ip / user-scoped-deletes / one-current-user all green.
The pre-push hook (full type-check + tests) also passed. CI runs the whole
bundle.

Refs #563 findings 10 and 11.

forget_memories narrowed its own input three ways and told the user about
none of them. forgetMemoriesMatching trimmed, dropped fragments under 4
characters and capped at 10; removeFromEconomicProfile did the same MINUS
the cap; and the handler built its "no stored match" list by filtering the
caller's raw strings against the trimmed facts the stores reported back.

Each produced the same failure: "Removed X." while something the user named
was never looked at. That is #563 finding 8's lie in a different costume —
the user believes data is gone that is still sitting there. The padded case
was the sharpest: "  photography  " could never appear in the unmatched list
however plainly it missed, because raw was being matched against trimmed.

One selector, selectForgetFacts → { wanted, tooShort, overCap }, used by both
stores and the handler, so all three agree by construction and the leftovers
have names. The reply now says what it did not attempt, on the
nothing-was-removed path too, where the silence was most misleading.

Counting the cap in usable facts also fixes a smaller bug: ten short
fragments used to consume the whole budget and starve a real fact behind them.

Mutation-proved: reverting stillUnknown to raw facts fails exactly the
padded-fact test; removing the over-cap clause fails exactly the over-cap one.

Refs #563 findings 10 and 11.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018waGt1ieA9TjpscqrbrnGb
@github-actions
github-actions Bot merged commit 798f73b into main Aug 29, 2026
6 checks passed
@github-actions
github-actions Bot deleted the fix/forget-report-dropped-facts branch August 29, 2026 08:59
catomean added a commit that referenced this pull request Aug 29, 2026
…ential (#847)

Closes the tail of the #563 audit.

FINDING 3 — an acknowledge flips an intent to buyer_confirmed and fires a
"someone says they paid you" card into the seller's queue. One card is a
prompt to check a wallet; a hundred is a denial-of-attention attack whose
payoff is ship-the-goods-for-no-money. The budget in front of it was keyed on
the caller, and the abuse shape is many addresses aimed at ONE seller.

rateLimitPaymentClaim bounds it at 10/hour per recipient, asked once on the
real transition — the idempotent re-claim paths return before it, cost an
attacker nothing, and so must not spend a genuine payer's allowance either.

The guard is INJECTED, not imported. Importing the limiter into the payments
domain dragged Upstash's ESM build into eight domain test suites that have no
business knowing about Redis — the layering violation announced itself. The
HTTP layer owns the policy; the domain owns when it is asked.

SUGGESTION 15 — the 402 body embeds `token` and WWW-Authenticate repeats it.
That token is what a payer later exchanges for a receipt, so anything caching
the response cached a credential for someone else's payment. Every other
payment response set no-store; this one did not.

SUGGESTION 14 — refreshPaymentStatus, reconcilePaymentIntent and
resolveSellerWallet each took a supabase client they had stopped using; all
three call getAdminClient() internally, so the signature advertised
caller-scoped RLS behaviour that no longer existed. Removing it from
resolveSellerWallet made resolveSellerReceiveInfo's client dead the same way,
so that goes too. buyerConfirmPayment genuinely uses its client and is
untouched — which is why the audit did not list it.

SUGGESTION 17 — the semantic fallback and its 0.45 floor had no coverage at
all: every forget test disables embeddings, so a silent revert to the old 0.75
would have changed which memories "forget that" reaches with nothing failing.
Added a stem-sharing-innocents corpus too, since INCIDENT_CORPUS holds only
targets and obvious strangers — a shape that flatters the matcher.

One of those tests documents rather than changes behaviour: a single generic
word is treated as a TOPIC, so "skills" clears every memory phrased with it.
That is the deliberate containment rule (memory-forget.test.ts:138), not an
over-match, and #838 makes it visible by naming each deleted memory.

Refs #563 finding 3, suggestions 14, 15, 17.


Claude-Session: https://claude.ai/code/session_018waGt1ieA9TjpscqrbrnGb

Co-authored-by: Georgy Butaev <41178744+g-but@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant