Thanks to visit codestin.com
Credit goes to github.com

Skip to content

test(coderd): guard chat history writes in test databases - #29243

Draft
mafredri wants to merge 6 commits into
mainfrom
test/chat-store-write-guard
Draft

test(coderd): guard chat history writes in test databases#29243
mafredri wants to merge 6 commits into
mainfrom
test/chat-store-write-guard

Conversation

@mafredri

@mafredri mafredri commented Sep 11, 2026

Copy link
Copy Markdown
Member

Tests could write chat_messages and chat_queued_messages rows through the store without allocating a chat snapshot, and such rows are invisible to consumers that gate on snapshot_version (CODAGT-749 was one such case). Production only writes these tables through chatstate transitions, which allocate first, so the gap existed in test fixtures alone.

dbtestutil.NewDB now wraps the store in a guard that rejects the thirteen writer methods on either table unless InsertChat or LockChatAndBumpSnapshotVersion ran earlier in the same transaction, and fails the test at cleanup for every rejection even when the caller dropped the error. dbgen.ChatMessage allocates inside its own transaction and the new dbgen.ChatQueuedMessage does the same; the direct seeds in chatd, chatstate, coderd, coderd/database and telemetry tests use them or an allocating InTx around the writer under test. TestGetChatsFilter.makeUnread seeded ContentVersion 0 and now seeds the current version through dbgen.ChatMessage; the has_unread filter it tests does not read content_version. Three tests in dbtestutil cover the guard: every guarded writer is rejected on the root handle and InsertChatMessages inside an unallocated transaction, with no rows landing, while a nested InTx write after the outer transaction allocated lands; every generated query that writes either table (INSERT INTO, MERGE INTO, UPDATE, DELETE FROM, with or without ONLY) is guarded or listed as search_tsv maintenance and every guard override has a rejection case; and the cleanup report fires when the error is dropped.

No production code changes. Raw-SQL fixtures are unchanged and unguarded by design; they live in coderd/x/chatd/chatstate/trigger_test.go, coderd/x/chatd/auto_archive_internal_test.go, coderd/database/dbpurge/dbpurge_test.go, coderd/database/querier_test.go, coderd/database/migrations/migrate_test.go and enterprise/coderd/usage/generator_test.go, for a separate cleanup. TestUpdateChatLastTurnSummary and TestUpdateChatSummary build their store with database.New(testSQLDB(t)) and stay outside the guard, but they seed through dbgen.ChatMessage now.

Verification: full make test (31272 tests, 0 failures), make lint, make fmt, make gen (no drift) and make build. With the guard installed and the seeds still unconverted, every rejection traced to a test seed; none came from a production path.

Sibling finding, not addressed here: InsertChatQueuedMessage, DeleteChatQueuedMessage, DeleteAllChatQueuedMessages, PopNextQueuedMessage, ReorderChatQueuedMessageToFront and SoftDeleteContextFileMessages have no callers outside the generated wrappers.

Refs CODAGT-1019

Plan

CODAGT-1019: test-time Store guard for chat history and queue writes

Direction

Outcome

Every Go test that opens a database through dbtestutil.NewDB fails when any code, production or test, calls a database.Store method that inserts or changes chat_messages or chat_queued_messages rows for a chat without having allocated a snapshot for that chat earlier in the same transaction. Production satisfies this by construction: chatstate.CreateChat inserts the chat row, chatstate.ChatMachine.Update calls LockChatAndBumpSnapshotVersion, both before any write. Nothing in production changes.

Observable end state

  • dbtestutil.NewDB returns the store wrapped in a guard: a database.Store implementation embedding the real store, overriding InTx, the two snapshot allocators and the thirteen writer methods.
  • A guarded write outside an allocation returns an error at the call site naming the method, the chat and the fix, and is recorded by method and chat; the test fails at cleanup with every recorded rejection, whether or not the caller propagated the error.
  • dbgen.ChatMessage and a new dbgen.ChatQueuedMessage allocate a snapshot inside their own transaction. Tests that seed rows use them. Tests that exercise a writer method itself call it inside InTx after LockChatAndBumpSnapshotVersion. No opt-out exists.
  • A unit test in coderd/database/dbtestutil/db_internal_test.go asserts that every generated query whose SQL writes either table is guarded or explicitly listed as a non-history writer, so a new writer query cannot be added without the guard learning about it.
  • Raw-SQL fixtures are untouched and unguarded.
  • CI passes on PostgreSQL 13 and 17.
  • CODAGT-1019 records the outcome after merge.

Recommended direction and reason

A database.Store wrapper, installed only by dbtestutil.NewDB. Reason: production writes these tables only through database.Store methods (verified: no raw SQL on either table in non-generated Go), so the wrapper sees every production write; it enforces the same rule the database triggers would with none of their machinery (no SQL, no duplicated column rules, no PostgreSQL version sensitivity, no sequence); it reports the Go call site of a rejection; and it could run in production later by wrapping the store in coderd.go, with no schema change. Precedent: enterprise/dbcrypt and dbmetrics are hand-written wrappers embedding database.Store. Measured on main e9429903d4 with the prototype installed in every NewDB: zero production-path rejections across chatd (647 pass), chatstate, chattool, chathooks, chatdebug, chatprompt, dbpurge, telemetry, toolsdk, the coderd chat tests and the coderd/database chat tests; 38 failing tests, all at 28 direct test seeds; nested Update inside Update and dbauthz above the guard (coderdtest) worked without false rejections.

High-level shape

  1. The guard type and its installation in NewDB, with the rejection recorder read at cleanup.
  2. dbgen.ChatMessage allocates a snapshot; dbgen.ChatQueuedMessage is added with the same shape.
  3. The 28 direct seed sites: 24 become dbgen calls, 4 call the writer inside an allocating InTx.
  4. The completeness test and the canary in the existing db_internal_test.go.
  5. Run the affected packages, lint, format; draft PR; update CODAGT-1019 after merge.

Ruled out (operator decisions and evidence)

  • Database triggers as the test-time layer: same rule as the wrapper, but the rule would live in SQL as well as Go, two shipped column rules would be duplicated, and its only surplus is rejecting raw-SQL fixtures, which the operator does not want catered for.
  • Production migration, staged rollout, runner or stream branches, snapshot-bumping triggers, chats.xmin, Go calling set_config, a bespoke analyzer, ruleguard: rejected earlier; reasons in the vault and ~/my-agent/artifacts/coder/codagt-1019-ideation/BRIEF.md.
  • Row-level security, privilege separation, the representable version model, post-hoc xmin grouping: rejected on evidence in the four lens reports.
  • A chatstate-based fixture path for dbgen: transitions cannot produce every seeded shape.
  • Helpers for raw-SQL fixtures: raw-SQL fixtures are bad and are not catered for. They are left unchanged and listed for a separate cleanup.
  • Test-side enforcement of integration-style testing (depguard, coverage ratchet): deferred until the practice is proven.
  • The Go capability handle (writers leave database.Store): not ruled out; it is a database.Store API change that closes one class the wrapper cannot see (a writer called on the callback store inside Update). Its timing is a separate decision with Hugo Dutka and the database owners.

Decisions already made

# Decision Settled by
1 First enforcement layer: a database.Store wrapper in tests. Layers compose; the capability handle stays open operator
2 No enforcement of integration-style testing now operator
3 Test databases only, installed by dbtestutil.NewDB operator
4 Fixtures allocate a snapshot in the same transaction, in dbgen; no opt-out operator
5 Every rejection fails the test, whether or not the caller propagated the error operator
6 Both chat_messages and chat_queued_messages are guarded operator
7 Raw-SQL fixtures are not catered for; they stay unchanged operator
8 Guarded methods: InsertChatMessages, SoftDeleteChatMessageByID, SoftDeleteChatMessagesAfterID, SoftDeleteContextFileMessages, InsertChatQueuedMessage, InsertChatQueuedMessageWithCreator, DeleteChatQueuedMessage, DeleteChatQueuedMessageReturningCount, DeleteAllChatQueuedMessages, DeleteAllChatQueuedMessagesReturningCount, PopNextQueuedMessage, ReorderChatQueuedMessageToFront, ReorderChatQueuedMessageToHead. Allocators: InsertChat, LockChatAndBumpSnapshotVersion. Explicitly unguarded writers of the tables: BackfillChatMessagesSearchTsv, ReindexStaleChatMessagesSearchTsv evidence: every query in queries/chats.sql that writes either table; the two search queries change only search_tsv columns, which the shipped triggers exclude
9 SoftDeleteChatMessageByID takes a message id; the guard reads the message's chat with GetChatMessageByID before checking evidence: it is the only writer without a chat id in its parameters
10 The guard's InTx passes the same guard instance to nested callbacks when the inner transaction store is the same value as the outer (tx == g.Store), so nested Update shares the allocation set; otherwise it wraps the transaction store with a fresh set. The root handle has a nil set and rejects every guarded write evidence: sqlQuerier.InTx reuses itself when already in a transaction (db.go:186-196); prototype run showed no false rejection on nested transitions
11 Rejections are recorded (method, chat id) in a recorder owned by NewDB; cleanup calls t.Errorf per rejection naming the method and chat and stating that the call site is the assertion that received the error, otherwise the callers of that method; the method also returns the error. No call stack capture operator: a captured call stack is rejected as machinery; the writer methods have few callers, so the method name locates a violator
12 The guard lives in coderd/database/dbtestutil (one file), unexported, wired in NewDB around database.New and below dbauthz. Moving it to its own package is deferred until a production use exists operator rule: build the simplest thing; dbcrypt shows the wrapper shape needs no shared package
13 Completeness test: parse coderd/database/queries.sql.go for query constants whose SQL matches `INSERT INTO UPDATE
14 Wrappers() appends the guard's name so double wrapping is detectable evidence: database.Store.Wrappers exists for this purpose (db.go:38, dbauthz uses it)
15 No new test files; the canary and the completeness test go into db_internal_test.go operator rule
16 ARCHITECTURE.md is not edited operator rule: cohesive or nothing; the guard is test infrastructure

Assumptions, constraints, tradeoffs

  • Assumption, verified on main e9429903d4: production writes both tables only through the methods in decision 8, all called from coderd/x/chatd/chatstate/transitions.go; InsertChat and LockChatAndBumpSnapshotVersion each have one production caller.
  • Constraint: the guard sees only paths some database-backed test executes, and only database.Store calls. Raw SQL by tests is invisible to it by design.
  • Constraint: a write on the callback store inside an Update callback and a writer that allocates a snapshot itself both pass. The first is what the capability handle would close.
  • Constraint: tests that build their own store with database.New(sqlDB) bypass NewDB and the guard; none were found in the run packages.
  • Tradeoff: fixtures now allocate a snapshot per seeded row and take the chat row lock while doing so. Measured: no assertion changed outcome in the run packages. A seed issued while a task runs on the same chat is a visible history change the runner acts on, which is what such a seed means.
  • Tradeoff: the completeness test reads generated source text. It is the one place a regex touches SQL; it is anchored on the sqlc -- name: headers and a table-name pattern.

Risks that affect the direction

  • Risk: a database-backed test in an unmeasured package seeds either table through the store (known unmeasured: enterprise/coderd/usage/generator_test.go has one dbgen site, fixed by the dbgen change, and one raw SQL site, unaffected). Trigger: CI failure with the guard message naming the method and chat. Response: replace the seed with the dbgen call.
  • Risk: a test asserts an exact snapshot_version after seeding. Trigger: assertion failure. Response: recompute; the shift is one per seeded row.
  • Risk: a guarded method call runs on a store handle other than the one that allocated (for example the root handle inside a callback). Trigger: guard rejection in a production path. Response: that is a real defect in the caller; fix the caller, not the guard.
  • Risk: the guard is silently absent (a test constructs its own store). Trigger: vacuous pass. Response: the canary exercises one rejection per table per run through NewDB.

🤖 This PR was created with the help of Coder Agents, and will be reviewed by a human. 🏂🏻

Every chat_messages and chat_queued_messages write must run in the
transaction that allocated a snapshot for the chat, which production
does through chatstate.CreateChat and ChatMachine.Update. Nothing
enforced this for tests, so fixtures wrote history rows the runner
could not attribute to a snapshot.

dbtestutil.NewDB now wraps the store in a guard that rejects the
thirteen writer methods unless InsertChat or
LockChatAndBumpSnapshotVersion ran earlier in the same transaction,
and fails the test at cleanup for every rejection even when the
caller dropped the error. dbgen.ChatMessage and the new
dbgen.ChatQueuedMessage allocate inside their own transaction, and
the direct seeds in chatd, chatstate, coderd, database and telemetry
tests move to them or to an allocating InTx around the writer under
test. A completeness test parses the generated queries so a new
writer cannot be added without the guard learning about it.

Raw-SQL fixtures are unchanged and unguarded by design.
The seed helpers stopped using their context once they moved to dbgen,
so the parameter now misstates what they do; drop it and its callers'
argument. TestUpdateChatLastTurnSummary and TestUpdateChatSummary build
their store without NewDB and were still bumping the snapshot and
inserting the message in two autocommit statements, the shape the guard
exists to reject; they seed through dbgen.ChatMessage like every other
fixture. The canary no longer re-proves the allocating path that every
dbgen seed in the suite already exercises.
A database.Store handle, including a transaction handle, may be used
from more than one goroutine, so the per-transaction allocation set
needs a mutex like the recorder already has. Two more chatd seed
helpers had kept a context parameter they no longer use; it goes the
same way as the others.
The cleanup report is what fails a test whose caller dropped the
rejection error, and no test exercised it: deleting the block left the
guard tests green. A fake testing.TB captures NewDB's cleanups and
Errorf calls so the report can be asserted directly. The soft-delete
pass-through comment now names the real mechanism (GetChatMessageByID
excludes deleted rows; the trigger treats an identical rewrite as a
no-op), and the completeness failure message describes both directions
of a mismatch.
@linear-code

linear-code Bot commented Sep 11, 2026

Copy link
Copy Markdown

CODAGT-1019

@mafredri

Copy link
Copy Markdown
Member Author

/coder-agents-review

🤖

@coder-agents-review

coder-agents-review Bot commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

Chat: Review in progress (17/17 reviewers complete) | View chat
Requested: 2026-09-11 16:19 UTC by @mafredri

deep-review v0.9.0 | Round 1 | 5c73fa6..e784d9b

Last posted: Round 1, 13 findings (4 P3, 1 P4, 5 Nit, 3 Note), COMMENT. Review

Finding inventory

Finding inventory, PR #29243

Findings

# Sev Status Location Summary Round Reviewer Posted
CRF-1 P3 Open db_internal_test.go:122 Completeness test asserts an override method exists per writer, never that it calls require; 11 of 13 overrides never exercised R1 Meruem P3, Hisoka P3, Bisky Note Yes
CRF-2 P3 Open chatwriteguard.go:90 No test asserts an allocated write succeeds or that nested transitions share the set; the tx==g.Store branch is untested and its pointer-identity invariant is only documented R1 Ryosuke P3, Razor P3, Bisky P3 Yes
CRF-3 P3 Open dbgen.go:131 dbgen.ChatMessage/ChatQueuedMessage now take FOR UPDATE via deadline-less genCtx; a caller holding the chat row lock on another session hangs until the test timeout R1 Komugi Yes
CRF-4 P3 Open db.go:149 Cleanup rejection message omits the fix and its "call site is the assertion that received this error" clause is wrong for the drop-the-error path it exists to serve R1 Leorio P3, Gon Note Yes
CRF-5 P4 Open chatwriteguard.go:57 Raw-SQL fixtures write both tables with no snapshot and bypass the guard; the promised cleanup has no ticket (human decision) R1 Pariston Yes
CRF-6 Note Open db_internal_test.go:128 Completeness guarantee boundaries: regex misses UPDATE ONLY / DELETE FROM ONLY / MERGE INTO; whitelist is by method name not columns; table names hardcoded R1 Knuckle, Knov Yes
CRF-7 Note Open dbgen.go:142 dbgen.ChatMessage rewrites a seeded ContentVersion of 0 (V0) to V1 via takeFirst; the now-canonical fixture API cannot express V0 R1 Mafuuu Yes
CRF-8 Note Open chatwriteguard.go:146 SoftDeleteChatMessageByID skips the guard for missing/already-deleted ids; benign only because two triggers short-circuit the no-op R1 Hisoka, Ryosuke, Razor, Knov, Mafu-san Yes
CRF-9 Note Dropped by orchestrator (design-intended, disclosed in PR body; noted in review body) dbgen.go:126 snapshot_version bumps once per seeded row, not per turn R1 Pariston, Razor No
CRF-10 Nit Open chatwriteguard.go:45 list() reimplements slices.Clone; reverse cleanup loop reimplements slices.Backward R1 Ging-go Yes
CRF-11 Nit Open chatwriteguard.go:107 require/mark read as testify assertions; rename requireSnapshot/markAllocated R1 Gon Yes
CRF-12 Nit Open db_internal_test.go:99 covered names a set that also holds exempt (non-overridden) queries R1 Gon Yes
CRF-13 Nit Open db.go:145 rejections local names a *chatWriteRecorder, not a slice R1 Gon Yes
CRF-14 Nit Open querier_test.go:13073 insertMessage seed helper reimplements dbgen.ChatMessage's allocate-then-insert R1 Robin Yes

Round log

Round 1

Netero-only first pass: no findings, mechanical floor clean. Panel of 16 (Bisky, Hisoka, Mafu-san, Mafuuu, Pariston, Ging-go, Gon, Leorio, Komugi, Knuckle, Ryosuke, Takumi, Meruem, Robin + wildcards Knov, Razor). No P0/P1/P2. 4 P3, 1 P4, 3 Notes, 5 Nits posted. Reviewed against 5c73fa6..e784d9b.

Dropped with reason:

  • CRF-9 (snapshot_version per seeded row): keep-argument is that a future test asserting an exact version would shift; dropped as inline because it is design-intended and already disclosed in the PR body, folded into the review body instead.
  • Robin's "second parser of queries.sql.go" note: keep-argument is the two parsers could drift on what a "query" is; dropped as pure awareness with no action and no existing drift.
  • Takumi's "cleanup reads the recorder once" note: keep-argument is it qualifies operator decision 5 (every rejection fails the test); dropped because it requires a goroutine outliving the test body (already a test defect) and the guard still blocks the write itself, only the report is lost.
About deep-review

CRF = Coder Review Finding (P0-P4, Nit, Note)

Reviewer Focus
Bisky tests
Chopper ops/errors
Churn-guard change verification
Ging language modernization
Gon naming
Hisoka edge cases
Killua perf
Kite change integrity
Knov contracts
Knuckle SQL
Komugi flake/determinism
Kurapika security
Law decomposition
Leorio docs
Luffy product
Mafu-san process
Mafuuu contracts
Melody dispatch/pairing
Meruem structural
Nami frontend
Netero mechanical checks
Pariston premise testing
Pen-botter product gaps
Razor verification
Robin duplication
Ryosuke Go arch
Takumi concurrency
Zoro shape

🤖 Managed by Coder Agents.

@coder-agents-review coder-agents-review Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test-only change, and a well-built one. The guard sits at the single layer where it sees every database.Store write a test issues (dbtestutil.NewDB wraps database.New directly, below any dbauthz), the guarded method set is pinned to the generated SQL by a completeness test, the recorder-plus-cleanup design makes a dropped error still fail the test, and the nested-transaction pointer-identity logic holds under dbauthz re-wrapping. Six reviewers independently verified the SoftDeleteChatMessageByID bypass is a genuine trigger no-op, and the disclosed per-row snapshot_version bump (one per seeded message, not per turn) is design-intended and did not break an existing assertion. No production code changes.

No P0/P1/P2. Findings: 4 P3, 1 P4, 3 Notes, 5 Nits.

The P3 cluster is about the safety net having gaps the PR's own framing does not admit. The completeness test proves an override method exists per writer query but never that it guards (11 of 13 overrides are never driven to a rejection by any test), so a future copy-paste override that drops require reopens the exact CODAGT-749 class silently. The guard's own suite asserts only rejections, never that an allocated write is permitted or that a nested transition shares the set, leaving the one load-bearing branch untested. The dbgen seed helpers now take a FOR UPDATE lock through a deadline-less context, so a future nested-seed caller holding that chat's lock hangs until the test timeout rather than failing loud. Meruem's table-driven-test fix closes the first two at once.

One deferral needs a human decision, not silent acceptance: raw-SQL fixtures write both tables with no snapshot and bypass the guard entirely (CRF-5). The PR body lists the files "for a separate cleanup" but there is no ticket, so under a no-follow-up assumption the gap is permanent. File a ticket or state explicitly that the raw-SQL boundary is accepted as-is.

Hisoka, who tried hardest to break it: "I came to fight the guard. It fought back well."

🤖 This review was automatically generated with Coder Agents.

Comment thread coderd/database/dbtestutil/db_internal_test.go
Comment thread coderd/database/dbtestutil/chatwriteguard.go
})
var msgs []database.InsertChatMessagesRow
err := db.InTx(func(tx database.Store) error {
if _, err := tx.LockChatAndBumpSnapshotVersion(genCtx, seed.ChatID); err != nil {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 [CRF-3] dbgen.ChatMessage now opens its own transaction and takes FOR UPDATE on the chat row through a deadline-less context; called while that chat's row lock is already held, the seed blocks until the test times out instead of returning. (Komugi)

Before this PR ChatMessage issued a single InsertChatMessages with no row lock, so it never blocked on the chat row. Now it wraps LockChatAndBumpSnapshotVersion (... FOR UPDATE) plus the insert in a fresh InTx. When a caller holds that chat's FOR UPDATE lock in an outer transaction and calls dbgen.ChatMessage on the root store, the inner transaction runs on a separate session and waits for the lock. PostgreSQL does not report this as a deadlock because the two sessions differ, and genCtx (derived from context.Background()) never cancels the waiting query. A CI timeout reads as a flake, not as a clear seed error.

No current caller triggers it (every converted seed runs after its transition closes), but this helper is now the canonical seed path across ~24 sites, so the natural next nested-seed caller lands on it. Sibling, same root cause and fix: dbgen.ChatQueuedMessage (dbgen.go:169) locks the same row the same way. Pin it by giving genCtx a bounded deadline here so a contended seed fails loud, or by reusing the caller's transaction store when one is passed so the lock is re-entrant within one session.

🤖

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not changed. The observation holds (genCtx derives from context.Background() and both fixtures take FOR UPDATE in their own transaction), but it is a property of the whole dbgen package: every helper uses the deadline-less genCtx, including the three others that already open InTx (dbgen.go:976, 1041, 1630). A timeout in these two helpers alone would be inconsistent with the package, and reusing the caller's transaction is not possible when the caller passes the root store. No current caller holds the chat lock while seeding (full suite passes). A dbgen-wide deadline convention is a separate change; leaving this thread open for the human reviewer.

🤖 Posted using /amend-review skill via Coder Agents.

db = newChatWriteGuard(db, rejections)
t.Cleanup(func() {
for _, r := range rejections.list() {
t.Errorf("chat write guard rejected %s for chat %s outside a chat state transition; "+

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 [CRF-4] The cleanup failure message names the problem and where to look but drops the fix, and this is the one message the developer actually reads. (Leorio P3, Gon Note)

The cleanup path exists precisely for the case where the caller dropped the returned error. In that case the developer never sees the add message; the cleanup line is all they get. So the remedy is missing exactly when it is the only message on screen.

The returned error from add prescribes the treatment ("write history through chatstate.ChatMachine.Update or CreateChat, or dbgen in tests"); this cleanup message says "the call site is the assertion that received this error, otherwise search for callers of ..." and stops. That first clause is also wrong for this path: if an assertion had received the error, the test would have failed there and this line would not fire. When it fires, no assertion received the error, so the clause sends the reader looking for something that does not exist. Give the cleanup message the same content the returned error carries: name the method, the chat, and dbgen.ChatMessage/dbgen.ChatQueuedMessage (or chatstate.ChatMachine.Update/CreateChat) as the fix. Gon separately noted the two messages are maintained independently and will drift.

🤖

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Keeping the text. The cleanup sentence was proposed during implementation and accepted by the orchestrator together with the decision not to capture call stacks. Its second clause, "otherwise search for callers of ", is the instruction for the dropped-error path; the first clause covers the case where the error was asserted on and the cleanup still runs (the assertion failed the test first, and the cleanup line points back at it). The fix text lives in the returned error so the two messages carry different information rather than duplicate it. Leaving open for the human reviewer.

🤖 Posted using /amend-review skill via Coder Agents.

r.rejections = nil
}

// chatWriteGuard is a database.Store that rejects writes to chat_messages

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P4 [CRF-5] The guard closes the store-method instance of the bug but not the class: raw-SQL fixtures write the same tables with no snapshot and bypass it entirely, with no ticket for the promised cleanup. (Pariston)

coderd/x/chatd/chatstate/trigger_test.go, coderd/x/chatd/auto_archive_internal_test.go, coderd/database/dbpurge/dbpurge_test.go, enterprise/coderd/usage/generator_test.go and the two others named in the PR body execute INSERT INTO chat_messages / INSERT INTO chat_queued_messages against a raw *sql.DB. These are exactly the snapshot-less history rows CODAGT-749 was about, and the guard is blind to them by construction.

This is operator decision 7 (raw-SQL fixtures not catered for), so it is a deliberate boundary, not an oversight; some of trigger_test.go's raw inserts legitimately exercise the DB triggers and should stay raw. But the "separate cleanup" has no ticket, so under a no-follow-up assumption the gap is permanent: a future author copying an existing raw-SQL seed in one of the non-trigger files reintroduces invisible-row history and nothing flags it. This needs a human decision: file a ticket for the fixture-seed cleanup, or state explicitly that the raw-SQL boundary is accepted as permanent.

🤖

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Operator decision (plan decision 7): raw-SQL fixtures are not catered for and stay unchanged, and this unit files no tickets. The PR body names the six files and states the boundary. Whether to file a cleanup ticket or accept the boundary as permanent is the human reviewer's call; leaving open.

🤖 Posted using /amend-review skill via Coder Agents.

Comment thread coderd/database/dbtestutil/chatwriteguard.go Outdated
Comment thread coderd/database/dbtestutil/chatwriteguard.go Outdated
Comment thread coderd/database/dbtestutil/db_internal_test.go Outdated
Comment thread coderd/database/dbtestutil/db.go Outdated
@@ -13072,24 +13072,30 @@ func TestInsertChatMessages(t *testing.T) {

insertMessage := func(t *testing.T, store database.Store, ctx context.Context, chatID, userID, modelConfigID uuid.UUID, content string) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit [CRF-14] The insertMessage seed helper reimplements the allocate-then-insert body that this PR just gave dbgen.ChatMessage. (Robin)

This helper opens InTx, calls LockChatAndBumpSnapshotVersion, then InsertChatMessages with the full params slice, which is exactly the shape of dbgen.ChatMessage that insertSystemTextMessage, insertOrphanProviderToolCall, and the telemetry seeds all switched to. It only seeds one message to exercise the model-switch trigger, so it is a seed, not a writer-under-test, and dbgen.ChatMessage(t, store, ...) produces the same insert and fires the same trigger. Leaving the hand-rolled copy beside the new helper is the duplication the guard was meant to retire, and the allocation logic now lives in two places that can drift.

🤖

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not changed. The enclosing test is TestInsertChatMessages, and the plan names this site (querier_test.go:13093) as one of four tests of a writer that call it directly inside an allocating InTx. The helper's InsertChatMessages call is the subject under test, including the model-switch trigger it fires; routing it through dbgen.ChatMessage would test the fixture rather than the writer. The duplication with dbgen.ChatMessage is real but intentional at this site. Leaving open for the human reviewer.

🤖 Posted using /amend-review skill via Coder Agents.

…ection

The completeness test proved an override exists per writer query but
not that it rejects; only two of the thirteen were ever driven to the
guard error. A table now calls each writer on the root handle and the
completeness test requires the table to name every override, so a
future override that drops the check fails a test instead of passing
silently. A nested InTx case covers the one branch that shares the
outer allocation set.

Also widen the writer query pattern to ONLY and MERGE INTO forms, and
rename require, mark, covered and rejections to say what they hold.
… own test

The rejection test also proved the positive path, so a failure there
would report under a name that says "rejects". The nested allocation
case now has a test named for what it asserts. The table comment also
claimed only the chat id is set, which the SoftDeleteChatMessageByID
entry contradicts; it now describes the id the guard reads.
@mafredri

Copy link
Copy Markdown
Member Author

/coder-agents-review

🤖

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant