Repository navigation
Releases: ahmetrende/database-queryhub
Release list
v1.0.36
Cancel now stops a running write or schema change. An error message no longer
shows a value that the database read from a row. A sweep on real servers closed
the phrasings that the first version missed. Every web response carries an
X-Request-ID. A state change and its audit row commit together. Both services
warn about pending migrations. The mssql CI job tests SQL Server live and found
three defects. This release fixes one. Two stay open. Patch bump.
Upgrading
- Apply the migrations before you restart, as before. From now on, the web
process answers/readyzwith 503 while a migration file is missing from the
ledger. Both services log a CRITICAL line at start. This release adds no
migration. - Restart every QueryHub process. The cancel path, the audit writes and the
web app changed. - The text log format of the web process changed. Each line now has
[id]
before the message, and[-]outside a request. Update a log parser that
matches the old format. A JSON line gets atrace_idfield inside a request.
Added
- Every web response carries an
X-Request-ID. The server makes the id and
ignores one that the client sends. An unhandled 500 also holds it as
error.traceId. Each log line of a web request carries it astrace_idin
JSON, or as[id]in text. A user report that quotes the id leads to its log
lines. - Startup guards. Both services log a CRITICAL line when migrations are
pending. The web app logs one when its base URL ishttp://and the host is
not loopback./readyzanswers 503 while migrations are pending. A check
stays silent when it cannot know: no migrations directory, no ledger, or a
login that cannot read the ledger. - Live SQL Server tests in CI. The
mssqljob runs
tests/test_integration_mssql.pyagainst a throwaway SQL Server 2022 and fails
if the tests skip. It found three defects. The Fixed section below covers the first. The
other two are open and written indocs/KNOWN_LIMITATIONS.md: the login of
deploy/grant_mssql.sqlhas noSHOWPLAN, so a script of several writes
commits only its last statement, and Stop cannot reach a running SQL Server
query. - A test fails when a setting has no documentation.
tests/test_config_docs_sync.pychecks everybot_configkey and every
environment variable that the code reads, and every local link in the docs. The
reference now lists six keys and six variables that it missed.
Changed
- SQL Server errors show the message itself. The driver tuple and the
SQLSTATE prefix no longer appear in front of it.
Fixed
- Cancel stops a running write or DDL request. The signal used the RO login,
and PostgreSQL refuses it for a backend that runs as another login. The
request ran untilquery_timeout_sec. The signal now uses the login of the
tier that the request runs at, and never a higher one. The runaway watchdog
stops long writes too. Each cancel opens one more session for that login, so a
login at its connection limit makes the cancel fail. - A state change and its audit row commit together. A password change, an
IdP sync, the cancel of a scheduled query and the decline of a window request
were two commits. A failure between them left a change with no audit row, or
half of an IdP sync. Each now runs in one transaction.tests/test_audit_discipline.py
fails on a new standalone audit row for a state change. deploy/grant_mssql.sqlno longer deniesCONTROL SERVER. That denial
also deniedCONNECT SQL, so its logins could not log in. The script now
revokes it from a login that an older version made.
Security
- An error message no longer shows a value that the database read from a
row.SELECT full_name::int FROM customers WHERE id = 1failed with that
name in the message, and the result file would have masked it. While PII
masking is on,[hidden]replaces the value. A value that the requester typed
in the SQL stays visible, and a CSV import shows every value. The list covers
PostgreSQL, SQL Server, ClickHouse and Athena. Masking still does not stop
inference with a predicate and a row count (docs/COMPLIANCE.md, section 4). - The error scrubber has a time limit. The content detectors took 38 seconds
on 200 KB of one character, so a large cell could block an executor thread
while it was cast.scrub()reads at most 8000 characters in the hiding
step and 1000 in the content detectors. - A sweep on real servers closed the phrasings that the first version
missed. It ran 642 failing statements on PostgreSQL 17 and SQL Server 2022,
with a sentinel value in every column type. Of them, 187 still showed the
value: 138 of 398 on PostgreSQL and 49 of 244 on SQL Server. A cast to
regclassor a call ofcurrent_setting()with a column puts a value where a
name goes, so a name that is not in the requester's SQL is hidden too. A typo
that the requester wrote stays visible. All the cases now pass.
docs/COMPLIANCE.mdlists what stays: the free text ofRAISE,ASSERT,
THROWandRAISERROR.
v1.0.35
QueryHub refuses a mistake in a write or a script when you submit it, and the
pre-flight can no longer run a hidden second statement. An admin sets the owner
teams of a connection, gives a person or a team read access on every server,
and marks a server super-admin-only. A decision on the web retires the Slack
cards. The DM poller survives a database outage. Patch bump.
Upgrading
- Run
scripts/apply_migrations.py. It applies 141–143. Apply 141 before
you restart, because the new code readstarget_servers.super_admin_only. - Restart every QueryHub process. Each one runs its own copy of the
executor and the pre-flight. - A new key,
pre_flight_validate_scripts, has the defaulton
(migration 143). Set it tooffto go back to the old check. It needs no
restart.
Added
- QueryHub refuses a mistake in a write or in a script when you submit it.
It plans every statement and runs none. A wrong column name, a
missing table or a syntax error that PostgreSQL rejects now stops the
request before an approver sees it. The message names the statement.
Setpre_flight_validate_scriptstooffto go back to the old check. - An admin sets the owner teams of a connection.
GET/POST/DELETE /admin/connections/{conn}/ownersmanage the hand-made owners. QueryHub
refuses a synced owner and names the sync. The lead of each
owner team approves requests to that connection from any requester, up to
the fleet's ceiling (RO today). Each change writes an audit row
(target_owner_added,target_owner_removed) and runs the lead-approver
reconcile for that one target. The Connections screen has an Owners dialog,
and the picker names each team's lead. - A person or a team can get read access on every server, and a server can
be super-admin-only (migration 141).GET/POST/DELETE /admin/grants/fleetmanage oneall_targetsrow per
subject, RO only, with an optional fleet-wide waiver in the same
transaction. A write needs a super-admin andaccess_model_v2. QueryHub
refuses a mirrored row and names its source.PUT /admin/connections/{conn}/super-admin-onlymarks a server. On a
marked server, fleet-wide grants, fleet-wide waivers and the admin shortcut
count for no one but a super-admin. A grant or a waiver that names the
server still counts. Only a super-admin can write one.
- An access request can be approved on the web. The Connections screen
lists pending requests with the tier that Provision grants. A decision there
retires the admin cards in Slack and tells the requester. - A super-admin can add a reason to a run that needs no approval. The
web shows "Add a reason" where no reason is asked for. The audit log keeps
the reason with the request.
Changed
- User-facing messages follow Simplified Technical English. Slack, the
web API and the MCP server give the cause first, then the fix. Only the
words changed. No check decides differently. - A column named
tier_nameis no longer masked as a person name. The
namerule now skips a column name that has the wordtier. Other name
columns stay masked. - The masking Edit box shows its three settings at once. Replace with the
same reach saves on the original row. A real replace writes the new row,
then disables the old one. A refused duplicate says how to change its
settings. - The access-request card has no SQL recipe. Approve writes the per-user
grant, so the admin does not run SQL first. The card keeps the attempted
query of the requester. - The Scheduled panel says where a run goes.
GET /scheduledsends
runOnandreplicaName. - The screens use Simplified Technical English. The admin screens,
sign-in, home and panels carry design's copy. Masking says Disable and
Enable. - Athena connections show the AWS Architecture icon.
THIRD_PARTY_NOTICES.mdnames Amazon as the owner of the mark.
Fixed
- A comment in front of a write no longer hides its row estimate. A
request that started with-- ...or/* ... */got no "~N rows affected"
line, because the check read the comment as the first word. QueryHub also
refused anEXPLAINwith a comment in front as a syntax error. Both work now. - Approving again after a lapse restores access. The approval kept the old
end date. An active grant of the same tier keeps the end date it has. - The DM poller survives a database outage. The loop read its interval in
thewhilecondition, outside thetry. One outage ended the thread, and
no DM went out until a restart. A failed read now keeps the last good
interval, and the interval is at least one second.
Security
- The pre-flight
EXPLAINcannot run a hidden second statement. QueryHub
now sends it with the extended protocol, and PostgreSQL itself refuses a
text that holds more than one statement. A tail such asROLLBACK; DELETE ...
could leave the read-only transaction when the statement split and the
server disagreed. Measured on PostgreSQL 18: the tail deleted the row. Now the
server refuses it, and QueryHub logs a warning.
v1.0.34
The web editor undoes every edit, expands * to exactly the columns a
statement reads, and quotes reserved words. For a table that the catalog does
not list, such as a partition, QueryHub reads its columns live from the
target. A super-admin can choose where one query runs, a grant can carry
auto-approve, and a deleted instance shows as deleted. The trusted-portal seam
is ready, and a sync that would disable many people waits for a super-admin.
Patch bump.
Upgrading
- Run
scripts/apply_migrations.py. It applies 132–140. WEB_SESSION_SECRETmust be at least 32 bytes when you set it, or the
web process does not start. If you do not set it, QueryHub derives the key
from the master key, as before.- An unreadable
secrets.encstops the process. It used to read the
plaintext environment instead.QH_SECRETS_PLAINTEXT_FALLBACK=1allows that
on purpose. - New
bot_configkeys:web_slack_team_id,target_ssl_verify_hosts,
target_ssl_verify_exempt_hosts,web_employment_grace_hours,
idp_clock_skew_seconds,idp_outbox_enabled,idp_outbox_retention_days,
idp_sync_max_disableandathena_freshness_stale_hours. See
docs/CONFIGURATION.md. - The
*expansion reads a relation's columns live through
GET /connections/{conn}/databases/{db}/columns, with the RO login
(PostgreSQL only). It needs no new privilege: every login can read
pg_attribute.
Added
-
A grant can carry auto-approve, in the same transaction. The web grant
form has an Auto-approve box with a tier (RO by default, never above the
grant's tier, never DDL)./sql granthas an "Auto-approve read-only
queries" box.POST /admin/grantstakesautoApprove/autoApproveTier.- QueryHub writes one waiver per granted database, with the grant's expiry.
If a waiver fails to write, QueryHub reverts the grant. - QueryHub does not write a waiver when the subject already holds an equal
or broader one. The response (autoApprove), the toast and the Slack
summary name the held waiver that covers the grant. - A person's waiver goes to
auto_approve_grants(the migration-109 mirror
projects it). A team's waiver is anaccess_grantrow in the grant's
transaction.
- QueryHub writes one waiver per granted database, with the grant's expiry.
-
A super-admin can choose where one query runs: Auto (as before), the
primary, or a named read replica (runOnonPOST /queries, migration 137).
See OPERATIONS.md §26.- A chosen replica skips the automatic rules. If it cannot run the query,
the request fails and says why. The request never runs on the primary
instead. - QueryHub audits each honoured choice (
execution_run_on_forced). The
status poll and history rows say where a query ran (ranOn). - A scheduled request keeps the choice, and now keeps
unmaskedtoo. The
scheduler used to drop it, so a scheduled unmasked result arrived masked.
- A chosen replica skips the automatic rules. If it cannot run the query,
-
An Athena approver sees how far the archive reaches. The hint adds
"Archive complete up to UTC", with any known gaps, or "Archive coverage
unknown". It comes from the marker that the archive writes
(engine_config.freshness_marker). See docs/CONFIGURATION.md.- The hourly catalog refresh reads it, for enabled and disabled targets, and
stores the verdict intarget_servers.archive_freshness(migration 140). - If the archive does not rewrite the marker within
athena_freshness_stale_hours(36), the hint adds a warning. - If QueryHub cannot read the whole marker, the hint says unknown, never
complete.
- The hourly catalog refresh reads it, for enabled and disabled targets, and
Changed
-
QueryHub offers auto-approve only where the subject can already query.
A waiver skips review. It grants no access. A waiver written anywhere else
did nothing until the subject got access there. On that day, the access
arrived already approved.- The Auto-approve form lists nothing until you pick a person or team. Then
it lists only the connections and databases they reach, with the tier
capped at what they hold there. POST /admin/auto-grantsand/auto-grants/bulkrefuse a target that the
subject cannot query, and a tier above the one the subject holds. They
answer409, name what they refuse and write nothing. Each refusal carries
connectionId/databaseId, so the form marks the row that caused it.- A fleet-wide row reads "every server they can reach", not "all databases".
- The Auto-approve form lists nothing until you pick a person or team. Then
-
A target whose instance was deleted shows as deleted, and no one can
enable it. It looked like any other disabled target, and the admin screen
offered to enable it. Measured 2026-09-30: 26 of 68 disabled targets were
deleted instances. Every one had an endpoint that no longer resolved.- Migration 139 adds
target_servers.deleted_at/deleted_reason, with a
CHECK that forbids a target that is both deleted and enabled. So no path
can enable a deleted target. - The hourly inventory sync marks any target whose instance is gone, enabled
or not. If the endpoint returns, the sync clears the mark but does not
enable the target. - The admin connection list shows deleted targets last, under their own
heading, with no controls. A request to enable one gets409 deleted. - Fixed: an instance recreated under the same name gives its endpoint both a
deleted and a live inventory row. QueryHub judged such an endpoint by
whichever row came last, so it could disable a live target as deleted. The
live row now wins.
- Migration 139 adds
-
A pod lead approves requests on their pod's servers from anyone. The
lead's approver role used to require the request to come from the lead's own
pod as well. So a person with access to another pod's database reached no
approver except the admins. This applied to 58 of 120 single-server personal
grants, and to 28 of 268 requests decided in 30 days.
scripts/sync_team_approvers.pynow writes each owned-server row for any
requester (all_teams). The ceiling (RO by default) and the rule that no one
approves their own request stay the same. -
The trusted-portal seam is ready for its first connection (AUTH.md §1.2).
Everything here is inert whileidp_assertion_enabledis off.- QueryHub accepts an assertion with up to
idp_clock_skew_seconds(10) of
clock skew. It refuses an assertion when no email domain is configured or
when the assertion lives longer than 120 s. It also refuses any assertion
on a websocket. - The reconcile has a dry run. It never disables an admin, a role holder or
the sync account. - The reconcile and the outbox answer only the sync principal. That
principal needs no admin role. - QueryHub writes the notification outbox only while
idp_outbox_enabledis
on. The daily cleanup trims it.
- QueryHub accepts an assertion with up to
-
A portal sync that would disable more than five requesters waits for a
super-admin. The reconcile disables everyone who is not on the panel's
list. So a wrong list would block that many people from Slack, the web and
MCP within one tick. For example, the list is wrong when a role is not
granted yet. A run over the limit now changes nothing and answers
409 approval_required. Every super-admin gets one Slack card that asks
whether they approve.- Approve covers those people, once, for 24 hours. Reject keeps the list
blocked until it changes. - The limit is
idp_sync_max_disable(default 5, migration 138). All of this
is inert whileidp_assertion_enabledis off.
- Approve covers those people, once, for 24 hours. Reject keeps the list
-
The S3 metrics dashboard matches the admin panel's Metrics view: the
same typeface, cards, section labels and colours. The build inlines the
typeface, so the page stays one file. As in the panel, RW is blue and
rejected is amber. Status mixes are stacked bars, and the per-team, top-user
and admin-workload charts are the panel's labelled bars. The filters and
every figure stay the same. -
A super-admin can end a session, run a mixed script and set
search_path.pg_terminate_backend/pg_cancel_backendask first and
run as the elevated role. A script that mixes tiers runs as one request at
its highest tier.SET search_pathtakes a list of schema names for the one
request. QueryHub still refuses all three for everyone else. See
OPERATIONS.md §25. -
ClickHouse
systemandinformation_schematables are readable. A
requester with a grant on a ClickHouse target can query its partition sizes,
columns, running merges and query log. Before, QueryHub refused them as
server internals. Table functions stay default-deny. -
QueryHub records a statement that the web refuses in
submission_failures, as it already did for the Slack modal's refusals. The
record holds the SQL (with passwords masked) and the reason. QueryHub does
not record a confirmation prompt. -
The MCP
describe_databasesearch picks its tables in SQL. A loose
filter used to read every column of every matching table and keep 25. On the
largest catalogue here, that read 31,412 rows in 124 ms. The search now reads
353 rows in 4 ms. -
An Excel download of a CSV result needs only one conversion. QueryHub
keeps the workbook beside the result and deletes it with the result. -
The container image shows its build. The build stamp was empty in the
image, which has no.git. The release stamps the commit and version, and
QH_IMAGE_DIGESTadds the image digest. -
Every admin screen stops at 1600 px on a wide monitor, centred beside
the nav. The approval queue's detail pane stops at 1120 px. This replaces the
Audit log's own 1480 px cap from 1.0.33. Nothing changes below about 1860 px
wide. -
The admin panel is one click away at the head of the left column, with
the approval badge on it. The Developer / Admin tabs left the top bar. When
the sidebar is hidden, the entry becomes an icon beside its toggle. -
The admin panel opens in your real role. The "Viewing as" switch is gone.
-
Only a super-admin sees the kill switch's button. Other admins still see
it...
v1.0.33
Two engines execute: ClickHouse and Amazon Athena, both read-only. A read-only
query can run on a healthy read replica. A submitted password is no longer
stored anywhere. Patch bump: every new bot_config key keeps the old behaviour
by default, and the audit contract is unchanged. New actions are added, and no
existing row changes meaning.
Upgrading
- Run
scripts/apply_migrations.py. It applies 126–131. 129 changes how a
password in submitted SQL is stored. 130 corrects 129's trigger, which named
a status the enum does not have, and so failed every write torequests. The
runner applies both together; do not stop between them. - Optional extras.
pip install '.[clickhouse]'for ClickHouse targets.
Athena uses'.[aws]'(boto3). Neither is needed if you run neither engine. - New
bot_configkeys, each defaulting to what happened before:
replica_routing(off),replica_max_lag_seconds,
replica_health_ttl_seconds,replica_read_your_writes_minutes, and
<engine>_blocked_functions. See docs/CONFIGURATION.md. - SQL copied back out of history may be refused. A statement stored with
PASSWORD '***REDACTED***'is refused until the password is typed again.
Security
- A submitted password is never stored.
requests.queryis written with
every password literal masked. The original is kept Fernet-encrypted in
requests.query_secretonly while the request can still run, and dropped
when it ends, including when it is handed to a DBA. Saved workspaces,
templates, favourites and refused submissions keep the masked text. So do
Slack messages, snippets, upload comments and log lines. - ClickHouse runs read-only by construction. The login is
readonly=1,
table functions are refused unless allowed,SETTINGSclauses are refused,
andsystem.*andinformation_schemaare blocked. An operator can refuse
more functions withclickhouse_blocked_functions, and cannot allow any. - An Athena approver sees what a query can scan before approving it. The
bound is read from the objects the query can touch, not from catalog
statistics, which were measured five weeks stale.
Added
- ClickHouse executes, over the native protocol (9440, TLS). The read-only
login cannot set server-side limits, so a watchdog enforces the timeout and
a cancel by closing the connection, which stops the query on the server.
The schema catalog is read at most daily, and only when the inventory says
the service is running: an idle service is billed when a query wakes it.
Import withimport_targets_from_inventory.py --clickhouse. - Amazon Athena executes: Glue-backed schema, result values kept as the
exact strings Athena returns, and the bytes scanned recorded in the audit
trail. - Read replicas. A read-only PostgreSQL request runs on a healthy, enabled
replica of its target, and on the primary otherwise. Replicas are linked
from the inventory (target_servers.replica_of, migration 131) and never
appear in a picker. Some reads stay on the primary: those about the server
itself (pg_stat_*,pg_locks, WAL functions), and a requester's reads
just after their own write there. A replica that fails a read hands it to
the primary once. The result says it came from a replica, and
requests.executed_target_idrecords which one. Off until
replica_routing = 'on'; see docs/OPERATIONS.md §26. - Admin: enable, disable or set a credential on many connections at once,
all or nothing. Edit a role in place. See one team's effective access, and
a person's, resolved per database. Add auto-approve for a team or for many
targets in one step. A queue lists requests waiting for a DBA to run by
hand, and closes them from the web. - Requesters can ask for auto-approve from the web, and an admin decides
there or in Slack. - Imported targets are labelled with the cloud they run on (
tags.provider).
Changed
- A new connection can take the name a disabled one holds. The disabled
one takes its engine's suffix (orders→orders-pg). An enabled holder
keeps its name: the importers suffix the newcomer, and the admin form
refuses the name. - "Ask to skip review" is "Request auto-approve", and every screen says
auto-approve for the same idea. - A team's auto-approve applies to its members when they submit, as the
screens already showed. - A connection test shows green or red. A connection an admin enables or
adds appears in the list without a page reload. - The audit log stops at 1480 px on a wide screen, so the actor and the
request id stay next to the action they belong to.
Fixed
- A request handed to a DBA no longer shows as running forever on the
web. It says "Needs a DBA", with no spinner and no Stop button. - The RDS import step no longer stops on a name clash.
- The ClickHouse catalog compares the inventory's naive timestamps in the
inventory's own time zone, and stores its flags as booleans.
v1.0.32
The panel names people instead of logins, the masking ladder's second rung is
held to its schema, and two screens that were broken only in the built bundle
work again. Patch bump: the bot_config keys and the audit contract are
unchanged.
Security
- A table-level masking exemption is held to its schema. The ladder's table
rung compared BARE table names, so a row written for one schema's table also
covered a same-named table in every other schema of that database — the fault
the column rung had, on the louder rung: this one returnsskip_all, masking
off for the ENTIRE result rather than one column. The rung now runs through
the same_schema_admitscheck, which can only ever REFUSE: a row with no
schema, a statement that writes the table unqualified, and SQL that will not
parse are all admitted exactly as before. Measured the way the column rung
was — the resolver replayed over every completed request that recorded its
result columns, 3,545 requests and 911 invocations, with and without the
schema on table rows: no answer changed. 1.0.31's notes described this fix
a release early; the code is here.
Added
- Masking exemptions can be edited from the panel.
PATCH /admin/mask-exemptions/{id}now carries the reason, the strength, the join
behaviour and the audience, not justenabled. Reach stays immutable — a
row's reason describes that row's reach, so a row whose reach moved carries a
reason that is a lie about production data. Narrowing is still
turn-off-and-create. Scope fields are DECLARED and REFUSED (400 reach_immutable, naming which ones) rather than dropped, because a client
that believes it just narrowed a row, against a server that ignored the
field, shows an operator a protection they do not have. Migration 125 adds
updated_by/updated_at. - A target whose schema catalog stops refreshing says so. The hourly
refresh logged a warning and returned the same either way, so a target could
serve a stale snapshot for days — an expired credential, a host that moved —
and the first symptom was somebody asking why a new table is missing from the
picker. Migration 124 addsschema_refresh_health, one row per target,
overwritten each run. A target counts as failed only when NOTHING was
snapshotted.
Changed
- People are shown by name, not by login. A person reached the API as three
different strings — a Slack id, afirst.lastlogin, and an ascii-folded
display name — and every screen printed whichever one it got. They are now
resolved once, from the two people tables: an id by id, a login by the local
part of the stored email, and a stored name by its ascii fold. Measured over
180 days of requests: 47 of 49 distinct principals resolve, 38 of them to a
different string than the screen printed — including the ranked lists, where
the folded and unfolded spellings of one colleague were two rows with the
count split between them. What cannot be resolved is returned unchanged: a
service principal belongs to no person, and the auto-approver is not sent
through the lookup at all. - The audit trail names the requester, not the actor. Its one name slot
held the admin who pressed Approve, which for a run of auto-approvals is the
same three words repeated; the person who asked for the query is what an
auditor scanning the trail is looking for, and 76% of the rows the screen
shows by default carry one. The actor is still in the rail, one click away. - The Slack sign-in names the workspace. The authorize request never sent
team, so Slack rendered the account picker — and because it had to ask, it
asked for consent too, on an app the workspace had already authorised. It is
the stricter flow as well as the quieter one: somebody in two workspaces
could previously pick the wrong one and learn it later from
wrong_workspace. - The masking screen was redesigned. Two lines per exemption grouped by
server, with the full story one click away; add another column without
walking the whole form again; several columns of one table at once under one
reason; and the form shows how that table is really queried before you decide
about joins.
Fixed
- A view is listed under Views, not under Tables as well.
GET /connectionsdropped the relation kind, so the tree drew every relation
under "Tables" and then drew the views again once the lazy schema load
answered. One real database read "Tables 41" for 2 tables and 39 monitoring
views; 77 of 84 catalogued databases were mixed that way, 2,968 views in
total. The refs now carry the catalog's own relkind and one shared helper
decides the branch, so the tree and the fleet search cannot drift apart. - The admin Connections screen stopped throwing.
qhEndpointHover is not defined, every open since 2026-09-10: the helper was declared in one file,
called from another, and never put onwindow. The raw prototype hides that
completely — every top-level declaration is a global there — while the built
bundle gives each file its own module scope. Two guard tests now read the
shipped sources and fail on a name that is used but neither declared nor
exported, and on two files that declare the same top-level name. - A foreign table is listed alongside ordinary tables in the browser.
- An edit that changes nothing says so. The endpoint answered
changed: false, writing and auditing nothing, while the screen announced
"Exemption updated." — which is a screen about reducing protection claiming
an edit that did not happen. The audit row is still written for every edit,
including a reason corrected to itself; only a change to what the exemption
reaches sends a notification.
v1.0.31
Masking exemptions became writable from the panel, and two ways masking was
wrong in the stored model were found while building it. Patch bump: the
bot_config keys and the audit contract are unchanged.
Security
- A schema on a narrow exemption no longer widens it.
pii_masking_exemptions
rows that name a table or a column also carry a schema, to NARROW what they
cover. One branch read it as though the row were schema-scoped, so a single
exemption on one column lifted masking for EVERY statement whose tables were
all schema-qualified in that schema. Measured on the live fleet before the
fix: three such rows on two targets, firing on 25 real requests since
2026-08-13. The columns those requests returned were already exempt by their
own rows, so nothing measurable was exposed — the hole was in what the next
query would have got. It mattered more than its age suggests because the new
Add-exemption form makes a schema mandatory on the column rung, which would
have made the accident the default. - A schema now narrows what it names. The mirror of the same confusion:
table_namematches the bare name, so an exemption for one schema's table
also freed a same-named table in another. A row that names a schema is held
to it, and the rule can only ever refuse — a statement that writes the table
unqualified, or that will not parse, is unchanged, because refusing there
would start masking data that comes back unmasked today. Replayed over 3,211
completed requests: no answer changed.
Added
- Masking exemptions can be written from the panel. Create, switch off,
remove, the catalog the pickers read, and a preview. Super-admin only; every
change is audited under data protection. The rung is the contract:scopeis
what the operator confirmed on screen and everything narrower is forced to
NULL, so a stale form field cannot store a different row than the sentence
that was agreed. Fleet-wide comes from the scope, never from a missing
connection. - A preview that reads one real row. A generated
SELECT … LIMIT 1on the
read-only credential, in a read-only transaction, at most six columns, and
only for a table a real query has touched in the last 30 days — that query is
evidence the table is live, never re-executed. Both sides of the diff run
through the real masker, so the screen cannot promise an outcome the executor
would not produce. - Masking changes notify the admins (migration 122). Twenty-one triggers
covered the tables deciding who may READ what; the table deciding what they
SEE had none. It is the one with no subject of its own, so the event carries
no user and the poller fans it out to the people who can undo it. - Targets carry the cloud account they live in. Resolved from the RDS
inventory of every reachable account rather than from the endpoint, whose
middle token is an opaque resource id and not an account number. Shown as one
line on the hover already under a connection name.
Fixed
- A database that is not on the server named with it is refused at submit.
Nothing checked the pair; the catalog knew and the check was already written
for auto-approve grants. Pre-flight also stopped failing open on
database "x" does not exist, which arrives as a connection error and was
being treated like a timeout — an answer that can never change waited on an
approver and then failed at execution. - The database select is read by name, not by a prefix scan. Its action_id
is salted with the target so Slack re-fetches its options, and the block can
then hold more than one entry. - The connections payload asks four questions, not one per row. 313
statements and 656ms became 9 and 61ms for a 50-connection reader; the admin
listing went from 121/231ms to 5/18ms, byte-identical output. - The hourly catalog refresh diffs instead of rewriting (migration 123). It
deleted and re-inserted every row each hour: 172,450,815schema_columns
rows written over the life of a table holding 156,736. A full fleet refresh
now writes none of them. - The audit search uses an index instead of reading every row.
- Every authority question has one answer whichever door it is asked
through.
v1.0.30
The audit trail rebuilt on a derived vocabulary, and a masking ladder that can
express every reach the model already stored.
Added
- The Audit trail, on categories nobody has to maintain. The screen used to
need a chip per action name, which is why it showed 37 of 132. Every row now
carries a category and an effect derived server-side from its action name by
an ordered pattern table (migration 118), so a new action type inherits a
category for free and one nothing claims is counted asunclassifiedrather
than filed under something it is not. Measured: 47 patterns classify 121 of
132 action types, and the 8 that stay unclassified are visible on screen as a
count instead of being absorbed. - Three dimensions instead of one. Seven kinds (requests, data protection,
access, connections, configuration, usage, unclassified), six effects (added,
changed, removed, read, decided, ran) and three actor kinds (a person, the
auto-approver, a background job). Data protection x Read is every time
somebody looked at unmasked personal data — a question the old vocabulary
could not ask. Requests x the auto-approver is what was approved without
anybody looking, which is 78% of that category. - Every count carries its denominator, and every exclusion says its size.
Two slices are excluded by default and both are stated with a control to
include them: the per-request lifecycle the queue and history screens already
show, and product usage. Including the first triples the list, so it says so.
Chips state the count they would produce, because a filter whose size you can
only learn by clicking it is how a bucket stays unexamined. - A detail rail for the payload, with the request block first — 77% of rows
have one — the raw action name shown as the evidence the category was derived
from, and the authority a machine acted under named where it can be resolved. - Two more rungs on the masking ladder: a whole table, whose absence forced
the wider "whole schema" choice, and fleet-wide, which the stored model always
allowed and the screen could not produce. Fleet-wide is gated harder than
anything else there: off the top of the ladder, a typed confirmation, and
restricted to super-admins to start.
Fixed
- The masking-exemptions screen listed every reach except the two it could not
name, so one live exemption could be read but not reproduced.
v1.0.29
The release the access-model rewrite landed in. Authorization now resolves from
nine tables instead of seventeen, a third door opened for programs, and the
screens that read the new model stopped disagreeing with the routes that write
it.
Added
- The nine-table access model, switched on.
principal,
principal_identity,principal_credential,team,team_member,
access_grant,role_assignment,principal_settingandtierreplace
seventeen tables whose relationships were implied rather than declared. The
old tables were copied in, verified row by row against a snapshot tool built
for the purpose, and reads moved over behind onebot_configkey
(access_model_v2) so the switch could be thrown and thrown back. Legacy
writes are projected into the new tables for as long as both exist, and
authorization-change notifications fire from either side. - A scoped approver is told a request is waiting for them. An approver whose
authority covers one team or one target used to learn about a pending request
only by opening the queue. - The Roles screen. Who can approve what, as sentences rather than three
chips the reader has to assemble: fleet-wide admins, team-scoped approvers,
their tier ceilings and their expiry dates, with the rows that are mirrored
from the old table marked as such. Readable by any admin; writable by a
super-admin. - An org-structure importer. A generic loader for team/person/service
structure exported from an internal service catalog, plus the inventory tables
it lands in, so team membership and the team that owns a target can be
reconciled from the system of record instead of typed twice. A target's owning
team is now a relation, not a join computed at request time. - A team's lead is synced as an approver for what that team owns, and may
approve their own team's requests and only their own. - An MCP surface — a third door, for programs. Alongside Slack and the web
UI, QueryHub speaks the Model Context Protocol over stdio: six tools
(list_connections,describe_database,classify_sql,submit_query,
query_status,fetch_result). It resolves the caller's own grants, refuses
what they may not run, masks what comes back and audits every step. Off by
default (mcp_enabled), read-only by default (mcp_max_tier), and an
auto-approved query returns its first rows inside the same call — measured at
1.4 s end to end. - A masking-exemptions screen. Every place PII masking is deliberately
switched off, super-admin only: what each exemption reaches, whether it also
stops reading the values, whether it survives a join, which name rule caught
the column in the first place, and the reason somebody wrote for it. Read-only
in this release. - The browser Back button now walks the app instead of leaving it, and dismisses
the topmost modal before navigating.
Changed
- Admin tables scroll inside their own box, so a wide table no longer scrolls
the page sideways. describe_databaseand the schema response ask about PII once per response
once per response instead of once per column, so the rule catalog is read once
no matter how wide the table is.
Fixed
- A withdrawn request stayed on the admin queue when it was withdrawn from
the web: the buttons were still on the card, and pressing one answered
"already decided". The web path now retires the admin cards the way the Slack
path does, and the withdrawal is written to the audit trail. - The audit screen was hiding most of what it exists to show. It filtered
audit_logthrough a hand-written list of 37 action names against 132 real
actions, so most of them were invisible — a requester withdrawing a request,
masking being switched off for a column, every auto-approve grant. The filter is
inverted: everything is shown except the per-request lifecycle the queue and
history screens already show. Visible action types went from 37 to 123. - Six admin routes wrote to the legacy tables while the screens above them read
the new model — creating, renaming and deleting a team, setting a person's
teams, copying access, and effective access. An emptied table answers
"nothing" rather than failing, so none of them surfaced as an error. The
Teams list was the dangerous one: it served new-model ids against an old
sequence, so a few more teams would have made a rename edit a different team
than the one on screen, silently. /sql whoamiand/sql rolesnamed no teams at all after the switch: the
view behind them read only the legacy tables. It unions both models now, which
needs no switch to be correct — whichever side is empty contributes nothing.- Halting the fleet from Slack wrote no audit row, though the same action from
the web did. - The access-request card's copy-paste recipe named tables that no longer exist,
and the flow accepted a request against the control-plane database it must
refuse. - An admin's own auto-approve waiver was ignored, and a drift gate counted
tombstoned rows as live.
Security
- A tier ceiling admitted every tier. A ceiling that could not be read
resolved to rank 99 — a value chosen to fail closed on the request side of
the comparison, which on the ceiling side meant "anything is below this".
Both sides now fail closed, and the request's tier is derived by the engine-
aware shared rule rather than from a display label that reportsrofor a
blocked statement. - Two repository gates stopped telling the truth after the access-model cutover:
the sensitive-data scanner's dynamic denylist and the copy-verification gate
both read only the old tables, so a name that existed only in the new model
was not on the denylist. Both read both models now, so a name that lives only in
the new one is covered.
v1.0.28
Added
- Identity assertions from an internal developer portal. The
/apiroutes
accept an Ed25519-signedX-IDP-Assertionheader bound to the exact request
(method, path and query, body) and act as the asserted principal, so a portal
can proxy QueryHub for its users without a browser session. Off by default
(idp_assertion_enabled), keys inidp_public_keys, replayed assertions
refused through a short-livedjtiledger (migration 101). - A principal reconcile endpoint that the portal's sync account, and only
that account, may call to enable and disable requesters from its directory.
It never writes to theadminstable, refuses a list that would disable
everyone, reports addresses it cannot resolve, and writes an audit row
(idp_principal_sync) on every run. - A notification outbox (migration 102) recording each pending request and
the admins told about it, with two admin-gated routes for the portal to poll
and acknowledge, so a portal can show an approvals queue without owning an
admins table of its own. Slack notifications are unchanged. - Requests that arrive this way carry
origin = idpin the request record and
the audit trail, next toslackandweb.
v1.0.27
Added
- The person-first access screen. Open a person and see everywhere they stand
— their own grants, what their teams give them, what they reach as an admin,
and any auto-approve window — resolved by the same resolver a submission uses,
so the screen cannot disagree with what runs. Granting the same access to
several people is one panel and one call, and a refusal keeps the list on
screen because nothing was written. Copying someone's access shows exactly what
would be written and revoked, by server name, before anything happens.
Fixed
- A modal taller than the viewport trapped its own buttons. The overlay is
fixed so the page cannot scroll, the modal clipped, and the body had no
overflow of its own — so editing a connection put Save and Cancel off-screen
with nothing to reach them. One rule on.qh-modal, shared by all eight. - Two fields the new screen reads were missing or the wrong shape: the
copy-access dry run did not returnwouldCopyAutoApprove(an absent field
draws silence, which reads as "none" for the one option that skips human
review), andautoApproveCopiedreturned names where the client counted. It is
a count now, withautoApproveCopiedTargetsbeside it.