From 36161a0a5ecf568b1c3b2aa1db61573b0f497856 Mon Sep 17 00:00:00 2001 From: Georgy Butaev <41178744+g-but@users.noreply.github.com> Date: Fri, 28 Aug 2026 08:18:46 +0200 Subject: [PATCH 1/4] fix(cat): @cat has resolved to nobody for two days MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The email-derived-handle retirement renamed the Cat. Its predicate — `username = split_part(email, '@', 1)` — is exactly right for a person, whose public crawlable handle then republishes their email local part. It is exactly wrong for the Cat, because deriving the handle from the address is HOW the Cat is created: cat-account.ts registers `cat@orangecat.invalid` precisely so handle_new_user mints `cat`. So the script matched, and at 2026-08-26 21:58:59Z the handle became `user_0234d5e38e66`. `@cat` is the platform's advertised interface and it resolves by username, so every `@cat` in a message or under a post has since resolved to nobody. No reply anywhere. Found by typing `@` in the composer in production and seeing no Cat in the menu. Nothing went red. The account still existed, /profiles/cat still 301'd through profile_username_history, CI was green, health was 200. The only symptom was silence from the feature whose job is to answer. Three defects, three fixes: POLICY — the retirement could not tell a leaked address from a system identity. Now excluded by property, not by name: RFC 2606 reserves `.invalid` for undeliverable addresses, so there is no mailbox, no owner, and nothing personal in the local part. Hardcoding 'cat' in SQL would have put a second definition of the handle where it could drift from config/cat-identity.ts. Applied to the rename script and to count_email_derived_usernames(), which would otherwise have gone from 0 to 1 and turned the nightly gate red for a profile that is fine. CODE — ensureCatAccount looked the Cat up BY USERNAME, so its self-healing walked straight past a rename: lookup missed, createUser said "already registered", second lookup missed, returned null on every tick. It now keys on the login address, which no policy has reason to rewrite, and treats the handle as a field to assert rather than a key to search by. A rename now repairs itself on the next tick. GATE — nothing checked the product's own claim. checkCatHandle asserts that the name we tell people to type reaches the Cat; a lookup by id would have stayed green throughout. The literal it carries is pinned to CAT_USERNAME by a test, so the duplicate cannot drift. Proven by mutation: restoring the username lookup, removing the re-assert, and drifting the gate's literal each turn the suite red. The migration was rehearsed against production inside a transaction and rolled back — UPDATE 1, DELETE 1, 28 real retired handles untouched, gate still reads 0. Also restores the github.com/catomean links lost when this worktree was reset; the owner rename left them pointing at a redirect. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012dpTLxh5GJWeWTF1UEvcD5 --- CLAUDE.md | 2 +- README.md | 2 +- __tests__/unit/ci/e2e-reset-fixture.test.ts | 2 +- __tests__/unit/services/cat-account.test.ts | 85 +++++++++++++++++-- .../services/cat-handle-invariant.test.ts | 40 +++++++++ docs/architecture/TECHNICAL.md | 2 +- ...ERIFICATION_MASTER_CHECKLIST_2026-02-18.md | 2 +- docs/operations/enable-cd.md | 16 ++-- scripts/check-data-invariants.mjs | 62 ++++++++++++++ scripts/rename-email-derived-usernames.sql | 18 +++- src/config/navigation.ts | 4 +- src/services/mentions/cat-account.ts | 81 ++++++++++++++++-- ...000_system_accounts_keep_their_handles.sql | 82 ++++++++++++++++++ 13 files changed, 366 insertions(+), 32 deletions(-) create mode 100644 __tests__/unit/services/cat-handle-invariant.test.ts create mode 100644 supabase/migrations/20260828070000_system_accounts_keep_their_handles.sql diff --git a/CLAUDE.md b/CLAUDE.md index e4a91c423..a2b49515c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -74,7 +74,7 @@ push branch → open PR → CI green → auto-merge.yml squash-merges it `.github/workflows/auto-merge.yml` calls the fleet's canonical sweep — the policy no longer lives in this repo. It is defined once in -`maonakamoto/dotfiles`, `scripts/ci/auto-merge-sweep.sh`, and a fix made to a +`catomean/dotfiles`, `scripts/ci/auto-merge-sweep.sh`, and a fix made to a local copy here would reach nobody. The sweep merges **one** PR per sweep, and only when: it is not a draft, carries no hold label, every check has finished green, GitHub calls it cleanly mergeable, and main's own CI is currently green. One car per sweep is deliberate — a PR's diff --git a/README.md b/README.md index d945b8ecf..70a0b09ce 100644 --- a/README.md +++ b/README.md @@ -87,7 +87,7 @@ Functional composition replaces inheritance. Each middleware does one thing. ### Setup ```bash -git clone https://github.com/maonakamoto/orangecat.git +git clone https://github.com/catomean/orangecat.git cd orangecat npm install cp .env.example .env.local diff --git a/__tests__/unit/ci/e2e-reset-fixture.test.ts b/__tests__/unit/ci/e2e-reset-fixture.test.ts index c3e64c326..84cab293a 100644 --- a/__tests__/unit/ci/e2e-reset-fixture.test.ts +++ b/__tests__/unit/ci/e2e-reset-fixture.test.ts @@ -11,7 +11,7 @@ * CI turned a stalled merge queue into a genuinely red main. * * These lived in auto-merge-base-guard.test.ts until the sweep itself moved to - * maonakamoto/dotfiles (where its behaviour is tested against the canonical + * catomean/dotfiles (where its behaviour is tested against the canonical * script). The fixture script stayed HERE, so its guard stays here too — in its * own file, because the thing it tests no longer shares a file-worth of context * with a script this repo no longer carries. diff --git a/__tests__/unit/services/cat-account.test.ts b/__tests__/unit/services/cat-account.test.ts index 4692cd15b..bcba5c3a0 100644 --- a/__tests__/unit/services/cat-account.test.ts +++ b/__tests__/unit/services/cat-account.test.ts @@ -10,6 +10,13 @@ * on every tick: it must not create a second Cat, it must recover from a * half-built account, and it must refuse rather than improvise when it cannot * establish one — a caller that gets null must not invent a sender. + * + * Recovering from a RENAME is here because the suite below used to cover only + * a deleted profile, and production broke the other way: on 2026-08-26 the + * email-derived-handle retirement renamed the Cat from `cat` to + * `user_0234d5e38e66`, this file searched by username, found nothing, and + * returned null on every tick for two days. Self-healing that keys on the field + * most likely to break heals nothing. */ import { ensureCatAccount } from '@/services/mentions/cat-account'; @@ -20,31 +27,45 @@ function adminWith({ profile, createError, profileAfterCreate, + updateError, }: { profile: Row; createError?: { message: string }; profileAfterCreate?: Row; + updateError?: { message: string }; }) { const createUser = jest.fn().mockResolvedValue({ error: createError ?? null }); - const update = jest.fn(() => ({ eq: jest.fn().mockResolvedValue({ error: null }) })); + const update = jest.fn(() => ({ + eq: jest.fn().mockResolvedValue({ error: updateError ?? null }), + })); let lookups = 0; + const lookupColumns: string[] = []; const admin = { auth: { admin: { createUser } }, from: () => ({ select: () => ({ - eq: () => ({ - maybeSingle: () => { - lookups += 1; - const row = lookups === 1 ? profile : (profileAfterCreate ?? profile); - return Promise.resolve({ data: row, error: null }); - }, - }), + eq: (column: string) => { + lookupColumns.push(column); + return { + maybeSingle: () => { + lookups += 1; + const row = lookups === 1 ? profile : (profileAfterCreate ?? profile); + return Promise.resolve({ data: row, error: null }); + }, + }; + }, }), update, }), }; - return { admin: admin as never, createUser, update, lookups: () => lookups }; + return { + admin: admin as never, + createUser, + update, + lookups: () => lookups, + lookupColumns, + }; } describe('ensureCatAccount', () => { @@ -103,4 +124,50 @@ describe('ensureCatAccount', () => { await ensureCatAccount(admin); expect(update).toHaveBeenCalledWith(expect.objectContaining({ name: 'Cat' })); }); + + it('finds the Cat by its login address, not by the handle that can be taken away', async () => { + const { admin, lookupColumns } = adminWith({ + profile: { id: 'cat-1', username: 'cat' }, + }); + await ensureCatAccount(admin); + + // Searching by `username` is what made the 2026-08-26 rename unrecoverable: + // the field being repaired was the field being searched by. + expect(lookupColumns).toContain('email'); + expect(lookupColumns).not.toContain('username'); + }); + + it('restores the handle when something has renamed the Cat', async () => { + const { admin, update } = adminWith({ + profile: { id: 'cat-1', username: 'user_0234d5e38e66' }, + }); + + await expect(ensureCatAccount(admin)).resolves.toEqual({ + id: 'cat-1', + username: 'cat', + }); + expect(update).toHaveBeenCalledWith({ username: 'cat' }); + }); + + it('does not write on an ordinary tick', async () => { + const { admin, update } = adminWith({ profile: { id: 'cat-1', username: 'cat' } }); + await ensureCatAccount(admin); + // This runs on every worker tick; a rename repair that writes unconditionally + // is a write per tick forever. + expect(update).not.toHaveBeenCalled(); + }); + + it('still returns the account when the handle cannot be restored', async () => { + const { admin } = adminWith({ + profile: { id: 'cat-1', username: 'user_0234d5e38e66' }, + updateError: { message: 'duplicate key value violates unique constraint' }, + }); + + // Somebody else holding `cat` is bad, but refusing to return the account + // would turn a wrong name into total silence — strictly worse. + await expect(ensureCatAccount(admin)).resolves.toEqual({ + id: 'cat-1', + username: 'user_0234d5e38e66', + }); + }); }); diff --git a/__tests__/unit/services/cat-handle-invariant.test.ts b/__tests__/unit/services/cat-handle-invariant.test.ts new file mode 100644 index 000000000..0aecb92cd --- /dev/null +++ b/__tests__/unit/services/cat-handle-invariant.test.ts @@ -0,0 +1,40 @@ +/** + * The nightly gate hardcodes the Cat's handle. This is what stops that from + * becoming a second definition of it. + * + * `scripts/check-data-invariants.mjs` runs against production over PostgREST + * and cannot import from `src/` — it is a standalone .mjs with no build step and + * no path aliases. So it carries the string `cat` of its own. That is exactly + * the shape that has already cost this codebase real bugs: three definitions of + * what an `@handle` is meant the resolver notified one person while the + * renderer linked another. + * + * A duplicated literal is acceptable only when something fails the moment the + * two disagree. This is that something. + */ + +import { readFileSync } from 'fs'; +import { join } from 'path'; +import { CAT_USERNAME } from '@/config/cat-identity'; + +const script = readFileSync( + join(process.cwd(), 'scripts/check-data-invariants.mjs'), + 'utf8' +); + +describe('the invariant gate and the Cat agree on the handle', () => { + it('checks the handle the Cat actually answers to', () => { + const declared = /const CAT_HANDLE = '([^']+)'/.exec(script); + + // If this is null the check was renamed or removed. Either way the gate no + // longer guards `@cat`, which is the thing worth knowing. + expect(declared).not.toBeNull(); + expect(declared![1]).toBe(CAT_USERNAME); + }); + + it('asks by handle, not by id — an id lookup would pass while @cat was broken', () => { + // The whole failure this gate exists for was: account healthy, handle gone. + // Querying profiles by id would have been green throughout. + expect(script).toMatch(/profiles\?select=[^`]*&username=eq\.\$\{CAT_HANDLE\}/); + }); +}); diff --git a/docs/architecture/TECHNICAL.md b/docs/architecture/TECHNICAL.md index f10f6a2ee..b146c6526 100644 --- a/docs/architecture/TECHNICAL.md +++ b/docs/architecture/TECHNICAL.md @@ -216,7 +216,7 @@ create table funding_pages ( 1. Clone the repository: ```bash - git clone https://github.com/maonakamoto/orangecat.git + git clone https://github.com/catomean/orangecat.git cd orangecat ``` diff --git a/docs/development/WORKFLOW_VERIFICATION_MASTER_CHECKLIST_2026-02-18.md b/docs/development/WORKFLOW_VERIFICATION_MASTER_CHECKLIST_2026-02-18.md index 1ad824cd7..61065e153 100644 --- a/docs/development/WORKFLOW_VERIFICATION_MASTER_CHECKLIST_2026-02-18.md +++ b/docs/development/WORKFLOW_VERIFICATION_MASTER_CHECKLIST_2026-02-18.md @@ -120,7 +120,7 @@ Objective: verify **all critical workflows** in Orangecat one by one, capture ev ## Phase 9 — Operational & Quality Gates - ☑ Health endpoint behavior (rerun passed: `@p0 health endpoint responds`) -- ☑ P0 matrix runs green in CI with required secrets — [GitHub Actions run 28722171024](https://github.com/maonakamoto/orangecat/actions/runs/28722171024) / commit `d3e1bbe6`: **11/11 passed** (2026-07-05) +- ☑ P0 matrix runs green in CI with required secrets — [GitHub Actions run 28722171024](https://github.com/catomean/orangecat/actions/runs/28722171024) / commit `d3e1bbe6`: **11/11 passed** (2026-07-05) - ☑ No skip-based false green in required P0 checks — bootstrap mints reset tokens; missing secrets skip matrix explicitly - ☑ Lint/type-check/unit tests pass on final state — CI job green; local unit 713/713; type-check pass (2026-07-05) - ☑ Workflow YAML validity check (all `.github/workflows/*.yml` parse successfully after fixes) diff --git a/docs/operations/enable-cd.md b/docs/operations/enable-cd.md index eaa629187..143992e0a 100644 --- a/docs/operations/enable-cd.md +++ b/docs/operations/enable-cd.md @@ -10,7 +10,7 @@ can't take the site down; worst case it stays on the current version). ### Required repo secrets (`Settings → Secrets and variables → Actions`) -`https://github.com/maonakamoto/orangecat/settings/secrets/actions` +`https://github.com/catomean/orangecat/settings/secrets/actions` | Secret | Value | | -------------------------------------- | ------------------------------------------------ | @@ -30,8 +30,8 @@ Optional repo **variables** (`vars.*`): `OC_BOX` (default `root@167.233.22.31`), ssh-keygen -t ed25519 -C "orangecat-cd-deploy" -f ~/.ssh/orangecat_cd -N "" # 2. store the PRIVATE half as the secret (file -> gh, never echoed) -gh secret set SELFHOST_SSH_KEY -R maonakamoto/orangecat < ~/.ssh/orangecat_cd -ssh-keyscan -H 167.233.22.31 2>/dev/null | gh secret set SELFHOST_KNOWN_HOSTS -R maonakamoto/orangecat +gh secret set SELFHOST_SSH_KEY -R catomean/orangecat < ~/.ssh/orangecat_cd +ssh-keyscan -H 167.233.22.31 2>/dev/null | gh secret set SELFHOST_KNOWN_HOSTS -R catomean/orangecat # 3. authorize the PUBLIC half on the box (security change — do this deliberately) ssh-copy-id -i ~/.ssh/orangecat_cd.pub root@167.233.22.31 @@ -41,16 +41,16 @@ ssh-copy-id -i ~/.ssh/orangecat_cd.pub root@167.233.22.31 ### Set the public build values ```bash -gh secret set NEXT_PUBLIC_SUPABASE_URL -R maonakamoto/orangecat -b "https://supabase.orangecat.ch" -grep '^NEXT_PUBLIC_SUPABASE_ANON_KEY=' .env.local | cut -d= -f2- | tr -d '"' | gh secret set NEXT_PUBLIC_SUPABASE_ANON_KEY -R maonakamoto/orangecat -grep '^NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=' .env.local | cut -d= -f2- | tr -d '"' | gh secret set NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY -R maonakamoto/orangecat +gh secret set NEXT_PUBLIC_SUPABASE_URL -R catomean/orangecat -b "https://supabase.orangecat.ch" +grep '^NEXT_PUBLIC_SUPABASE_ANON_KEY=' .env.local | cut -d= -f2- | tr -d '"' | gh secret set NEXT_PUBLIC_SUPABASE_ANON_KEY -R catomean/orangecat +grep '^NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=' .env.local | cut -d= -f2- | tr -d '"' | gh secret set NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY -R catomean/orangecat ``` ## Deploy now / verify ```bash -gh workflow run cd.yml -R maonakamoto/orangecat # deploy current main -gh run watch "$(gh run list -R maonakamoto/orangecat --workflow cd.yml --limit 1 --json databaseId --jq '.[0].databaseId')" --exit-status +gh workflow run cd.yml -R catomean/orangecat # deploy current main +gh run watch "$(gh run list -R catomean/orangecat --workflow cd.yml --limit 1 --json databaseId --jq '.[0].databaseId')" --exit-status curl -fsS https://orangecat.ch/api/health && echo " ✓ live" ``` diff --git a/scripts/check-data-invariants.mjs b/scripts/check-data-invariants.mjs index a93c9cbcf..01bd67a14 100644 --- a/scripts/check-data-invariants.mjs +++ b/scripts/check-data-invariants.mjs @@ -303,6 +303,12 @@ async function checkSilentlyDroppedCatTurns() { // three that used to: the handle_new_user trigger, ensureProfile(), and two // profile form pre-fills. Each is covered by a test, so a regression here means // a FOURTH one was added. +// +// System accounts (RFC 2606 `.invalid` addresses) are excluded from the count +// as of 20260828070000. They matched the predicate perfectly while leaking +// nothing — an undeliverable address has no mailbox, so no owner, so no +// personal information in its local part — and the retirement that predicate +// authorised took `@cat` off the platform for two days. See checkCatHandle. const EMAIL_DERIVED_USERNAME_BASELINE = 0; async function checkEmailDerivedUsernames() { @@ -322,6 +328,61 @@ async function checkEmailDerivedUsernames() { } } +/** + * `@cat` must point at the Cat. + * + * This exists because on 2026-08-26 it stopped, and nothing anywhere noticed + * for two days. The email-derived-handle retirement renamed the Cat from `cat` + * to `user_0234d5e38e66` — correctly, by its own predicate, since the Cat's + * handle IS derived from `cat@orangecat.invalid`. Every `@cat` on the platform + * then resolved to nobody: no reply in any message, none under any post. The + * profile still existed, /profiles/cat still 301'd through the history table, + * CI was green and health was 200. The only observable symptom was silence, + * from a feature whose whole job is to answer. + * + * So this checks the product claim rather than a schema fact: not "the Cat + * account exists" — it did throughout — but "the name the platform tells people + * to type reaches it". Those came apart, which is the entire lesson. + * + * Reads by handle deliberately. The resolver looks mentions up by username, so + * this asks the question in the same terms the resolver does, and a lookup by + * id would pass while `@cat` stayed broken. + */ +async function checkCatHandle() { + // Kept in step with src/config/cat-identity.ts by + // __tests__/unit/services/cat-handle-invariant.test.ts, which fails if the + // handle there ever changes without this literal changing with it. + const CAT_HANDLE = 'cat'; + const rows = await rest(`profiles?select=id,email&username=eq.${CAT_HANDLE}`); + + if (rows.length === 0) { + violation( + 'cat.handle_resolves', + `no profile answers to @${CAT_HANDLE}, so every @${CAT_HANDLE} in a message or under a ` + + `post resolves to nobody and the Cat replies to nothing. The account itself may be ` + + `perfectly healthy — check whether something renamed it (the handle-retirement script ` + + `did exactly this once), then let the worker re-assert it via ensureCatAccount`, + [] + ); + return; + } + + // A handle held by the WRONG account is impersonation of the platform's own + // agent, and that is worth naming separately from "missing". + const holder = rows[0]; + if (!String(holder.email ?? '').endsWith('.invalid')) { + violation( + 'cat.handle_resolves', + `@${CAT_HANDLE} is held by an account with a deliverable email address, which means it is ` + + `not the platform's agent — somebody is receiving every mention meant for the Cat`, + [holder.id] + ); + return; + } + + notes.push(`cat: @${CAT_HANDLE} resolves to the Cat`); +} + async function checkOrphanedProfiles() { const count = Number(await rpc('count_orphaned_profiles')); @@ -454,6 +515,7 @@ async function main() { checkSilentlyDroppedCatTurns, checkOrphanedProfiles, checkEmailDerivedUsernames, + checkCatHandle, checkOrphanedCatConversations, checkOrphanedActors, ]; diff --git a/scripts/rename-email-derived-usernames.sql b/scripts/rename-email-derived-usernames.sql index 192d91931..c40205cd3 100644 --- a/scripts/rename-email-derived-usernames.sql +++ b/scripts/rename-email-derived-usernames.sql @@ -25,7 +25,8 @@ -- 'user_' || left(replace(p.id::text,'-',''),12) AS new_handle, -- (p.name = split_part(u.email,'@',1)) AS name_also_leaks -- FROM public.profiles p JOIN auth.users u ON u.id = p.id --- WHERE p.username = split_part(u.email,'@',1) ORDER BY 1;" +-- WHERE p.username = split_part(u.email,'@',1) +-- AND u.email NOT LIKE '%.invalid' ORDER BY 1;" -- -- THEN: sudo docker exec -i supabase-db psql -U postgres -v ON_ERROR_STOP=1 \ -- -f - < scripts/rename-email-derived-usernames.sql @@ -33,6 +34,14 @@ -- REVERSIBLE: profile_username_history holds the old handle for every row this -- touches, so `UPDATE profiles SET username = h.old_username FROM -- profile_username_history h WHERE h.profile_id = profiles.id` puts them back. +-- +-- SYSTEM ACCOUNTS ARE EXCLUDED (`.invalid`, RFC 2606). Learned the hard way: +-- the first run retired the Cat. Deriving the handle from the address is HOW +-- the Cat is created — cat-account.ts registers `cat@orangecat.invalid` so that +-- handle_new_user mints `cat` — so it matched the predicate perfectly while +-- leaking nothing, and `@cat` resolved to nobody for two days. An undeliverable +-- address has no mailbox, so no owner, so no personal information in its local +-- part. See 20260828070000_system_accounts_keep_their_handles.sql. BEGIN; @@ -44,6 +53,7 @@ SELECT lower(p.username), p.id FROM public.profiles p JOIN auth.users u ON u.id = p.id WHERE p.username = split_part(u.email, '@', 1) + AND u.email NOT LIKE '%.invalid' ON CONFLICT (old_username) DO NOTHING; -- Same shape as handle_new_user() and neutralUsernameFor(); see @@ -54,7 +64,8 @@ SET username = 'user_' || left(replace(p.id::text, '-', ''), 12), updated_at = now() FROM auth.users u WHERE u.id = p.id - AND p.username = split_part(u.email, '@', 1); + AND p.username = split_part(u.email, '@', 1) + AND u.email NOT LIKE '%.invalid'; -- A display name set to the email local part is the same leak wearing another -- label. NULL rather than a placeholder: the UI already falls back to the @@ -64,6 +75,7 @@ SET name = NULL, updated_at = now() FROM auth.users u WHERE u.id = p.id - AND p.name = split_part(u.email, '@', 1); + AND p.name = split_part(u.email, '@', 1) + AND u.email NOT LIKE '%.invalid'; COMMIT; diff --git a/src/config/navigation.ts b/src/config/navigation.ts index 9a65d54a9..aef344cb8 100644 --- a/src/config/navigation.ts +++ b/src/config/navigation.ts @@ -438,7 +438,7 @@ export const footerNavigation = { }, { name: 'GitHub', - href: 'https://github.com/maonakamoto/orangecat', + href: 'https://github.com/catomean/orangecat', icon: GitHubIcon, }, ], @@ -447,7 +447,7 @@ export const footerNavigation = { // change. External URLs are marked with `external: true`. bottomBar: [ { name: 'Documentation', href: ROUTES.DOCS }, - { name: 'Source Code', href: 'https://github.com/maonakamoto/orangecat', external: true }, + { name: 'Source Code', href: 'https://github.com/catomean/orangecat', external: true }, { name: 'Roadmap', href: ROUTES.ROADMAP }, { name: 'Support', href: ROUTES.SUPPORT }, ] as { name: string; href: string; external?: boolean }[], diff --git a/src/services/mentions/cat-account.ts b/src/services/mentions/cat-account.ts index 95a5c2edb..1b4f2d5b5 100644 --- a/src/services/mentions/cat-account.ts +++ b/src/services/mentions/cat-account.ts @@ -9,8 +9,20 @@ * * Idempotent by design, and cheap when it is a no-op: one indexed lookup. It is * safe to call on every worker tick, and doing so makes the account - * self-healing — if the profile is ever deleted, the Cat comes back rather than - * every `@cat` on the platform quietly resolving to nobody. + * self-healing — if the profile is ever deleted OR RENAMED, the Cat comes back + * rather than every `@cat` on the platform quietly resolving to nobody. + * + * The rename half was learned in production. This file used to FIND the Cat by + * its username, which is the one field about the Cat that another policy is + * entitled to change: on 2026-08-26 the email-derived-handle retirement renamed + * `cat` to `user_0234d5e38e66` (see + * supabase/migrations/20260828070000_system_accounts_keep_their_handles.sql). + * The lookup then missed, creation said "already registered", the second lookup + * missed too, and this returned null on every tick thereafter — self-healing + * that could not heal, because the thing it searched by was the thing that + * broke. Identity is now keyed on the login address, which is a literal in this + * file and cannot be reassigned, and the handle is treated as a field to + * ASSERT rather than a key to search by. */ import { CAT_USERNAME, CAT_DISPLAY_NAME } from '@/config/cat-identity'; @@ -54,7 +66,7 @@ export async function ensureCatAccount( ): Promise { const existing = await findCatProfile(admin); if (existing) { - return existing; + return assertCatHandle(admin, existing); } // No profile. Either the auth user does not exist either, or it does and its @@ -93,14 +105,24 @@ export async function ensureCatAccount( } logger.info('Cat account established', { id: profile.id }, 'CatAccount'); - return profile; + return assertCatHandle(admin, profile); } +/** + * Find the Cat by the one thing about it that cannot be reassigned. + * + * NOT by username. `@cat` is what the platform advertises, which makes the + * handle the thing most worth repairing and therefore the worst possible thing + * to search by — if it is wrong, the lookup that would notice returns nothing. + * The login address is a literal in this file, belongs to a domain RFC 2606 + * guarantees nobody can receive mail at, and no product policy has any reason + * to rewrite it. + */ async function findCatProfile(admin: SupabaseClient): Promise { const { data, error } = await admin .from(DATABASE_TABLES.PROFILES) .select('id, username') - .eq('username', CAT_USERNAME) + .eq('email', CAT_EMAIL) .maybeSingle(); if (error) { @@ -109,3 +131,52 @@ async function findCatProfile(admin: SupabaseClient): Promise } return data ? { id: data.id as string, username: data.username as string } : null; } + +/** + * Make the account answer to `@cat`, whatever it currently says. + * + * A no-op on every ordinary tick — the comparison is free and the write only + * happens when the handle has actually drifted. When it has, this is the whole + * repair: the resolver looks mentions up by username, so restoring it is what + * makes `@cat` mean the Cat again. + * + * A failure here is reported and swallowed rather than propagated. The account + * still exists and the Cat can still WRITE under the wrong handle; refusing to + * return it would turn a wrong name into total silence, which is strictly + * worse. The mismatch is logged at error level because it means something + * outside this file is renaming a system account. + */ +async function assertCatHandle( + admin: SupabaseClient, + profile: CatAccount +): Promise { + if (profile.username === CAT_USERNAME) { + return profile; + } + + logger.error( + 'The Cat is not answering to its own handle', + { found: profile.username, expected: CAT_USERNAME }, + 'CatAccount' + ); + + const { error } = await admin + .from(DATABASE_TABLES.PROFILES) + .update({ username: CAT_USERNAME }) + .eq('id', profile.id); + + if (error) { + // The likeliest cause is the unique index: something else holds `cat`. + // That is impersonation of the platform's own agent, so it is worth the + // loud log even though the Cat keeps working under the wrong name. + logger.error( + 'Could not restore the Cat handle', + { error: error.message, holding: profile.username }, + 'CatAccount' + ); + return profile; + } + + logger.info('Restored the Cat handle', { was: profile.username }, 'CatAccount'); + return { ...profile, username: CAT_USERNAME }; +} diff --git a/supabase/migrations/20260828070000_system_accounts_keep_their_handles.sql b/supabase/migrations/20260828070000_system_accounts_keep_their_handles.sql new file mode 100644 index 000000000..f0697604c --- /dev/null +++ b/supabase/migrations/20260828070000_system_accounts_keep_their_handles.sql @@ -0,0 +1,82 @@ +-- A system account's handle is not a leaked email address. Stop retiring it. +-- +-- WHAT BROKE: scripts/rename-email-derived-usernames.sql retires every profile +-- where `username = split_part(email, '@', 1)`. That predicate is exactly right +-- for a person — it means their public, crawlable handle republishes their +-- email local part. It is exactly wrong for the Cat, because deriving the +-- handle from the address is HOW the Cat is created: cat-account.ts registers +-- `cat@orangecat.invalid` precisely so `handle_new_user` mints the handle +-- `cat`. So the retirement matched, and on 2026-08-26 21:58:59Z the Cat's +-- handle became `user_0234d5e38e66`. +-- +-- The damage was total and silent. `@cat` is the platform's advertised +-- interface — it is in the Cat's own bio, in the composer placeholder, and in +-- the docs — and it resolves by username. With no profile named `cat`: +-- +-- * every `@cat` in a message or under a post resolved to nobody, so nothing +-- was queued and the Cat answered nothing, for two days; +-- * ensureCatAccount() could not even repair it. It looked the Cat up BY +-- USERNAME, found nothing, tried to create the auth user, got "already +-- registered", looked again, still found nothing, and returned null — +-- permanently. Its self-healing was written for a DELETED profile and a +-- rename walks straight past it. +-- +-- Nothing went red. The retirement is correct-by-design for people, the profile +-- still existed, /profiles/cat still 301'd through profile_username_history, +-- and health stayed 200. +-- +-- WHY `.invalid` IS THE RIGHT LINE, and not a list of names to maintain: +-- RFC 2606 reserves `.invalid` for addresses that are guaranteed undeliverable. +-- An account there has no mailbox, so it has no owner and no correspondence, +-- so its local part cannot be anybody's personal information. That is the +-- actual property the retirement cares about — "this handle republishes a +-- person's email" — rather than a hardcoded 'cat', which would put a second +-- definition of the Cat's handle in SQL, to drift against +-- src/config/cat-identity.ts. Any future system identity gets this for free by +-- using a `.invalid` address, which it should be doing anyway. + +-- 1. The counting function the nightly gate reads (check-data-invariants.mjs, +-- EMAIL_DERIVED_USERNAME_BASELINE = 0). Without this exclusion, putting the +-- Cat's handle back would take the count from 0 to 1 and turn the gate red +-- for a profile that is fine — which is how a gate teaches people to ignore +-- it. +CREATE OR REPLACE FUNCTION public.count_email_derived_usernames() +RETURNS bigint +LANGUAGE sql +SECURITY DEFINER +SET search_path TO 'public' +AS $$ + SELECT count(*)::bigint + FROM public.profiles p + JOIN auth.users u ON u.id = p.id + WHERE p.username = split_part(u.email, '@', 1) + -- RFC 2606: undeliverable by definition, so there is no person and no leak. + AND u.email NOT LIKE '%.invalid'; +$$; + +COMMENT ON FUNCTION public.count_email_derived_usernames() IS + 'How many profiles still publish their email local part as a public handle. Excludes RFC 2606 .invalid addresses, which have no mailbox and therefore no owner to expose. A ratchet for check-data-invariants.mjs: it may fall or hold, never rise.'; + +-- 2. Put back what the retirement should never have taken. Written as a +-- property of system accounts rather than as `username = 'cat'`, so it +-- repairs any system identity caught the same way and states no handle +-- literal of its own. +UPDATE public.profiles p +SET username = h.old_username, + updated_at = now() +FROM public.profile_username_history h +JOIN auth.users u ON u.id = h.profile_id +WHERE h.profile_id = p.id + AND u.email LIKE '%.invalid' + AND p.username IS DISTINCT FROM h.old_username; + +-- 3. Drop the history rows that claim a system handle is retired. Leaving them +-- would be a contradiction in the data — `cat` recorded as a former handle +-- of the very profile that currently holds it — and `old_username` is the +-- primary key, so a stale row would also block recording a real future +-- rename. Nothing is lost: history exists to keep OLD handles resolving, and +-- this handle is current again, so the direct lookup answers first. +DELETE FROM public.profile_username_history h +USING auth.users u +WHERE u.id = h.profile_id + AND u.email LIKE '%.invalid'; From 19616992511c4796664f5ad62c85a9558999c2fe Mon Sep 17 00:00:00 2001 From: Georgy Butaev <41178744+g-but@users.noreply.github.com> Date: Fri, 28 Aug 2026 08:28:11 +0200 Subject: [PATCH 2/4] fix(docs): point at bitbaum, the canonical owner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit catomean is itself a redirect now — the repos live in the bitbaum org. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012dpTLxh5GJWeWTF1UEvcD5 --- CLAUDE.md | 2 +- README.md | 2 +- __tests__/unit/ci/e2e-reset-fixture.test.ts | 2 +- docs/architecture/TECHNICAL.md | 2 +- ...W_VERIFICATION_MASTER_CHECKLIST_2026-02-18.md | 2 +- docs/operations/enable-cd.md | 16 ++++++++-------- src/config/navigation.ts | 4 ++-- 7 files changed, 15 insertions(+), 15 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index a2b49515c..1d3e0ed45 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -74,7 +74,7 @@ push branch → open PR → CI green → auto-merge.yml squash-merges it `.github/workflows/auto-merge.yml` calls the fleet's canonical sweep — the policy no longer lives in this repo. It is defined once in -`catomean/dotfiles`, `scripts/ci/auto-merge-sweep.sh`, and a fix made to a +`bitbaum/dotfiles`, `scripts/ci/auto-merge-sweep.sh`, and a fix made to a local copy here would reach nobody. The sweep merges **one** PR per sweep, and only when: it is not a draft, carries no hold label, every check has finished green, GitHub calls it cleanly mergeable, and main's own CI is currently green. One car per sweep is deliberate — a PR's diff --git a/README.md b/README.md index 70a0b09ce..fd674e269 100644 --- a/README.md +++ b/README.md @@ -87,7 +87,7 @@ Functional composition replaces inheritance. Each middleware does one thing. ### Setup ```bash -git clone https://github.com/catomean/orangecat.git +git clone https://github.com/bitbaum/orangecat.git cd orangecat npm install cp .env.example .env.local diff --git a/__tests__/unit/ci/e2e-reset-fixture.test.ts b/__tests__/unit/ci/e2e-reset-fixture.test.ts index 84cab293a..bd26eb3ce 100644 --- a/__tests__/unit/ci/e2e-reset-fixture.test.ts +++ b/__tests__/unit/ci/e2e-reset-fixture.test.ts @@ -11,7 +11,7 @@ * CI turned a stalled merge queue into a genuinely red main. * * These lived in auto-merge-base-guard.test.ts until the sweep itself moved to - * catomean/dotfiles (where its behaviour is tested against the canonical + * bitbaum/dotfiles (where its behaviour is tested against the canonical * script). The fixture script stayed HERE, so its guard stays here too — in its * own file, because the thing it tests no longer shares a file-worth of context * with a script this repo no longer carries. diff --git a/docs/architecture/TECHNICAL.md b/docs/architecture/TECHNICAL.md index b146c6526..88ee4b1bf 100644 --- a/docs/architecture/TECHNICAL.md +++ b/docs/architecture/TECHNICAL.md @@ -216,7 +216,7 @@ create table funding_pages ( 1. Clone the repository: ```bash - git clone https://github.com/catomean/orangecat.git + git clone https://github.com/bitbaum/orangecat.git cd orangecat ``` diff --git a/docs/development/WORKFLOW_VERIFICATION_MASTER_CHECKLIST_2026-02-18.md b/docs/development/WORKFLOW_VERIFICATION_MASTER_CHECKLIST_2026-02-18.md index 61065e153..38e617779 100644 --- a/docs/development/WORKFLOW_VERIFICATION_MASTER_CHECKLIST_2026-02-18.md +++ b/docs/development/WORKFLOW_VERIFICATION_MASTER_CHECKLIST_2026-02-18.md @@ -120,7 +120,7 @@ Objective: verify **all critical workflows** in Orangecat one by one, capture ev ## Phase 9 — Operational & Quality Gates - ☑ Health endpoint behavior (rerun passed: `@p0 health endpoint responds`) -- ☑ P0 matrix runs green in CI with required secrets — [GitHub Actions run 28722171024](https://github.com/catomean/orangecat/actions/runs/28722171024) / commit `d3e1bbe6`: **11/11 passed** (2026-07-05) +- ☑ P0 matrix runs green in CI with required secrets — [GitHub Actions run 28722171024](https://github.com/bitbaum/orangecat/actions/runs/28722171024) / commit `d3e1bbe6`: **11/11 passed** (2026-07-05) - ☑ No skip-based false green in required P0 checks — bootstrap mints reset tokens; missing secrets skip matrix explicitly - ☑ Lint/type-check/unit tests pass on final state — CI job green; local unit 713/713; type-check pass (2026-07-05) - ☑ Workflow YAML validity check (all `.github/workflows/*.yml` parse successfully after fixes) diff --git a/docs/operations/enable-cd.md b/docs/operations/enable-cd.md index 143992e0a..dd7b8fde6 100644 --- a/docs/operations/enable-cd.md +++ b/docs/operations/enable-cd.md @@ -10,7 +10,7 @@ can't take the site down; worst case it stays on the current version). ### Required repo secrets (`Settings → Secrets and variables → Actions`) -`https://github.com/catomean/orangecat/settings/secrets/actions` +`https://github.com/bitbaum/orangecat/settings/secrets/actions` | Secret | Value | | -------------------------------------- | ------------------------------------------------ | @@ -30,8 +30,8 @@ Optional repo **variables** (`vars.*`): `OC_BOX` (default `root@167.233.22.31`), ssh-keygen -t ed25519 -C "orangecat-cd-deploy" -f ~/.ssh/orangecat_cd -N "" # 2. store the PRIVATE half as the secret (file -> gh, never echoed) -gh secret set SELFHOST_SSH_KEY -R catomean/orangecat < ~/.ssh/orangecat_cd -ssh-keyscan -H 167.233.22.31 2>/dev/null | gh secret set SELFHOST_KNOWN_HOSTS -R catomean/orangecat +gh secret set SELFHOST_SSH_KEY -R bitbaum/orangecat < ~/.ssh/orangecat_cd +ssh-keyscan -H 167.233.22.31 2>/dev/null | gh secret set SELFHOST_KNOWN_HOSTS -R bitbaum/orangecat # 3. authorize the PUBLIC half on the box (security change — do this deliberately) ssh-copy-id -i ~/.ssh/orangecat_cd.pub root@167.233.22.31 @@ -41,16 +41,16 @@ ssh-copy-id -i ~/.ssh/orangecat_cd.pub root@167.233.22.31 ### Set the public build values ```bash -gh secret set NEXT_PUBLIC_SUPABASE_URL -R catomean/orangecat -b "https://supabase.orangecat.ch" -grep '^NEXT_PUBLIC_SUPABASE_ANON_KEY=' .env.local | cut -d= -f2- | tr -d '"' | gh secret set NEXT_PUBLIC_SUPABASE_ANON_KEY -R catomean/orangecat -grep '^NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=' .env.local | cut -d= -f2- | tr -d '"' | gh secret set NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY -R catomean/orangecat +gh secret set NEXT_PUBLIC_SUPABASE_URL -R bitbaum/orangecat -b "https://supabase.orangecat.ch" +grep '^NEXT_PUBLIC_SUPABASE_ANON_KEY=' .env.local | cut -d= -f2- | tr -d '"' | gh secret set NEXT_PUBLIC_SUPABASE_ANON_KEY -R bitbaum/orangecat +grep '^NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=' .env.local | cut -d= -f2- | tr -d '"' | gh secret set NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY -R bitbaum/orangecat ``` ## Deploy now / verify ```bash -gh workflow run cd.yml -R catomean/orangecat # deploy current main -gh run watch "$(gh run list -R catomean/orangecat --workflow cd.yml --limit 1 --json databaseId --jq '.[0].databaseId')" --exit-status +gh workflow run cd.yml -R bitbaum/orangecat # deploy current main +gh run watch "$(gh run list -R bitbaum/orangecat --workflow cd.yml --limit 1 --json databaseId --jq '.[0].databaseId')" --exit-status curl -fsS https://orangecat.ch/api/health && echo " ✓ live" ``` diff --git a/src/config/navigation.ts b/src/config/navigation.ts index aef344cb8..2831cf10b 100644 --- a/src/config/navigation.ts +++ b/src/config/navigation.ts @@ -438,7 +438,7 @@ export const footerNavigation = { }, { name: 'GitHub', - href: 'https://github.com/catomean/orangecat', + href: 'https://github.com/bitbaum/orangecat', icon: GitHubIcon, }, ], @@ -447,7 +447,7 @@ export const footerNavigation = { // change. External URLs are marked with `external: true`. bottomBar: [ { name: 'Documentation', href: ROUTES.DOCS }, - { name: 'Source Code', href: 'https://github.com/catomean/orangecat', external: true }, + { name: 'Source Code', href: 'https://github.com/bitbaum/orangecat', external: true }, { name: 'Roadmap', href: ROUTES.ROADMAP }, { name: 'Support', href: ROUTES.SUPPORT }, ] as { name: string; href: string; external?: boolean }[], From a5528484c07ab5aa48fca98f1aaa9c3825c1960d Mon Sep 17 00:00:00 2001 From: Georgy Butaev <41178744+g-but@users.noreply.github.com> Date: Fri, 28 Aug 2026 08:45:10 +0200 Subject: [PATCH 3/4] fix(mentions): the @ menu was offering rows nobody can read MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Production has 84 profiles and 74 of them have no display name — the correct consequence of no longer minting names out of email local parts, where NULL is honest. The menu did not account for that, so what it actually showed on a bare `@` was two rows of `user_d58c7dccec41`, printed twice each: once as the name, because the name falls back to the handle, and once as the subtitle. Three changes, all following from the data rather than from taste. A nameless row now shows its handle ONCE, in the position the eye reads first, instead of the same hex string on two lines. A nameless profile is not offered at all on a bare `@`. You cannot be searching for a name that does not exist, so those rows were pure noise in the one position that matters most — the first thing anyone sees when they discover the feature. Type any part of the handle and they come straight back through the existing prefix rules. At equal match quality, a named profile outranks a nameless one. `@u` matches `ursula` and `user_d58c7dccec41` equally well by anything the score can see, and only one of them is a row a human can act on. The tiebreak sits after match quality, never before it, so an exact handle match still wins — a demotion that overrode "this is plainly the row being asked for" would be a worse bug than the one it fixed. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012dpTLxh5GJWeWTF1UEvcD5 --- .../unit/domain/mention-autocomplete.test.ts | 48 ++++++++++++++++++- .../mentions/MentionSuggestions.tsx | 24 ++++++---- src/domain/mentions/rank.ts | 44 +++++++++++++++-- 3 files changed, 103 insertions(+), 13 deletions(-) diff --git a/__tests__/unit/domain/mention-autocomplete.test.ts b/__tests__/unit/domain/mention-autocomplete.test.ts index 2eee6f636..073a951bb 100644 --- a/__tests__/unit/domain/mention-autocomplete.test.ts +++ b/__tests__/unit/domain/mention-autocomplete.test.ts @@ -125,8 +125,54 @@ describe('rankMentionSuggestions', () => { }); it('falls back to the handle when a profile has no display name', () => { - const items = rankMentionSuggestions('', [{ id: 'x', username: 'solo' }], null); + const items = rankMentionSuggestions('so', [{ id: 'x', username: 'solo' }], null); expect(items[0].name).toBe('solo'); + expect(items[0].isAnonymous).toBe(true); + }); + + // Most accounts on OrangeCat have no display name — 14 of the first 20 on + // 2026-08-28 — because handles stopped being minted from email local parts + // and NULL is the honest result. So this is the menu's common case, not an + // edge one. + describe('nameless profiles', () => { + const nameless = [ + { id: 'a', username: 'user_d58c7dccec41' }, + { id: 'b', username: 'user_09bf1419e7e7' }, + ]; + + it('offers none of them on a bare @, where they are unrecognisable hex', () => { + expect(rankMentionSuggestions('', nameless, null)).toHaveLength(0); + }); + + it('still offers the Cat on a bare @ — it is the row worth discovering', () => { + const items = rankMentionSuggestions('', nameless, cat); + expect(items).toHaveLength(1); + expect(items[0].isCat).toBe(true); + }); + + it('offers one the moment its handle is typed', () => { + const items = rankMentionSuggestions('user_d58', nameless, null); + expect(items[0].username).toBe('user_d58c7dccec41'); + }); + + it('sinks below a named person who matches just as well', () => { + const items = rankMentionSuggestions( + 'u', + [{ id: 'a', username: 'user_d58c7dccec41' }, { id: 'b', username: 'ursula', name: 'Ursula' }], + null + ); + expect(items[0].username).toBe('ursula'); + }); + + it('still wins on an exact handle match against a named person', () => { + // Demotion must not override "this is plainly the row being asked for". + const items = rankMentionSuggestions( + 'user_d58c7dccec41', + [{ id: 'b', username: 'ursula', name: 'user_d58c7dccec41 fan' }, { id: 'a', username: 'user_d58c7dccec41' }], + null + ); + expect(items[0].username).toBe('user_d58c7dccec41'); + }); }); it('honours the limit', () => { diff --git a/src/components/mentions/MentionSuggestions.tsx b/src/components/mentions/MentionSuggestions.tsx index fde8beb42..a68945464 100644 --- a/src/components/mentions/MentionSuggestions.tsx +++ b/src/components/mentions/MentionSuggestions.tsx @@ -84,7 +84,13 @@ export default function MentionSuggestions({ - {item.name} + + {/* A nameless profile's `name` IS its handle, so printing both + lines gave two identical rows of hex — the most common shape + in the menu, since most accounts have no display name. Show + the handle once, in the position the eye reads first. */} + {item.isAnonymous ? `@${item.username}` : item.name} + {item.isCat && ( // `text-on-accent` rather than `text-white`: white on this // orange is 3.10:1 and fails AA (see tailwind.config.ts). @@ -93,13 +99,15 @@ export default function MentionSuggestions({ )} - {/* The handle is always shown, including for the Cat: the point of - the menu is that you learn `@cat` exists and can type it next - time without opening anything. */} - - @{item.username} - {item.isCat && ' · ask about this thread'} - + {/* For a named profile the handle is always shown, including for the + Cat: the point of the menu is that you learn `@cat` exists and + can type it next time without opening anything. */} + {!item.isAnonymous && ( + + @{item.username} + {item.isCat && ' · ask about this thread'} + + )} ))} diff --git a/src/domain/mentions/rank.ts b/src/domain/mentions/rank.ts index e87c6af44..fd7809c53 100644 --- a/src/domain/mentions/rank.ts +++ b/src/domain/mentions/rank.ts @@ -39,6 +39,17 @@ export interface MentionSuggestion { avatarUrl: string | null; /** Renders the Cat differently, and explains why it is at the top. */ isCat: boolean; + /** + * This profile has no display name, so `name` above is just the handle again. + * + * Worth a flag rather than leaving the renderer to compare the two strings: + * it is the difference between a row you can recognise and a row you cannot, + * and both the order and the markup depend on it. Measured on production + * 2026-08-28: 14 of the first 20 profiles, because the handle-retirement + * fix correctly stopped inventing display names out of email local parts — + * NULL is honest, and it means most accounts genuinely have no name yet. + */ + isAnonymous: boolean; } /** How many rows the menu shows. Enough to choose from, few enough to scan. */ @@ -63,12 +74,14 @@ function toSuggestion(profile: MentionCandidateProfile): MentionSuggestion | nul if (!username) { return null; } + const name = profile.name?.trim(); return { id: profile.id, username, - name: profile.name?.trim() || username, + name: name || username, avatarUrl: profile.avatar_url ?? null, isCat: isCatHandle(username), + isAnonymous: !name, }; } @@ -80,6 +93,12 @@ function toSuggestion(profile: MentionCandidateProfile): MentionSuggestion | nul * and handle beats display name. Ties keep the order the query returned rather * than being re-sorted alphabetically, because that order is already * newest-first and stable. + * + * A nameless profile sinks below every named one, UNLESS the query matches its + * handle — in which case it is plainly the row being asked for and leads. The + * asymmetry is the point: `user_d58c7dccec41` is unrecognisable, so offering it + * to someone who has typed nothing about it is noise, while offering it to + * someone typing `user_d58` is precisely right. */ function score(suggestion: MentionSuggestion, query: string): number { const q = normalizeUsername(query); @@ -87,7 +106,6 @@ function score(suggestion: MentionSuggestion, query: string): number { return 0; } const handle = normalizeUsername(suggestion.username); - const name = suggestion.name.toLowerCase(); if (handle === q) { return -3; @@ -95,7 +113,9 @@ function score(suggestion: MentionSuggestion, query: string): number { if (handle.startsWith(q)) { return -2; } - if (name.startsWith(query.toLowerCase())) { + // Only for a real name. For a nameless profile `name` is the handle again, so + // this would silently re-run the check above and promote a row nobody can read. + if (!suggestion.isAnonymous && suggestion.name.toLowerCase().startsWith(query.toLowerCase())) { return -1; } return 0; @@ -125,8 +145,24 @@ export function rankMentionSuggestions( // The Cat is placed deliberately, so drop it from the general pool rather // than letting the people search list it a second time further down. .filter(s => !s.isCat) + // On a bare `@` there is nothing to match on, so a nameless profile is a + // row of hex offered to someone who cannot possibly be looking for it — + // and with most accounts unnamed it is what the menu opened with. You + // cannot be searching for a name that does not exist; type any of the + // handle and they come back immediately via the prefix rules above. + .filter(s => query.length > 0 || !s.isAnonymous) .map((s, index) => ({ s, index, rank: score(s, query) })) - .sort((a, b) => a.rank - b.rank || a.index - b.index) + // Match quality first, then recognisability, then the order the query + // returned. The middle term matters because most accounts have no name: + // `@u` matches both `ursula` and `user_d58c7dccec41` as handle prefixes, + // equally well by any measure the score can see, and only one of them is a + // row a human can act on. + .sort( + (a, b) => + a.rank - b.rank || + Number(a.s.isAnonymous) - Number(b.s.isAnonymous) || + a.index - b.index + ) .map(entry => entry.s); const deduped: MentionSuggestion[] = []; From 742b7ae0f5d990c196c8d61f2b7a78d6df4b6c9e Mon Sep 17 00:00:00 2001 From: Georgy Butaev <41178744+g-but@users.noreply.github.com> Date: Fri, 28 Aug 2026 08:52:01 +0200 Subject: [PATCH 4/4] chore: leave the CLAUDE.md owner line to #803 Both branches edit the same line; one owner avoids a conflict that would stop CI running on whichever loses. Co-Authored-By: Claude Opus 5 --- CLAUDE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index 1d3e0ed45..e4a91c423 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -74,7 +74,7 @@ push branch → open PR → CI green → auto-merge.yml squash-merges it `.github/workflows/auto-merge.yml` calls the fleet's canonical sweep — the policy no longer lives in this repo. It is defined once in -`bitbaum/dotfiles`, `scripts/ci/auto-merge-sweep.sh`, and a fix made to a +`maonakamoto/dotfiles`, `scripts/ci/auto-merge-sweep.sh`, and a fix made to a local copy here would reach nobody. The sweep merges **one** PR per sweep, and only when: it is not a draft, carries no hold label, every check has finished green, GitHub calls it cleanly mergeable, and main's own CI is currently green. One car per sweep is deliberate — a PR's