Skip to content

Commit cb18ce7

Browse files
committed
feat!: read checks through the control API
People can list a target's checks with their newest results, read one check's recent results, and, with view.sql, read its SQL. Checks are keyed by binding_key, now a typed, indexed column on the audit-result mirror, because one audit name such as not_null covers many models and columns. A model rename or a severity or threshold change starts a new check history, and the old one stays readable. SQL reads are audited like node SQL. BREAKING CHANGE: control stores created by 0.188.x are not upgraded. The server refuses them with an error saying to recreate the control store; create a new one when upgrading.
1 parent 6726252 commit cb18ce7

21 files changed

Lines changed: 992 additions & 8 deletions

File tree

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
{
2+
"audit_name": "not_null",
3+
"checks": [
4+
{"binding_key": "6f1c0e2a-orders-order-id", "attached_target_name": "orders", "attached_column_name": "order_id"},
5+
{"binding_key": "0b9d4c71-customers-customer-id", "attached_target_name": "customers", "attached_column_name": "customer_id"},
6+
{"binding_key": "9a3e5d10-archived-orders-order-id", "attached_target_name": "archived_orders", "attached_column_name": "order_id"}
7+
],
8+
"results": [
9+
{"check": 0, "result_id": "orders-not-null-1", "occurred_at": "2026-02-03T04:05:06Z", "outcome": "fail", "violation_count": 3, "executed_sql": "SELECT order_id FROM orders WHERE order_id IS NULL /* first 'run' 🧾 */"},
10+
{"check": 0, "result_id": "orders-not-null-2", "occurred_at": "2026-02-04T04:05:06Z", "outcome": "pass", "violation_count": 0, "executed_sql": "SELECT order_id FROM orders WHERE order_id IS NULL /* second 'run' 🧾 */"},
11+
{"check": 1, "result_id": "customers-not-null-1", "occurred_at": "2026-02-03T05:05:06Z", "outcome": "error", "violation_count": null, "executed_sql": "SELECT customer_id FROM customers WHERE customer_id IS NULL", "execution_error": "Binder Error: Referenced column \"customer_id\" not found\n\nLINE 1: SELECT customer_id FROM customers WHERE email = 'private@example.com'"},
12+
{"check": 2, "result_id": "archived-orders-not-null-1", "occurred_at": "2026-02-02T04:05:06Z", "outcome": "pass", "violation_count": 0, "executed_sql": "SELECT order_id FROM archived_orders WHERE order_id IS NULL"}
13+
]
14+
}

‎clients/typescript/src/schema.d.ts‎

Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,54 @@ export interface paths {
210210
patch?: never;
211211
trace?: never;
212212
};
213+
"/api/control/locations/{location_id}/targets/{target}/checks": {
214+
parameters: {
215+
query?: never;
216+
header?: never;
217+
path?: never;
218+
cookie?: never;
219+
};
220+
get: operations["listPersonChecks"];
221+
put?: never;
222+
post?: never;
223+
delete?: never;
224+
options?: never;
225+
head?: never;
226+
patch?: never;
227+
trace?: never;
228+
};
229+
"/api/control/locations/{location_id}/targets/{target}/checks/{binding_key}": {
230+
parameters: {
231+
query?: never;
232+
header?: never;
233+
path?: never;
234+
cookie?: never;
235+
};
236+
get: operations["readPersonCheckHistory"];
237+
put?: never;
238+
post?: never;
239+
delete?: never;
240+
options?: never;
241+
head?: never;
242+
patch?: never;
243+
trace?: never;
244+
};
245+
"/api/control/locations/{location_id}/targets/{target}/checks/{binding_key}/sql": {
246+
parameters: {
247+
query?: never;
248+
header?: never;
249+
path?: never;
250+
cookie?: never;
251+
};
252+
get: operations["readPersonCheckSql"];
253+
put?: never;
254+
post?: never;
255+
delete?: never;
256+
options?: never;
257+
head?: never;
258+
patch?: never;
259+
trace?: never;
260+
};
213261
"/api/control/locations/{location_id}/targets/{target}/connection-tests": {
214262
parameters: {
215263
query?: never;
@@ -769,6 +817,52 @@ export interface components {
769817
ChangeNotice: {
770818
topic: string;
771819
};
820+
/** @description One stored check result. Its SQL, error texts and evidence rows need their own permissions. */
821+
CheckResult: {
822+
attached_column_name?: string | null;
823+
attached_target_kind?: string | null;
824+
attached_target_name?: string | null;
825+
attachment_kind: string;
826+
audit_definition_name: string;
827+
audit_description?: string | null;
828+
audit_name: string;
829+
/** @description Identity of the check; a renamed model or changed severity or thresholds starts a new one. */
830+
binding_key: string;
831+
evaluation_mode: string;
832+
/** Format: uuid */
833+
event_id: string;
834+
/** Format: int64 */
835+
evidence_count?: number | null;
836+
evidence_truncated?: string | null;
837+
invocation_id: string;
838+
measured_value?: string | null;
839+
/** Format: int64 */
840+
minimum_samples?: number | null;
841+
/** Format: date-time */
842+
occurred_at: string;
843+
outcome: string;
844+
reused: string;
845+
run_id: string;
846+
run_scope_phase: string;
847+
/** Format: int64 */
848+
sample_count?: number | null;
849+
sample_unit?: string | null;
850+
severity: string;
851+
thresholds_json?: string | null;
852+
/** Format: int64 */
853+
violation_count?: number | null;
854+
};
855+
/** @description A check's latest SQL and error texts, which quote SQL; `view.sql` reads them, audited. */
856+
CheckSql: {
857+
binding_key: string;
858+
/** Format: uuid */
859+
event_id: string;
860+
evidence_error?: string | null;
861+
evidence_sql?: string | null;
862+
executed_sql?: string | null;
863+
execution_error?: string | null;
864+
measurement_sql?: string | null;
865+
};
772866
CliLoginApprovalRequest: {
773867
approve: boolean;
774868
browser_code: string;
@@ -2127,6 +2221,124 @@ export interface operations {
21272221
};
21282222
};
21292223
};
2224+
listPersonChecks: {
2225+
parameters: {
2226+
query?: never;
2227+
header?: never;
2228+
path: {
2229+
location_id: string;
2230+
target: string;
2231+
};
2232+
cookie?: never;
2233+
};
2234+
requestBody?: never;
2235+
responses: {
2236+
/** @description The newest result of every check */
2237+
200: {
2238+
headers: {
2239+
[name: string]: unknown;
2240+
};
2241+
content: {
2242+
"application/json": components["schemas"]["CheckResult"][];
2243+
};
2244+
};
2245+
/** @description Session rejected */
2246+
401: {
2247+
headers: {
2248+
[name: string]: unknown;
2249+
};
2250+
content?: never;
2251+
};
2252+
403: {
2253+
headers: {
2254+
[name: string]: unknown;
2255+
};
2256+
content: {
2257+
"application/json": components["schemas"]["ApiErrorBody"];
2258+
};
2259+
};
2260+
};
2261+
};
2262+
readPersonCheckHistory: {
2263+
parameters: {
2264+
query?: {
2265+
/** @description Maximum results, newest first; defaults to 50, capped at 1000 */
2266+
limit?: number;
2267+
};
2268+
header?: never;
2269+
path: {
2270+
location_id: string;
2271+
target: string;
2272+
binding_key: string;
2273+
};
2274+
cookie?: never;
2275+
};
2276+
requestBody?: never;
2277+
responses: {
2278+
/** @description Empty for an unknown check */
2279+
200: {
2280+
headers: {
2281+
[name: string]: unknown;
2282+
};
2283+
content: {
2284+
"application/json": components["schemas"]["CheckResult"][];
2285+
};
2286+
};
2287+
/** @description Session rejected */
2288+
401: {
2289+
headers: {
2290+
[name: string]: unknown;
2291+
};
2292+
content?: never;
2293+
};
2294+
403: {
2295+
headers: {
2296+
[name: string]: unknown;
2297+
};
2298+
content: {
2299+
"application/json": components["schemas"]["ApiErrorBody"];
2300+
};
2301+
};
2302+
};
2303+
};
2304+
readPersonCheckSql: {
2305+
parameters: {
2306+
query?: never;
2307+
header?: never;
2308+
path: {
2309+
location_id: string;
2310+
target: string;
2311+
binding_key: string;
2312+
};
2313+
cookie?: never;
2314+
};
2315+
requestBody?: never;
2316+
responses: {
2317+
200: {
2318+
headers: {
2319+
[name: string]: unknown;
2320+
};
2321+
content: {
2322+
"application/json": null | components["schemas"]["CheckSql"];
2323+
};
2324+
};
2325+
/** @description Session rejected */
2326+
401: {
2327+
headers: {
2328+
[name: string]: unknown;
2329+
};
2330+
content?: never;
2331+
};
2332+
403: {
2333+
headers: {
2334+
[name: string]: unknown;
2335+
};
2336+
content: {
2337+
"application/json": components["schemas"]["ApiErrorBody"];
2338+
};
2339+
};
2340+
};
2341+
};
21302342
requestConnectionTest: {
21312343
parameters: {
21322344
query?: never;

‎crates/sqlbuild-orchestration/src/api/_helpers/authentication/password_sessions.rs‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,18 @@ pub(crate) fn person_routes() -> Router<ControlStore> {
109109
"/api/control/locations/{location_id}/targets/{target}/nodes/{node_key}/sql",
110110
get(crate::api::_helpers::resources::person_node_sql::read),
111111
)
112+
.route(
113+
"/api/control/locations/{location_id}/targets/{target}/checks",
114+
get(crate::api::_helpers::resources::person_checks::list),
115+
)
116+
.route(
117+
"/api/control/locations/{location_id}/targets/{target}/checks/{binding_key}",
118+
get(crate::api::_helpers::resources::person_checks::history),
119+
)
120+
.route(
121+
"/api/control/locations/{location_id}/targets/{target}/checks/{binding_key}/sql",
122+
get(crate::api::_helpers::resources::person_checks::sql),
123+
)
112124
.layer(DefaultBodyLimit::max(PASSWORD_BODY_BYTES))
113125
}
114126

‎crates/sqlbuild-orchestration/src/api/_helpers/resources/mod.rs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ pub(crate) mod client;
55
pub(crate) mod docker_image;
66
pub(crate) mod location_health;
77
pub(crate) mod location_work;
8+
pub(crate) mod person_checks;
89
pub(crate) mod person_location_work;
910
pub(crate) mod person_node_sql;
1011
pub(crate) mod person_operations;
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
//! Check reads use live person grants; SQL needs its own permission and is audited.
2+
3+
use axum::extract::{Path, Query, State};
4+
use axum::{Extension, Json};
5+
use uuid::Uuid;
6+
7+
use crate::api::errors::ApiError;
8+
use crate::api::models::ReadQuery;
9+
use crate::credentials::models::{AuditContext, AuthenticatedPerson, DefaultRole};
10+
use crate::store::models::ControlStore;
11+
use crate::warehouse_metadata::main::read_person_check::read_person_check;
12+
use crate::warehouse_metadata::main::read_person_check_sql::read_person_check_sql;
13+
use crate::warehouse_metadata::main::read_person_checks::read_person_checks;
14+
use crate::warehouse_metadata::models::{CheckResult, CheckSql};
15+
16+
#[utoipa::path(get, path = "/api/control/locations/{location_id}/targets/{target}/checks", operation_id = "listPersonChecks",
17+
params(("location_id" = Uuid, Path), ("target" = String, Path)),
18+
responses((status = 200, body = Vec<CheckResult>, description = "The newest result of every check"),
19+
(status = 401, description = "Session rejected"),
20+
(status = 403, body = crate::api::models::ApiErrorBody)), security(("session" = []), ("cliSession" = [])))]
21+
pub(crate) async fn list(
22+
(State(store), Extension(context)): (State<ControlStore>, Extension<AuditContext>),
23+
Extension(default_role): Extension<DefaultRole>,
24+
Path((location_id, target)): Path<(Uuid, String)>,
25+
Extension(AuthenticatedPerson(principal, _)): Extension<AuthenticatedPerson>,
26+
) -> Result<Json<Vec<CheckResult>>, ApiError> {
27+
Ok(Json(
28+
read_person_checks(
29+
&store,
30+
(&context, principal),
31+
(location_id, &target),
32+
default_role,
33+
)
34+
.await?,
35+
))
36+
}
37+
38+
#[utoipa::path(get, path = "/api/control/locations/{location_id}/targets/{target}/checks/{binding_key}", operation_id = "readPersonCheckHistory",
39+
params(("location_id" = Uuid, Path), ("target" = String, Path), ("binding_key" = String, Path),
40+
("limit" = Option<u32>, Query, description = "Maximum results, newest first; defaults to 50, capped at 1000")),
41+
responses((status = 200, body = Vec<CheckResult>, description = "Empty for an unknown check"),
42+
(status = 401, description = "Session rejected"),
43+
(status = 403, body = crate::api::models::ApiErrorBody)), security(("session" = []), ("cliSession" = [])))]
44+
pub(crate) async fn history(
45+
(State(store), Extension(context)): (State<ControlStore>, Extension<AuditContext>),
46+
Extension(default_role): Extension<DefaultRole>,
47+
(Path((location_id, target, binding_key)), Query(options)): (
48+
Path<(Uuid, String, String)>,
49+
Query<ReadQuery>,
50+
),
51+
Extension(AuthenticatedPerson(principal, _)): Extension<AuthenticatedPerson>,
52+
) -> Result<Json<Vec<CheckResult>>, ApiError> {
53+
Ok(Json(
54+
read_person_check(
55+
&store,
56+
(&context, principal),
57+
(location_id, &target, &binding_key),
58+
(options.limit.unwrap_or(50), default_role),
59+
)
60+
.await?,
61+
))
62+
}
63+
64+
#[utoipa::path(get, path = "/api/control/locations/{location_id}/targets/{target}/checks/{binding_key}/sql", operation_id = "readPersonCheckSql",
65+
params(("location_id" = Uuid, Path), ("target" = String, Path), ("binding_key" = String, Path)),
66+
responses((status = 200, body = Option<CheckSql>), (status = 401, description = "Session rejected"),
67+
(status = 403, body = crate::api::models::ApiErrorBody)), security(("session" = []), ("cliSession" = [])))]
68+
pub(crate) async fn sql(
69+
(State(store), Extension(context)): (State<ControlStore>, Extension<AuditContext>),
70+
Path((location_id, target, binding_key)): Path<(Uuid, String, String)>,
71+
Extension(AuthenticatedPerson(principal, _)): Extension<AuthenticatedPerson>,
72+
) -> Result<Json<Option<CheckSql>>, ApiError> {
73+
Ok(Json(
74+
read_person_check_sql(
75+
&store,
76+
(&context, principal),
77+
(location_id, &target, &binding_key),
78+
)
79+
.await?,
80+
))
81+
}

‎crates/sqlbuild-orchestration/src/api/models.rs‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,9 @@ use serde::{Deserialize, Serialize};
55
use crate::api::_helpers::authentication::password_sessions;
66
use crate::api::_helpers::authentication::{cli_login, oidc};
77
use crate::api::_helpers::resources::{
8-
administrators, audit, changes, location_health, location_work, person_location_work,
9-
person_node_sql, person_operations, run_reads, runs, snapshots, trigger_reads,
10-
warehouse_metadata,
8+
administrators, audit, changes, location_health, location_work, person_checks,
9+
person_location_work, person_node_sql, person_operations, run_reads, runs, snapshots,
10+
trigger_reads, warehouse_metadata,
1111
};
1212

1313
#[derive(Debug, utoipa::OpenApi)]
@@ -24,6 +24,9 @@ use crate::api::_helpers::resources::{
2424
person_operations::statistics,
2525
person_operations::fingerprint,
2626
person_operations::node_status,
27+
person_checks::list,
28+
person_checks::history,
29+
person_checks::sql,
2730
person_location_work::request,
2831
person_location_work::result,
2932
person_node_sql::read,

‎crates/sqlbuild-orchestration/src/store/_helpers/migration.rs‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,9 @@ pub(crate) fn verify_schema(rows: &[Row], digest: &str) -> Result<(), ControlErr
2020
.any(|row| row.get("checksum").and_then(|value| value.as_str()) != Some(digest))
2121
{
2222
return Err(ControlError::Schema(
23-
"control store schema checksum mismatch".into(),
23+
"control store schema checksum mismatch; recreate the control store, because \
24+
pre-release schemas are not upgraded"
25+
.into(),
2426
));
2527
}
2628
Ok(())

0 commit comments

Comments
 (0)