--- status: located opened: 2026-10-02 located-in: [mesh-catalog modules/postgres/client.ts (readOnlyQuery)] fixed-by: amended-design: --- # 193 — The store seat's read-only query is read-only by convention, and its answer is unreadable ## What was observed Asking the store seat's `query` verb for a count through the console returned this. Rows are each wrapped in an object under a key named `BEGIN`: the column name, then the value, then the word `ROLLBACK`. A query returning nothing gave the column name and `ROLLBACK` alone. The answer to `select count(*) as n from ` was: > `rows: [ {BEGIN: "n"}, {BEGIN: "46"}, {BEGIN: "ROLLBACK"} ]` A reader can work it out. A program cannot, and a query with two columns loses which value belongs to which. ## Why this is here **The cause is the same line that makes the query read-only.** The holder's tool sends `BEGIN TRANSACTION READ ONLY; ; ROLLBACK;` to the command-line client as one string. The client prints a command tag for each of the three statements, and the parser takes the first line, `BEGIN`, as the header. **And it is not read-only.** [ADR 0159](../../02-DECISIONS/0159-a-tool-call-names-the-machine-and-a-holder-serves-its-seats-verbs.md) decided the store seat's `query` verb is "one read-only statement against one database". The only thing enforcing that is the wrapping transaction, and the caller's statement is pasted inside it as text. A statement that begins by ending the transaction (a commit, then anything) runs whatever follows it outside the read-only transaction, with the holder's own role, the administrative one that creates every consumer's role and database. A rule stated in a decision and enforced by string concatenation is enforced by nothing. *This is read from the code, not tried against the live store, and it should not be tried there.* The lab bed is where it gets proven. Every caller with `invokes` on the store seat's `query` can do this. The console has `invokes: ["*"]`, so that includes anyone logged in on a machine running the console. ## What a fix looks like - **One statement, refused otherwise.** Send the caller's statement alone, through the client's single-statement path (the extended protocol takes one statement per call and refuses more). The read-only property then comes from the session, not from text around the statement. - **Read-only by role, not by transaction.** Run the verb as a role that can only read, granted `pg_read_all_data`, not as the administrative role. A statement that escapes every wrapper still cannot write. - **Rows as rows.** Parse the client's output with the column names it returns, or use a driver instead of the command-line client, so a row is an object keyed by its columns. - **The check 0159 lacks:** a test that sends a commit followed by a write and asserts the write is refused and nothing changed. Another asserts a two-column row comes back keyed by both columns.