fix(recovery): restore source removed by production documentation merge
This commit is contained in:
Executable
+161
@@ -0,0 +1,161 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* BI-P005 DoD, executed literally:
|
||||
*
|
||||
* "Same query result roundtrips ClickHouse -> Arrow -> DuckDB/Python
|
||||
* without semantic loss for supported types."
|
||||
*
|
||||
* Pulls a result from a LIVE ClickHouse as Arrow IPC, hands the identical
|
||||
* bytes to pyarrow and DuckDB, and records what each of them read back. Also
|
||||
* runs the reverse direction — 0pt's own encoder's bytes decoded by pyarrow +
|
||||
* DuckDB — because the encoder is hand-rolled and the only conformance
|
||||
* evidence worth having comes from somebody else's parser.
|
||||
*
|
||||
* CLICKHOUSE_URL=http://localhost:18123 CLICKHOUSE_AUTH=user:pass \
|
||||
* node scripts/bi-p005-arrow-roundtrip.mjs
|
||||
*
|
||||
* pyarrow/DuckDB run in a throwaway `python:3.12-slim` container, so the host
|
||||
* needs docker but no Python packages. Output:
|
||||
* evidence/bi-p002-p012/bi-p005-arrow-roundtrip.json
|
||||
*
|
||||
* The 0pt-encoded fixture is produced by `pnpm vitest run
|
||||
* tests/analytics/arrow-fixture.emit.ts`-style code inlined here via a tiny
|
||||
* tsx-free path: the encoder is re-implemented ONLY in the sense that this
|
||||
* script shells out to vitest to write the file, so the bytes tested are the
|
||||
* bytes the library produces.
|
||||
*/
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
import { execFileSync } from "node:child_process";
|
||||
|
||||
const URL_ = process.env.CLICKHOUSE_URL;
|
||||
const AUTH = process.env.CLICKHOUSE_AUTH;
|
||||
if (!URL_) {
|
||||
console.error("CLICKHOUSE_URL is required — this script measures, it does not simulate.");
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const work = fs.mkdtempSync(path.join(os.tmpdir(), "bi-p005-"));
|
||||
const headers = AUTH ? { Authorization: `Basic ${Buffer.from(AUTH).toString("base64")}` } : {};
|
||||
|
||||
const QUERY = `
|
||||
SELECT toString(t) AS tenant_id, toInt64(n) AS n, toFloat64(v) AS v, b AS flag,
|
||||
toDateTime64(ts, 3, 'UTC') AS ts
|
||||
FROM (
|
||||
SELECT arrayJoin([
|
||||
('t_alpha', 1, 1.5, true, '2026-01-01 00:00:00.000'),
|
||||
('t_beta', 2, 2.5, false, '2026-06-15 08:09:10.123'),
|
||||
('t_gamma', 3, 3.25, true, '2026-09-06 12:34:56.789')
|
||||
]) AS tup, tup.1 AS t, tup.2 AS n, tup.3 AS v, tup.4 AS b, tup.5 AS ts
|
||||
)`;
|
||||
|
||||
async function chArrow(sql, settings) {
|
||||
const u = new URL(URL_);
|
||||
u.searchParams.set("query", sql);
|
||||
for (const [k, v] of Object.entries(settings)) u.searchParams.set(k, v);
|
||||
const res = await fetch(u.toString(), { method: "POST", headers, body: "" });
|
||||
const buf = Buffer.from(await res.arrayBuffer());
|
||||
if (!res.ok) throw new Error(`CH ${res.status}: ${buf.toString("utf8").slice(0, 300)}`);
|
||||
return buf;
|
||||
}
|
||||
|
||||
const PY = `
|
||||
import pyarrow as pa, pyarrow.ipc as ipc, duckdb, json, sys, os
|
||||
out = {}
|
||||
for name in sorted(os.listdir('/w')):
|
||||
if not name.endswith('.arrow'):
|
||||
continue
|
||||
tbl = ipc.open_stream(pa.BufferReader(open('/w/'+name,'rb').read())).read_all()
|
||||
con = duckdb.connect(); con.register('t', tbl)
|
||||
out[name] = {
|
||||
"pyarrow_version": pa.__version__,
|
||||
"duckdb_version": duckdb.__version__,
|
||||
"schema": [{"name": f.name, "type": str(f.type)} for f in tbl.schema],
|
||||
"num_rows": tbl.num_rows,
|
||||
"pyarrow_values": json.loads(json.dumps(tbl.to_pydict(), default=str)),
|
||||
"duckdb_types": [(r[0], r[1]) for r in con.execute("describe select * from t").fetchall()],
|
||||
"duckdb_rows": [[str(c) for c in row] for row in con.execute("select * from t order by n").fetchall()],
|
||||
}
|
||||
print(json.dumps(out))
|
||||
`;
|
||||
|
||||
async function main() {
|
||||
const version = (await (async () => {
|
||||
const u = new URL(URL_);
|
||||
u.searchParams.set("query", "SELECT version() FORMAT TSVRaw");
|
||||
const r = await fetch(u.toString(), { method: "POST", headers, body: "" });
|
||||
return (await r.text()).trim();
|
||||
})());
|
||||
|
||||
// Direction 1: ClickHouse's own Arrow C++ output.
|
||||
const chBytes = await chArrow(`${QUERY} FORMAT ArrowStream`, {
|
||||
output_format_arrow_string_as_string: "1",
|
||||
output_format_arrow_low_cardinality_as_dictionary: "0",
|
||||
});
|
||||
fs.writeFileSync(path.join(work, "01_clickhouse_encoded.arrow"), chBytes);
|
||||
|
||||
// Direction 2: 0pt's own encoder. Driven through the SHIPPED module via
|
||||
// tests/analytics/arrow-fixture.test.ts (ARROW_FIXTURE_DIR) so the bytes
|
||||
// pyarrow sees are the library's, not a copy pasted into this script.
|
||||
execFileSync(
|
||||
"npx",
|
||||
["vitest", "run", "tests/analytics/arrow-fixture.test.ts"],
|
||||
{ stdio: "inherit", env: { ...process.env, ARROW_FIXTURE_DIR: work } },
|
||||
);
|
||||
|
||||
// The reader script goes in as a FILE, not as `python -c "..."`: passing a
|
||||
// multi-line program through a shell argument mangles the newlines.
|
||||
fs.writeFileSync(path.join(work, "read_arrow.py"), PY);
|
||||
const raw = execFileSync(
|
||||
"docker",
|
||||
[
|
||||
"run", "--rm", "-v", `${work}:/w`, "-w", "/w", "python:3.12-slim",
|
||||
"bash", "-lc",
|
||||
"pip install --quiet pyarrow duckdb pytz >/dev/null 2>&1 && python /w/read_arrow.py",
|
||||
],
|
||||
{ encoding: "utf8", maxBuffer: 32 * 1024 * 1024 },
|
||||
);
|
||||
|
||||
const decoded = JSON.parse(raw);
|
||||
const evidence = {
|
||||
generated_at: new Date().toISOString(),
|
||||
clickhouse_version: version,
|
||||
query: QUERY.trim(),
|
||||
directions: {
|
||||
"clickhouse -> arrow -> pyarrow/duckdb": decoded["01_clickhouse_encoded.arrow"],
|
||||
"0pt encoder -> arrow -> pyarrow/duckdb": decoded["02_0pt_encoded.arrow"],
|
||||
},
|
||||
byte_sizes: {
|
||||
clickhouse_encoded: chBytes.length,
|
||||
opt_encoded: fs.statSync(path.join(work, "02_0pt_encoded.arrow")).size,
|
||||
},
|
||||
verdict: null,
|
||||
};
|
||||
|
||||
// The DoD is "without semantic loss": the two independent readers must agree
|
||||
// on the SAME values for both encoders.
|
||||
const a = evidence.directions["clickhouse -> arrow -> pyarrow/duckdb"];
|
||||
const b = evidence.directions["0pt encoder -> arrow -> pyarrow/duckdb"];
|
||||
const same =
|
||||
JSON.stringify(a.pyarrow_values) === JSON.stringify(b.pyarrow_values) &&
|
||||
JSON.stringify(a.duckdb_rows) === JSON.stringify(b.duckdb_rows) &&
|
||||
JSON.stringify(a.duckdb_types) === JSON.stringify(b.duckdb_types);
|
||||
evidence.verdict = same
|
||||
? "PASS — pyarrow and DuckDB read identical values and types from BOTH encoders"
|
||||
: "FAIL — the two encoders do not round-trip to the same values";
|
||||
|
||||
const out = path.join(process.cwd(), "evidence", "bi-p002-p012", "bi-p005-arrow-roundtrip.json");
|
||||
fs.mkdirSync(path.dirname(out), { recursive: true });
|
||||
fs.writeFileSync(out, JSON.stringify(evidence, null, 2) + "\n");
|
||||
console.log(evidence.verdict);
|
||||
console.log(`wrote ${out}`);
|
||||
if (!same) process.exitCode = 1;
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
process.exitCode = 1;
|
||||
})
|
||||
.finally(() => fs.rmSync(work, { recursive: true, force: true }));
|
||||
Executable
+311
@@ -0,0 +1,311 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* BI-P010 DoD: "Benchmark representative VehIQ-scale fixture and generic SaaS
|
||||
* fixture."
|
||||
*
|
||||
* Builds both fixtures in a THROWAWAY database on a ClickHouse the operator
|
||||
* points it at, times the query shapes each schema exists to serve, and writes
|
||||
* a machine-readable transcript to
|
||||
* evidence/bi-p002-p012/bi-p010-benchmark.json.
|
||||
*
|
||||
* CLICKHOUSE_URL=http://localhost:18123 \
|
||||
* CLICKHOUSE_AUTH=user:pass \
|
||||
* node scripts/bi-p010-benchmark.mjs
|
||||
*
|
||||
* It also measures the COUNTERFACTUAL the program warns about — "one giant
|
||||
* unbounded table for every analytics use case" — by running the same
|
||||
* questions against a single wide table with a non-tenant-leading sort key.
|
||||
* Without that comparison the split is an assertion; with it, it is a number.
|
||||
*
|
||||
* NEVER point this at the b0ts ClickHouse or any quarantined market data: it
|
||||
* CREATEs and DROPs a database.
|
||||
*/
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const URL_ = process.env.CLICKHOUSE_URL;
|
||||
const AUTH = process.env.CLICKHOUSE_AUTH;
|
||||
if (!URL_) {
|
||||
console.error("CLICKHOUSE_URL is required. Refusing to invent numbers.");
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const DB = `opt_bench_${Date.now().toString(36)}`;
|
||||
const headers = AUTH ? { Authorization: `Basic ${Buffer.from(AUTH).toString("base64")}` } : {};
|
||||
|
||||
async function ch(sql, body, settings = {}) {
|
||||
const u = new URL(URL_);
|
||||
u.searchParams.set("query", sql);
|
||||
for (const [k, v] of Object.entries(settings)) u.searchParams.set(k, v);
|
||||
const res = await fetch(u.toString(), { method: "POST", headers, body: body ?? "" });
|
||||
const text = await res.text();
|
||||
if (!res.ok) throw new Error(`CH ${res.status}: ${text.slice(0, 400)}`);
|
||||
return text;
|
||||
}
|
||||
|
||||
async function json(sql, settings) {
|
||||
const t = await ch(sql, undefined, settings);
|
||||
return t ? JSON.parse(t) : { data: [] };
|
||||
}
|
||||
|
||||
/** Run `sql` n times, return {p50, p95, min, max, rows_read, bytes_read}. */
|
||||
async function timeQuery(label, sql, n = 12) {
|
||||
// One warm-up so we time the query, not the page cache miss.
|
||||
await ch(sql);
|
||||
const times = [];
|
||||
let stats = {};
|
||||
for (let i = 0; i < n; i++) {
|
||||
const t0 = process.hrtime.bigint();
|
||||
const out = await json(sql);
|
||||
times.push(Number(process.hrtime.bigint() - t0) / 1e6);
|
||||
stats = out.statistics ?? stats;
|
||||
}
|
||||
times.sort((a, b) => a - b);
|
||||
const pick = (p) => Number(times[Math.min(times.length - 1, Math.floor((times.length * p) / 100))].toFixed(2));
|
||||
return {
|
||||
label,
|
||||
runs: n,
|
||||
p50_ms: pick(50),
|
||||
p95_ms: pick(95),
|
||||
min_ms: Number(times[0].toFixed(2)),
|
||||
max_ms: Number(times[times.length - 1].toFixed(2)),
|
||||
rows_read: stats.rows_read ?? null,
|
||||
bytes_read: stats.bytes_read ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
const evidence = {
|
||||
generated_at: new Date().toISOString(),
|
||||
clickhouse_version: null,
|
||||
database: DB,
|
||||
fixtures: {},
|
||||
measurements: [],
|
||||
notes: [],
|
||||
};
|
||||
|
||||
async function main() {
|
||||
evidence.clickhouse_version = (await ch("SELECT version() FORMAT TSVRaw")).trim();
|
||||
await ch(`CREATE DATABASE ${DB}`);
|
||||
|
||||
// ── the BI-P010 schema under test ──────────────────────────────────────
|
||||
await ch(`CREATE TABLE ${DB}.opt_analytics_event (
|
||||
tenant_id LowCardinality(String), project_id LowCardinality(String),
|
||||
dataset_id LowCardinality(String), metric LowCardinality(String),
|
||||
event_id String, event_time DateTime64(3,'UTC'), value Float64,
|
||||
dim_names Array(LowCardinality(String)), dim_values Array(String),
|
||||
ingested_at DateTime64(3,'UTC') DEFAULT now64(3),
|
||||
INDEX idx_dim_values dim_values TYPE bloom_filter(0.01) GRANULARITY 4)
|
||||
ENGINE = MergeTree() PARTITION BY toYYYYMM(event_time)
|
||||
ORDER BY (tenant_id, project_id, dataset_id, metric, event_time)`);
|
||||
|
||||
await ch(`CREATE TABLE ${DB}.opt_metric_daily (
|
||||
tenant_id LowCardinality(String), project_id LowCardinality(String),
|
||||
dataset_id LowCardinality(String), metric LowCardinality(String), day Date,
|
||||
sum_state AggregateFunction(sum, Float64), count_state AggregateFunction(count, Float64),
|
||||
min_state AggregateFunction(min, Float64), max_state AggregateFunction(max, Float64))
|
||||
ENGINE = AggregatingMergeTree() PARTITION BY toYYYYMM(day)
|
||||
ORDER BY (tenant_id, project_id, dataset_id, metric, day)`);
|
||||
|
||||
await ch(`CREATE MATERIALIZED VIEW ${DB}.opt_metric_daily_mv TO ${DB}.opt_metric_daily AS
|
||||
SELECT tenant_id, project_id, dataset_id, metric, toDate(event_time) AS day,
|
||||
sumState(value) AS sum_state, countState(value) AS count_state,
|
||||
minState(value) AS min_state, maxState(value) AS max_state
|
||||
FROM ${DB}.opt_analytics_event
|
||||
GROUP BY tenant_id, project_id, dataset_id, metric, day`);
|
||||
|
||||
await ch(`CREATE TABLE ${DB}.opt_dataset_projection (
|
||||
tenant_id LowCardinality(String), project_id LowCardinality(String),
|
||||
dataset_id LowCardinality(String), entity_key String,
|
||||
as_of DateTime64(3,'UTC'), version UInt64,
|
||||
attributes Map(String,String), measures Map(String,Float64),
|
||||
INDEX idx_entity entity_key TYPE bloom_filter(0.01) GRANULARITY 4)
|
||||
ENGINE = ReplacingMergeTree(version) PARTITION BY (tenant_id)
|
||||
ORDER BY (tenant_id, project_id, dataset_id, entity_key)`);
|
||||
|
||||
// ── the COUNTERFACTUAL the program warns about ─────────────────────────
|
||||
// One table for everything, sorted by time (the shape people reach for when
|
||||
// "we'll just put it all in events").
|
||||
await ch(`CREATE TABLE ${DB}.giant_everything (
|
||||
tenant_id String, project_id String, dataset_id String, metric String,
|
||||
entity_key String, event_time DateTime64(3,'UTC'), value Float64,
|
||||
dim_names Array(String), dim_values Array(String),
|
||||
attributes Map(String,String), measures Map(String,Float64))
|
||||
ENGINE = MergeTree() PARTITION BY toYYYYMM(event_time)
|
||||
ORDER BY (event_time, tenant_id)`);
|
||||
|
||||
// ── FIXTURE 1: generic SaaS event stream ───────────────────────────────
|
||||
// 200 tenants x 12 metrics x 90 days x 25 events/day ~= 5.4M events.
|
||||
const SAAS_TENANTS = 200, SAAS_METRICS = 12, SAAS_DAYS = 90, SAAS_PER_DAY = 25;
|
||||
const saasRows = SAAS_TENANTS * SAAS_METRICS * SAAS_DAYS * SAAS_PER_DAY;
|
||||
console.log(`seeding SaaS fixture: ${saasRows.toLocaleString()} events...`);
|
||||
const t0saas = Date.now();
|
||||
await ch(`INSERT INTO ${DB}.opt_analytics_event
|
||||
SELECT concat('t_', toString(number % ${SAAS_TENANTS})) AS tenant_id,
|
||||
'p_main' AS project_id,
|
||||
concat('ds_', toString(number % 8)) AS dataset_id,
|
||||
concat('metric_', toString(number % ${SAAS_METRICS})) AS metric,
|
||||
toString(number) AS event_id,
|
||||
toDateTime64('2026-06-01 00:00:00', 3, 'UTC') + toIntervalSecond(number % ${SAAS_DAYS * 86400}) AS event_time,
|
||||
toFloat64(number % 997) AS value,
|
||||
['plan','region'] AS dim_names,
|
||||
[if(number % 3 = 0, 'pro', 'free'), if(number % 5 = 0, 'eu-north', 'eu-central')] AS dim_values,
|
||||
now64(3) AS ingested_at
|
||||
FROM numbers(${saasRows})`);
|
||||
const saasSeedMs = Date.now() - t0saas;
|
||||
|
||||
// ── FIXTURE 2: VehIQ-scale vehicle projections ─────────────────────────
|
||||
// 2M vehicles for the tenant under test, plus 2M belonging to other
|
||||
// tenants — so a tenant-scoped lookup has to prove it prunes.
|
||||
const VEHICLES = 2_000_000, DECOYS = 2_000_000;
|
||||
console.log(`seeding VehIQ fixture: ${(VEHICLES + DECOYS).toLocaleString()} vehicle projections...`);
|
||||
const t0veh = Date.now();
|
||||
await ch(`INSERT INTO ${DB}.opt_dataset_projection
|
||||
SELECT 't_vehiq' AS tenant_id, 'p_main' AS project_id, 'ds_vehicles' AS dataset_id,
|
||||
concat('YV1', leftPad(toString(number), 14, '0')) AS entity_key,
|
||||
toDateTime64('2026-09-01 00:00:00', 3, 'UTC') AS as_of,
|
||||
1 AS version,
|
||||
map('make', arrayElement(['volvo','saab','audi','vw','toyota'], (number % 5) + 1),
|
||||
'fuel', arrayElement(['diesel','petrol','ev','hybrid'], (number % 4) + 1)) AS attributes,
|
||||
map('asking_price', toFloat64(50000 + number % 900000),
|
||||
'mileage_km', toFloat64(number % 400000)) AS measures
|
||||
FROM numbers(${VEHICLES})`);
|
||||
await ch(`INSERT INTO ${DB}.opt_dataset_projection
|
||||
SELECT concat('t_other_', toString(number % 50)) AS tenant_id, 'p_main', 'ds_vehicles',
|
||||
concat('WBA', leftPad(toString(number), 14, '0')),
|
||||
toDateTime64('2026-09-01 00:00:00', 3, 'UTC'), 1,
|
||||
map('make','bmw'), map('asking_price', toFloat64(number % 500000))
|
||||
FROM numbers(${DECOYS})`);
|
||||
const vehSeedMs = Date.now() - t0veh;
|
||||
|
||||
// ── the same data in the giant table ───────────────────────────────────
|
||||
console.log("seeding the counterfactual giant table...");
|
||||
await ch(`INSERT INTO ${DB}.giant_everything
|
||||
SELECT tenant_id, project_id, dataset_id, metric, '' AS entity_key, event_time, value,
|
||||
dim_names, dim_values, map() AS attributes, map() AS measures
|
||||
FROM ${DB}.opt_analytics_event`);
|
||||
await ch(`INSERT INTO ${DB}.giant_everything
|
||||
SELECT tenant_id, project_id, dataset_id, '' AS metric, entity_key,
|
||||
as_of AS event_time, 0 AS value, [] AS dim_names, [] AS dim_values, attributes, measures
|
||||
FROM ${DB}.opt_dataset_projection`);
|
||||
|
||||
await ch(`OPTIMIZE TABLE ${DB}.opt_analytics_event FINAL`, undefined, { max_execution_time: "600" });
|
||||
await ch(`OPTIMIZE TABLE ${DB}.opt_dataset_projection FINAL`, undefined, { max_execution_time: "600" });
|
||||
await ch(`OPTIMIZE TABLE ${DB}.giant_everything FINAL`, undefined, { max_execution_time: "900" });
|
||||
|
||||
const sizes = await json(`SELECT table, sum(rows) AS rows, sum(bytes_on_disk) AS bytes
|
||||
FROM system.parts WHERE database='${DB}' AND active GROUP BY table ORDER BY table FORMAT JSON`);
|
||||
evidence.fixtures = {
|
||||
saas: { events: saasRows, tenants: SAAS_TENANTS, metrics: SAAS_METRICS, days: SAAS_DAYS, seed_ms: saasSeedMs },
|
||||
vehiq: { vehicles_under_test: VEHICLES, decoy_vehicles: DECOYS, seed_ms: vehSeedMs },
|
||||
on_disk: sizes.data.map((r) => ({ table: r.table, rows: Number(r.rows), bytes: Number(r.bytes) })),
|
||||
};
|
||||
|
||||
// ── measurements ───────────────────────────────────────────────────────
|
||||
const T = "t_57"; // one representative SaaS tenant
|
||||
|
||||
evidence.measurements.push(
|
||||
await timeQuery(
|
||||
"SaaS dashboard panel: 90-day metric total for ONE tenant (purpose-built event table)",
|
||||
`SELECT sum(value) AS value FROM ${DB}.opt_analytics_event
|
||||
WHERE tenant_id = '${T}' AND metric = 'metric_3' FORMAT JSON`,
|
||||
),
|
||||
await timeQuery(
|
||||
"SAME question against ONE GIANT TABLE (time-leading sort key)",
|
||||
`SELECT sum(value) AS value FROM ${DB}.giant_everything
|
||||
WHERE tenant_id = '${T}' AND metric = 'metric_3' FORMAT JSON`,
|
||||
),
|
||||
await timeQuery(
|
||||
"SaaS time series: daily series for ONE tenant from the AggregatingMergeTree rollup",
|
||||
`SELECT day, sumMerge(sum_state) AS value FROM ${DB}.opt_metric_daily
|
||||
WHERE tenant_id = '${T}' AND metric = 'metric_3' GROUP BY day ORDER BY day FORMAT JSON`,
|
||||
),
|
||||
await timeQuery(
|
||||
"SAME time series computed from raw events instead of the rollup",
|
||||
`SELECT toDate(event_time) AS day, sum(value) AS value FROM ${DB}.opt_analytics_event
|
||||
WHERE tenant_id = '${T}' AND metric = 'metric_3' GROUP BY day ORDER BY day FORMAT JSON`,
|
||||
),
|
||||
await timeQuery(
|
||||
"SaaS group-by: one tenant sliced by a dimension",
|
||||
`SELECT dim_values[indexOf(dim_names, 'plan')] AS grp, count() AS value
|
||||
FROM ${DB}.opt_analytics_event WHERE tenant_id = '${T}' AND metric = 'metric_3'
|
||||
GROUP BY grp ORDER BY value DESC FORMAT JSON`,
|
||||
),
|
||||
await timeQuery(
|
||||
"VehIQ point lookup: one vehicle by key, tenant-scoped (purpose-built projection)",
|
||||
`SELECT entity_key, measures['asking_price'] AS p FROM ${DB}.opt_dataset_projection
|
||||
WHERE tenant_id = 't_vehiq' AND project_id = 'p_main' AND dataset_id = 'ds_vehicles'
|
||||
AND entity_key = 'YV100000001234567' FORMAT JSON`,
|
||||
),
|
||||
await timeQuery(
|
||||
"SAME point lookup against ONE GIANT TABLE",
|
||||
`SELECT entity_key, measures['asking_price'] AS p FROM ${DB}.giant_everything
|
||||
WHERE tenant_id = 't_vehiq' AND dataset_id = 'ds_vehicles'
|
||||
AND entity_key = 'YV100000001234567' FORMAT JSON`,
|
||||
),
|
||||
await timeQuery(
|
||||
"VehIQ aggregate: average asking price by make for ONE tenant across 2M vehicles",
|
||||
`SELECT attributes['make'] AS grp, avg(measures['asking_price']) AS value
|
||||
FROM ${DB}.opt_dataset_projection
|
||||
WHERE tenant_id = 't_vehiq' AND project_id = 'p_main' AND dataset_id = 'ds_vehicles'
|
||||
GROUP BY grp ORDER BY value DESC FORMAT JSON`,
|
||||
8,
|
||||
),
|
||||
await timeQuery(
|
||||
"SAME aggregate against ONE GIANT TABLE",
|
||||
`SELECT attributes['make'] AS grp, avg(measures['asking_price']) AS value
|
||||
FROM ${DB}.giant_everything
|
||||
WHERE tenant_id = 't_vehiq' AND dataset_id = 'ds_vehicles' AND entity_key != ''
|
||||
GROUP BY grp ORDER BY value DESC FORMAT JSON`,
|
||||
8,
|
||||
),
|
||||
);
|
||||
|
||||
// Index pruning evidence: how many granules the tenant predicate skips.
|
||||
const plan = await ch(
|
||||
`EXPLAIN indexes = 1 SELECT sum(value) FROM ${DB}.opt_analytics_event
|
||||
WHERE tenant_id = '${T}' AND metric = 'metric_3' FORMAT TSVRaw`,
|
||||
);
|
||||
const giantPlan = await ch(
|
||||
`EXPLAIN indexes = 1 SELECT sum(value) FROM ${DB}.giant_everything
|
||||
WHERE tenant_id = '${T}' AND metric = 'metric_3' FORMAT TSVRaw`,
|
||||
);
|
||||
const granules = (t) => {
|
||||
const m = [...t.matchAll(/Granules: (\d+)\/(\d+)/g)].pop();
|
||||
return m ? { read: Number(m[1]), total: Number(m[2]) } : null;
|
||||
};
|
||||
evidence.index_pruning = {
|
||||
purpose_built_event_table: granules(plan),
|
||||
one_giant_table: granules(giantPlan),
|
||||
note:
|
||||
"Granules read/total for the SAME question. The tenant-leading ORDER BY is what turns the " +
|
||||
"forced tenant predicate (BI-P011) into a primary-key range scan instead of a full scan.",
|
||||
};
|
||||
|
||||
evidence.notes.push(
|
||||
"Fixtures are synthetic and generated in-server with numbers(); no production or b0ts data was read or written.",
|
||||
"Each timing is the p50/p95 of repeated runs after one warm-up, measured client-side over HTTP on the same host.",
|
||||
"The 'ONE GIANT TABLE' rows are the counterfactual BI-P010 warns against, measured rather than asserted.",
|
||||
);
|
||||
|
||||
const out = path.join(process.cwd(), "evidence", "bi-p002-p012", "bi-p010-benchmark.json");
|
||||
fs.writeFileSync(out, JSON.stringify(evidence, null, 2) + "\n");
|
||||
console.log(`wrote ${out}`);
|
||||
for (const m of evidence.measurements) {
|
||||
console.log(` ${m.p50_ms.toString().padStart(9)} ms p50 ${String(m.rows_read).padStart(10)} rows ${m.label}`);
|
||||
}
|
||||
console.log(" pruning:", JSON.stringify(evidence.index_pruning));
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
process.exitCode = 1;
|
||||
})
|
||||
.finally(async () => {
|
||||
try {
|
||||
await ch(`DROP DATABASE IF EXISTS ${DB}`);
|
||||
console.log(`dropped ${DB}`);
|
||||
} catch (e) {
|
||||
console.error("cleanup failed:", e.message);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* BI-P061: is "only one published release per dashboard" actually enforced?
|
||||
*
|
||||
* The application checks for a live release before publishing, and that check
|
||||
* is a read-then-write race. This script measures BOTH directions against a
|
||||
* real Postgres and writes evidence/bi-p061/dashboard-release-race.json:
|
||||
*
|
||||
* guard_absent the partial unique index dropped -> both concurrent
|
||||
* publishes commit, and the dashboard has TWO live versions.
|
||||
* guard_present the index in place -> the loser fails with 23505.
|
||||
*
|
||||
* Without the first run, the second proves nothing: a race that never
|
||||
* interleaved would also show one published release.
|
||||
*
|
||||
* ANALYTICS_TEST_DATABASE_URL=postgres://postgres:test@127.0.0.1:25441/opt \
|
||||
* node scripts/bi-p061-release-race.mjs
|
||||
*
|
||||
* It CREATEs and DROPs its own throwaway schemas and touches nothing else.
|
||||
*/
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import postgres from 'postgres';
|
||||
|
||||
const URL_ = process.env.ANALYTICS_TEST_DATABASE_URL;
|
||||
if (!URL_) {
|
||||
console.error('ANALYTICS_TEST_DATABASE_URL is required. Refusing to invent numbers.');
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const STAMP = Date.now().toString(36);
|
||||
const SCHEMAS = { guard_present: `bi_p061_ev_on_${STAMP}`, guard_absent: `bi_p061_ev_off_${STAMP}` };
|
||||
const MIGRATIONS = ['sql/008_analytics_domain_model.sql', 'sql/010_analytics_dashboard_releases.sql'];
|
||||
const HASH = (c) => c.repeat(64);
|
||||
|
||||
const connect = (schema, max = 1) =>
|
||||
postgres(URL_, { max, onnotice: () => undefined, connection: { search_path: schema } });
|
||||
|
||||
async function setup(schema, { dropIndex }) {
|
||||
const admin = postgres(URL_, { max: 1, onnotice: () => undefined });
|
||||
await admin.unsafe(`CREATE SCHEMA IF NOT EXISTS ${schema}`);
|
||||
await admin.end({ timeout: 5 });
|
||||
|
||||
const c = connect(schema, 2);
|
||||
for (const f of MIGRATIONS) await c.unsafe(fs.readFileSync(path.join(process.cwd(), f), 'utf8'));
|
||||
if (dropIndex) await c.unsafe('DROP INDEX opt_analytics_dashboard_release_one_published');
|
||||
await c.unsafe(`
|
||||
INSERT INTO opt_analytics_dashboard_definition
|
||||
(dashboard_id, tenant_id, project_id, name, current_version, owner_id)
|
||||
VALUES ('dash_race', 't_alpha', 'p_main', 'Race', 1, 'u_1');
|
||||
INSERT INTO opt_analytics_dashboard_version
|
||||
(dashboard_version_id, dashboard_id, tenant_id, project_id, version, layout_json, layout_hash)
|
||||
VALUES ('dv_1', 'dash_race', 't_alpha', 'p_main', 1, '{}', '${HASH('a')}'),
|
||||
('dv_2', 'dash_race', 't_alpha', 'p_main', 2, '{}', '${HASH('b')}');`);
|
||||
return c;
|
||||
}
|
||||
|
||||
/** Both sessions read "nothing is live", then both write. */
|
||||
async function race(schema) {
|
||||
const conns = [connect(schema), connect(schema)];
|
||||
const publish = (conn, n) =>
|
||||
conn.begin(async (tx) => {
|
||||
await tx.unsafe(
|
||||
`SELECT release_id FROM opt_analytics_dashboard_release
|
||||
WHERE dashboard_id='dash_race' AND state='published'`,
|
||||
);
|
||||
await new Promise((r) => setTimeout(r, 60));
|
||||
await tx.unsafe(`
|
||||
INSERT INTO opt_analytics_dashboard_release
|
||||
(release_id, dashboard_id, dashboard_version_id, tenant_id, project_id, version, state,
|
||||
document_hash, published_at)
|
||||
VALUES ('rel_${n}', 'dash_race', 'dv_${n}', 't_alpha', 'p_main', ${n},
|
||||
'published', '${HASH(n === 1 ? 'a' : 'b')}', now())`);
|
||||
});
|
||||
const results = await Promise.allSettled([publish(conns[0], 1), publish(conns[1], 2)]);
|
||||
for (const c of conns) await c.end({ timeout: 5 });
|
||||
return {
|
||||
committed: results.filter((r) => r.status === 'fulfilled').length,
|
||||
rejected: results
|
||||
.filter((r) => r.status === 'rejected')
|
||||
.map((r) => ({ code: r.reason?.code ?? null, constraint: r.reason?.constraint_name ?? null })),
|
||||
};
|
||||
}
|
||||
|
||||
async function publishedRows(conn) {
|
||||
const rows = await conn.unsafe(
|
||||
`SELECT release_id, version FROM opt_analytics_dashboard_release
|
||||
WHERE dashboard_id='dash_race' AND state='published' ORDER BY version`,
|
||||
);
|
||||
return rows.map((r) => ({ release_id: r.release_id, version: r.version }));
|
||||
}
|
||||
|
||||
const out = { recorded_at: new Date().toISOString() };
|
||||
const cleanup = [];
|
||||
try {
|
||||
for (const [label, schema] of Object.entries(SCHEMAS)) {
|
||||
const conn = await setup(schema, { dropIndex: label === 'guard_absent' });
|
||||
cleanup.push([schema, conn]);
|
||||
if (!out.postgres_version) {
|
||||
const v = await conn.unsafe('SHOW server_version');
|
||||
out.postgres_version = v[0].server_version;
|
||||
}
|
||||
const result = await race(schema);
|
||||
out[label] = {
|
||||
partial_unique_index_present: label === 'guard_present',
|
||||
transactions_committed: result.committed,
|
||||
rejections: result.rejected,
|
||||
published_releases_after: await publishedRows(conn),
|
||||
};
|
||||
}
|
||||
|
||||
out.verdict = {
|
||||
counterfactual_produced_two_live_dashboards: out.guard_absent.published_releases_after.length === 2,
|
||||
guard_left_exactly_one: out.guard_present.published_releases_after.length === 1,
|
||||
loser_error_code: out.guard_present.rejections[0]?.code ?? null,
|
||||
application_check_alone_is_sufficient: false,
|
||||
note:
|
||||
'DashboardReleaseService.publish() reads for a live release before writing. Both sessions in ' +
|
||||
'guard_absent passed that check and both committed. The partial unique index is what holds.',
|
||||
};
|
||||
|
||||
const dir = path.join(process.cwd(), 'evidence/bi-p061');
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
const file = path.join(dir, 'dashboard-release-race.json');
|
||||
fs.writeFileSync(file, `${JSON.stringify(out, null, 2)}\n`);
|
||||
console.log(`wrote ${file}`);
|
||||
console.log(JSON.stringify(out.verdict, null, 2));
|
||||
} finally {
|
||||
for (const [schema, conn] of cleanup) {
|
||||
await conn.unsafe(`DROP SCHEMA IF EXISTS ${schema} CASCADE`);
|
||||
await conn.end({ timeout: 5 });
|
||||
}
|
||||
}
|
||||
Executable
+53
@@ -0,0 +1,53 @@
|
||||
#!/usr/bin/env sh
|
||||
# check-masterplan-lock.sh
|
||||
#
|
||||
# Fails the build if this repo has drifted from the canonical SIAX Non-Platform
|
||||
# masterplan (sax3l/siax-masterplan). Dependency-free (POSIX sh + grep only) so
|
||||
# it runs before any language toolchain is installed.
|
||||
#
|
||||
# What it guards against:
|
||||
# 1. PLAN/MASTERPLAN_INDEX.md being deleted (silent regression to a parallel
|
||||
# truth with no pointer back to the canonical docs).
|
||||
# 2. The "locked architecture principles" section being stripped or edited
|
||||
# away, which is the actual enforcement text (one canonical owner per
|
||||
# domain/truth, no cross-service SQL, no frontend-tenant-trust, no
|
||||
# production-mock-fallbacks, API-first).
|
||||
# 3. The status vocabulary line (MISSING -> ... -> PRODUCTION_READY) going
|
||||
# missing, which would let a repo invent its own status labels.
|
||||
#
|
||||
# See PLAN/MASTERPLAN_INDEX.md and sax3l/siax-masterplan for the source of
|
||||
# truth. Do not weaken this check to make a red build pass -- fix the drift
|
||||
# in PLAN/MASTERPLAN_INDEX.md instead (or resync it from siax-masterplan).
|
||||
|
||||
set -eu
|
||||
|
||||
PLAN_FILE="PLAN/MASTERPLAN_INDEX.md"
|
||||
fail=0
|
||||
|
||||
if [ ! -f "$PLAN_FILE" ]; then
|
||||
echo "::error::${PLAN_FILE} is missing. This repo must carry a repo-local copy of the SIAX Non-Platform masterplan index (source: sax3l/siax-masterplan). Restore it -- do not remove this check."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
check_contains() {
|
||||
needle="$1"
|
||||
label="$2"
|
||||
if ! grep -qF "$needle" "$PLAN_FILE"; then
|
||||
echo "::error::${PLAN_FILE} no longer contains ${label} ('${needle}'). This looks like drift away from the canonical masterplan (sax3l/siax-masterplan). Refresh PLAN/MASTERPLAN_INDEX.md from source instead of editing this check."
|
||||
fail=1
|
||||
fi
|
||||
}
|
||||
|
||||
check_contains "Låsta arkitekturprinciper" "the locked-architecture-principles heading"
|
||||
check_contains "En canonical owner per domän och sanning" "the one-canonical-owner-per-domain principle"
|
||||
check_contains "aldrig egen SQL" "the no-own-SQL / no-parallel-truth clause"
|
||||
check_contains "ingen frontend-tenant-trust" "the no-frontend-tenant-trust clause"
|
||||
check_contains "PRODUCTION_READY" "the status vocabulary"
|
||||
check_contains "sax3l/siax-masterplan" "the pointer back to the canonical masterplan repo"
|
||||
|
||||
if [ "$fail" -ne 0 ]; then
|
||||
echo "::error::masterplan lock check FAILED -- see above."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "masterplan lock check OK -- ${PLAN_FILE} still matches the canonical principles."
|
||||
Executable
+74
@@ -0,0 +1,74 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Blocking guard for finding UI-09 (measured 2026-09-26): 0pt.app carried 82
|
||||
* `color-contrast` violations at axe-core 4.10.2 impact=serious — the estate's
|
||||
* single largest a11y defect. Every one of them came from the same shape: a
|
||||
* Tailwind alpha modifier on a muted text colour.
|
||||
*
|
||||
* Measured WCAG 2.2 contrast against the two theme backgrounds in
|
||||
* styles/globals.css (--background: oklch(1 0 0) light / oklch(0.145 0 0) dark,
|
||||
* --muted-foreground: oklch(0.556 0 0) / oklch(0.708 0 0)):
|
||||
*
|
||||
* text-muted-foreground 4.73:1 light 7.63:1 dark PASS (AA needs 4.5)
|
||||
* text-muted-foreground/70 2.71:1 4.20:1 FAIL
|
||||
* text-muted-foreground/60 2.29:1 3.37:1 FAIL
|
||||
* text-muted-foreground/50 1.96:1 2.67:1 FAIL
|
||||
* text-muted-foreground/40 1.69:1 2.11:1 FAIL
|
||||
* text-muted-foreground/10 1.13:1 1.13:1 FAIL
|
||||
*
|
||||
* The unmodified token passes in both themes, so the fix is simply never to
|
||||
* fade it. Purely decorative text is exempt — but it has to SAY so: the element
|
||||
* must carry `aria-hidden`, which is also what makes axe skip it. The single
|
||||
* remaining case (app/bestall/page.tsx, the oversized "01" masthead numeral) is
|
||||
* `aria-hidden` + `select-none` + `pointer-events-none` and is allowed by that
|
||||
* rule.
|
||||
*
|
||||
* This is a source-level guard, not a browser run. The full axe-core job that
|
||||
* UI-09 also asks for needs a build + serve in CI and is tracked separately;
|
||||
* until it exists this stops the regression at the shape that caused it.
|
||||
*/
|
||||
import { readdirSync, readFileSync, statSync } from 'node:fs';
|
||||
import { join, relative } from 'node:path';
|
||||
|
||||
const ROOT = new URL('../..', import.meta.url).pathname.replace(/\/$/, '');
|
||||
const SKIP = new Set(['node_modules', '.next', '.git', 'dist', 'build', 'coverage', '.turbo']);
|
||||
const EXT = /\.(tsx|ts|jsx|js|css)$/;
|
||||
/** Alpha-modified colour utilities on TEXT. Background/border alphas are fine. */
|
||||
const PATTERN = /\btext-(muted-foreground|foreground|primary|secondary|accent-foreground|card-foreground|popover-foreground)\/\d+/g;
|
||||
|
||||
function walk(dir, out = []) {
|
||||
for (const name of readdirSync(dir)) {
|
||||
if (SKIP.has(name)) continue;
|
||||
const p = join(dir, name);
|
||||
if (statSync(p).isDirectory()) walk(p, out);
|
||||
else if (EXT.test(name)) out.push(p);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const violations = [];
|
||||
for (const file of walk(ROOT)) {
|
||||
const src = readFileSync(file, 'utf8');
|
||||
const lines = src.split('\n');
|
||||
lines.forEach((line, i) => {
|
||||
for (const m of line.matchAll(PATTERN)) {
|
||||
// Decorative text is exempt when the element declares itself hidden to AT.
|
||||
// The attribute may sit on an earlier line of the same JSX element.
|
||||
const window = lines.slice(Math.max(0, i - 6), i + 1).join('\n');
|
||||
if (/\baria-hidden\b/.test(window)) continue;
|
||||
violations.push({ file: relative(ROOT, file), line: i + 1, token: m[0], text: line.trim() });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (violations.length) {
|
||||
console.error(`\n✗ ${violations.length} alpha-faded text colour(s) found — WCAG 2.2 AA 1.4.3 (finding UI-09).\n`);
|
||||
for (const v of violations) {
|
||||
console.error(` ${v.file}:${v.line} ${v.token}`);
|
||||
console.error(` ${v.text.slice(0, 160)}`);
|
||||
}
|
||||
console.error('\n Use the unmodified token (text-muted-foreground = 4.73:1 light / 7.63:1 dark).');
|
||||
console.error(' If the text really is decoration, put aria-hidden on the element.\n');
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('✓ no alpha-faded text colours — WCAG 2.2 AA 1.4.3 contrast shape is clean (UI-09)');
|
||||
Executable
+5
@@ -0,0 +1,5 @@
|
||||
<!-- GENERATED by siax-doctor repo-contract --apply (repo-contract-gate/v2 — 2026-09-23T21:12:27.075Z); template-fil, redigera via siax-templates - EJ handredigering. Saknas en rad här upptäcks det av RC-lagret. -->
|
||||
#!/usr/bin/env sh
|
||||
# GENERATED by siax-doctor repo-contract --apply (repo-contract-gate/v2). Stub = ej kopplad an; koppla till @siax/doctor via siax-templates.
|
||||
echo "repo-contract-gate: scripts/doctor ar en GENERATED stub - koppla den till @siax/doctor via siax-templates" >&2
|
||||
exit 0
|
||||
@@ -0,0 +1,608 @@
|
||||
#!/usr/bin/env node
|
||||
// siax-doctor (BACKLOG P1-19, control AGENT-04): "Repot är
|
||||
// self-diagnostiserande: `pnpm siax:doctor` kör hela standarden lokalt och
|
||||
// skriver ut avvikelser med kontroll-ID."
|
||||
//
|
||||
// This is a repo-local, zero-dependency subset -- NOT the published
|
||||
// @siax/doctor package from sax3l/siax-standard. That package was
|
||||
// deliberately not taken on as a dependency here: at the time this was
|
||||
// written it wasn't verified reachable/stable from this repo's frozen
|
||||
// lockfile install, and a doctor script that can silently fail to install
|
||||
// is worse than no doctor script. This checks the same class of
|
||||
// machine-checkable facts using only Node's built-ins -- no network in the
|
||||
// default mode, no registry.
|
||||
//
|
||||
// =============================================================================
|
||||
// 2026-09-07 -- WHY THIS SCRIPT WAS REWRITTEN: it validated PRESENCE, never TRUTH
|
||||
// =============================================================================
|
||||
// The previous version's manifest checks were all `requireJsonField`, whose
|
||||
// entire body is "does this key exist". Measured consequence: running it with
|
||||
// siax.repo.json `operationalState: "running"` (false at the time -- every
|
||||
// declared host answered 402/404) and with `"down"` (true) produced 39/40 and
|
||||
// byte-identical output. A descriptor that asserted a live service therefore
|
||||
// survived every gate this repo had.
|
||||
//
|
||||
// DESIGN, and what is deliberately NOT done:
|
||||
//
|
||||
// 1. OFFLINE (default, and the only thing anything automated runs).
|
||||
// Everything here is a pure function of the repo's own bytes, so it is
|
||||
// deterministic and cannot go red because a network was missing. Three
|
||||
// kinds of check:
|
||||
// (a) VALUE checks -- enums, formats, patterns. `operationalState` must
|
||||
// be one of the standard's five states, `gitHost` must literally be
|
||||
// git.siax.io, `node` must match the fleet's server pattern, every
|
||||
// declared fqdn must be a bare well-formed hostname.
|
||||
// (b) INTERNAL CONSISTENCY -- the repo's files must agree with each
|
||||
// other. Every prod hostname in siax.repo.json must actually appear
|
||||
// in this repo's own deploy/contract config (siax.config.json
|
||||
// `environments.*.domain`, contracts/openapi.yaml `servers:`), and
|
||||
// vice versa. A hostname nothing deploys is a hostname nobody owns.
|
||||
// (c) SUBSTANTIATION -- the one that closes the historical hole.
|
||||
// Liveness is not knowable offline. But whether a liveness CLAIM is
|
||||
// BACKED is. `operationalState` must be matched by a dated probe
|
||||
// record in evidence/operational-state.json whose results agree
|
||||
// with it: "running" requires every prod probe to be a 2xx,
|
||||
// "down" requires none of them to be. Claiming "running" next to a
|
||||
// recorded 402 now fails. The record's own `ok` flags are re-derived
|
||||
// from `httpStatus`, so a hand-edited `ok: true` next to a 402 fails
|
||||
// too.
|
||||
//
|
||||
// 2. LIVE (`--live`, or SIAX_DOCTOR_LIVE=1) -- opt-in, for humans and for a
|
||||
// scheduled job that HAS network. Actually probes the declared prod hosts,
|
||||
// compares reality to the declared state, and checks that the security
|
||||
// headers next.config.mjs promises are really on the wire.
|
||||
// `--write-evidence` rewrites evidence/operational-state.json from that
|
||||
// probe. This is NOT wired into the blocking CI path on purpose: a gate
|
||||
// that needs the public internet fails for reasons that have nothing to do
|
||||
// with the change under review, and people learn to ignore it.
|
||||
//
|
||||
// Exit code 0 = every check passed. Exit code 1 = at least one check failed
|
||||
// (see the printed control-ID to find the BACKLOG/standard item).
|
||||
// `--json` prints a machine-readable result instead of the table; that is what
|
||||
// tests/repo/siax-doctor.test.ts consumes so this script is enforced by the
|
||||
// blocking "CI (SIAX Cloud) / quality" job without any workflow change.
|
||||
// =============================================================================
|
||||
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { join, dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
// ROOT is the repo being diagnosed. SIAX_DOCTOR_ROOT lets a test point the
|
||||
// SAME checks at a fixture tree, so the negative cases ("operationalState says
|
||||
// running while the recorded probes say 402") are proven by the suite on every
|
||||
// run -- not just once, in a transcript, by whoever wrote the check.
|
||||
const ROOT = process.env.SIAX_DOCTOR_ROOT
|
||||
? process.env.SIAX_DOCTOR_ROOT
|
||||
: join(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
|
||||
const argv = process.argv.slice(2);
|
||||
const WANT_JSON = argv.includes("--json");
|
||||
const WANT_LIVE = argv.includes("--live") || process.env.SIAX_DOCTOR_LIVE === "1";
|
||||
const WANT_WRITE_EVIDENCE = argv.includes("--write-evidence");
|
||||
const PROBE_TIMEOUT_MS = Number(process.env.SIAX_DOCTOR_TIMEOUT_MS || 15000);
|
||||
|
||||
/**
|
||||
* How stale a liveness claim may be before it stops counting as evidence.
|
||||
* Only enforced for states that assert the service is up ("running"/"degraded")
|
||||
* -- a repo that is honestly "down" does not decay into a lie by sitting still.
|
||||
*/
|
||||
const MAX_EVIDENCE_AGE_DAYS = 90;
|
||||
|
||||
/** @type {{id: string, label: string, check: () => boolean | string}[]} */
|
||||
const checks = [];
|
||||
|
||||
/** @type {{id: string, label: string, run: () => Promise<boolean | string>}[]} */
|
||||
const liveChecks = [];
|
||||
|
||||
function file(path) {
|
||||
return join(ROOT, path);
|
||||
}
|
||||
|
||||
function readJson(path) {
|
||||
if (!existsSync(file(path))) return { error: `missing file: ${path}` };
|
||||
try {
|
||||
return { value: JSON.parse(readFileSync(file(path), "utf8")) };
|
||||
} catch (e) {
|
||||
return { error: `invalid JSON in ${path}: ${e.message}` };
|
||||
}
|
||||
}
|
||||
|
||||
function readText(path) {
|
||||
if (!existsSync(file(path))) return { error: `missing file: ${path}` };
|
||||
return { value: readFileSync(file(path), "utf8") };
|
||||
}
|
||||
|
||||
function requireFile(id, label, path) {
|
||||
checks.push({
|
||||
id,
|
||||
label,
|
||||
check: () => (existsSync(file(path)) ? true : `missing: ${path}`),
|
||||
});
|
||||
}
|
||||
|
||||
function requireJsonField(id, label, path, fieldPath) {
|
||||
checks.push({
|
||||
id,
|
||||
label,
|
||||
check: () => {
|
||||
const { value: data, error } = readJson(path);
|
||||
if (error) return error;
|
||||
let cur = data;
|
||||
for (const key of fieldPath.split(".")) {
|
||||
if (cur == null || !(key in cur)) return `missing field "${fieldPath}" in ${path}`;
|
||||
cur = cur[key];
|
||||
}
|
||||
return true;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The check the old `requireJsonField` should have been: presence AND a
|
||||
* predicate on the value. `expectation` is prose printed on failure.
|
||||
*/
|
||||
function requireJsonValue(id, label, path, fieldPath, predicate, expectation) {
|
||||
checks.push({
|
||||
id,
|
||||
label,
|
||||
check: () => {
|
||||
const { value: data, error } = readJson(path);
|
||||
if (error) return error;
|
||||
let cur = data;
|
||||
for (const key of fieldPath.split(".")) {
|
||||
if (cur == null || !(key in cur)) return `missing field "${fieldPath}" in ${path}`;
|
||||
cur = cur[key];
|
||||
}
|
||||
if (predicate(cur)) return true;
|
||||
return `${path} "${fieldPath}" = ${JSON.stringify(cur)} -- expected ${expectation}`;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function requireTextMatch(id, label, path, pattern, patternLabel) {
|
||||
checks.push({
|
||||
id,
|
||||
label,
|
||||
check: () => {
|
||||
const { value: text, error } = readText(path);
|
||||
if (error) return error;
|
||||
return pattern.test(text) ? true : `${path} does not contain ${patternLabel}`;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function check(id, label, fn) {
|
||||
checks.push({ id, label, check: fn });
|
||||
}
|
||||
|
||||
// -- REPO-* / DOC-* : required root files --------------------------------
|
||||
requireFile("DOC-01", "README.md exists", "README.md");
|
||||
requireFile("REPO-08", "LICENSE exists", "LICENSE");
|
||||
requireFile("REPO-13", ".editorconfig exists", ".editorconfig");
|
||||
checks.push({
|
||||
id: "REPO-15/P0-19",
|
||||
label: "no committed .env (real secrets)",
|
||||
check: () => (existsSync(file(".env")) ? "a real .env file is committed -- should never happen" : true),
|
||||
});
|
||||
requireFile("P1-15", "CODEOWNERS exists", "CODEOWNERS");
|
||||
requireFile("P1-3", "CHANGELOG.md exists", "CHANGELOG.md");
|
||||
requireFile("SEC-*", "SECURITY.md exists", "SECURITY.md");
|
||||
requireFile("AGENT-01", "AGENTS.md exists", "AGENTS.md");
|
||||
requireFile("DOC-01", "CONTRIBUTING.md exists", "CONTRIBUTING.md");
|
||||
|
||||
// -- DOC-02/DOC-04 : ARCHITECTURE.md at the exact standard path -----------
|
||||
requireFile("DOC-02", "docs/architecture/ARCHITECTURE.md exists at the standard path", "docs/architecture/ARCHITECTURE.md");
|
||||
requireTextMatch(
|
||||
"DOC-02/v2.0",
|
||||
"ARCHITECTURE.md has the v2.0 '## 5. Ytor och paritet' section",
|
||||
"docs/architecture/ARCHITECTURE.md",
|
||||
/^## 5\. Ytor och paritet/m,
|
||||
"'## 5. Ytor och paritet'",
|
||||
);
|
||||
requireTextMatch(
|
||||
"DOC-04",
|
||||
"ARCHITECTURE.md has a 'Senast verifierad mot kod' date field",
|
||||
"docs/architecture/ARCHITECTURE.md",
|
||||
/Senast verifierad mot kod:\*\*\s*\d{4}-\d{2}-\d{2}/,
|
||||
"a dated 'Senast verifierad mot kod' field",
|
||||
);
|
||||
|
||||
// -- P1-1 / REPO-03/04/06 : manifests, PRESENCE ---------------------------
|
||||
requireFile("P1-1/REPO-04", "siax.repo.json exists", "siax.repo.v3.json");
|
||||
for (const field of ["repo", "category", "subcategory", "appId", "owner", "lifecycle", "gitHost", "node", "domains", "consumes", "consumedBy", "replacesSaaS", "children", "operationalState"]) {
|
||||
requireJsonField("P1-1/REPO-04", `siax.repo.json has required field "${field}"`, "siax.repo.v3.json", field);
|
||||
}
|
||||
requireFile("P1-1/REPO-06", "siax.config.json exists", "siax.config.json");
|
||||
for (const field of ["framework", "build", "start", "port", "healthcheck", "environments"]) {
|
||||
requireJsonField("P1-1/REPO-06", `siax.config.json has required field "${field}"`, "siax.config.json", field);
|
||||
}
|
||||
|
||||
// -- REPO-04/value : manifests, VALUES (not just presence) -----------------
|
||||
// Enums mirror schema/siax.repo.schema.json (standard v2.0). Kept literal here
|
||||
// on purpose: the doctor must work offline, and fetching the schema over the
|
||||
// network is the exact dependency this script refuses to take on.
|
||||
const OPERATIONAL_STATES = ["planned", "building", "running", "degraded", "down"];
|
||||
const LIFECYCLES = ["active", "archive", "retire", "external"];
|
||||
const CATEGORIES = ["platform", "extension", "business", "consultant", "customer", "mobile"];
|
||||
const ENVS = ["dev", "test", "staging", "prod"];
|
||||
|
||||
requireJsonValue(
|
||||
"REPO-04/GOV-06", 'siax.repo.json "operationalState" is one of the standard\'s five states',
|
||||
"siax.repo.v3.json", "operationalState",
|
||||
(v) => OPERATIONAL_STATES.includes(v), `one of ${OPERATIONAL_STATES.join("|")}`,
|
||||
);
|
||||
requireJsonValue(
|
||||
"REPO-04/GOV-06", 'siax.repo.json "lifecycle" is a standard lifecycle',
|
||||
"siax.repo.v3.json", "lifecycle",
|
||||
(v) => LIFECYCLES.includes(v), `one of ${LIFECYCLES.join("|")}`,
|
||||
);
|
||||
requireJsonValue(
|
||||
"REPO-04/GOV-01", 'siax.repo.json "category" is a standard category',
|
||||
"siax.repo.v3.json", "category",
|
||||
(v) => CATEGORIES.includes(v), `one of ${CATEGORIES.join("|")}`,
|
||||
);
|
||||
requireJsonValue(
|
||||
"REPO-04", 'siax.repo.json "gitHost" is git.siax.io (schema const)',
|
||||
"siax.repo.v3.json", "gitHost",
|
||||
(v) => v === "git.siax.io", '"git.siax.io"',
|
||||
);
|
||||
requireJsonValue(
|
||||
"REPO-04", 'siax.repo.json "node" is null or a real fleet node',
|
||||
"siax.repo.v3.json", "node",
|
||||
(v) => v === null || (typeof v === "string" && /^server(5|6|7|8|9|10|11)$/.test(v)),
|
||||
"null, or server5..server11",
|
||||
);
|
||||
requireJsonValue(
|
||||
"REPO-06", 'siax.config.json "port" is a plausible TCP port',
|
||||
"siax.config.json", "port",
|
||||
(v) => Number.isInteger(v) && v > 0 && v < 65536, "an integer in 1..65535",
|
||||
);
|
||||
requireJsonValue(
|
||||
"REPO-06/CD-04", 'siax.config.json "healthcheck.path" is an absolute path',
|
||||
"siax.config.json", "healthcheck.path",
|
||||
(v) => typeof v === "string" && v.startsWith("/"), "a path starting with /",
|
||||
);
|
||||
|
||||
// A bare hostname: labels of [a-z0-9-], at least one dot, no scheme/port/path.
|
||||
const FQDN_RE = /^(?=.{1,253}$)(?!-)[a-z0-9-]{1,63}(?<!-)(\.(?!-)[a-z0-9-]{1,63}(?<!-))+$/;
|
||||
|
||||
check("NET-01", "every siax.repo.json domain entry is well-formed", () => {
|
||||
const { value: repo, error } = readJson("siax.repo.v3.json");
|
||||
if (error) return error;
|
||||
if (!Array.isArray(repo.domains)) return '"domains" is not an array';
|
||||
const problems = [];
|
||||
for (const [i, d] of repo.domains.entries()) {
|
||||
if (!d || typeof d !== "object") { problems.push(`domains[${i}] is not an object`); continue; }
|
||||
if (typeof d.fqdn !== "string" || !FQDN_RE.test(d.fqdn)) {
|
||||
problems.push(`domains[${i}].fqdn=${JSON.stringify(d.fqdn)} is not a bare lowercase hostname (no scheme, port or path)`);
|
||||
}
|
||||
if (!ENVS.includes(d.env)) problems.push(`domains[${i}].env=${JSON.stringify(d.env)} not in ${ENVS.join("|")}`);
|
||||
if (typeof d.purpose !== "string" || d.purpose.trim() === "") problems.push(`domains[${i}].purpose is empty`);
|
||||
}
|
||||
const seen = new Set();
|
||||
for (const d of repo.domains) {
|
||||
if (d && typeof d.fqdn === "string") {
|
||||
if (seen.has(d.fqdn)) problems.push(`duplicate fqdn ${d.fqdn}`);
|
||||
seen.add(d.fqdn);
|
||||
}
|
||||
}
|
||||
return problems.length === 0 ? true : problems.join("; ");
|
||||
});
|
||||
|
||||
/** Hostnames this repo's own deploy/contract config actually points at. */
|
||||
function hostsInDeployConfig() {
|
||||
const hosts = new Set();
|
||||
const { value: cfg } = readJson("siax.config.json");
|
||||
if (cfg && cfg.environments && typeof cfg.environments === "object") {
|
||||
for (const env of Object.values(cfg.environments)) {
|
||||
if (env && typeof env.domain === "string") hosts.add(env.domain.toLowerCase());
|
||||
}
|
||||
}
|
||||
const { value: openapi } = readText("contracts/openapi.yaml");
|
||||
if (openapi) {
|
||||
// `servers:` entries are `- url: https://host`. Deliberately a narrow match:
|
||||
// any https URL in the contract counts as a host this repo points at.
|
||||
for (const m of openapi.matchAll(/^\s*-?\s*url:\s*["']?https?:\/\/([a-z0-9.-]+)/gim)) {
|
||||
hosts.add(m[1].toLowerCase());
|
||||
}
|
||||
}
|
||||
return hosts;
|
||||
}
|
||||
|
||||
check("NET-01/REPO-06", "every declared prod hostname appears in this repo's own deploy/contract config", () => {
|
||||
const { value: repo, error } = readJson("siax.repo.v3.json");
|
||||
if (error) return error;
|
||||
if (!Array.isArray(repo.domains)) return '"domains" is not an array';
|
||||
const configured = hostsInDeployConfig();
|
||||
const orphans = repo.domains
|
||||
.filter((d) => d && d.env === "prod" && typeof d.fqdn === "string")
|
||||
.map((d) => d.fqdn.toLowerCase())
|
||||
.filter((f) => !configured.has(f));
|
||||
return orphans.length === 0
|
||||
? true
|
||||
: `declared but nothing in siax.config.json/contracts/openapi.yaml points at: ${orphans.join(", ")}`;
|
||||
});
|
||||
|
||||
check("REPO-06/NET-01", "every siax.config.json environment domain is declared in siax.repo.json domains", () => {
|
||||
const { value: repo, error: e1 } = readJson("siax.repo.v3.json");
|
||||
if (e1) return e1;
|
||||
const { value: cfg, error: e2 } = readJson("siax.config.json");
|
||||
if (e2) return e2;
|
||||
const declared = new Map(
|
||||
(Array.isArray(repo.domains) ? repo.domains : [])
|
||||
.filter((d) => d && typeof d.fqdn === "string")
|
||||
.map((d) => [d.fqdn.toLowerCase(), d.env]),
|
||||
);
|
||||
const problems = [];
|
||||
for (const [envName, env] of Object.entries(cfg.environments ?? {})) {
|
||||
if (!env || typeof env.domain !== "string") continue;
|
||||
const fqdn = env.domain.toLowerCase();
|
||||
if (!declared.has(fqdn)) {
|
||||
problems.push(`siax.config.json environments.${envName}.domain=${fqdn} is not in siax.repo.json domains`);
|
||||
} else if (declared.get(fqdn) !== envName) {
|
||||
problems.push(`${fqdn} is env=${declared.get(fqdn)} in siax.repo.json but environments.${envName} in siax.config.json`);
|
||||
}
|
||||
}
|
||||
return problems.length === 0 ? true : problems.join("; ");
|
||||
});
|
||||
|
||||
// -- GOV-06 : operationalState must be SUBSTANTIATED, not merely present ----
|
||||
const EVIDENCE_PATH = "evidence/operational-state.json";
|
||||
requireFile("GOV-06", `${EVIDENCE_PATH} exists (substantiates operationalState)`, EVIDENCE_PATH);
|
||||
|
||||
check("GOV-06", "the recorded probe evidence is internally coherent", () => {
|
||||
const { value: ev, error } = readJson(EVIDENCE_PATH);
|
||||
if (error) return error;
|
||||
const problems = [];
|
||||
if (!Array.isArray(ev.probes) || ev.probes.length === 0) problems.push("no probes recorded");
|
||||
const when = Date.parse(ev.checkedAt ?? "");
|
||||
if (Number.isNaN(when)) problems.push(`checkedAt=${JSON.stringify(ev.checkedAt)} is not a parseable timestamp`);
|
||||
else if (when > Date.now() + 86_400_000) problems.push(`checkedAt=${ev.checkedAt} is in the future`);
|
||||
for (const [i, p] of (ev.probes ?? []).entries()) {
|
||||
if (!p || typeof p !== "object") { problems.push(`probes[${i}] is not an object`); continue; }
|
||||
if (typeof p.fqdn !== "string" || !FQDN_RE.test(p.fqdn)) problems.push(`probes[${i}].fqdn is not a hostname`);
|
||||
if (!Number.isInteger(p.httpStatus) && p.httpStatus !== null) problems.push(`probes[${i}].httpStatus must be an integer or null (unreachable)`);
|
||||
if (typeof p.ok !== "boolean") problems.push(`probes[${i}].ok must be a boolean`);
|
||||
// `ok` is not allowed to disagree with the status it sits next to.
|
||||
const derived = Number.isInteger(p.httpStatus) && p.httpStatus >= 200 && p.httpStatus < 400;
|
||||
if (typeof p.ok === "boolean" && p.ok !== derived) {
|
||||
problems.push(`probes[${i}] (${p.fqdn}) records ok=${p.ok} next to httpStatus=${JSON.stringify(p.httpStatus)}`);
|
||||
}
|
||||
}
|
||||
return problems.length === 0 ? true : problems.join("; ");
|
||||
});
|
||||
|
||||
check("GOV-06", "every declared prod hostname has a probe result on record", () => {
|
||||
const { value: repo, error: e1 } = readJson("siax.repo.v3.json");
|
||||
if (e1) return e1;
|
||||
const { value: ev, error: e2 } = readJson(EVIDENCE_PATH);
|
||||
if (e2) return e2;
|
||||
const probed = new Set((ev.probes ?? []).map((p) => String(p?.fqdn ?? "").toLowerCase()));
|
||||
const missing = (Array.isArray(repo.domains) ? repo.domains : [])
|
||||
.filter((d) => d && d.env === "prod" && typeof d.fqdn === "string")
|
||||
.map((d) => d.fqdn.toLowerCase())
|
||||
.filter((f) => !probed.has(f));
|
||||
return missing.length === 0 ? true : `no probe recorded for: ${missing.join(", ")}`;
|
||||
});
|
||||
|
||||
check("GOV-06", "operationalState agrees with the recorded probe results", () => {
|
||||
const { value: repo, error: e1 } = readJson("siax.repo.v3.json");
|
||||
if (e1) return e1;
|
||||
const { value: ev, error: e2 } = readJson(EVIDENCE_PATH);
|
||||
if (e2) return e2;
|
||||
|
||||
const state = repo.operationalState;
|
||||
if (ev.operationalState !== state) {
|
||||
return `siax.repo.json says "${state}" but ${EVIDENCE_PATH} was recorded for "${ev.operationalState}" -- re-probe (\`node scripts/siax-doctor.mjs --live --write-evidence\`) instead of editing one side`;
|
||||
}
|
||||
|
||||
const prodFqdns = new Set(
|
||||
(Array.isArray(repo.domains) ? repo.domains : [])
|
||||
.filter((d) => d && d.env === "prod" && typeof d.fqdn === "string")
|
||||
.map((d) => d.fqdn.toLowerCase()),
|
||||
);
|
||||
const prodProbes = (ev.probes ?? []).filter((p) => prodFqdns.has(String(p?.fqdn ?? "").toLowerCase()));
|
||||
const up = prodProbes.filter((p) => p.ok === true);
|
||||
const down = prodProbes.filter((p) => p.ok !== true);
|
||||
const describe = (list) => list.map((p) => `${p.fqdn}=${p.httpStatus ?? "unreachable"}`).join(", ");
|
||||
|
||||
// Staleness only bites states that ASSERT the service is up. A repo that is
|
||||
// honestly "down" does not become a liar by not being re-probed.
|
||||
if (state === "running" || state === "degraded") {
|
||||
const ageDays = (Date.now() - Date.parse(ev.checkedAt ?? "")) / 86_400_000;
|
||||
if (Number.isFinite(ageDays) && ageDays > MAX_EVIDENCE_AGE_DAYS) {
|
||||
return `operationalState "${state}" is backed only by evidence ${Math.round(ageDays)} days old (limit ${MAX_EVIDENCE_AGE_DAYS}) -- an unrefreshed liveness claim is an unproven one`;
|
||||
}
|
||||
}
|
||||
|
||||
switch (state) {
|
||||
case "running":
|
||||
// The exact historical defect: "running" while every host answered 402/404.
|
||||
return down.length === 0
|
||||
? true
|
||||
: `operationalState "running" but the recorded probes say otherwise: ${describe(down)}`;
|
||||
case "degraded":
|
||||
return up.length > 0
|
||||
? true
|
||||
: `operationalState "degraded" implies something still answers, but no prod host does: ${describe(down)}`;
|
||||
case "down":
|
||||
return up.length === 0
|
||||
? true
|
||||
: `operationalState "down" but these prod hosts answered: ${describe(up)}`;
|
||||
case "planned":
|
||||
case "building":
|
||||
return up.length === 0
|
||||
? true
|
||||
: `operationalState "${state}" but these prod hosts already answer: ${describe(up)} -- that is "running"/"degraded"`;
|
||||
default:
|
||||
return `unknown operationalState ${JSON.stringify(state)}`;
|
||||
}
|
||||
});
|
||||
|
||||
// -- SEC-06 / P0-13 : the security headers CHANGELOG.md claims must exist ----
|
||||
// Text-level tripwire so `pnpm siax:doctor` alone catches the claim going
|
||||
// stale. tests/security/security-headers.test.ts is the strict version (it
|
||||
// evaluates the config and asserts each value).
|
||||
check("SEC-06/P0-13", "next.config.mjs actually sets the security headers CHANGELOG.md claims", () => {
|
||||
const { value: text, error } = readText("next.config.mjs");
|
||||
if (error) return error;
|
||||
if (!/async\s+headers\s*\(/.test(text)) return "next.config.mjs has no async headers()";
|
||||
const missing = ["Content-Security-Policy", "Strict-Transport-Security", "X-Frame-Options", "X-Content-Type-Options", "Referrer-Policy"]
|
||||
.filter((h) => !text.includes(h));
|
||||
return missing.length === 0 ? true : `next.config.mjs never mentions: ${missing.join(", ")}`;
|
||||
});
|
||||
|
||||
// -- AGENT-02/03 : prompts -------------------------------------------------
|
||||
requireFile("AGENT-02", "prompts/000-INDEX.md exists", "prompts/000-INDEX.md");
|
||||
|
||||
// -- DX-03 : devcontainer --------------------------------------------------
|
||||
requireFile("DX-03", ".devcontainer/devcontainer.json exists", ".devcontainer/devcontainer.json");
|
||||
|
||||
// -- P1-2 : ADRs -------------------------------------------------------------
|
||||
requireFile("P1-2", "docs/architecture/decisions/ has at least one ADR", "docs/architecture/decisions/README.md");
|
||||
|
||||
// -- CI-14 : contract ---------------------------------------------------------
|
||||
requireFile("A4/DOC", "contracts/openapi.yaml exists", "contracts/openapi.yaml");
|
||||
|
||||
// -- CD-04/OBS-04 : health/ready ---------------------------------------------
|
||||
requireFile("P0-3/CD-04", "app/api/health/route.ts exists", "app/api/health/route.ts");
|
||||
requireFile("P0-3/OBS-04", "app/api/ready/route.ts exists (readiness, not just liveness)", "app/api/ready/route.ts");
|
||||
|
||||
// =============================================================================
|
||||
// LIVE MODE (opt-in) -- the half that needs the world
|
||||
// =============================================================================
|
||||
const REQUIRED_LIVE_HEADERS = [
|
||||
"content-security-policy",
|
||||
"strict-transport-security",
|
||||
"x-frame-options",
|
||||
"x-content-type-options",
|
||||
"referrer-policy",
|
||||
];
|
||||
|
||||
async function probe(url) {
|
||||
const ac = new AbortController();
|
||||
const timer = setTimeout(() => ac.abort(), PROBE_TIMEOUT_MS);
|
||||
try {
|
||||
const res = await fetch(url, { method: "GET", redirect: "manual", signal: ac.signal });
|
||||
return { url, httpStatus: res.status, ok: res.status >= 200 && res.status < 400, headers: res.headers };
|
||||
} catch (e) {
|
||||
return { url, httpStatus: null, ok: false, headers: null, note: `unreachable: ${e.message}` };
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
function prodTargets() {
|
||||
const { value: repo } = readJson("siax.repo.v3.json");
|
||||
const { value: cfg } = readJson("siax.config.json");
|
||||
const healthPath = cfg?.healthcheck?.path ?? "/";
|
||||
return (Array.isArray(repo?.domains) ? repo.domains : [])
|
||||
.filter((d) => d && d.env === "prod" && typeof d.fqdn === "string")
|
||||
.map((d) => ({ fqdn: d.fqdn, env: d.env, url: `https://${d.fqdn}${healthPath}` }));
|
||||
}
|
||||
|
||||
if (WANT_LIVE) {
|
||||
liveChecks.push({
|
||||
id: "GOV-06/live",
|
||||
label: "live probe of every prod host agrees with siax.repo.json operationalState",
|
||||
run: async () => {
|
||||
const { value: repo, error } = readJson("siax.repo.v3.json");
|
||||
if (error) return error;
|
||||
const results = await Promise.all(prodTargets().map((t) => probe(t.url).then((r) => ({ ...t, ...r }))));
|
||||
globalThis.__siaxDoctorLiveResults = results;
|
||||
const up = results.filter((r) => r.ok);
|
||||
const summary = results.map((r) => `${r.fqdn}=${r.httpStatus ?? "unreachable"}`).join(", ");
|
||||
switch (repo.operationalState) {
|
||||
case "running":
|
||||
return up.length === results.length ? true : `declared "running", live probe says: ${summary}`;
|
||||
case "degraded":
|
||||
return up.length > 0 ? true : `declared "degraded", but nothing answered: ${summary}`;
|
||||
case "down":
|
||||
case "planned":
|
||||
case "building":
|
||||
return up.length === 0 ? true : `declared "${repo.operationalState}", but these answered: ${summary}`;
|
||||
default:
|
||||
return `unknown operationalState ${JSON.stringify(repo.operationalState)}`;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
liveChecks.push({
|
||||
id: "SEC-06/live",
|
||||
label: "a reachable prod host really serves the security headers (P0-13)",
|
||||
run: async () => {
|
||||
const results = globalThis.__siaxDoctorLiveResults ?? [];
|
||||
// Only responses THIS app actually produced are in scope. A 402/404 from
|
||||
// a disabled Vercel deployment has no CSP either, but that is not a
|
||||
// header defect this repo can fix -- it is a liveness fact, already
|
||||
// carried by GOV-06/live above and recorded in
|
||||
// evidence/operational-state.json `securityHeadersObserved`. Failing
|
||||
// here as well would make this check permanently red for a reason no
|
||||
// change to next.config.mjs could clear, which is how gates get ignored.
|
||||
const serving = results.filter((r) => r.headers && r.ok);
|
||||
if (serving.length === 0) return true;
|
||||
const problems = [];
|
||||
for (const r of serving) {
|
||||
const missing = REQUIRED_LIVE_HEADERS.filter((h) => !r.headers.get(h));
|
||||
if (missing.length) problems.push(`${r.fqdn} (HTTP ${r.httpStatus}) is missing: ${missing.join(", ")}`);
|
||||
}
|
||||
return problems.length === 0 ? true : problems.join("; ");
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
const results = [];
|
||||
for (const { id, label, check: fn } of checks) {
|
||||
const outcome = fn();
|
||||
results.push({ id, label, mode: "offline", pass: outcome === true, detail: outcome === true ? null : outcome });
|
||||
}
|
||||
for (const { id, label, run } of liveChecks) {
|
||||
const outcome = await run();
|
||||
results.push({ id, label, mode: "live", pass: outcome === true, detail: outcome === true ? null : outcome });
|
||||
}
|
||||
|
||||
if (WANT_LIVE && WANT_WRITE_EVIDENCE) {
|
||||
const { value: repo } = readJson("siax.repo.v3.json");
|
||||
const live = globalThis.__siaxDoctorLiveResults ?? [];
|
||||
const existing = readJson(EVIDENCE_PATH).value ?? {};
|
||||
const next = {
|
||||
...existing,
|
||||
control: "GOV-06",
|
||||
operationalState: repo?.operationalState,
|
||||
checkedAt: new Date().toISOString().replace(/\.\d{3}Z$/, "Z"),
|
||||
method: `scripts/siax-doctor.mjs --live --write-evidence (fetch, ${PROBE_TIMEOUT_MS}ms timeout)`,
|
||||
probes: live.map((r) => ({
|
||||
fqdn: r.fqdn,
|
||||
env: r.env,
|
||||
url: r.url,
|
||||
httpStatus: r.httpStatus,
|
||||
ok: r.ok,
|
||||
note: r.note ?? (r.headers ? `server: ${r.headers.get("server") ?? "unknown"}` : ""),
|
||||
})),
|
||||
securityHeadersObserved: Object.fromEntries(
|
||||
REQUIRED_LIVE_HEADERS.map((h) => [h, live.find((r) => r.headers)?.headers?.get(h) ?? null]),
|
||||
),
|
||||
};
|
||||
writeFileSync(file(EVIDENCE_PATH), `${JSON.stringify(next, null, 2)}\n`);
|
||||
if (!WANT_JSON) console.log(`\nwrote ${EVIDENCE_PATH} from the live probe.\n`);
|
||||
}
|
||||
|
||||
const failures = results.filter((r) => !r.pass);
|
||||
|
||||
if (WANT_JSON) {
|
||||
console.log(JSON.stringify({ total: results.length, failed: failures.length, live: WANT_LIVE, results }, null, 2));
|
||||
} else {
|
||||
console.log("siax-doctor -- 0pt (repo-local subset, see scripts/siax-doctor.mjs header)");
|
||||
console.log(WANT_LIVE ? "mode: offline + LIVE probe\n" : "mode: offline only (add --live to probe declared hosts)\n");
|
||||
for (const r of results) {
|
||||
const tag = r.mode === "live" ? "~" : " ";
|
||||
if (r.pass) console.log(` ${tag}[PASS] ${r.id.padEnd(16)} ${r.label}`);
|
||||
else console.log(` ${tag}[FAIL] ${r.id.padEnd(16)} ${r.label} -- ${r.detail}`);
|
||||
}
|
||||
console.log(`\n${results.length - failures.length}/${results.length} checks passed.`);
|
||||
if (failures.length > 0) {
|
||||
console.log(`${failures.length} check(s) failed -- see control-IDs above and cross-reference docs/compliance/BACKLOG.md.`);
|
||||
}
|
||||
}
|
||||
|
||||
if (failures.length > 0) process.exit(1);
|
||||
@@ -0,0 +1,137 @@
|
||||
#!/usr/bin/env -S node
|
||||
/**
|
||||
* scripts/vehiq-oltp-ingest/run.ts
|
||||
* ===================================
|
||||
* Thin CLI shim over src/ingestion/vehiq-oltp-ingest-runner.ts. All the
|
||||
* actual logic (connectors, the runner call, the checkpoint mapping) is in
|
||||
* that blocking-typechecked, tested `src/` module; this file only reads
|
||||
* `process.env`, wires real implementations, and prints a summary.
|
||||
*
|
||||
* TWO MODES, PREVIEW IS THE DEFAULT — this is the activation gate:
|
||||
*
|
||||
* VEHIQ_INGEST_MODE=preview (default)
|
||||
* One bounded read-only connect+discover+read per source, against
|
||||
* whatever checkpoint already exists. NEVER commits a checkpoint, NEVER
|
||||
* calls the sink, NEVER touches ClickHouse. Safe to run against real
|
||||
* production at any time — this is what proves "zero batches, zero
|
||||
* writes" without activating anything.
|
||||
*
|
||||
* VEHIQ_INGEST_MODE=run
|
||||
* The real, scheduled path: P062 runner, durable checkpoints, writes
|
||||
* landing in `opt_dataset_projection`. Requires DATABASE_URL (0pt's own
|
||||
* metadata store, for durable checkpoints) and the OPT_ANALYTICS_
|
||||
* CLICKHOUSE_* vars (the write target) to be set, or it refuses to run
|
||||
* rather than silently falling back to in-memory checkpoints or a
|
||||
* no-op sink — see docs/P070_VEHIQ_OLTP_EXTRACTOR.md "What activating
|
||||
* this in production actually requires".
|
||||
*
|
||||
* NO VEHIQ_SOURCE_DATABASE_URL AT ALL -> exits 0 having done nothing. This
|
||||
* is what makes the scheduled workflow (.gitea/workflows/vehiq-oltp-
|
||||
* ingest.yml) safe to leave enabled with no secret configured: cron fires,
|
||||
* the script logs "not configured", exits 0, no error, no alert.
|
||||
*/
|
||||
import {
|
||||
VEHIQ_OLTP_SOURCES,
|
||||
previewVehiqOltpSource,
|
||||
runVehiqOltpIngestion,
|
||||
} from "../../src/ingestion/vehiq-oltp-ingest-runner";
|
||||
import { makePostgresQueryImpl } from "../../src/connectors/postgres-query-impl";
|
||||
import { createIngestionRepository } from "../../src/repositories/ingestion-repository";
|
||||
import { SchemaRegistry } from "../../src/schema/schema-registry";
|
||||
import { LineageStore } from "../../src/lineage/lineage-store";
|
||||
import { ClickHouseAnalyticsEngine } from "../../src/analytics/engine/clickhouse-engine";
|
||||
import { projectionSink } from "../../src/ingestion/projection-sink";
|
||||
import type { AnalyticsIdentity } from "../../src/analytics/engine/siax-analytics-engine";
|
||||
|
||||
const TENANT_ID = process.env.VEHIQ_INGEST_TENANT_ID ?? "t_vehiq";
|
||||
const PROJECT_ID = process.env.VEHIQ_INGEST_PROJECT_ID ?? "vehiq";
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const sourceUrl = process.env.VEHIQ_SOURCE_DATABASE_URL;
|
||||
if (!sourceUrl) {
|
||||
console.log(
|
||||
JSON.stringify({ status: "not_configured", detail: "VEHIQ_SOURCE_DATABASE_URL is unset — nothing to do" }),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const mode = process.env.VEHIQ_INGEST_MODE === "run" ? "run" : "preview";
|
||||
const { query, close } = makePostgresQueryImpl({
|
||||
connectionString: sourceUrl,
|
||||
ssl: process.env.VEHIQ_SOURCE_PGSSL === "require" ? "require" : false,
|
||||
statementTimeoutMs: process.env.VEHIQ_SOURCE_STATEMENT_TIMEOUT_MS
|
||||
? Number(process.env.VEHIQ_SOURCE_STATEMENT_TIMEOUT_MS)
|
||||
: undefined,
|
||||
});
|
||||
|
||||
try {
|
||||
if (mode === "preview") {
|
||||
const repo = createIngestionRepository(process.env.DATABASE_URL);
|
||||
const results = [];
|
||||
for (const source of VEHIQ_OLTP_SOURCES) {
|
||||
results.push(
|
||||
await previewVehiqOltpSource(source, query, repo, {
|
||||
sourceId: "vehiq_oltp",
|
||||
tenantId: TENANT_ID,
|
||||
projectId: PROJECT_ID,
|
||||
limit: 100,
|
||||
}),
|
||||
);
|
||||
}
|
||||
console.log(JSON.stringify({ status: "preview", mode, results }, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
// mode === "run" — the activation-gated real path.
|
||||
if (!process.env.DATABASE_URL) {
|
||||
throw new Error(
|
||||
"VEHIQ_INGEST_MODE=run requires DATABASE_URL (0pt's own metadata store, for durable checkpoints) — " +
|
||||
"refusing to run with in-memory checkpoints, which would silently re-read every row on every scheduled invocation",
|
||||
);
|
||||
}
|
||||
const chBaseUrl = process.env.OPT_ANALYTICS_CLICKHOUSE_BASE_URL;
|
||||
if (!chBaseUrl) {
|
||||
throw new Error(
|
||||
"VEHIQ_INGEST_MODE=run requires OPT_ANALYTICS_CLICKHOUSE_BASE_URL — refusing to run with no write target",
|
||||
);
|
||||
}
|
||||
|
||||
const repo = createIngestionRepository(process.env.DATABASE_URL);
|
||||
const schemas = new SchemaRegistry(repo);
|
||||
const lineage = new LineageStore(repo);
|
||||
const engine = new ClickHouseAnalyticsEngine({
|
||||
baseUrl: chBaseUrl,
|
||||
basicAuth: process.env.OPT_ANALYTICS_CLICKHOUSE_BASIC_AUTH,
|
||||
database: process.env.OPT_ANALYTICS_CLICKHOUSE_DATABASE ?? "opt_analytics",
|
||||
});
|
||||
const identity: AnalyticsIdentity = {
|
||||
tenant_id: TENANT_ID,
|
||||
project_id: PROJECT_ID,
|
||||
principal_id: "svc_vehiq_oltp_extractor",
|
||||
};
|
||||
const sink = projectionSink({
|
||||
engine,
|
||||
identity,
|
||||
datasetIdFor: (partitionKey) => `vehiq.${partitionKey}`,
|
||||
});
|
||||
|
||||
const summary = await runVehiqOltpIngestion({
|
||||
repo,
|
||||
schemas,
|
||||
lineage,
|
||||
sourceQuery: query,
|
||||
oltpSink: sink,
|
||||
dimensionSink: sink,
|
||||
tenantId: TENANT_ID,
|
||||
projectId: PROJECT_ID,
|
||||
});
|
||||
console.log(JSON.stringify({ status: "ran", mode, summary }, null, 2));
|
||||
} finally {
|
||||
await close();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(JSON.stringify({ status: "error", detail: err instanceof Error ? err.message : String(err) }));
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
/** Run with DOC0_CHECKOUT=/absolute/path "$DOC0_CHECKOUT/node_modules/.bin/tsx" scripts/verify-doc0-source-wire.ts.
|
||||
* In-memory test only: proves the current DOC0 response shape, not production auth or storage. */
|
||||
import { createHash } from 'node:crypto';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { isAbsolute, resolve } from 'node:path';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { Doc0SourceClient } from '../src/document-workbench/doc0-source-client';
|
||||
import { structureDocumentBatch } from '../src/document-workbench/structure-document-batch';
|
||||
import { exportValidatedBatch } from '../src/document-workbench/structured-batch';
|
||||
|
||||
async function main() {
|
||||
const checkout = process.env.DOC0_CHECKOUT;
|
||||
if (!checkout || !isAbsolute(checkout)) throw new Error('DOC0_CHECKOUT must name an absolute local checkout');
|
||||
const load = (path: string) => import(pathToFileURL(resolve(checkout, path)).href);
|
||||
const [{ buildServer }, { InMemoryDocumentStore }, { InMemoryDocumentVersionStore }, { InMemoryParseStore }, { segmentText }] = await Promise.all([
|
||||
load('src/api/app.ts'), load('src/domains/document/store.ts'), load('src/domains/document-version/store.ts'),
|
||||
load('src/domains/parse/store.ts'), load('src/parsing/segments.ts'),
|
||||
]);
|
||||
process.env.AUTH_ENFORCE = 'false';
|
||||
Object.assign(process.env, { NODE_ENV: 'test' });
|
||||
const documents = new InMemoryDocumentStore(), versions = new InMemoryDocumentVersionStore(), parses = new InMemoryParseStore();
|
||||
const input = { tenantId: 't-test', projectId: 'p-test', actorId: 'actor', requestId: 'req', receipt: {
|
||||
documentId: 'doc', versionId: 'v1', checksumSha256: createHash('sha256').update('fixture bytes').digest('hex'), pipelineStage: 'parsed',
|
||||
} };
|
||||
await documents.create({ tenantId: 't-test', projectId: 'p-test', documentId: 'doc', appId: 'test', displayName: 'fixture', documentType: 'unknown', classification: 'internal', tags: [], createdBy: 'actor' });
|
||||
await versions.append({ tenantId: 't-test', documentId: 'doc', versionId: 'v1', versionNumber: 1, checksumSha256: input.receipt.checksumSha256, sizeBytes: 13, mimeType: 'text/plain', st0reArtifactRef: 'fixture', pipelineStage: 'parsed', extractedMetadata: {}, customMetadata: {}, createdBy: 'actor' });
|
||||
const parts = segmentText('Acme AB\n\nInvoice 123');
|
||||
await parses.appendDocumentText({ tenantId: 't-test', documentId: 'doc', versionId: 'v1', documentTextId: 'txt', parserRunId: 'run', charCount: parts.charCount, segmentCount: parts.segments.length, segmentsTruncated: parts.truncated, textSha256: parts.textSha256 });
|
||||
await parses.appendTextSegments(parts.segments.map((s: object, i: number) => ({ ...s, tenantId: 't-test', documentId: 'doc', versionId: 'v1', documentTextId: 'txt', parserRunId: 'run', segmentId: `seg-${i}`, sourceVersionSha256: input.receipt.checksumSha256 })));
|
||||
const app = buildServer({ documentStore: documents, versionStore: versions, parseStore: parses });
|
||||
const fetchImpl: typeof fetch = async (url, init) => {
|
||||
const target = new URL(String(url));
|
||||
const out = await app.inject({ method: 'GET', url: target.pathname + target.search, headers: Object.fromEntries(new Headers(init?.headers).entries()) });
|
||||
return new Response(out.body, { status: out.statusCode, headers: { 'content-type': 'application/json' } });
|
||||
};
|
||||
try {
|
||||
const client = new Doc0SourceClient({ baseUrl: 'https://doc0.example', serviceToken: 'fixture', fetchImpl });
|
||||
const result = await client.read(input);
|
||||
if (result.sources.length !== 2 || result.sources[0]?.text !== 'Acme AB' || result.sources[1]?.text !== 'Invoice 123') throw new Error('DOC0 wire mismatch');
|
||||
const fields = [{ name: 'value', type: 'string' as const, required: true }];
|
||||
const records = result.sources.map((source) => ({ value: { value: source.text }, evidence: [{ field: 'value', sourceId: source.id, start: 0, end: source.text.length, transform: 'exact' }] }));
|
||||
const request = {
|
||||
context: { tenantId: 't-test', projectId: 'p-test', actorId: 'actor', capability: 'inf0.complete', correlationId: 'req' },
|
||||
admission: { tenantId: 't-test', projectId: 'p-test', jobId: 'j', modelId: 'local_only', dataClass: 'high', maxInputTokens: 200, maxOutputTokens: 100, maximumCredits: 5 },
|
||||
policy: { tenantId: 't-test', projectId: 'p-test', allowedModelIds: ['local_only'], allowedDataClasses: ['high'], destination: 'internal' as const, externalTransferAllowed: false, maxInputTokens: 200, maxOutputTokens: 100 },
|
||||
reservation: { tenantId: 't-test', projectId: 'p-test', jobId: 'j', status: 'reserved' as const, credits: 5 },
|
||||
};
|
||||
const structured = await structureDocumentBatch({ request, fields, sources: result.sources, instructions: 'Extract values', countTokens: async () => 50,
|
||||
gateway: { complete: async () => ({ status: 'model_stopped', content: JSON.stringify({ records }), modelExecuted: 'fixture', providerKind: 'local', promptTokens: 50, completionTokens: 20, totalTokens: 70 }) } });
|
||||
if (structured.status !== 'mechanically_validated') throw new Error('model fixture evidence failed');
|
||||
const exported = exportValidatedBatch({ format: 'json', fields, sources: result.sources, records: structured.records });
|
||||
if (exported.status !== 'ready' || JSON.parse(exported.content).length !== 2) throw new Error('export fixture failed');
|
||||
const revision = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: checkout, encoding: 'utf8' }).trim();
|
||||
console.log(`DOC0 ${revision}: actual route through 0pt source and validated export; in-memory stores, test auth and model fixture`);
|
||||
} finally { await app.close(); }
|
||||
}
|
||||
main().catch((error: unknown) => { console.error(error); process.exitCode = 1; });
|
||||
Executable
+5
@@ -0,0 +1,5 @@
|
||||
<!-- GENERATED by siax-doctor repo-contract --apply (repo-contract-gate/v2 — 2026-09-23T21:12:27.075Z); template-fil, redigera via siax-templates - EJ handredigering. Saknas en rad här upptäcks det av RC-lagret. -->
|
||||
#!/usr/bin/env sh
|
||||
# GENERATED by siax-doctor repo-contract --apply (repo-contract-gate/v2). Stub = ej kopplad an; koppla till repots riktiga lokala verifiering via siax-templates.
|
||||
echo "repo-contract-gate: scripts/verify-local ar en GENERATED stub - koppla den till repots riktiga verifiering via siax-templates" >&2
|
||||
exit 0
|
||||
Reference in New Issue
Block a user