Skip to content

fix(db,agent,ui): a Redis ACL user was dropped, a right answer was scored wrong, and five more - #502

Open
cevheri wants to merge 1 commit into
mainfrom
chore/backlog-round13
Open

fix(db,agent,ui): a Redis ACL user was dropped, a right answer was scored wrong, and five more#502
cevheri wants to merge 1 commit into
mainfrom
chore/backlog-round13

Conversation

@cevheri

@cevheri cevheri commented Aug 26, 2026

Copy link
Copy Markdown
Member

Round 13 of the docs/BACKLOG.md sweep. Seven entries closed, five new findings recorded.
Every fix was measured on both arms with a control arm, so no assertion here is vacuous.

Ordered by cost/value, cheapest-and-highest first.

What is fixed

D29 - Redis authenticated as a principal the user did not choose (security)

RedisProvider.connect() built its ioredis options with host, port, password, db and
no username, so every Redis 6 ACL user was silently replaced by default. The connection
form collects the value and connection-string-parser.ts fills it from redis://user:pw@host -
only the one place that matters ignored it. Every managed Redis (Redis Cloud, ElastiCache RBAC,
Azure Cache) issues least-privilege credentials as ACL users, so this is not exotic.

Measured against redis:latest with default left on nopass ~* &* +@all and probe defined
on >probepw ~* +@all -info, four arms through the running product:

arm result
no username (before the fix's shape) ACL WHOAMI = default, INFO succeeds, green
user: probe (after) WHOAMI = probe, INFO refused NOPERM, degraded (amber)
control: probe + wrong password connection fails - so the username really is sent
control: no username + wrong password green - default is nopass, any password works

The second half of the entry's Done-when needed no new machinery: getHealth() already throws,
POST /api/db/test-connection already separates connect from health, and the amber data-tone
banner already existed. It was being fed a lie; now it is fed the truth.

Provider tri-sync honoured: code, docs/providers/redis.md (new ACL section with the
measurement and the INFO-derived surfaces a restricted user loses), and the integration tests
move together.

B45 - a run that produced the right index was told it had not answered

The optimization verifier composed on the investigation baseline, whose emptiness census reads
rowCount. A plan artifact legitimately carries rowCount: 0 - a plan is a description of a
statement, arriving in one column - so runs that compared real costs and recommended the correct
index were scored unanswered / empty-evidence. The product contradicted its own good answer,
in its own voice, at the end of the run.

The census now takes a caller-supplied exemption set, and only the optimization rule passes
one. agent-investigation.1 and the two verifiers composing on it are deliberately untouched
and pinned by a test, so the boundary is stated rather than implicit. Because the rule changed
its mind about runs already scored on main, the id moves agent-query-optimization.2 -> .3.

The exemption is one artifact kind and nothing more: an empty bounded read cited by an
optimization report still ends the run empty-evidence, because zero rows from a read is the
answer that read gave.

B54 - a refused grounding capture left the ledger silent

The repo's own rule is that the ledger is the authority on what a run did. That held only for
captures that succeeded: the refusal branch pushed a sentence into the model's prompt and
returned, so an ungrounded run's ledger had nothing between the drive starting and the run
ending - in exactly the case an operator has to diagnose.

Both refusal sites (found by grepping the mechanism, not the entry) now record
context-unavailable carrying the reason code, the capture's own sentence, and - where the row
budget refused the read - rows projected against rows allowed.

Its own event kind rather than a context-captured carrying an absence, for two reasons that
point the same way: a refused capture has no honest fingerprint or tableCount (and
tableCount: 0 would assert the database has no tables), and three readers treat
context-captured as proof an inventory exists.

The two numbers still travel as prose inside the provider's refusal message. That is pinned by a
test that drives a real over-budget queryReadOnly through bun:sqlite and asserts the parse
against the error the provider itself threw - so a reword goes red in a test instead of going
quiet in production. Residual recorded as B73 with its blast radius measured.

B37 - a server config fault disabled the agent and blamed the connection

A seed-connections.yaml the server could not read made GET /api/connections/managed throw, so
the browser held an empty servedSeeds and the rail said "Sample (Employees) cannot be rebuilt
on the server: its settings live in this browser"
- of a connection this application seeds
itself. False twice over, and it pointed the operator at the wrong file.

The fault was a joint, not a component: servedSeeds was an array, and an array cannot say "I do
not have this", so a failed load and a legitimately empty list were the same value. Fixed at the
three places that lose the distinction - the endpoint attributes its own failure
(reason: "seed-config-unreadable"), the browser holds a load state, and the rail reads the
reason rather than guessing it.

Verified in Chrome against a real malformed seed file:

The server could not read its own connection configuration, so it cannot resolve a connection
for a run. This is not a problem with Sample (Employees) - the server log says what failed.

data-tone="warning", no browser-settings claim, Start disabled. Control: the element is absent
on a healthy config, so the new copy appears only when the server actually failed.

B25 - a covered foreign key was reported as unindexed

SQLite stores no DDL for the index it builds to enforce a UNIQUE constraint, so the composed
index read (sql IS NOT NULL) could not see it and fk_unindexed fired on a covered key - a
false finding presented to the user as a fact.

The capture now reads those out of the table's own CREATE TABLE and lists them as
(unique constraint). Plain words rather than the engine's sqlite_autoindex_<table>_<n>,
because that name is not derivable: a named CONSTRAINT uq_x UNIQUE (...) does not lend the
index its name (measured), and inventing one would put an identifier in the inventory that may
not exist in the database.

Coverage stays a prefix test - UNIQUE (note, parent_id) does not cover parent_id, and the
control fixture asserts the finding still fires there. PRIMARY KEY is not synthesized: an
INTEGER PRIMARY KEY is the rowid, with no index behind it at all.

PRAGMA index_list does list auto-indexes, and was measured to confirm it - but the M1 statement
guard refuses any word beginning PRAGMA_, so it is unreachable on the agent path. The DDL is
the only source.

Two side effects worth a release note: this changes the inventory fingerprint for any SQLite
database with a UNIQUE constraint (a run resumed across this deploy sees a moved descriptor),
and raises the per-table index count for those tables.

H10 - a security gate asserted something it never checked

ROUTES_WITHOUT_A_PROVIDER claimed its entries reach no database or LLM provider, and the test
only verified that each key named a real route. An allowlisted route that later grew a provider
call would have escaped the session sweep forever, silently.

It now reads each allowlisted route's own source and matches the provider entry points by
module-specifier prefix (so a new factory export is covered the day it lands), including the
indirect @/lib/api/schema-route helper that db/schema/list and db/schema/relations reach a
provider through without naming @/lib/db at all. Proven non-vacuous by temporarily
allowlisting db/query and watching it fail with the expected message.

Zero real violations on the allowlist today. The one entry that does reach a provider
(agent/drive, the durable transport's callback) is documented as such, and a second assertion
keeps that exemption from quietly becoming a new unchecked allowlist.

D28 - closed by writing the decision down

The scheme-versus-parameter TLS asymmetry now reads as a decision on withSSLMode rather than an
artefact: a secure scheme is the only spelling a self-hosted deployment has, so a verifying
default there would refuse the ordinary --tls-port install.

Also

buildConnectionPayload's managed arm is a credential boundary - a seed travels as a bare
seed:<id> reference so nothing about how to authenticate leaves the server's config - and it
had no direct test, its coverage arriving incidentally through other hooks. Now pinned, including
the assertion that no credential appears in a managed request body.

New backlog entries (with measurements)

  • B72 - the investigation, database-assessment and data-analysis verifiers still judge a
    plan-only report by the emptiness census. Reachable (inspect_plan is offered to all three);
    closing it changes what three released verifier ids mean.
  • B73 - the row-budget pair travels as prose and is recovered by regex, with two prose
    consumers. Doing it properly touches the error type every provider throws (~40 call sites).
  • B74 - UNIQUE (a COLLATE NOCASE) is reported as covering a key it cannot serve for a
    binary lookup. A false negative in the direction opposite the one B25 fixed.
  • T4 - eight backlog ids cited in src/ name entries that no longer exist (B7, B8, B17,
    B18, B24, B27, B43 in 8 files, B47 in 3). Pre-existing on main; the drift guard enforces the
    two-way invariant only for docs/AGENT.md, so source citations rot unnoticed. Not mass-fixed
    here on purpose: each needs its claim re-verified, and the two possibilities are opposite
    (deleted because fixed, or deleted by mistake).
  • T5 - the new allowlist check reads one level deep; a route reaching a provider through a
    new indirect helper would still pass.

D14's Done-when was corrected: dropping bufferPoolUsage is no longer an option, because
MySQL, MongoDB, Couchbase and ClickHouse all fill it with a real figure.

Verification

All gates run locally, one command each:

gate result
format clean
lint 0 errors, 128 warnings (all pre-existing repo-wide)
typecheck clean
knip clean
core tests 351/351 files
component groups 33/33
evals 197 pass
build clean
build:lib + attw clean
coverage:check 43452/43452 lines (100.00%)

What was NOT verified

Stated plainly rather than implied:

  • B45 and B54 were not driven against a live model. Both are covered by the eval harness on
    both arms, and B54's timeline copy ("Schema not captured") has unit coverage, but neither was
    seen rendered from a real run.
  • D29's amber banner was verified through the API, not by clicking the modal. The route
    returns degraded: true with the server's own NOPERM sentence, and the hook-to-banner chain is
    covered by pre-existing tests; the rendered banner itself was not re-driven in the browser.
  • The 8 dangling ids in T4 were measured, not fixed.
  • docs/providers/redis.md still carries stale redis.ts:<line> anchors, now ~17 lines further
    out of date below connect(). The class is already tracked as DOC1; not partially corrected
    here, because a half-fixed anchor looks authoritative while still being wrong.

Not merged - for review.

…ored wrong, and five more

Round 13 of the BACKLOG sweep. Seven entries closed, each measured on both arms with a
control, and five new findings recorded with their measurements.

D29 - Redis authenticated as a principal the user did not choose. `RedisProvider.connect()`
built its ioredis options without `username`, so every Redis 6 ACL user was silently
replaced by `default`. The form collects the value and the connection-string parser fills it
from `redis://user:pw@host`; only the one place that matters ignored it. Measured against
`redis:latest` with `default` left `nopass` and `probe` defined `on >pw ~* +@ALL -info`:
before, `ACL WHOAMI` answered `default` and `INFO` succeeded, so a restricted principal
reported full health; after, WHOAMI answers `probe` and the refused `INFO` reaches the
connection test as a degraded (amber) result rather than a green tick. Two controls make it
non-vacuous: `probe` with a wrong password now fails, and no-username with a wrong password
still succeeds because `default` is `nopass`. No new machinery was needed for the degraded
half - the amber path already existed and now gets fed the truth.

B45 - an optimization run that produced the right index was told it had not answered. The
verifier composed on the investigation baseline, whose emptiness census reads `rowCount`, and
a plan artifact legitimately carries `rowCount: 0` because a plan is a description arriving in
one column. So the product contradicted its own correct recommendation in its own voice. The
census now takes a caller-supplied exemption set and only the optimization rule passes one -
`agent-investigation.1` and the two verifiers composing on it are untouched. Because the rule
changed its mind about released runs, the id moves `agent-query-optimization.2` -> `.3`. An
empty bounded read cited by an optimization report still ends the run `empty-evidence`.

B54 - a refused grounding capture left the ledger silent, in exactly the case an operator has
to diagnose. Both refusal sites now record `context-unavailable` carrying the reason code, the
capture's own sentence, and - where the row budget refused the read - rows projected against
rows allowed. Its own event kind rather than a `context-captured` carrying an absence: a
refused capture has no honest fingerprint or table count, and three readers take
`context-captured` as proof an inventory exists. The two numbers still travel as prose inside
the provider's refusal message; that is pinned by a test driving a real over-budget
`queryReadOnly` through `bun:sqlite` and asserting the parse against the error the provider
itself threw, so a reword goes red instead of going quiet (residual recorded as B73).

B37 - a seed config the server could not read disabled the agent everywhere and blamed the
connection, saying "its settings live in this browser" of connections this application seeds
itself. The fault was a joint: `servedSeeds` was an array, and an array cannot say "I do not
have this", so a failed load and an empty list were the same value. The endpoint now attributes
its own failure (`reason: "seed-config-unreadable"`), the browser holds a load state, and the
rail says which absence it is. Verified in Chrome against a real malformed seed file: the new
copy renders with `data-tone="warning"` and no browser-settings claim, and is absent on a
healthy config.

B25 - SQLite stores no DDL for the index it builds to enforce a `UNIQUE` constraint, so the
composed index read could not see it and a covered foreign key was reported as unindexed. The
capture now reads those out of the table's own `CREATE TABLE` and lists them as
`(unique constraint)` - plain words, because a named `CONSTRAINT` does not lend the index its
name (measured) and inventing one would put an identifier in the inventory that does not
exist. Coverage stays a prefix test: `UNIQUE (note, parent_id)` does not cover `parent_id`.
`PRIMARY KEY` is not synthesized - an `INTEGER PRIMARY KEY` is the rowid with no index behind
it. Note this changes the inventory fingerprint for any SQLite database with a UNIQUE
constraint, and raises the per-table index count for those tables.

H10 - the route-auth allowlist asserted that its entries reach no provider and never checked
it, so an allowlisted route that later grew a provider call would escape the session sweep
forever. It now reads each allowlisted route's source and matches the provider entry points by
module-specifier prefix, including the indirect `@/lib/api/schema-route` helper that two routes
reach a provider through without naming `@/lib/db` at all. Zero real violations today.

D28 - closed by writing the decision down where it is made: the scheme-versus-parameter TLS
asymmetry is now a documented decision on `withSSLMode` rather than an artefact.

Also: `buildConnectionPayload`'s managed arm - a credential boundary - had no direct test and
was covered only incidentally through hooks; it is now pinned with the assertion that no
credential appears in a managed request body.

New entries, each with its measurement: B72 (three verifiers still judge a plan-only report by
the emptiness census), B73 (the row-budget pair travels as prose), B74 (a `COLLATE` unique
constraint is reported as covering a key it cannot serve), T4 (eight backlog ids cited in
`src/` name entries that no longer exist - pre-existing on main, and the drift guard covers
only `docs/AGENT.md`), T5 (the allowlist check reads one level deep). D14's "Done when" was
corrected: dropping `bufferPoolUsage` is no longer an option, because four providers fill it.

Gates, all run locally: format, lint (0 errors), typecheck, knip, 351/351 core files,
33/33 component groups, 197 evals, build, build:lib, attw, coverage 43452/43452 lines (100.00%).
@sonarqubecloud

Copy link
Copy Markdown

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant