Skip to content

feat(cat): answer @cat on the wall, with the thread as context - #774

Merged
github-actions[bot] merged 2 commits into
mainfrom
feat/cat-wall-posts
Aug 26, 2026
Merged

feat(cat): answer @cat on the wall, with the thread as context#774
github-actions[bot] merged 2 commits into
mainfrom
feat/cat-wall-posts

Conversation

@catomean

Copy link
Copy Markdown
Collaborator

Tagging the Cat under a post now gets a public reply on that post.

Why a database trigger

Wall posts have no server seam. post-composer.ts calls a Postgres function straight from the browser, so unlike a private message there is nowhere in the app to notice a mention.

A trigger is also stronger on its own merits: it can't be skipped by a client that forgets to call an endpoint, or by a second client written later.

The trigger is a prefilter, not the rule

Detection has one implementation — domain/mentions/parse.ts + services/mentions/resolve.ts — and it stays there. Getting @cat., @catalogue and bob@example.com right is not something to write twice in two languages and hope they agree.

So the trigger asks only "does this contain @cat at all?" and the worker applies the resolver's verdict. It may over-select; it must never under-select — which is why the pattern is a plain case-insensitive substring rather than a clever one.

An over-selected row is completed, not failed. Retrying a post that never asked anything three times and then logging an error would be noise about nothing.

Context is the thread, not the tagged post

On X, tagging Grok under a reply gets you an answer about that reply. @cat is this realistic? three replies deep is a question about the conversation — answering it from the last sentence alone is the difference between an assistant and an autocomplete.

writeTimelineReply

The same split as write-message.ts, for the same reason: create_timeline_event re-imposes the RLS actor check by hand (v_actor_id IS DISTINCT FROM auth.uid()"Actor mismatch"), which is right for a browser and impossible for a worker with no auth.uid() at all.

Relaxing it isn't an option — it's the only thing between one authenticated user and posting as another — so authorization stays there, and this exists for callers whose authority was established elsewhere.

Two details that decide whether the reply is actually correct:

  • it inherits the parent's visibility — answering a followers-only post in public would republish the question to people who couldn't see it
  • it writes the timeline_event_visibility row, without which the reply exists and appears on nobody's timeline

Trigger behaviour, proven against the real schema

Inside BEGIN … ROLLBACK:

--- a post tagging the Cat is queued ---           INSERT 0 1
--- a post with no mention is not ---              INSERT 0 1
--- the Cat's own post is never queued ---         INSERT 0 1
--- the prefilter deliberately over-selects ---    INSERT 0 1
--- firing twice on the same post ---              INSERT 0 0   ← one reply owed

queued: 2
  @cat is this goal realistic?
  browsing the @catalogue today      ← discarded by the resolver

AFTER_ROLLBACK queue rows: 0

The Cat's own post is never queued, so there is no self-answer loop.

Verification

  • npm run verify — exit 0 (2373 tests)
  • 4 new worker tests, including that a discarded over-selection is completed rather than failed
  • live end-to-end test to follow after deploy

🤖 Generated with Claude Code

catomean and others added 2 commits August 26, 2026 15:30
Tagging the Cat under a post now gets a public reply on that post.

Wall posts have no server seam. post-composer.ts calls a Postgres
function straight from the BROWSER, so unlike a private message there is
nowhere in the app to notice a mention — the notice has to happen in the
database. A trigger is also the stronger choice on its own merits: it
cannot be skipped by a client that forgets to call an endpoint, or by a
second client written later.

THE TRIGGER IS A PREFILTER, NOT THE RULE. Detection has one
implementation — domain/mentions/parse.ts plus services/mentions/resolve.ts
— and it stays there, because getting `@cat.`, `@catalogue` and
`bob@example.com` right is not something to write twice in two languages
and hope they agree. The trigger asks only "does this contain @cat at
all?"; the worker applies the resolver's verdict. It may over-select and
must never under-select, which is why the pattern is a plain
case-insensitive substring rather than a clever one. An over-selected row
is completed, not failed — retrying a post that never asked anything
three times and then logging an error would be noise about nothing.

Context is the THREAD, not the tagged post. On X, tagging Grok under a
reply gets you an answer about that reply. "@cat is this realistic?"
three replies deep is a question about the conversation, and answering it
from the last sentence alone is the difference between an assistant and
an autocomplete.

writeTimelineReply is the same split as write-message.ts, for the same
reason: create_timeline_event re-imposes the RLS actor check by hand
(`v_actor_id IS DISTINCT FROM auth.uid()` raises "Actor mismatch"), which
is right for a browser and impossible for a worker that has no auth.uid()
at all. Relaxing it is not an option — it is the only thing between one
authenticated user and posting as another — so authorization stays there
and this exists for callers with authority established elsewhere.

The reply inherits its parent's visibility. Answering a followers-only
post in public would republish the question to people who could not see
it. It also writes the timeline_event_visibility row, without which the
reply exists and appears on nobody's timeline.

Trigger behaviour proven against the real schema inside BEGIN ... ROLLBACK:
a tagging post is queued, a plain post is not, `@catalogue` is queued and
later discarded, THE CAT'S OWN POST IS NEVER QUEUED (no self-answer
loop), and a second fire returns INSERT 0 0 — one post owes one reply.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
20260826160000 was taken by 20260826160000_profile_username_history.sql,
merged from a parallel session while this branch was open. Supabase keys
schema_migrations on the version alone, so two files sharing a timestamp
fail on the second INSERT — caught by the Migration Replay gate:

  ERROR: duplicate key value violates unique constraint
  "schema_migrations_pkey", Key (version)=(20260826160000) already exists

Neither migration is wrong; the number is. Renamed rather than merged,
because they do unrelated things.
@catomean
catomean force-pushed the feat/cat-wall-posts branch from 07d63ca to 7e566b1 Compare August 26, 2026 13:36
@github-actions
github-actions Bot merged commit 8c67ea0 into main Aug 26, 2026
8 checks passed
@github-actions
github-actions Bot deleted the feat/cat-wall-posts branch August 26, 2026 13:42
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