ธีม
Mistakes — Routing, read-state, caches & serialization
State that outlives one render: what is in the URL, what "seen" means per user, and what a round trip drops.
Each entry: Symptom → Cause → Fix → Where it lives now. The always-loaded index of every entry across all nine files is .claude/rules/mistakes.md; add new entries here, then run npm run mistakes:index.
"Unread" highlight inside an item vanishes the moment you open it — mark seen AFTER capturing seenAt for the open view
Symptom: VPA writes a comment on a หนังสือ. Receiver sees the grid "X คอมเมนต์" badge and the doc-card "อัปเดต" pill correctly. They click the หนังสือ to read the comment → the inline comment banner ("คอมเมนต์ใหม่: …"), the "X ใหม่" thread header, and the per-row is-unread highlight all FAIL to appear. The user can't see WHICH comment is new even though they opened the doc specifically to read it. Worse on iPad Safari normal-mode (probably timing-related) which is why it looked like an iPad-specific bug at first. Cause: The expand-click handler in inbox.js did expandedDocs.add(id); markCommentsSeen(id); render();. The markCommentsSeen writes now into localStorage BEFORE render runs. Then renderCommentBanner and renderCommentsList both read getCommentsSeenAt(docId) → get now → filter effectiveTs(e) > seenAt returns nothing → no banner, no "ใหม่" pill, no is-unread row. The outer grid/card highlights only "work" because they render BEFORE expansion (different render pass). Fix: Capture the pre-expand seenAt into a module-scope Map (expandedDocsSeenAt) at the moment of expansion, then call markCommentsSeen to persist "I saw it" globally. Pass the frozen value into renderCommentBanner(doc, role, seenAtOverride) and renderCommentsList(doc, role, seenAtOverride) so the expanded body keeps showing what was new at expand-time. Clear the Map entry on collapse / back-to-grid / doc delete so a re-expand without a fresh comment shows no highlight (matches "they already read it"). Where: src/js/projects/inbox.js toggleDocExpansion() is the single chokepoint; openDocumentDetail (deep-link), the projectsBackToGrid handler, and onDocDeleteClick all touch expandedDocsSeenAt alongside expandedDocs. Pattern to reuse: any time a "mark seen" persistence happens at the same moment the view first shows the unread item, freeze the read-side state before the write, and let the renderer use the frozen value while the storage carries the new value.
Per-user read-state means a newly-granted account INHERITS the whole backlog as unread — baseline them at first run, and never trust a sentinel that was set on a no-op
Symptom: "I want my email to see หนังสือโครงการ like samomdkkuvpa sees it, but when I log in with my email it shows many '1 อัปเดต' — as samomdkkuvpa I don't." Both accounts have the SAME role/seat and see the same documents, so it reads like a permission or scoping bug. It isn't. Cause: read-state is per user — project_doc_views rows keyed by user_id, plus a user-scoped localStorage map. Measured: samomdkkuvpa had 26 rows for 26 documents (clean because it has been reading them for months); the newly-granted account had 0, so getDocSeenAt() returned 0 for every doc and every card rendered an "อัปเดต" pill for activity that predates the person's access. Working as designed, and wrong as a product: joining an inbox should not mean inheriting a year of unread. The existing first-run pass only MIGRATED localStorage → server, so a brand-new user (nothing in localStorage either) got nothing. Fix: planSeenAtRows() — pure, unit-tested — splits the two cases. MIGRATE an existing reader's local map; BASELINE a reader with no history anywhere to "seen as of now". Gated on the server map being empty, because baselining someone who already has rows would mark their genuinely-unread documents as read. Two traps this hid behind, both worth the entry on their own:
- A sentinel set on a no-op poisons the next fix. The old code did
if (rows.length === 0) { setItem(sentinel); return; }— so every user who had opened the tab once was already flagged "migrated" with zero rows written, and would have skipped the new BASELINE branch forever. The key had to be bumped (…BulkMigrated→…BulkMigrated.v2). Only set a "done" marker for work you actually did, or version the marker when the rule changes. - Re-running a
merge-duplicatesupsert can move state BACKWARDS.bulkUpsertMyDocViewsposts withprefer: resolution=merge-duplicates, which OVERWRITESseen_at. Bumping the sentinel makes every established user re-run the pass, and any localStorage entry older than their server row would have re-flagged already-read documents.planSeenAtRowsnow emits a local value only when strictly newer than the server's. Also: the pass resolves AFTER the first paint, so it returns whether it wrote anything andindex.jsrepaints — otherwise the new reader still sees one screenful of pills until they reload. Where:src/js/projects/inbox.js(planSeenAtRows,migrateLocalSeenAtToServer,BULK_MIGRATED_SENTINEL_KEY),src/js/projects/index.js(repaint on change),src/js/projects/seen-baseline.test.js(9 cases). Rule: any per-user read/seen/ack state needs a defined answer to "what does a user who joins TODAY see?". The default — "has seen nothing" — is almost never it. And before comparing two accounts' views, check whether the difference is authorization or accumulated per-user state; they look identical in a screenshot.
Migrating a SHARED workflow account to a personal one moves the AUTHORIZATION but leaves every uid-bound row behind — read state, signature assignments, notifications
Symptom: after granting a personal kkumail account the staff seat, its หนังสือโครงการ inbox showed "1 ใหม่" but NOT the "1 อัปเดต" that sastaff shows on the same project. Looks like the seat isn't fully equivalent to the role. Cause: the two badges have completely different sources, which is easy to miss because they render side by side:
- "N ใหม่" =
docs.filter(d => d.status === 'sent').length— pure document STATUS, identical for every uni_staff viewer. It matched immediately. - "N อัปเดต" =
docHasUnseenBeyondStatusBadge()→getDocSeenAt()— PER USER, fromproject_doc_viewskeyed byuser_id. So the seat was working perfectly; what differed was accumulated per-user state. Worse, the first-run BASELINE (added the same day, see the entry above) had marked all 26 documents seen for the new account — correct for a genuinely new person, exactly wrong for someone taking over an existing workflow, where the point is to inherit the predecessor's pending work. Fix:tools/proj-handover.mjs— a dry-run-by-default transfer ofproject_doc_viewsfrom the shared account to the personal one. It REPLACES rather than merges: parity requires that a document the source has never opened has NO row on the target either, or its "อัปเดต" stays hidden. sastaff had 22 rows for 26 documents; a merge would have left 4 baseline rows masking 4 genuine unreads. Verified per-document afterwards: 9 unseen for sastaff, 9 for the target, 0 mismatches. The same class is WORSE forsa_prof(checked because the same migration is planned for อาจารย์): a signature request names ONEprof_id.scopeProjectsForRole()keeps only documents whosesign_requestsname the viewer, anddocPendingSignForProf()("N รอลงนาม") matches the same uid — so a migrated อาจารย์ account does not merely lose badges, it sees a completely EMPTY inbox. Measured: saprof 11 documents visible, a personal account 0. NEW requests are fine (list_project_profs()already returns rolesa_profORprof-seat holders, 0086), so only the pre-existing ones are stranded —--sign-requestsrepoints them, and it MOVES rather than copies because a request has exactly one professor. Where:tools/proj-handover.mjs; badge sources insrc/js/projects/inbox.js(renderProjectListRow,renderProjectCard,docPendingSignForProf); prof scoping insrc/js/projects/index.js(scopeProjectsForRole). Residual to know about:getDocSeenAt()falls back to a user-scoped localStorage map when the server has no row for a document, so if the target account had already opened one of the source's never-opened documents on that device, the local cache can still mask it. Clearprojects.docSeenAt.<uid>(or site data) on that device after a handover if a badge looks wrong. Rule: "granted the permission" ≠ "took over the job". Before migrating a shared account, enumerate every table with auser_id/ assignee column scoped to it — read state, assignments, notifications, drafts — and decide per table whether it COPIES (the shared account stays live) or MOVES (it is being retired). A permission grant migrates none of them.
Module-scope caches make an in-place account switch show two accounts at once — reload instead of teaching every module to reset
Symptom: switching accounts in the admin app leaves the previous account's data on screen — a stale projects list, the old shop state, a section the new account cannot open. Cause: the account switcher swaps the Supabase session in place (setAuthSession) and lets the onAuthChange subscriber repaint. But every feature module holds module-scope caches (cache.projects + the seenAt map, shop state, PR/VS lists, the team tree, initialSectionApplied) written for a page that serves ONE account for its lifetime. Nothing resets them, and the next module added will have the same gap by default. Fix: admin-main.js records bootUserId on the first signed-in fire; if onAuthChange later reports a DIFFERENT non-null id, location.replace(pathname) — hard reload, hash dropped (a deep link like #projects/PRJ-XXXX may be a section the new account cannot open) and no back-history entry into a page rendered for the previous account. Gated on bootUserId being set, so an ordinary first sign-in (null → user) does NOT reload, and on the id CHANGING, so the 25-minute token refresh — which re-fires with the same id — does not either. Rule: prefer one reload over N cache-reset call sites when identity changes underneath a long-lived page. The reset approach is correct exactly once and then rots with every module you add.
A path-only router silently discards sub-state — and its own tab handler is what clears the hash you just wrote
Symptom (reported): "when I'm in ติดตามสถานะ inside a ticket and I reload to see refreshed progress, it switches to กระดานปัญหา, and when I tap ติดตามสถานะ I have to โหลดประวัติของฉัน and tap the ticket again." Reloading is the natural way to check for progress on a ticket, so the app threw away the user's place at exactly the moment they most wanted it kept. Cause: the public site routes by PATH (PATH_ROUTES in main.js: /vssound → the VS tab). Everything below a tab — which of the three VS modes is showing, which ticket/problem is open — lived only in DOM state. Nothing persisted it, so a reload rebuilt the default (board) view. Fix: the hash carries the sub-state (#track, #track/VS-XXXX, #problem/VS-XXXX, #report) in a small vs-route.js. The hash was free — nothing else in the public bundle reads or writes it. Three traps that cost real debugging time:
- The path router clears the hash on every tab activation. Its
shown.bs.tabhandler doeshistory.pushState(null, '', tabToPath(target))— a bare pathname, so the hash is dropped. Leaving the VS tab and returning left the URL saying "board" while the DOM still showed ติดตามสถานะ, and a reload then obeyed the URL. Fixed by re-syncing the URL FROM the live view on re-entry (syncRouteFromView) — sync the URL to the view, not the view to the URL; the user's place is the thing worth keeping. It must run in asetTimeout(…, 0)because the path router's listener is registered LATER in main.js but fires in the same synchronousshown.bs.tabchain. - Every hash write fires
hashchange, which re-enters the router. Guard with alastWrittenvalue compared on the way in, plus anapplyingflag. - Never decide "is the user signed in" before
authReady. On a cold reloadgetUser()is null for a perfectly valid session, so restoring#track/<id>immediately would always take the signed-out path.await authReadyfirst, then fall back to the by-id guest lookup — which grants nothing new, since the id is already in the user's own URL. Second site, 2026-10-16 —/me, caught before shipping. The page's "เข้าสู่ระบบเพื่อดูข้อมูลของคุณ" prompt was toggled straight from theonAuthChangecallback, whose FIRST call is alwaysnull(it fires on subscribe, before the session restore). A signed-in person opening/medirectly was told to sign in for as long as the restore took. Now the prompt shows only onceauthReadyhas settled (syncMePagein main.js);tools/browser/me-page.mjswatches for the flash with a MutationObserver from the first byte, and goes red with the gate removed. AnonAuthChangenull is "not known yet" untilauthReady— any UI that SAYS "you are signed out" must wait for it.Also:replaceState, notpushState. The mode radios are a segmented control; one history entry per tap makes the back button feel broken. Where:src/js/vs-route.js; writers invs-form.js toggleVitalSoundMode,vs-tracking.js(openTicketDetail/trackWithTicketId/logoutTrack),vs-board.js(vsBoardOpen/vsBoardBack). Writers callwindow.vsSetRouterather than importing, to avoid a cycle (vs-route imports those modules). Rule: any view a user would REFRESH to update needs its identity in the URL. And when adding sub-state under an existing router, check what that router does to the URL on navigation — a handler that rewrites the whole path will erase it.
A snapshot table that COPIES a foreign resource id makes the original's delete path destroy history — count references, and count them AFTER the write
Symptom (designed out, not observed): adding the long-missing deleteTeamFile so a replaced/removed ทีม SAMO portrait stops orphaning in Drive. The obvious implementation — trash the file whenever the member's photo_url changes — would silently blank that person's card in a PUBLISHED ปีการศึกษา, months later. Cause: publish_team_term copies m.photo_url verbatim into team_archive_members. The archive is a snapshot of the ROW, but the photo is not copied — both rows point at the SAME Drive file id. So the live table does not own that file; it shares it. Deleting through one reference breaks the other, and the archive is exactly the thing that can never be regenerated. The live data hid this completely: at the time of writing there is 1 live photo and 0 archived, so shared_live_and_archive measured 0 — the mechanism is in place and produces the sharing on the next publish, which is the worst kind of latent bug (a query says you are fine, the code says you are not). Fix: deleteTeamPhotoIfUnused() in src/js/team/api.js counts references in team_members AND team_archive_members and only then calls the GAS delete. Two details that are the whole point:
- A failed count must not read as "no references."
live.error || archived.errorskips the delete — the recurring fail-open shape in this repo. - Call it AFTER the row is gone or repointed, never from the form action. Deleting on the นำรูปออก click would destroy a photo the DB still uses if the admin then cancels the editor. With the write committed first, the ref-count is simply the truth and needs no special-casing for "the row I am editing". Where:
src/js/team/api.jsdeleteTeamPhotoIfUnused;appscript/prform.gshandleDeleteTeamFile(guarded by the existingfileLivesUnderTop_(file, 'Team'), and adding no new Google service so the OAuth scopes are unchanged — see the re-consent entry above); call sites inteam/index.jsonMemberSubmit/onDeleteMemberandteam/terms.jsonArchivePhoto/ the archive delete. Rule: before adding a delete path for a row that references an EXTERNAL resource (a Drive file, an uploaded blob, an S3 key), grep for every table that copies that reference — a snapshot/archive/audit table usually copies the id without copying the resource. If one exists, the delete is a refcount, not a delete. And measuring the current data proves nothing when the sharing is created by a code path that has not run yet.
An allow-list feeding a BACKUP has the opposite safe default from one feeding a public projection — the same construct, inverted failure mode
Symptom: none observed — found by asking "what else enumerates team columns?" after adding two. buildExportJson (src/js/team/io.js) and the two create* calls in importJson (src/js/team/index.js) both list fields explicitly, and neither carried is_board, photo_url or photo_focus. So the export → restructure → re-import round trip that io.js exists FOR would have silently wiped every member portrait and emptied the whole คณะกรรมการ grid. photo_url had been missing since 0103; nothing failed, because nothing had been restored yet. Cause: this repo has trained itself, correctly, that a hand-built column list is the safe pattern — get_public_team_chart() names keys one by one precisely so a new column is NOT published by accident (0086/0103/0104), and returns setof <table> is banned for exactly the opposite reason (0079/0080). But the safe DIRECTION depends on which way the data flows:
| allow-list feeds | a column left out is… | correct default |
|---|---|---|
| a public projection | not published | omit (fail closed) |
| a backup / round trip | destroyed on restore | include (fail loud) |
Reaching for the projection habit on a serializer inverts the guarantee. And it cannot be caught by reading either file alone: export and import were internally consistent with each other — both simply forgot the same three columns. Fix: add the fields to both sides, and pin the key sets in src/js/team/io.test.js (buildExportJson round-trip fidelity) so the next column added to team_nodes/team_members fails a test instead of vanishing on a restore. Those tests immediately caught an error in my own expectation list (project_seat), which is the argument for writing them rather than re-reading the code. shop_source is deliberately still excluded — 0094 reverted shop scoping and the column is inert; that exclusion is a comment in the file, not an oversight. Rule: whenever you add a column to a table that has an export/serialize path, grep for every function that enumerates that table's fields and classify each by data direction. "It's an allow-list, allow-lists are safe" is not the analysis.
A scroll-to-top fix applied in the tab handler misses every link that navigates programmatically
Symptom (reported twice — the second time AFTER it had been "fixed" and shipped): "when i press ดูอัปเดตทั้งหมด in the เบื้องหลังการพัฒนา it jump to here it should jump to the top." The reader lands mid-timeline on /updates, several releases down. Cause: the fix lived in the shown.bs.tab handler and was guarded by if (location.pathname !== want) — "only scroll on a real page change", which is correct, and which is also false for half the callers. window.navigateTo() pushes the new path BEFORE activating the tab, so by the time the handler runs location.pathname already EQUALS want and the whole block is skipped. Clicking a nav pill goes through the handler's own pushState and scrolls; every link that calls navigateTo() — the ดูอัปเดตทั้งหมด CTA, the ฝ่าย tool links — does not. One rule, two code paths, and the path that was tested was the one that worked. Fix: navigateTo() scrolls to top itself when it actually moved. Back/forward is unaffected, because popstate calls applyPathRoute() directly rather than navigateTo(), leaving the browser's own scroll restoration in charge. Where: src/js/main.js window.navigateTo + the shown.bs.tab handler above it (which keeps its own copy, for the pill-click path). Rule: when a behaviour is attached to an EVENT but the state it tests is set by the CALLER, enumerate the callers. A guard reading location.* inside a handler is really asking "how did I get here?", and the answer differs per entry point — which is the routing-flavoured version of "authorization is per-PATH, not per-table".
An upsert that sends EVERY column wipes the ones the file did not have
Symptom (found by inspection, before it ran on real data): re-importing a corrected name-list that happens to omit the sai column would clear sai_code for every student in the file — and with it their house — while the preview said, truthfully by its own arithmetic, "แก้ไข 1,800".
Cause: toUpsertRow() emitted the full IMPORT_OWNED_COLUMNS list with row[c] ?? null, so a column the CSV never contained arrived as an explicit null. PostgREST's resolution=merge-duplicates builds its ON CONFLICT DO UPDATE SET from the keys present in the body, so an explicit null is a write and an absent key is a no-op — the difference between "this person has no ชื่อเล่น any more" and "this file does not talk about ชื่อเล่น". The parser knew which columns were in the header and threw that away.
The preview could not have caught it either: diffAgainstExisting() compared the same full column list, so the columns about to be destroyed were counted as ordinary changes. Both halves used the same wrong set, so they agreed.
Fix: parseStudentsCsv() now returns presentColumns (the import-owned columns actually in the header); toUpsertRow(row, batch, present) emits only those plus kkumail (the conflict target), and diffAgainstExisting(rows, existing, present) compares only those. A column IN the file but empty on a row still writes null — that is a real clear, not a gap. Pinned by three tests in house/io.test.js, including the "does not report a change it will not make" one, because a preview that over-reports is how the destructive version looked correct.
Where it lives now: src/js/house/io.js (parseStudentsCsv, toUpsertRow, diffAgainstExisting), src/js/house/index.js (runImport, onCsvPicked), src/js/house/io.test.js.
Rules: (1) In an upsert, absent and null are different words. Send a column only when the source actually said something about it. (2) A PREVIEW and the WRITE it previews must be computed from the same scope — and when they are both wrong in the same way they will still agree, so the test has to check the preview against the world, not against the writer.
An export that carries a GENERATED column re-imports as the real one
Symptom: the students CSV export included nickname, which is coalesce(nickname_self, nickname_imported) in Postgres. The importer resolves a nickname header to nickname_imported. So export → re-import promoted every student's SELF-chosen ชื่อเล่น into the university's column, permanently — the one thing nickname_imported / nickname_self were split apart to prevent.
Cause: two vocabularies for one file. The export wrote the TABLE's column names; the import canonicalised to its own spellings (sai, nickname_th), so nobody could see by reading either list that nickname meant different things at the two ends.
Fix: one vocabulary — the schema's. CSV_COLUMNS now canonicalises to nickname_imported / sai_code, and every friendly spelling the world sends (sai, nickname_th, ชื่อเล่น, อีเมล) is an ALIAS resolved at the door. The generated nickname left the export. house is generated too and stays, because the importer has no alias for it, so it round-trips as an ignored column — which is the actual test: not "is it derived" but "would the importer read it back as something else".
Where it lives now: src/js/house/io.js (CSV_COLUMNS, HEADER_ALIAS, EXPORT_COLUMNS), src/js/house/io.test.js.
Rules: (1) An export meant as a backup must be re-importable by the importer that exists, and that is a TEST, not an intention. (2) Accept the world's spellings as aliases; keep exactly one of your own. Two canonical vocabularies for one field is the drift class this repo pays for most (class 6).
Stripping a คำนำหน้า off a name renames the people whose name STARTS with one
Symptom (reported by the owner reading the spec, before any file was imported): "you shouldn't strip นาย / นางสาว — some people have นาย in their names." Correct: นายก would have been stored as ก, นางาม as าม.
Cause: the importer treated a leading นาย / นาง / น.ส. as a title and cut it, on the theory that this app dropped its คำนำหน้า column (0113) and "นายสมชาย" is not a first name. The theory is right about the common case and silently wrong about the rest — นาย and นาง are the openings of real Thai names, and a whitespace-free prefix carries no signal that separates the two. Nothing downstream could ever detect the rename: the row looks like an ordinary name that happens to be short.
This is the SAME class the same file already refuses elsewhere. parseStudentsCsv rejects a combined "ชื่อ-สกุล" column rather than splitting it on whitespace, with the reasoning written out — and then stripped a prefix two functions later. A rule was applied in one place and its opposite in another, in one file.
Fix: looksTitled() replaces stripTitle() — it REPORTS the match as a per-row warning and changes nothing. A file where every row is titled is a file to send back, and one line per row is how a human notices that. The spec now says plainly that we will not fix it, and why.
Where it lives now: src/js/house/io.js (looksTitled), src/js/house/io.test.js, docs/house-data-spec-th.md.
Rules: (1) Never normalise a person's NAME by guessing. Case, whitespace and digits have canonical forms; names do not. Report and keep. (2) When you have already written "we refuse to guess here, because guessing renames people", grep the same file for the other places it guesses — the reasoning generalises even when the code did not. (3) kkumail is the counter-example that proves the line: lowercasing it is not a preference but a requirement (a plain UNIQUE index plus lower()=lower() lookups, enforced by a table trigger since 0119), and it misidentifies nobody because the comparison was already case-insensitive.
"แก้ชื่อในหน้าตัวเอง แล้วชื่อ-นามสกุลในระบบบ้านสลับกัน" — one module split a name on whitespace while another refused a whole file for it
Symptom: nobody reported it, which is the worst part. A person whose ระบบบ้าน record correctly held first_name_th = 'สมชาย ใจดี', last_name_th = 'ดีมาก' found it silently rewritten to first_name_th = 'สมชาย', last_name_th = 'ใจดี ดีมาก' — the first time they saved ANYTHING on their own card, including an unrelated ชื่อเล่น edit. students.self_edited then recorded first_name_th as a value the person had chosen, so the next import preserved the corruption on their behalf.
Cause: src/js/my-seat.js offered one combined ชื่อ-สกุล box, because team_members.full_name was one column. On the way to ระบบบ้าน — which stores the split — it did this:
js
const [first, ...rest] = String(body.full_name || '').trim().split(/\s+/);
...(rest.length ? { first_name_th: first, last_name_th: rest.join(' ') } : {})and update_my_identity passed the patch into update_my_student_record, which writes both columns unconditionally. A comment above it claimed "the server prefers the students row's own split when it has one" — true of the ทีม SAMO direction it was written about, and false of the direction it was on.
src/js/house/io.js REFUSES an entire CSV for making exactly this guess, with the reasoning spelled out (_combined_name: "สมชาย ณ อยุธยา" and "สมชาย ใจดี ดีมาก" both have three tokens and different answers). Two implementations of one rule, and one of them was the negation of the other.
Fix: migration 0135 gives team_members the same first_name_th / last_name_th split and derives full_name from it, so no caller has anything to reconstruct. The card has two boxes; a row that has only a combined name keeps it and shows it beneath the empty boxes. Nothing is backfilled — a row acquires the split when a human types one.
The paragraph was not the fix. src/js/name-split.test.js walks every module under src/js and fails the build on a .split( within three lines of first_name_th / last_name_th, and pins the card's editable field list against team_members_self_update_guard's SQL allow-list so the two lists cannot drift.
Where it lives now: supabase/migrations/0135_team_names_split_too.sql, src/js/my-seat.js (DETAIL_FIELDS, displayFields), src/js/team/index.js (readMemberName), src/js/name-split.test.js, tools/team0135-name-split.mjs.
Rules: (1) Store the PARTS, derive the WHOLE, never split an existing whole. (2) A rule enforced at one boundary and violated at another is the default outcome, not the unlucky one — when a module refuses to guess, grep for every other module that consumes the same field. (3) A guess that writes to a column tracked as "the user's own choice" is worse than a guess: it launders itself into consent.
"this person is the same person but it detects wrong because no email" — a single-pass identity key made "unknown" mean "different"
Symptom: ตรวจสอบข้อมูล reported รหัส 663070019-9 2 คน for ชญาภา เลาหะตานนท์ — one posting carrying chayapa.l@kkumail.com, the other carrying no address at all. One human, reported as a รหัสนักศึกษา clash between two.
Cause: identity.js documents its rule as "rows with NO kkumail sharing a รหัส → one person", and implemented it as a single-pass key:
js
const keyOf = (r) => (r.em ? `e:${r.em}` : r.sid ? `s:${r.sid}` : `r:${r.id}`);The row WITH the address is keyed by the address, so a no-address row can never reach it — rule 2 only ever grouped no-address rows with each other. The documented rule and the code disagreed, and the comment was believed.
Underneath that: treating an ABSENT identity as a DISTINCT one. A cell holding nothing (or the live -) is not a claim to be a different human; it is the absence of a claim. Counting it as a second person is the fail-open direction (class 2).
Fix: two passes. Build sid → {emails that claim it} first, then a no-address row joins that person iff exactly one email-person claims its รหัส. Two claimants leaves it separate — that ambiguity is the finding.
The safety property is preserved and now tested: 0108's 673070332-6, one mistyped รหัส worn by two humans, has an address on BOTH rows, so rule 2 never looks at them and the clash is still reported.
Resolving them also had to not become silence. A posting with no kkumail is still broken — every resolver in this app joins team_members.kkumail, so it is invisible to the person's own card and to every permission lookup — so it now raises mail_gap, the rare finding with one obviously correct answer and a one-click apply.
Where it lives now: src/js/team/identity.js (rule 2 as a second pass, mail_gap), src/js/team/health.js (the card + data-hfillmail handler), src/js/team/health.test.js (six cases, including both directions of the ambiguity).
Rules: (1) When a comment states a rule the code cannot implement in one pass, the comment is a plan, not a description — check it against an example. (2) Absent ≠ different. An unresolvable identity must not be counted as a distinct one. (3) When a merge silences a finding, check that the thing being merged is not itself broken — resolving it correctly must not turn a wrong finding into no finding.
The checkout form kept the PREVIOUS account's email after an in-place account switch — and the new recap asked the buyer to confirm it
Symptom: Found by review, not by a report. Switch accounts with the Gmail-style switcher (no page reload), open SAMO Shop checkout, and the ชื่อ/อีเมล/เบอร์ fields still hold the person who was signed in before. Cause: src/js/shop/checkout.js keeps buyerName / buyerEmail / buyerPhone at MODULE SCOPE, prefilled as if (!state.buyerEmail) state.buyerEmail = user.email || ''. That guard exists for a good reason — a typed edit must survive the re-render every slip upload triggers — but it never overwrites, and account-switch.js has no location.reload, so the module state outlives the account it was filled from. onAuthChange re-renders checkout, which re-runs a prefill that declines to change anything. Same class as the module-scope caches entry above it. Why it got worse before it got better: the checkout had just grown a contact recap above the confirm button. That turned a stale default into the thing the buyer is explicitly asked to approve, and placing the order writes a stranger's address as the only channel staff have for slip and pickup. Fix: applyBuyerPrefill(state, user) — one exported function with two rules that pull in opposite directions, which is why "just always overwrite" is also wrong: when user.id differs from the prefillUid the state was filled from, replace ALL THREE fields (typed or not); when it is the same account, fill only what is empty, so edits survive a re-render and a placed order re-prefills. Where: src/js/shop/checkout.js (applyBuyerPrefill, state.prefillUid), guarded by src/js/checkout-prefill.test.js (7 cases, including the switch-with-a-typed-value case and an account with NO email — the anonymous route, where inheriting the previous address is the most misleading outcome). Rule: a "fill only if empty" prefill is correct only while the IDENTITY behind it cannot change. The moment an app can switch accounts without a reload, every such prefill needs to remember WHOSE data it holds — the emptiness of a field is not a proxy for the freshness of it. And when a value that was previously a quiet default becomes something the user must confirm, re-examine where it came from: the display change did not create this bug, but it raised the cost of it from "wrong default" to "you approved a stranger's address".
An INSERT is a write path too — the import guard covered UPDATE only
Symptom: none yet, because the roster file has not landed. Found by asking "will there be an issue when data from dataanalytic come, or people edit their names". Measured on a rollback transaction:
people : ชื่อที่เจ้าตัวกรอก นามสกุลจริง ← what the person typed
students : ชื่อจากไฟล์ นามสกุลจากไฟล์ ← what the file said
linked : yes conflicts: 0Cause: 0125's students_keep_self_edits and both of 0138's hooks are before update. For the ~380 ทีม SAMO members who are not yet in ระบบบ้าน, the import does not UPDATE their placement — it INSERTs it. On that path self_edited is empty (the row is new), so nothing was protected, the file's spelling won, and the registry silently disagreed with the placement pointing at it. The person's own card reads students, so their edit was simply gone.
Class 4, fourth instance: a fix applied to one PATH is not a fix.
Fix: the reconciliation moved into students_link_person — one BEFORE INSERT trigger that resolves the person and then reconciles against them, in that order. Folded into the existing function rather than added beside it, because two triggers on one event fire in NAME order and a rule that depends on nobody renaming a trigger is not a rule. An import keeps the registry's value and records a conflict; a human-created row wins and now mirrors UP, which an INSERT never did either.
Where it lives now: supabase/migrations/0139_an_insert_is_a_write_path_too.sql, tools/house0139-insert-path.mjs (10/10).
Rules: (1) Enumerate INSERT, UPDATE and DELETE separately for every invariant — "the write path is guarded" is only ever true of the one you looked at. (2) A trigger whose correctness depends on firing after another trigger should be the same trigger.
"in next next week, it still show ใช้ไปแล้วจริง value, which it would be reset by then"
Symptom. Browsing forward on the จองโควตา Claude board, the week card kept reading "287 / 700% ใช้ไปแล้วจริงสัปดาห์นี้" — a measured figure — on weeks that had not started, whose pool will have reset once or twice before they do.
Cause. Two different SCOPES reading one payload. The pane has a hero panel that means right now and a week card that means the week the arrows landed on. right_now.week was built to serve the hero, and the card was pointed at it too, so every number in the card silently became a fact about today. It was correct on the current week — which is the week it was built and tested on — and wrong on every other.
Fix. The card's three numbers are now scoped to the week being drawn, in SQL: measured_used_pct is the newest sample INSIDE [week_start, week_end), reserved_pct counts only that week's unfinished bookings, and is_current lets the panels that genuinely mean "now" say so. A future week measures NULL and the card falls back to the booked ledger with a label that says which quantity is on screen.
NULL, not 0 — that is the load-bearing half. A future week returning zero draws an empty bar and reads as "nothing used yet", which is indistinguishable from a real reading and is the same bug wearing a plausible number.
Where it lives now. get_claude_board() in supabase/migrations/0156_claude_the_week_card_describes_the_week_on_screen.sql; paintWeekMeter() and paintMeasured() in src/js/claude/index.js.
The general rule. When one payload serves two panels with different time scopes, the narrower scope has to be in the payload — not assumed by whoever reads it. "Now" and "the period on screen" agree exactly while you are looking at the current period, which is when the feature is built, demonstrated and reviewed. The disagreement only appears when someone presses the arrow.
"I'm looking from เจ้าหน้าที่คณะ and I don't see file highlighting anymore"
Symptom. After the shared sastaff login was retired and the work moved to a named person's own account, the new account saw no "ใหม่" pill on any attached file and no blue "อัปเดต" badge anywhere in กล่องจดหมาย. Reported as "is this a bug, are there many things missing more?"
Cause. Not a bug — the BASELINE rule, working. planSeenAtRows() in src/js/projects/inbox.js gives a user with no seen history anywhere (no project_doc_views rows, no localStorage map) one bulk write marking every currently-visible หนังสือ as seen RIGHT NOW, so a newly-granted person does not inherit a year of unread. fileNewnessForViewer() then flags a file only when uploaded_at > seenAt, and every existing file predates the baseline.
Measured on the live DB rather than reasoned about:
sql
count(*) filter (where f.uploaded_at > coalesce(dv.seen_at, '-infinity')
and f.uploaded_by is distinct from v.id)→ woratho@kku.ac.th (the successor, account created that morning): 0 of 91 files could highlight, and its 43 project_doc_views rows all carry the same first-open timestamp. The owner's own long-lived account: 40. That single query is what separates "the highlight code broke" from "there is nothing to highlight".
The inconsistency that IS worth knowing. The purge REASSIGNED the predecessor's project_notifications (the successor opens with 77 unread) but could not carry over project_doc_views — those rows were FK-deleted with the old account, so the baseline filled them as seen. The same person is therefore told "77 things you have not read" by the bell and "you are caught up" by every per-document highlight. Two systems answering "what have you seen".
Where it lives now. planSeenAtRows() + fileNewnessForViewer() in src/js/projects/inbox.js; seen-baseline.test.js.
The general rule. A successor account inherits AUTHORIZATION, not READ-STATE — and a read-state system that baselines on first sight is indistinguishable, from the user's chair, from one that is broken. When someone reports a highlight missing, the first question is not "which code path draws it" but "how many rows currently QUALIFY for it, for this exact user id". Ask the database before reading the renderer.
A DELIBERATE omission in the account purge cost 42 of 43 comments their edit button
Symptom. Asked to check whether the three project desks (ผู้ส่งหนังสือ / เจ้าหน้าที่คณะ / อาจารย์) had lost any highlighting when the shared samomdkkuvpa / sastaff / saprof logins were retired. The highlights were fine. What was gone: the แก้ไข and ลบ buttons on 42 of the 43 comments in the system — for everyone, in every role.
Cause — and this is the part worth reading: it was NOT an oversight. A person's id is stored in this schema in two shapes:
| shape | example | reassigned by the purge? |
|---|---|---|
| a uid COLUMN | project_files.uploaded_by, project_documents.created_by, project_sign_requests.prof_id | ✅ all of them |
| a uid INSIDE a JSONB array | project_documents.timeline[].by, project_sign_requests.timeline[].by | ❌ none — on purpose |
tools/purge-shared-project-accounts.mjs says so in its own header, under "WHAT IS NOT REASSIGNED, deliberately": "Rewriting history to say someone did something they did not do is worse than the two people no longer being able to edit an old shared-account comment." That is a real trade-off, honestly made and written down.
What made it the wrong call was the SIZE of the cost, which nobody had measured. isMineComment in src/js/projects/inbox.js is c.by === myId and is the only thing that renders a comment's edit/delete controls — so the price was not "two people cannot edit an old comment", it was 42 of the 43 comments in the entire system, uneditable and undeletable by every account. The same comparison in the signing section (e.by !== myId) scored a person's own past actions as somebody else's. Shown that number, the owner reversed the trade-off in one sentence.
So the defect is not the decision, it is that the decision was recorded and never re-costed. The note says what would be lost; it does not say how much, and the number is one query away.
Why the proof said the purge was clean. proj0165 §D4 read "no project_files row is left unattributed by the purge" and asserted uploaded_by is null. A uid pointing at a DELETED account is exactly "unattributed" — and is null cannot see one. The prose was right and the predicate was narrower than the prose, on a table that was not even the one holding the problem.
Fix. Migration 0166 remaps the three retired uids inside both timelines to the same people the COLUMN pass already chose (read back out of the columns, not invented), with a full _timeline_backup_0166 snapshot, in-transaction assertions in both directions (nothing unresolvable left AND no entry lost against the snapshot), and the rollback in the file. proj0165 §D4/§D5 now ask whether the uid RESOLVES, and §D7/§D8 scan both timelines with a control that fails if the scan sees no uids at all. Falsified by corrupting one entry inside the proof's own rolled-back transaction and watching D7 go red.
What was NOT affected, checked for all three roles: every highlight keys off the entry's role string (vp_admin / uni_staff / sa_prof), which the successors still resolve to through their seat — so the blue "อัปเดต" badge, the ใหม่ / ตีกลับ status badges, comment unread, ของฉัน and รอลงนาม never broke. docPendingSignForProf and isMine are role-based too.
The general rule. A documented trade-off is not a closed question — it is an estimate with no number in it. "Two people lose an edit button" and "42 of 43 comments lose it for everyone" are the same sentence until somebody counts, and the count was one query. When a note explains what a decision COSTS, measure the cost before trusting the note; when you WRITE one, put the number in it.
Underneath that: a uid stored in JSON is a uid, and a migration that walks the columns will not see it. When an account is deleted or merged, enumerate every SHAPE the id is stored in — column, JSONB array, array column, text note — not every TABLE. And a referential guard must ask whether the id RESOLVES, never whether it is null: the null case is the one the purge was least likely to leave behind.
"why does the week still say 61% used when nothing has measured it for four days"
Symptom. Not reported by a person — found while designing the pause switch, which is the only reason it is written up as a near miss rather than an outage. The จองโควตา Claude hero panel ("ใช้ได้เลยตอนนี้") and the rail both derive week.left_pct from Claude's measured 7-day utilization. The reporter on the VM had been dead since 21 Aug; the board went on printing the remainder computed from the last successful poll, and would have gone on printing it across the Wed 16:00 quota reset — a week whose pool is 700% again reported as ~61% spent, for ever.
Cause. claude_free_now() read the measurement as
sql
select * into v_sample from public.claude_usage_samples
order by sampled_at desc limit 1;with no freshness bound and no week bound. Correct every day it was tested, because the newest sample was always fifteen minutes old.
The 5-hour half of the same payload was fine, and the contrast is the lesson: every reader of five_hour_resets_at tests > now(), so a frozen 5-hour window falls out of play by itself within five hours. Nothing made the 7-day half expire, because nothing had asked what happens when sampling STOPS. Three separate conditions produce that — an admin pausing it, a dead timer, an expired credential — and only the first is a decision anyone makes.
The same defect had already been fixed one card up. 0156 scoped the week CARD's measured_used_pct to [week_start, week_end) after "in next next week, it still show ใช้ไปแล้วจริง value" was reported. The HERO was left alone because the hero means "right now" and the fix was framed as being about which WEEK is on screen — so the panel that most needed a time bound was the one the fix reasoned itself out of.
Fix. Migration 0167. One function, claude_latest_sample(), returns the newest sample only while it can still describe now: not when measurement is switched off, and not once the sample is older than claude_settings.sample_stale_minutes (45 = three missed ticks). It returns NO ROW rather than a zero, so every caller lands in the v_wk_left := null branch that has shipped since 0155 for "the reporter has never run" — a designed, tested path rather than a new one. All four "as of now" readers were moved onto it in the same migration, including claude_free_windows(), where a stale value was already inert; leaving that one is how the second copy starts.
Where it lives now. claude_latest_sample() in supabase/migrations/0167_claude_measurement_can_be_switched_off.sql; the JS half reads the same threshold out of board.settings.sample_stale_minutes (src/js/claude/monitor.js, staleAfterMs) instead of the 35 minutes it used to hardcode. Proof: tools/claude0167-monitoring-switch.sql §A, which tests the AGE rule with the switch left ON precisely so the fix cannot degenerate into a special case for the switch. Falsified by restoring the unbounded read: A2, A4, B2 and B5 go red and report the frozen 350.0.
The general rule. A measurement that means "right now" must expire, and the bound belongs on the READ. A cache with no TTL is recognisable; a database column with no TTL looks like a fact. Ask of every "latest reading": what does this return when nothing has written for a week — and if the answer is "the last one, indefinitely", the reading has no clock and something downstream is about to state it as current. The tell is that the bug is invisible while the writer is healthy, which is every moment you are testing.
A trailing slash matched no route and landed on the home tab, silently
Symptom. /tools/golden-period/ showed the landing page. So did /shop/, /team/ and every other route with a slash appended — which is what a pasted or auto-linkified URL very often has.
Cause. pathToTab() matched PATH_ROUTES by exact string equality against the raw location.pathname. '/shop/' !== '/shop', so the lookup missed, the /news/ regex did not apply, and the function returned null — and a null route falls through to the landing tab. Nothing errored, nothing logged; the app simply showed the wrong page, which reads to a student as "the link Bank sent me is broken".
It had been latent for every route. Adding the first NESTED path made it likely rather than theoretical: people paste /tools/golden-period/ far more readily than /shop/.
Fix. Normalise once at the top of pathToTab — pathname.replace(/\/+$/, '') || '/' — and match the NORMALISED value in every branch, including the /news/ regex. Guarded by route-normalise.test.js, which asserts both halves: that the strip exists, and that no branch still tests the raw pathname (fixing only the exact-match branch would have left /news/x/ broken while /shop/ worked).
Where it lives now. pathToTab() in src/js/main.js.
The general rule. A lookup keyed on a URL must normalise before it compares, and a routing miss must not resolve to a valid-looking page. Falling through to a landing tab is indistinguishable from a working link to the wrong place. Where a miss is possible, make it visible.
computeCensus() and computeGaps() classified the same held row differently on a whitespace-only cell
Symptom. Nothing observable yet — found in a revision self-review, not reported. computeCensus()'s heldSelf/heldAdmin split (the ภาพรวม tab's 165/152/13 numbers) and computeGaps()'s identical-sounding split (the ข้อมูลไม่ครบ tab's own 13) could in principle disagree for the exact same held row, which would put two Coordinated Numbers On One Screen back into open contradiction — the failure census.js's own file header was written to prevent.
Cause. computeGaps() (src/js/house/gaps.js) tests presence with plain JS truthiness: !h.student_id. computeCensus() (src/js/house/census.js) tests presence with its own has() helper, which additionally trims whitespace: typeof v === 'string' ? v.trim() !== '' : v != null && v !== ''. For every value seen in production so far — a real string, null, or '' — the two agree, so 41+41 tests across the two modules stayed green. They diverge only for a string that is non-empty but blank after trimming (' '), which a hand-edited spreadsheet cell can absolutely produce and neither fixture set happened to include.
gaps.js already exported splitHeld() — extracted in the same session specifically so "any OTHER reader classifies a held row the same way computeGaps does, rather than re-deriving the same two-line rule" — but census.js was never pointed at it; it kept a second, textually-similar copy written before splitHeld() existed, and the export's own docstring claim ("recomputed from the same rule so the two can never drift") described what the comment intended, not what the code did.
Fix. census.js now imports and calls splitHeld() from gaps.js instead of re-filtering held itself. Guarded by a differential test in census.test.js that builds a held row with student_id: ' ' and asserts computeCensus()'s counts equal splitHeld()'s own counts for the same row — reverting the import (restoring the local has()-based filter) turns that assertion red, confirmed by reintroducing the old code and watching it fail before restoring the fix.
Where it lives now. splitHeld() export in src/js/house/gaps.js; consumed by computeCensus() in src/js/house/census.js; differential guard in src/js/house/census.test.js.
The general rule. A comment claiming "recomputed from the same rule" is not the same rule unless it is the same function call. Two textually-similar filters pass every test built from the values already in hand and diverge only on a value neither fixture set thought to include — write the second call site as an import, never a paraphrase, even when (especially when) the paraphrase is two lines.
sai-grid.js's held-row split was a paraphrase of splitHeld(), not a call to it
Symptom. Nothing observable yet — found in a second revision pass over the same night's branch, immediately after the computeCensus() fix directly above. No live numeric disagreement: src/js/house/sai-grid.js's inline (!r.student_id || !r.first_name_th) ? heldAdmin : heldSelf happened to be byte-for-byte the same predicate splitHeld() used, so every test in both files still passed.
Cause. sai-grid.js's own file header (written the same session as computeSaiGrid()) claims: "reusing gaps.js's OWN two-tone split of a held row rather than inventing a third interpretation of it." The code never called splitHeld() — it kept a second, hand-typed copy of the same two-line rule instead of importing it, exactly the shape the computeCensus() entry above generalises from ("recomputed from the same rule" is not the same rule unless it is the same function call). The two only agreed because nobody had touched either copy since splitHeld() was extracted.
Fix. sai-grid.js now imports splitHeld from gaps.js, builds a Set of the heldAdmin rows by object identity (identity survives through groupOccupantsByCohort, since both read the same held array references), and classifies each held occupant by set membership instead of re-running the predicate. Guarded by a differential test in sai-grid.test.js that computes splitHeld() on a fixture set directly and asserts every cell in the grid agrees with it — not a hand-picked expected value, so it also survives a future change to splitHeld()'s own predicate. Verified the guard catches drift, not just disagreement with itself: simulated a future splitHeld() predicate change (added whitespace-trimming) with the inline copy reinstated, watched the new test go red (held_self vs expected held_admin), then restored both files and reran green.
Where it lives now. splitHeld() export in src/js/house/gaps.js; consumed by computeSaiGrid() in src/js/house/sai-grid.js via a heldAdminSet; differential guard in src/js/house/sai-grid.test.js.
The general rule. A file header claiming reuse is not proof of reuse — grep the function name at the call site. This is the SAME instance of the rule above, found in the sibling file the first fix didn't check: extracting a shared predicate into one module doesn't retire the copies already sitting in every OTHER module that needs it. When a class-6 fix lands, grep the codebase for the predicate's literal text, not just the one file that triggered the fix.
sai-grid.js's numeric-สาย grouping was a third re-typing, and its own comment named the wrong gaps.js group
Symptom. Nothing observable yet — found reviewing the same night's สาย-grid feature after the two splitHeld() entries above, once for the OTHER shared rule this module needs: "group these rows by numeric สาย, per รุ่น." computeSaiGrid() built its own bySai map with a hand-typed Number(saiOf(r)) / Number.isFinite / n <= 0 loop — textually identical to the loop computeGaps() already ran per cohort to build sai_gap (missing numbers in a รุ่น's own 1..max sequence) and sai_shared (more than one occupant on one number), but never calling either. Same shape as the splitHeld() bug: two copies agreeing only because nobody had edited either since the code was written.
Separately, computeSaiGrid()'s own file header mis-cited which gaps.js group its empty state matches: it claimed "exactly gaps.js's own sai_empty guard." Tracing the data model shows this is wrong — sai_empty reads the separate sais table, a flat code registry with no cohort column, shared across every รุ่น (ensureSais()'s own comment: "สาย are NOT a seeded range"); it asks whether a globally-declared code has zero occupants system-wide. computeSaiGrid() never receives d.sais and cannot ask that question — its empty cells are a hole in ONE รุ่น's own numbering, which is the sai_gap concept, not sai_empty. A reader trusting the comment to reason about why a cell went "ว่าง" would look in the wrong panel.
Cause. The grouping loop was written directly in sai-grid.js before anyone asked whether gaps.js already had the same math (it did, inline in computeGaps()'s per-cohort loop, just never extracted). The mislabelled comment was a guess made confident-sounding by proximity to the correctly-cited splitHeld()/groupOccupantsByCohort() reuse claims two paragraphs above it.
Fix. Extracted groupBySaiNumber(rows) from computeGaps()'s inline loop into an exported function in gaps.js, docstring cross-referencing both callers the way splitHeld()/groupOccupantsByCohort() already do. computeGaps() and computeSaiGrid() both call it now; sai-grid.js's own saiOf copy (now unused) was deleted rather than left dead. The header comment was corrected to name sai_gap/sai_shared, not sai_empty, and to say explicitly that computeSaiGrid() has no sais-table data to ask that question with. Guarded by a differential test pair in sai-grid.test.js that runs computeGaps() and computeSaiGrid() on the identical fixture and asserts the grid's empty/duplicate cells equal computeGaps()'s own sai_gap/sai_shared rows for that รุ่น — plus a non-vacuous control on each, since a differential assertion with an accidentally-empty expected list passes for the wrong reason.
Where it lives now. groupBySaiNumber() export in src/js/house/gaps.js; consumed by computeGaps() and by computeSaiGrid() in src/js/house/sai-grid.js; differential guard in src/js/house/sai-grid.test.js.
The general rule. The third copy of a rule is not a coincidence, it is a pattern — when a fix lands twice in one file for one shape (paraphrasing a shared predicate instead of importing it), audit the SAME file for every other inline loop that could be the next caller of the thing just extracted, not only the predicate that triggered the fix. And: a comment naming which sibling concept a state "matches" is a factual claim about a DIFFERENT function's inputs — check what that function actually receives before citing it.
The ผังตามสาย legend was a fourth copy of the same rule — hand-typed HTML this time, not JS
Symptom. Nothing observable yet — found on a later revision pass over the same night's สาย-grid feature, after the splitHeld() ×2 and groupBySaiNumber() entries above had each fixed a JS module re-deriving a rule instead of importing it. The legend beside the grid (src/html/tab-house.html) was five <span class="badge ..."> elements typed by hand — the same colour class, Bootstrap icon name and Thai label each cell already gets from SAI_CELL_STYLE in src/js/house/index.js. They agreed only because nobody had touched either side since the grid was built the same night; a future change to one state's colour or wording in SAI_CELL_STYLE (the kind of edit a UI tweak makes without re-reading the HTML it sits beside) would have left the legend describing colours the cells no longer use.
Cause. The legend was written as static markup at the same time as the grid's cell renderer, in the same commit, by the same author, from the same mental model — so it started in agreement and there was nothing in the diff to suggest the two had ever been separate facts. Unlike the two splitHeld() bugs above, this one was never a bug that could fire from bad DATA (a whitespace cell, a future predicate change) — it can only fire from a future EDIT to one side and not the other, which is exactly what the three splitHeld()/groupBySaiNumber() write-ups above say to check for once a class-6 fix lands in a file: grep for every OTHER place the same rule could have been re-typed, not only the one that triggered the first fix. Nobody had checked the HTML.
Fix. Replaced the five hand-typed badges with an empty <div id="houseSaiGridLegend"> and a new renderSaiGridLegend() in index.js that builds the legend from SAI_CELL_STYLE itself, in a SAI_LEGEND_ORDER array (mild → severe, the order a reader scans the legend in — deliberately not the same order as SEVERITY in sai-grid.js, which is worst-first for picking one cell's own colour when it has several occupants). Called from renderSaiGridPane() on every paint. The "more than one occupant" badge stays separate — it answers a different question than the five states do, so folding it into the same map would be its own false unification. Guarded by src/js/house/sai-grid-legend.test.js, a source-text differential test (no jsdom in this repo, same constraint the wireClaim/api.test.js guards in this feature already work under): it asserts the legend function reads SAI_CELL_STYLE rather than literal strings, that the state count in SAI_CELL_STYLE matches the count in SAI_LEGEND_ORDER, and that the old hand-typed badge markup is gone from the HTML. Verified the guard catches its own bug: reintroduced two of the five original hand-typed badges in the HTML, reran — 2 of 5 assertions went red as expected (the "not hand-typed" check and the "mount point exists" check); restored and diffed byte-identical against the pre-mutation file before trusting the result, then reran clean.
Where it lives now. renderSaiGridLegend() + SAI_LEGEND_ORDER in src/js/house/index.js; empty mount point in src/html/tab-house.html; guard in src/js/house/sai-grid-legend.test.js.
The general rule. A class-6 fix in one file's JS does not mean the rule has one home yet — the SAME night can produce a fourth copy in markup, not code, written by the same hand that just fixed the third one, because "I just wrote both, they obviously agree" is exactly the moment a rule is most likely to get a silent second definition. After extracting a shared predicate, check every RENDERER of its output, not only every OTHER computer of the same input — a legend, a printed report, an export column can each independently restate what a style map already says.
Shop admin: a save that awaits, then reads "the current thing", writes to whatever is current NOW
Symptom (found in the 2026-09-22 shop sweep): four admin paths read a module variable AFTER an await:
- The order modal (
modalOrder): closing order A and opening B during A's note save copied A's notes onto B's row and nulledmodalOrderwhile B was open. An item save followed by Esc threw onmodalOrder.id, AFTER the item was saved, so the total was never recomputed. - The PromptPay QR editor: an upload finished into "whichever account editor is open now", which put A's QR on account B, so B's buyers paid A.
- The product editor: it re-rendered from saved state on image pick, size/colour change or reload, discarding every typed field and stock number.
- The stock tab: it cached each product's grid for ever and wrote the stale grid back.
Cause: state named by "current", read after the moment it was current.
Fix: each handler captures its object before the first await, and redraws or writes only if that object is still the current one. readProductForm() is the one reader behind both save and collectProductEditorState(). The product save sends stock_matrix only if the grid was touched, and the stock tab drops unedited cache entries on reload. The verify queue writes only if updated_at still matches what was on screen (expectUpdatedAt).
The general rule: after an await, "current" is a different question. Capture the object you are acting on, and compare identity before you touch the screen or the row.
A student's ชื่อเล่น could run as a formula in an admin's spreadsheet — the ทีม SAMO and ระบบบ้าน exports had no CSV formula guard
Symptom (found 2026-09-23 checking HANDOFF §18b's hypothesis, not reported by a user): the shop's order CSV had been fixed on 2026-09-22 to neutralise cells starting with = + - @ (Excel and Sheets run those as formulas). The ทีม SAMO members export (team/io.js) and the ระบบบ้าน students export (house/io.js) had their OWN csvCell, which only quoted — and they carry text students type themselves (ชื่อเล่น, bio, names). =HYPERLINK(…) as a ชื่อเล่น would run in whichever admin's spreadsheet opened the file.
Cause: three exports, three csvCells. The fix went into the one being looked at (class 6 — a rule applied to the writers you HAPPENED to be looking at). And these two files are ROUND-TRIPPED (export → edit → re-import), so the shop's fix copied as-is would have stored its apostrophe on every re-import.
Fix: ONE rule, csvGuard / csvUnguard in src/js/utils.js, used by all three exports; the shared parseCsv (team/io.js, also ระบบบ้าน's) takes the apostrophe off on the way back in. A plain number (-1, a year_offset) is never prefixed. src/js/team/io.test.js round-trips =HYPERLINK(…), -อั้ม, @me and 'quoted back to exactly what was typed — mutation-checked (guard removed → red).
The general rule: a fix to one of N implementations of a rule is a fix to 1/N — grep for the other csvCells before calling a hole closed; and an escape added to an export needs its inverse on every import that reads it.
Switching to หน้าฝ่าย a second time snapped the ฝ่าย picker back and could wipe an unsaved edit
Symptom: an account that manages several ฝ่าย (the blanket dept_pages/ master grant) opens หน้าฝ่าย, picks the second ฝ่าย in the dropdown, starts editing a card — then clicks to another admin section and back (or just clicks the already-open หน้าฝ่าย sidebar item again). The picker has snapped back to the FIRST ฝ่าย in the list, and anything typed but not yet saved is gone with no warning.
Cause: admin-main.js calls initDeptPageAdmin(authGetUser()) on EVERY entry into the section, by design — the comment there explains this is needed so a real account SWITCH gets the new account's own ฝ่าย list rather than the previous account's. But dept-page-admin.js's initDeptPageAdmin() reset state.dept = mine[0].value and called refresh() (which re-fetches and repaints #dpaRows from the server) unconditionally, every single call — not only on account switches. Every other admin workspace in this app (team/index.js, house/index.js) uses a loaded-style flag so a same- account re-entry is a no-op; this one didn't, so a sidebar click that should have been free re-ran the whole mount.
Fix: track which uid the editor is currently wired for (wiredUserId), and skip the picker reset + refetch on a same-account re-entry — src/js/dept-page-admin.js. A real account switch is moot in practice anyway: admin-main.js's onAuthChange hard-reloads the whole page before initDeptPageAdmin could ever see a different uid (swapAccountReload), so the guard only needs to cover, and does cover, the same-account case that was actually breaking. dept-page-admin.test.js drives this with a hand-built DOM stand-in (no jsdom here) and asserts a second call doesn't re-fetch or reset the picker's value; mutation-checked (guard removed → red, dbRest called twice instead of once).
The general rule: a function called "every time a section opens" is called on a same-account RE-ENTRY too, not only on the account switch its comment was written to justify — match this app's "load once, re-entry is a no-op" convention unless the function is cheap AND stateless, and if it isn't, say what state would be lost by résetting it.
The cart drawer's qty cap never knew about anyone else's reservations
Symptom: a product's size/colour is fully claimed by OTHER buyers' pending orders (shop_reserved_matrix_all reports it as 0 left), but a buyer who added one unit earlier (before it sold out) opens the cart drawer — the + button is enabled and lets them raise the line's qty as if the full stock number were still free. Checkout still blocks the order correctly (productsWithStock()/cartLineProblems() catch it there), so no money is ever lost — only the drawer's own promise is wrong.
Cause: cart.js's productMap is built straight from listProducts() (setShopCartProducts), which never includes a reserved_matrix — that comes from a SEPARATE RPC (fetchReservedMatrixAll). products.js's reloadShop() and checkout.js's productsWithStock() each splice the reserved matrix onto their OWN copy of the product list for their own purposes, but neither fed it back into cart.js's shared productMap — so the drawer's maxFor() always read p.reserved_matrix as {} and treated every reservation as zero. checkout.js's own comment even named the gap ("the cart's product map … carries no reserved counts") without closing it for the drawer.
Fix: cart.js now holds its own reservedMatrix map, fed by setCartReservedMatrix() — called once when the shop tab is first shown (index.js, alongside the existing fetchReservedMatrixAll nobody reused) and again whenever checkout.js's refreshStock() learns a fresher one, so the drawer and checkout never disagree for longer than one stock refresh. The qty-cap logic itself was extracted into an exported pure function, maxQtyFor(p, it, reservedMatrixById), so it is unit-testable without the DOM render() needs. src/js/shop/cart-reserved-matrix.test.js mutation-checked (matrix splice removed → a fully-reserved variant reads as 99 again, not 1).
The general rule: a value fetched and spliced on for ONE reader's purpose does not reach a SIBLING reader of the same underlying list unless something explicitly feeds it there — docs/mistakes/app-state.md's other entries are about two renderers computing a count two different ways; this is the same shape one layer earlier: two renderers sharing one map object, where only one of them was ever handed the full data.
addItem merging an existing cart line only capped the TOTAL at 99, never at real stock
Symptom (HANDOFF §18f, cold review, CONFIRMED — fixed agent/2026-10-20, not deployed): a buyer adds 2 of a variant with 5 left, closes the popup, reopens it and adds 2 more — fine, 4 of 5 — then adds 2 more again: state.js's addItem let the line reach 6, past the real 5. The entry above ("the cart drawer's qty cap") fixed the DRAWER's own +/- for an existing line; this is the OTHER place a line's qty grows — the product popup's "Add" button, which calls addItem fresh each time, merging into whatever is already there.
Cause: addItem(item) capped the merged total at a hardcoded Math.min(99, …) — the only number it had, because state.js is deliberately kept free of any dependency on the product catalogue or stock data (see its own cross-tab-sync header comment). It had no way to know what the real ceiling for THIS variant was, so it used the one it always had.
Fix: addItem(item, maxQty = 99) — the CALLER passes the real cap. products.js's add-to-cart handler passes qtyCeilingFor(product, modalState.size, modalState.color) (see frontend-ui.md's popup entry for that function). checkout.js's "rebuild the cart from a failed order" call passes none, keeping the 99 default — it is restoring a quantity that was already in the cart, not adding past a limit, so there is nothing to cap against. src/js/shop/state.test.js, mutation-checked.
Where it lives now: src/js/shop/state.js (addItem), src/js/shop/products.js (the add-to-cart handler), src/js/shop/checkout.js (unchanged call site).
The general rule: a low-level state module that deliberately knows nothing about domain data cannot enforce a domain limit — the CALLER must pass it in, every time a new call site is added that merges into existing state. The same class as the drawer bug above, one layer closer to the write: capping the DISPLAY of a value is not the same as capping the WRITE.
csvGuard/csvUnguard did not round-trip a value that itself started with '
Symptom (HANDOFF §18f, cold review, CONFIRMED — fixed agent/2026-10-20, not deployed): a real value typed by a student that happens to start with an apostrophe followed by a formula character — e.g. a bio of '-not-a-number or '=looks-like-a-formula — came back DIFFERENT after export → edit → re-import: the leading apostrophe silently disappeared.
Cause: csvGuard prefixes a formula-looking value (=, +, -, @, tab, CR) with ' so Excel/Sheets shows it as text, and csvUnguard strips that ' back off on import. csvUnguard decided whether to strip by looking at what FOLLOWED the leading ' — if it looked like a formula trigger, strip it; otherwise leave it. That heuristic cannot tell "I added this quote to escape a formula" from "the real value already started with a quote, and what happens to follow it looks like a formula too" — both produce the identical string '<rest>, and the second case is exactly a real value starting with '.
Fix: csvGuard now ALSO escapes (doubles) a value that itself starts with ', and csvUnguard always strips exactly one leading ' with no guessing — the transform is now its own inverse by construction, not by heuristic, the same idea as SQL's ''. A 2000-iteration property test over randomly generated strings (src/js/team/io.test.js) backs the few curated examples that were there before; mutation-checked.
Where it lives now: src/js/utils.js (csvGuard, csvUnguard), src/js/team/io.test.js.
The general rule: when an escape character can also be a literal part of the real data, the escaping scheme must handle the escape character occurring IN the data — not just the characters it exists to escape. A guard built from "if it starts with X, guess what the original was" is unprovable without reading what follows; one that always escapes its own marker and always strips exactly one layer is provable by construction. Same test-writing lesson as .claude/rules/mistakes.md class 7's "never write a guard from the same list the code came from" — here the thing to avoid was writing examples, not a property: a handful of curated inputs passed for months while the actual failing shape (quote, then a formula character) was never one of them.
Two ฝ่ายสโม tag chips clicked quickly could silently drop one of them
Symptom: on a โครงการ's detail header, a sender-desk account (vpa/dev) clicks one ฝ่ายสโม tag chip, then a different chip, before the first click's round trip has returned. Only ONE of the two tags ends up saved — no error, no indication either chip's click "didn't count".
Cause: onToggleProjectDept() (src/js/projects/inbox.js) is a classic read-modify-write on a shared array column (projects.samo_depts): it reads the project's CURRENT tags from cache.projects, computes the whole new array, and writes the whole array back — there is no per-tag column to PATCH independently. btn.disabled = true only guards the ONE button clicked twice; it does nothing for two DIFFERENT chips on the same โครงการ. Both reads saw the same pre-toggle array, each computed and wrote its own version of "current + my one change", and whichever request's response landed last silently won, discarding the other chip's change.
Fix: chain every call through one module-scope promise (deptEditorQueue) and await onChanged() (which is what refreshes cache.projects) before the function returns — so a second chip clicked before the first lands now waits for the first's write to be reflected in cache before it computes its own next. src/js/projects/dept-chip- race.test.js fires two toggles back-to-back against a mocked setProjectSamoDepts and asserts both tags land; mutation-checked (queue
- await removed → red, only the second chip's tag survives).
The general rule: a button-disable guards one button against itself, not several buttons against each other — a read-modify-write on one shared field, reachable from more than one control, needs a SERIALIZATION point (a queue, a mutex, re-reading after every await), not a per-control disabled flag. The tell is several independent UI elements that each read, then later write, the SAME array/object field.
"The public หนังสือโครงการ mirror never refreshed after its first load" — a bug that did not exist, and its fix doubled every fetch
Symptom (as the night agent reported it, 2026-10-13): switching away from the public หนังสือโครงการ tab and back showed stale data, because projects-view.js's bootIfNeeded() returned early on every re-show, leaving mountCustomerProjects()'s refresh branch "dead code".
Cause — the report was wrong: mountCustomerProjects() registers its OWN shown.bs.tab listener on first mount (src/js/projects/index.js, the pills-projects-view-tab handler) that calls reloadProjects() on every re-show. The data always refreshed. The agent read the caller, found the callee's refresh branch unreachable FROM THAT CALLER, and concluded the behaviour was missing — without looking for the other path that provided it. Its "fix" removed the early return, so every re-show then ran TWO unordered full reloads (projects with every document, doc types, settings), and the older response could paint last. Its test passed because it mocked mountCustomerProjects — the module whose listener disproved the bug.
Fix: reverted to the early return; test and release note removed (code review, 2026-10-13, same day).
Where it lives now: src/js/projects-view.js (as before the agent), src/js/projects/index.js (the listener that does the refresh).
The general rule: "unreachable code, so the behaviour is missing" is two claims; check the second by OBSERVING the behaviour or grepping every other path to it (here: a second listener on the same event). A test that mocks the module that disproves the bug will confirm any bug you describe.
"Import the answers the ฝ่าย filled in" — the import-back tool would have dropped a whole sheet, skipped another, and written two wrong addresses
Symptom (owner, 2026-10-13): the ฝ่าย returned รายชื่อระบบบ้านมีปัญหา.xlsx (one tab per รุ่น, our own sheets with columns moved and renamed) and warned that in MD52 one student's kkumail and รหัส were "ซ้ำกับอีกคนนึง". Read against the live database before anything was written, the old tools/house-kkumail-import.mjs would have got six things wrong:
- MD53/MD54 imported nothing. Our sheet has
kkumail (ในระบบ)(what WE filled from the database — empty on a held row by definition) beforekkumail(their answer). "First header containing kkumail" read ours. - MD52 was skipped whole. Its held rows have no รหัส — the sheet ASKED for one — and matching was by รหัส only, so every answer there fell through
if (!sid) continuewithout a line of output. - The reported duplicate passed. One รหัส and one address pasted onto two people. The only check was "one รหัส carrying two DIFFERENT addresses"; identical copies merged silently in a
Map. @kkumaill.comwould have become a person's identity.promote_unresolved_rowchecks an address's shape, not its domain.- An address with another student's initials on a row. The รหัส and name agreed with each other, so the name check passed; the slip was in the address alone (its initials fit a different student on the same sheet, who had no answer).
- A ทีม SAMO member would have become two people. They had a
peoplerow from ทีม SAMO with no kkumail;resolve_person_idmatches on kkumail only, so promotion makes a second person — the ORDERING RULE indocs/state/phuriphatma.md, known since 2026-09-15 and not in the tool.
Fix: the tool reads several CSVs (one per tab), prefers the column we asked them to fill and never one marked ในระบบ/จากระบบ, places a no-รหัส line by รุ่น (file name) + สาย + the exact name we sent and writes the รหัส they gave onto the held row first, refuses a รหัส whose check digit is wrong (KKU's — weights 10..2, (10 - sum % 10) % 10, measured on every stored รหัส: no exception in ~3,800), refuses an address or รหัส on two people unless the address carries exactly one of them's initials, corrects a wrong domain ONLY when the corrected address has signed in, refuses an address whose initials cannot come from the Thai name, puts the address on an existing people row before promoting, and asserts INSIDE the transaction that every address is one student and one person holding that รหัส. Mutation-checked: with the pre-link removed the run aborts naming exactly that member's address. 19 promoted, 2 refused, 2 empty_row lines the ฝ่าย said do not exist were dismissed, 7 remarks (ติดต่อไม่ได้, ลาออก) went onto their held rows as note.
Where it lives now: tools/house-kkumail-import.mjs; studentIdChecksumOk() in src/js/team/fields.js (not yet used by any form).
The general rule: an import of answers a human typed into OUR sheet has to check what the human could get wrong, not only what our sheet could — the address itself, a pasted-twice row, a column they moved. And a rule written in a handoff note ("put the kkumail on the person FIRST") is not in the tool until it is an assertion the transaction fails on.
SAMO Shop: a product added to the cart in one browser tab could vanish if another tab added one too
Symptom (owner's worry, 2026-10-18 night-agent task 9): "login ด้วย username password แล้วซื้อแบบนี้ กดออกไปหน้านี้แล้วมีปัญหา" — sign in, add to cart, navigate away, come back, something is wrong. Every IN-APP navigation (the shop tab, /me, the home banner's shop slide) is client-side-routed — pushState + Bootstrap Tab.show(), never location.reload() — so the cart's own module state survives all of those for free; that part of the worry turned out not to be a bug.
Cause: shop/state.js loads cart into memory ONCE, at module-load time, from localStorage, and every mutation (addItem/updateQty/ removeItem) persists the WHOLE in-memory array back — not a merge. Each browser TAB gets its own module instance with its own copy. Open a product link in a second tab (or duplicate the tab, or open the shop in two windows) and add something in each: tab A's addItem reads its own stale cart (unaware of anything tab B wrote), computes [...stale, X], and persists it — overwriting whatever tab B had already saved. Whichever tab writes LAST wins outright; the other tab's item is gone from localStorage with no error, no toast, nothing — the customer finds out only when the order total at checkout is short a product.
Fix: state.js now listens for the browser's own storage event, which fires in every OTHER tab (never the one that wrote) the instant localStorage changes. On a hit for this cart's key, it reloads cart from storage and notifies subscribers — so the NEXT mutation in that tab starts from the freshest array instead of a stale one, and nothing another tab wrote is still in storage can get silently clobbered. Verified red-then-green: reverting the listener reproduces exactly this loss in src/js/shop/state.test.js (two module instances sharing one fake localStorage, one tab's registered listener invoked by hand since there is no real DOM in this repo's test runner).
Where it lives now: src/js/shop/state.js (the storage listener, guarded for typeof window === 'undefined' since the module is also imported under Node's jsdom-less test runner); src/js/shop/state.test.js. A live-browser walk of the rest of the flow (prefill, the no-email notice, a slip picked and typed fields surviving a navigate-away-and-back mid checkout) is written at tools/browser/shop-checkout-navigate-away.mjs but NOT RUN — this sandbox has no Chrome/Chromium binary and no network to install one; it is the next thing to run on a machine that has one.
The general rule: localStorage is shared STATE, not a message — writing the whole value back is "last write wins" across every tab that has the page open, the same shape class 6 already names for two database writers of one fact. A module that owns a piece of localStorage and lets more than one tab write it needs the storage event as its own module-scope cache's invalidation signal, same as an account switch needs one for an in-memory cache (see this file's "Module-scope caches…" entry above).
SAMO Shop admin: the "ต้องจัดการ" tile said one number, clicking it showed fewer rows
Symptom (found by /scrutinize on night-agent task 9's own diff, 0230, not reported by a person): the admin orders tab's own comment claims "The counts and the filter a tile applies are the same predicate" — but after 0230 added a SAMO-year picker defaulting to the current year, the "ยังไม่ส่งสลิป" / "สลิปไม่ถูกต้อง" / "มีปัญหา" tiles and the "รายรับสะสม" tile counted across every year while the view each one opens into (the orders table, the ยอดขาย tab) was year-filtered. A pending/review order left over from a previous team ("ค้างจากปีก่อน" — exactly the case 0230 exists to handle) inflated a tile's count with no way to reach it: clicking the tile opened a table that silently excluded it, with nothing telling the admin why the numbers didn't match.
Cause: renderStats() (src/js/shop/admin.js) computed scoped (attentionCounts) and revenue from state.orders — unfiltered — while renderOrdersTable()'s filterOrders() and the ยอดขาย tab's yearFilteredOrders() both gate on state.ordersYear. The year filter was added as one more fact filterOrders checks, but the tile counts were never updated to read the same filtered set — a scope added on one side of a count/destination pair and not the other (class 3/6: the tile's own comment documented the invariant it then broke).
Fix: scoped and revenue now read yearFilteredOrders() instead of state.orders. queue (the slip-review count, whose destination — the ตรวจสลิป tab — is deliberately never year-filtered, since unresolved review work must not hide behind whichever year happens to be picked) is left reading state.orders, unchanged on purpose. Added direct unit coverage for the shared predicate itself, orderMatchesYearFilter (src/js/shop/ data.test.js), which had none before — its PR mirror ticketMatchesYearFilter did.
Where it lives now: src/js/shop/admin.js (renderStats), src/js/shop/data.test.js.
The general rule: when a new filter is added to a view, grep every COUNT that promises to equal what a click on it reveals, not just the view's own render path — a count and its destination are two call sites for one invariant, and a comment stating the invariant is not a guard against a change made somewhere else.
Editing an archived announcement silently republished it
Symptom (found reviewing night-agent task 10's own diff, 0232, before any person hit it): 0232 added เก็บเข้าคลัง/เผยแพร่อีกครั้ง (archive/restore) for ประกาศ, including explicit support for reopening an archived post in the editor — admin-main.js's enterCreator() and tryCreatorDeepLink() both call loadAnnouncements('all') specifically so a #creator/{id} deep link or the manage card's pencil can resolve an archived row. But saving that edit — even a typo fix with no intent to republish — flipped the post straight back onto the public site.
Cause: publishAnnouncement() (src/js/announcements.js) built its PATCH/POST body with a hardcoded status: 'approved', written back when archiving didn't exist and every row WAS 'approved' by construction. 0232 added a second status value reachable through the editor without updating the one place that writes status on save — a flow the code's own comments show was anticipated (reopening an archived post was deliberately wired) but not carried all the way to the write path (class 6: a value correct until a sibling feature changes the population it is drawn from).
Fix: extracted the decision into a pure, tested function, nextAnnouncementStatus(beforeRow, isEditing) — a new post is always 'approved' (no UI offers "create pre-archived"); an edit preserves whatever the row already had. publishAnnouncement() now calls it instead of hardcoding the literal.
Where it lives now: src/js/announcements.js (nextAnnouncementStatus, used in publishAnnouncement), src/js/announcement-status.test.js.
The general rule: adding a new value to an enum-like column (here, status gaining 'archived') means re-auditing every WRITE site that sets that column to a literal, not just the read/filter sites — a write path that predates the new value has no reason to know it exists, even when a sibling change in the SAME feature deliberately made the new value reachable from that write's own entry point.
Archiving an announcement made its pictures deletable — an edit or delete elsewhere trashed them
Symptom (found in review, 2026-10-20, before it shipped). Two announcements sharing a cover — one archived, one live. Editing or deleting the live one trashed the shared picture in Drive; restoring the archived one showed a broken image.
Cause. filesToRetire(before, after, others) keeps every picture another article still uses, and others was globalAnnouncements — the list the last loadAnnouncements() filled, which is ONE status (the public feed's approved, or the manage pane's filter). Before the archive toggle (night agent, 2026-10-18) no archived rows existed, so "the approved list" and "every other article" were the same set. The new status created the input the keep list could not see (class 2: an unresolvable reference fails open — here, an unlisted reference reads "unused").
Fix. otherAnnouncementsAnyStatus() reads every other row, every status (writers can: announcements_read, 0093), right before retiring; when that read fails it retires NOTHING. The same review also made every reload after publish / delete / reorder use the manage filter, archiving clear pinned, and the bulk archive one PATCH id=in.(…) with a row-count check.
Where it lives now: src/js/announcements.js (otherAnnouncementsAnyStatus, archiveBody, archiveAnnouncements), src/js/admin-main.js (bulk archive).
The general rule: a keep-list built from a FILTERED cache is complete only while the filter excludes nothing. When a feature adds a state that hides rows from that filter, every "is anything else still using this?" check over the cache becomes a delete. Read the keep-list from the table, not the view.
"I already got the answer from them" — answered held rows still listed as work, in TWO places
Symptom (owner, 2026-10-22). "why some people that already have info like ติดต่อไม่ได้ still on the sheet, i already got answer from them". Six MD52 held rows answered ติดต่อไม่ได้ / คาดว่าลาออก on 2026-10-13 were still counted under ⛔ ต้องมีคนทำ on ข้อมูลไม่ครบ, and house-year-sheets.mjs --gaps-only would have sent them back to ฝ่ายข้อมูล on the next sheet.
Cause. 0217 deliberately keeps those rows OPEN (the person may still turn up), and every reader classified a held row by "is it open?" — so "open" was read as "still a question". The answer only travelled as a hint next to the name.
Fix. One predicate, isAnsweredOpen in src/js/house/follow-up.js: an open row the ฝ่าย answered. computeGaps moves those rows to a new tone, ตอบแล้ว ไม่ต้องทำอะไร (production ต้องมีคนทำ 12 → 6); hasIssue() in the sheet generator leaves them out unless they have a problem of their own (MD52 7 → 1). The tone order had been hand-written in three places; it is TONE_ORDER now, pinned by a test.
Where it lives now. follow-up.js (isAnsweredOpen), gaps.js (held_answered, TONE_ORDER), tools/house-year-sheets.mjs (hasIssue); tools/browser/house-sai-grid.mjs drives the page.
The general rule: "still open" and "still a question" are different states — when a row is kept open on purpose, every list that asks "what needs doing" must say why it is open, or it re-asks a question already answered. Fixing the page is half: grep for the other readers of the same population.
แลก Rotation: "โหลดข้อมูลไม่สำเร็จ · signal is aborted without reason" for everyone — and the safeguard that would have repeated it
Symptom (owner, 2026-10-24 ~21:00, round open). "that makes the web crash everyone can't use it" — /rotation showed โหลดข้อมูลไม่สำเร็จ · signal is aborted without reason (dbRest's 15 s timeout), and the rest of the site slowed with it.
Cause. Every write pings rotation_events, and EVERY open page re-reads rotation_board() on the ping. The board cost ~2 s of database time (a per-student scan of users by email, wishes and swaps_live per row), so one burst from the cohort queued past 15 s in PostgREST's small pool — the pool the whole site shares.
Fix 1 (0258, 2026-10-24). Set-based board, same output: ~2,035 ms → ~6 ms on production. Fix 2 was a regression (c2c5adb). To stop the board blanking on a blip, a failed background read re-ran load() on a FIXED 2–4 s timer, for ever, silently showing the old board. That is the outage's own shape: when the server is the thing failing, every page fails together and retries together, so the client keeps a struggling server at ~1 read / 3 s / page. It went live for real ~13 minutes on 2026-10-26, when the first 0262 broke rotation_board() for everyone (postgres-schema.md). Fix 3 (c3c602e, 2026-10-30).retryDelayMs: 3 s, doubling to the 60 s poll, ±25% jitter, one retry pending at a time; the status bar says "เชื่อมต่อไม่ได้ชั่วคราว ข้อมูลอาจไม่ล่าสุด · กำลังลองใหม่" until a read succeeds. Measured in the browser smoke with the board's fetch failing: 3 reads in 21 s (the fixed timer: 7).
Where it lives now. src/js/rotation/model.js (retryDelayMs), page.js load(); model.test.js pins the schedule and that load() uses it; tools/browser/rotation-page.mjs fails the board and counts the reads.
The general rule: a retry is load on the thing that just failed. When every client shares the failure (one server), a fixed short retry turns an outage into a sustained one — back off, jitter, and say the data is stale instead of hiding it. Test the retry against a failure EVERY client sees at once, not one flaky phone.
A remote change that lands mid-drag never painted if the drop itself was a no-op
Symptom (found by ESLint's no-unused-vars, 2026-10-20 — nothing reported it in use). scheduleRemoteRender() (src/js/team/index.js) sets pendingRender = true when a realtime update arrives while a user is dragging a node or member, so the render can be deferred until the gesture ends. onNodeDrop / onMemberDrop both reset pendingRender = false the instant the drag ends — but neither ever READ it, so the deferred render itself never happened unless the drop ALSO called render() for its own reasons.
Cause. Both handlers have an early return before their own render() call — onNodeDrop when evt.item.dataset.nodeId is missing, onMemberDrop when the id or destination node id is missing (an invalid drop: dropped outside a valid target, or SortableJS firing onEnd with no real move). On every OTHER path the handler's own render() happened to repaint the remote mutation too (realtime writes land straight into nodesById/membersByNode regardless of dragging, so any render draws current state) — which is exactly why nothing visibly broke in the paths anyone tries first. Only the no-op-drop path lost it, and only until some unrelated later realtime event coincidentally forced a render.
Fix. Both handlers capture pendingRender into a local before resetting it, and render once more on the early-return path if it was true: if (!id) { if (hadPendingRender) render(); return; }.
Where it lives now: src/js/team/index.js (onNodeDrop, onMemberDrop). No automated regression test — this module is DOM/SortableJS-driven with no exported seams, and the repo has no jsdom dependency (vitest's suite here tests pure logic only); adding one is a bigger infra call than this lint pass. Verified by hand-tracing every call site of pendingRender instead.
The general rule: a flag set to defer a side effect must be READ on every path that would otherwise skip it, not just reset. A let that is only ever written, never read, is not a style nit — no-unused-vars caught a real dropped-update bug here specifically because nothing in the file checked the flag it exists for.
"เริ่มทีมปี 2570 จากว่างแล้ว กดเพิ่มฝ่าย" — but no เพิ่มฝ่าย anywhere (0271)
Symptom (as reported, 2026-11-01): "when i click เริ่มจากว่าง in teamsamo 2570 it show เริ่มทีมปี 2570 จากว่างแล้ว กดเพิ่มฝ่าย but i don't see any ui to เพิ่มฝ่าย unlike the normal teamsamo and in ถาวร". The status line told the person to press a button the screen did not have.
Cause: the year bar decides "prepared" from team_draft_summary().prepared, which is "the draft holds at least one row" — deliberately, because the switch skips an empty draft, so pressing เริ่มจากว่าง and walking away cannot empty the team on 1 มี.ค. But start_team_draft(year, 'empty') writes no row. So after the press the year was still "ยังไม่เตรียม", the editor never opened, and the toolbar holding เพิ่มฝ่าย stayed hidden. Two readers of one state, a SERVER answer ("nothing to apply") and a UI question ("may I edit this year?"), were treated as one. The 0271 browser run pressed only คัดลอกจากทีมตอนนี้, which writes rows, so it never reached the empty case.
Fix: year-bar.js remembers the years started from empty this session and treats them as an editable draft; the head says "ยังว่าง — ถ้ายังว่างอยู่ ถึงวันเริ่มปี ระบบจะใช้ทีมตอนนี้ต่อ", the chip says ว่าง, and แสดงบนหน้าเกี่ยวกับเรา appears only once something is in it. The server rule is unchanged. tools/browser/team-years.mjs step 7b presses เริ่มจากว่าง, requires เพิ่มฝ่าย on screen and a draft row after using it — red on the old code (3 of 3 steps).
Where it lives now: src/js/team/year-bar.js (openedEmpty, viewOf), tools/browser/team-years.mjs §7b.
The general rule: a status message that names a control is a claim that the control is on screen — test the message's own instruction, not just the write behind it. And when a UI state is DERIVED from the data ("has rows"), ask which action creates that state with NO data: an empty start, a cleared list, a first-time setup. The two buttons of one choice need one browser step each; the run that pressed only the common one proved the other by analogy.
"Why is there SAMOWEB FOUNDER MD50 in เกี่ยวกับเรา" — a ถาวร group drawn under ประจำปี in a tab opened earlier (0273)
Symptom (owner, 2026-11-01, on production): a group that belonged with the ถาวร people showed on the public เกี่ยวกับเรา as a yearly ฝ่าย with nobody in it.
Cause: the ทีมและสิทธิ์ tab learns the ถาวร list (0272) once, when it LOADS. Live sync carried team_nodes and team_members only (0048), so a tab that was already open when someone else made a group ถาวร received the new group but not its mark, and drew it among the yearly ฝ่าย. The API log fits that and nothing else: "Past developer" was created and marked ถาวร at 11:28 Thai; at 11:29:40 a top-level group was created WITHOUT a mark (the add form marks it whenever the ถาวร side is open, so it came from the ประจำปี side), and at 11:29:53 one drag re-saved the positions of BOTH groups, which only happens when they are drawn in the same list, in a tab that did not know "Past developer" was ถาวร. A new yearly group is public by default, so it reached เกี่ยวกับเรา. Status: HYPOTHESIS for which tab did it (the log does not tell tabs apart). The missing sync is VERIFIED: a browser step that adds a ถาวร group from outside the open tab was red 2 of 2 on the old code.
Fix: 0273 adds team_standing_groups to the live-sync publication. The tab listens on a SEPARATE channel, because Realtime refuses a whole channel when one of its tables is not published (measured on samo-dev before 0273: the shared channel delivered no tree change at all). After any new top-level group it also re-reads the ถาวร list 2.5 s later, which covers a missed event and a database without 0273. Either half alone turns the step green (checked by switching each off).
Where it lives now: supabase/migrations/0273_standing_groups_live_sync.sql, src/js/team/realtime.js (standingChannel), src/js/team/index.js (applyRemoteChange, refreshStandingSoon), tools/browser/team-standing.mjs §5b.
The general rule: a list that changes how the tree is DRAWN is part of the tree. When a new table decides which side a row appears on, it needs every path the rows have: load, live sync, undo, export. Ask "what does a tab opened an hour ago believe?" A tab holds a copy of the database, and live sync decides which parts of that copy stay current.