fixed issues w/ accessing website on other computers

This commit is contained in:
Kevin Bell 2026-07-22 16:26:12 -06:00
parent c1e453d962
commit 8bb76d8e6c
13 changed files with 267 additions and 69 deletions

View File

@ -31,7 +31,7 @@ Start with the [final report](docs/final_report.md). Supporting deliverables:
- [Private-to-public architecture](docs/architecture.mmd) - [Private-to-public architecture](docs/architecture.mmd)
- [10-minute presentation outline](docs/presentation_outline.md) - [10-minute presentation outline](docs/presentation_outline.md)
- [Dashboard demo script](docs/demo_script.md) - [Dashboard demo script](docs/demo_script.md)
- [Dashboard-only Bolt deployment](docs/bolt_deployment.md) - [Optional public-hosting notes](docs/bolt_deployment.md)
The [data inventory](docs/data_inventory.md) and archived The [data inventory](docs/data_inventory.md) and archived
[project options](docs/project_options.md) provide source-discovery history; [project options](docs/project_options.md) provide source-discovery history;
@ -142,17 +142,34 @@ checksums. It shows no estimates if the approved aggregate bundle fails its
contract. Every checked-in asset is marked as a development preview and not a contract. Every checked-in asset is marked as a development preview and not a
population estimate. population estimate.
## Publish through Bolt ### Live demo from another computer
Do **not** import this full source repository into Bolt. Create a separate When both computers are on the same network, start the LAN demo from the
deployment project or repository containing only the contents of `dashboard/`, repository root. Stop any older dashboard process with `Ctrl-C` first:
so `index.html` is at that project's root. Do not copy `.env`, private data,
artifacts, models, SQL, pipeline scripts, credentials, or unrelated repository
history.
Follow the complete preflight, import, Bolt Hosting, and post-publication checks ```bash
in [docs/bolt_deployment.md](docs/bolt_deployment.md). No publishing action is node dashboard/server.mjs --lan
performed by this repository. ```
The server prints a **This computer** URL and one or more interface-labeled
**Other devices** URLs. Open the URL for the shared Wi-Fi or Ethernet interface
on the second computer; do not use `0.0.0.0` as the browser address. Confirm the
header reads **Validated sample aggregates**. Stop the server with `Ctrl-C`
after the demo.
The LAN page still checks every approved JSON asset against its SHA-256 digest.
Browsers do not expose the SubtleCrypto digest API to a non-loopback plain-HTTP
page, so the dashboard includes a dependency-free checksum implementation for
that case. These checks validate bundle consistency; plain HTTP does not
authenticate the network transport, so use a trusted demo network.
## Public hosting is not required
The current presentation plan is the same-network live demo above. Nothing
needs to be uploaded or published, and the server exposes only the dashboard's
explicit static-file allowlist. The older
[public-hosting checklist](docs/bolt_deployment.md) is retained only in case the
delivery requirements change later.
## Author ## Author

View File

@ -25,6 +25,28 @@ URL; browsers will block the JSON module requests. To use another port:
PORT=8080 node dashboard/server.mjs PORT=8080 node dashboard/server.mjs
``` ```
## Run a live demo on the same network
From the source-repository root, run:
```bash
node dashboard/server.mjs --lan
```
Stop any older dashboard process with `Ctrl-C` before starting. Or, from this
directory, run `npm run start:lan`. The server prints a **This computer** URL
and one or more interface-labeled **Other devices** URLs. Open the URL for the
shared Wi-Fi or Ethernet interface on the second computer; `0.0.0.0` is a bind
address, not the address to put in a browser. Confirm the in-app status reads
**Validated sample aggregates**, then stop the server with `Ctrl-C` after the
demo.
All data-contract and checksum validation remains enabled over LAN HTTP. When a
remote browser does not expose the SubtleCrypto digest API to the plain-HTTP
page, the dashboard uses its dependency-free SHA-256 implementation and still
rejects changed assets. The checks validate bundle consistency, not the
authenticity of a plain-HTTP connection, so use a trusted demo network.
Run the dependency-free contract checks with: Run the dependency-free contract checks with:
```bash ```bash
@ -68,28 +90,9 @@ operational connection, or row-level prediction output. VINs, plates, ZIPs,
stations, technician identifiers, raw source JSON, credentials, and operational stations, technician identifiers, raw source JSON, credentials, and operational
records must never enter this directory. records must never enter this directory.
## Publish through Bolt ## Public hosting is not required
Use a separate static Bolt project backed by a dashboard-only repository. Do The current delivery path is the same-network live demo. No Bolt project,
not import this source repository into Bolt. public URL, dependency install, or build step is needed. If public hosting is
ever reconsidered, publish only a separately reviewed copy of this dashboard
1. Run `npm test` from this directory and confirm every contract check passes. directory—never the private source repository.
2. Create a clean dashboard-only repository or Bolt project.
3. Copy only the *contents* of `dashboard/` into that project, so `index.html`
is at the project root.
4. Confirm that private `data/`, `artifacts/`, `models/`, `.env` files, SQL,
notebooks, and database tooling are absent.
5. Preview the project. There is no install or build step; if Bolt requests a
preview command, use `node server.mjs` with `HOST=0.0.0.0` and let Bolt
provide `PORT`.
6. Verify all four views, the development-sample messaging on every view, and
that the in-app status reads **Validated sample aggregates**.
7. Use **Publish** only after the aggregate bundle completes privacy review.
Bolt hosting is the default; Netlify can instead be selected before the
first publish if desired.
See Bolt's official documentation for [Git
integration](https://support.bolt.new/integrations/git) and [publishing with
Netlify](https://support.bolt.new/integrations/netlify).
No publishing action is performed by this repository.

View File

@ -10,6 +10,34 @@ const ENVELOPE_KEYS = Object.freeze([
"population_estimate_allowed", "population_estimate_allowed",
"schema_version", "schema_version",
]); ]);
const SHA256_INITIAL_STATE = Object.freeze([
0x6a09e667,
0xbb67ae85,
0x3c6ef372,
0xa54ff53a,
0x510e527f,
0x9b05688c,
0x1f83d9ab,
0x5be0cd19,
]);
const SHA256_ROUND_CONSTANTS = Object.freeze([
0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5,
0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3,
0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc,
0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7,
0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13,
0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3,
0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5,
0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208,
0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
]);
export const REQUIRED_ASSETS = Object.freeze({ export const REQUIRED_ASSETS = Object.freeze({
manifest: "data_manifest.json", manifest: "data_manifest.json",
@ -541,9 +569,80 @@ function parseJsonBytes(bytes, label) {
} }
} }
function rotateRight(value, shift) {
return ((value >>> shift) | (value << (32 - shift))) >>> 0;
}
function sha256HexFallback(bytes) {
const input = new Uint8Array(bytes);
const bitLength = input.byteLength * 8;
if (!Number.isSafeInteger(bitLength)) {
throw new DataContractError("Dashboard asset integrity verification failed.");
}
const paddedLength = Math.ceil((input.byteLength + 9) / 64) * 64;
const padded = new Uint8Array(paddedLength);
padded.set(input);
padded[input.byteLength] = 0x80;
const paddedView = new DataView(padded.buffer);
paddedView.setUint32(paddedLength - 8, Math.floor(bitLength / 0x100000000), false);
paddedView.setUint32(paddedLength - 4, bitLength >>> 0, false);
const state = [...SHA256_INITIAL_STATE];
const words = new Uint32Array(64);
for (let offset = 0; offset < paddedLength; offset += 64) {
for (let index = 0; index < 16; index += 1) {
words[index] = paddedView.getUint32(offset + index * 4, false);
}
for (let index = 16; index < 64; index += 1) {
const sigma0 =
rotateRight(words[index - 15], 7) ^
rotateRight(words[index - 15], 18) ^
(words[index - 15] >>> 3);
const sigma1 =
rotateRight(words[index - 2], 17) ^
rotateRight(words[index - 2], 19) ^
(words[index - 2] >>> 10);
words[index] =
(words[index - 16] + sigma0 + words[index - 7] + sigma1) >>> 0;
}
let [a, b, c, d, e, f, g, h] = state;
for (let index = 0; index < 64; index += 1) {
const sum1 = rotateRight(e, 6) ^ rotateRight(e, 11) ^ rotateRight(e, 25);
const choose = (e & f) ^ (~e & g);
const temporary1 =
(h + sum1 + choose + SHA256_ROUND_CONSTANTS[index] + words[index]) >>> 0;
const sum0 = rotateRight(a, 2) ^ rotateRight(a, 13) ^ rotateRight(a, 22);
const majority = (a & b) ^ (a & c) ^ (b & c);
const temporary2 = (sum0 + majority) >>> 0;
h = g;
g = f;
f = e;
e = (d + temporary1) >>> 0;
d = c;
c = b;
b = a;
a = (temporary1 + temporary2) >>> 0;
}
state[0] = (state[0] + a) >>> 0;
state[1] = (state[1] + b) >>> 0;
state[2] = (state[2] + c) >>> 0;
state[3] = (state[3] + d) >>> 0;
state[4] = (state[4] + e) >>> 0;
state[5] = (state[5] + f) >>> 0;
state[6] = (state[6] + g) >>> 0;
state[7] = (state[7] + h) >>> 0;
}
return state.map((word) => word.toString(16).padStart(8, "0")).join("");
}
async function sha256Hex(bytes, cryptoImplementation) { async function sha256Hex(bytes, cryptoImplementation) {
if (!cryptoImplementation?.subtle || typeof cryptoImplementation.subtle.digest !== "function") { if (!cryptoImplementation?.subtle || typeof cryptoImplementation.subtle.digest !== "function") {
throw new DataContractError("This browser cannot verify dashboard asset integrity."); return sha256HexFallback(bytes);
} }
let digest; let digest;
try { try {

View File

@ -6,6 +6,7 @@
"description": "Dependency-free static dashboard for approved Utah Vehicle Health aggregates.", "description": "Dependency-free static dashboard for approved Utah Vehicle Health aggregates.",
"scripts": { "scripts": {
"start": "node server.mjs", "start": "node server.mjs",
"start:lan": "node server.mjs --lan",
"test": "node --test tests/contract.test.mjs" "test": "node --test tests/contract.test.mjs"
}, },
"engines": { "engines": {

View File

@ -1,10 +1,12 @@
import { createReadStream, realpathSync, statSync } from "node:fs"; import { createReadStream, realpathSync, statSync } from "node:fs";
import { createServer } from "node:http"; import { createServer } from "node:http";
import { networkInterfaces } from "node:os";
import path from "node:path"; import path from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
const ROOT = realpathSync(path.dirname(fileURLToPath(import.meta.url))); const ROOT = realpathSync(path.dirname(fileURLToPath(import.meta.url)));
const HOST = process.env.HOST || "127.0.0.1"; const LAN_MODE = process.argv.includes("--lan");
const HOST = LAN_MODE ? "0.0.0.0" : process.env.HOST || "127.0.0.1";
const PORT = Number.parseInt(process.env.PORT || "4173", 10); const PORT = Number.parseInt(process.env.PORT || "4173", 10);
const TYPES = new Map([ const TYPES = new Map([
[".css", "text/css; charset=utf-8"], [".css", "text/css; charset=utf-8"],
@ -50,6 +52,28 @@ function safeFile(requestUrl) {
} }
} }
function displayUrls(host, port) {
if (host !== "0.0.0.0") {
const displayHost = host === "::" ? "::1" : host;
const urlHost = displayHost.includes(":") ? `[${displayHost}]` : displayHost;
return [{ label: "Open", url: `http://${urlHost}:${port}` }];
}
const entries = [{ label: "This computer", url: `http://127.0.0.1:${port}` }];
const seen = new Set();
for (const [interfaceName, addresses] of Object.entries(networkInterfaces())) {
for (const address of addresses || []) {
if (address.family !== "IPv4" || address.internal || seen.has(address.address)) continue;
seen.add(address.address);
entries.push({
label: `Other devices (${interfaceName})`,
url: `http://${address.address}:${port}`,
});
}
}
return entries;
}
const server = createServer((request, response) => { const server = createServer((request, response) => {
if (!request.url || !["GET", "HEAD"].includes(request.method || "")) { if (!request.url || !["GET", "HEAD"].includes(request.method || "")) {
response.writeHead(405, { Allow: "GET, HEAD" }); response.writeHead(405, { Allow: "GET, HEAD" });
@ -66,7 +90,7 @@ const server = createServer((request, response) => {
const extension = path.extname(file).toLowerCase(); const extension = path.extname(file).toLowerCase();
response.writeHead(200, { response.writeHead(200, {
"Content-Type": TYPES.get(extension) || "application/octet-stream", "Content-Type": TYPES.get(extension) || "application/octet-stream",
"Cache-Control": extension === ".json" ? "no-store" : "public, max-age=300", "Cache-Control": "no-store",
"X-Content-Type-Options": "nosniff", "X-Content-Type-Options": "nosniff",
"Referrer-Policy": "no-referrer", "Referrer-Policy": "no-referrer",
}); });
@ -77,6 +101,22 @@ const server = createServer((request, response) => {
createReadStream(file).on("error", () => response.destroy()).pipe(response); createReadStream(file).on("error", () => response.destroy()).pipe(response);
}); });
server.listen(PORT, HOST, () => { server.on("error", (error) => {
process.stdout.write(`Utah Vehicle Health dashboard: http://${HOST}:${PORT}\n`); if (error.code === "EADDRINUSE") {
process.stderr.write(
`Dashboard port ${PORT} is already in use. Stop the existing server or set a different PORT.\n`,
);
} else {
process.stderr.write(`Dashboard server could not start (${error.code || "unknown error"}).\n`);
}
process.exitCode = 1;
});
server.listen(PORT, HOST, () => {
const address = server.address();
const actualPort = typeof address === "object" && address ? address.port : PORT;
const urls = displayUrls(HOST, actualPort)
.map(({ label, url }) => ` ${label}: ${url}`)
.join("\n");
process.stdout.write(`Utah Vehicle Health dashboard:\n${urls}\n`);
}); });

View File

@ -214,6 +214,32 @@ test("browser loader verifies every raw asset digest before rendering", async ()
); );
}); });
test("LAN HTTP loader verifies asset digests without Web Crypto", async () => {
const validated = await loadDashboardData({
basePath: "http://dashboard.test/public/data/",
fetchImplementation: publicDataFetch(),
cryptoImplementation: null,
});
assert.equal(validated.manifest.schema_version, SCHEMA_VERSION);
const changedOverview = Buffer.concat([
readFileSync(path.join(PUBLIC_DATA, REQUIRED_ASSETS.overview)),
Buffer.from("\n"),
]);
await assert.rejects(
loadDashboardData({
basePath: "http://dashboard.test/public/data/",
fetchImplementation: publicDataFetch(
new Map([[REQUIRED_ASSETS.overview, changedOverview]]),
),
cryptoImplementation: null,
}),
(error) =>
error instanceof DataContractError &&
/Integrity verification failed for overview_period_county\.json/.test(error.message),
);
});
test("sha256 manifest covers and matches every approved data asset", () => { test("sha256 manifest covers and matches every approved data asset", () => {
const manifest = json(REQUIRED_ASSETS.shaManifest); const manifest = json(REQUIRED_ASSETS.shaManifest);
const expectedNames = Object.entries(REQUIRED_ASSETS) const expectedNames = Object.entries(REQUIRED_ASSETS)

View File

@ -63,7 +63,7 @@ flowchart TD
end end
EXPORT -.-> GUARDRAILS EXPORT -.-> GUARDRAILS
SITE --> BOLT[Separate dashboard-only<br/>Bolt project] SITE --> DEMO[Allowlisted local static server<br/>same-network live demo]
subgraph TESTS[Verification] subgraph TESTS[Verification]
direction LR direction LR
@ -80,5 +80,5 @@ flowchart TD
class SOURCE,SAMPLE,STAGE,EPISODES,MART,PRIVATE,REPORT private class SOURCE,SAMPLE,STAGE,EPISODES,MART,PRIVATE,REPORT private
class LOGISTIC,TREE model class LOGISTIC,TREE model
class PUBLIC,CONTRACT,SITE,VIEWS,BOLT public class PUBLIC,CONTRACT,SITE,VIEWS,DEMO public
class GUARDRAILS warning class GUARDRAILS warning

View File

@ -2,6 +2,11 @@
Last reviewed: 2026-07-21 Last reviewed: 2026-07-21
> **Not part of the current delivery plan.** The presentation now uses the
> same-network live-demo procedure in [demo_script.md](demo_script.md). This
> checklist is retained only as a boundary reference if public hosting is
> reconsidered later.
## Non-negotiable publication boundary ## Non-negotiable publication boundary
Publish a **separate Bolt project or GitHub repository containing only the Publish a **separate Bolt project or GitHub repository containing only the

View File

@ -121,13 +121,11 @@ developer console.
- Preserve the warning banner and fail-closed unavailable state on desktop and - Preserve the warning banner and fail-closed unavailable state on desktop and
mobile layouts. mobile layouts.
## Deployment boundary ## Demo boundary
Bolt receives a separate dashboard-only project whose root is the contents of The current delivery is a same-network live demo started with
`dashboard/`. Do not import or upload the full private-pipeline repository and `node dashboard/server.mjs --lan`. The local server resolves files only beneath
do not rely on a configurable subdirectory working root. The deployable project `dashboard/` and serves only its explicit shell, JavaScript, stylesheet, and
contains no `.env`, private `data/`, model artifacts, SQL, pipeline scripts, or approved aggregate allowlist. It does not expose `.env`, private `data/`, model
repository history outside the dashboard directory. artifacts, SQL, pipeline scripts, or repository history. Stop the server after
the demo; no public hosting step is required.
See [bolt_deployment.md](bolt_deployment.md) for the reviewed handoff and
post-publication checks.

View File

@ -5,16 +5,22 @@ Target length: **2 minutes 45 seconds**, embedded in the
## Before the audience arrives ## Before the audience arrives
1. From the repository root, run `node dashboard/server.mjs`. 1. Put the presenting computer and demo computer on the same trusted network.
2. Open `http://127.0.0.1:4173/#overview`. 2. Stop any older dashboard process, then from the repository root run
3. Confirm the header says the sample aggregates validated. `node dashboard/server.mjs --lan`.
4. Confirm the private-sample development-preview banner is visible. 3. On the demo computer, open the printed **Other devices** URL for the shared
5. Visit all four routes: Overview, Sample cohorts, Model & benchmark, and Data Wi-Fi or Ethernet interface with `/#overview` appended. On the presenting
computer, use the printed **This computer** URL.
4. Confirm the header says **Validated sample aggregates**. If an older copy of
the JavaScript was previously loaded, hard-refresh the page once.
5. Confirm the private-sample development-preview banner is visible.
6. Visit all four routes: Overview, Sample cohorts, Model & benchmark, and Data
& methods. & methods.
6. Reset Sample cohorts and leave its sort on largest support. 7. Reset Sample cohorts and leave its sort on largest support.
Do not open developer tools, private files, model artifacts, database clients, Do not open developer tools, private files, model artifacts, database clients,
or environment variables during the presentation. or environment variables during the presentation. Stop the server with
`Ctrl-C` when the demo is over.
## Live talk track ## Live talk track

View File

@ -172,7 +172,8 @@ The public boundary is intentionally narrow:
private read-only source private read-only source
-> private local extraction and model pipeline -> private local extraction and model pipeline
-> suppression-reviewed aggregate JSON -> suppression-reviewed aggregate JSON
-> dashboard-only static Bolt project -> allowlisted local static server
-> same-network demo browser
``` ```
The public dashboard and presentation materials contain no VIN, plate, ZIP, The public dashboard and presentation materials contain no VIN, plate, ZIP,
@ -180,10 +181,9 @@ station, technician identifier, private vehicle token, raw JSON, credential,
operational record, or row-level prediction. The browser never connects to the operational record, or row-level prediction. The browser never connects to the
source database. source database.
Bolt publication must use a separate project or repository containing only the The presentation uses `node dashboard/server.mjs --lan` only for the live demo.
contents of `dashboard/`, with `index.html` at its root. The full source The server has an explicit dashboard-file allowlist and is stopped afterward;
repository must not be imported into Bolt. The reviewed procedure is in the prototype is not published to a public host.
[bolt_deployment.md](bolt_deployment.md).
## Limitations ## Limitations
@ -222,4 +222,4 @@ ranking, production model, diagnosis, or individual decision tool.
- [Private-to-public architecture](architecture.mmd) - [Private-to-public architecture](architecture.mmd)
- [10-minute presentation outline](presentation_outline.md) - [10-minute presentation outline](presentation_outline.md)
- [Dashboard demo script](demo_script.md) - [Dashboard demo script](demo_script.md)
- [Dashboard-only Bolt deployment](bolt_deployment.md) - [Optional public-hosting notes](bolt_deployment.md)

View File

@ -22,7 +22,7 @@ model** and histogram gradient boosting as the **benchmark**.
| 3:10-4:20 | 5. Model comparison | Logistic final versus prevalence/previous-outcome baselines and boosted-tree benchmark | | 3:10-4:20 | 5. Model comparison | Logistic final versus prevalence/previous-outcome baselines and boosted-tree benchmark |
| 4:20-4:50 | 6. Model decision | Calibrated logistic wins the declared sample comparison and is easier to explain | | 4:20-4:50 | 6. Model decision | Calibrated logistic wins the declared sample comparison and is easier to explain |
| 4:50-7:35 | Live dashboard demo | Overview, Sample cohorts, Model & benchmark, Data & methods | | 4:50-7:35 | Live dashboard demo | Overview, Sample cohorts, Model & benchmark, Data & methods |
| 7:35-8:30 | 7. Privacy architecture | Private pipeline to suppressed aggregate JSON to dashboard-only Bolt project | | 7:35-8:30 | 7. Privacy architecture | Private pipeline to suppressed aggregate JSON to an allowlisted same-network demo server |
| 8:30-9:20 | 8. Limitations | Sampling, feed coverage, source/time confounding, heterogeneous non-pass, one-time holdout | | 8:30-9:20 | 8. Limitations | Sampling, feed coverage, source/time confounding, heterogeneous non-pass, one-time holdout |
| 9:20-10:00 | 9. Close | Prototype is complete and honest about what it can—and cannot—claim | | 9:20-10:00 | 9. Close | Prototype is complete and honest about what it can—and cannot—claim |
@ -124,7 +124,7 @@ Show:
```text ```text
private read-only data -> local modeling -> suppressed aggregate JSON private read-only data -> local modeling -> suppressed aggregate JSON
-> dashboard-only Bolt project -> allowlisted local static server -> same-network demo browser
``` ```
State that the public bundle has no VINs, plates, ZIPs, stations, technician State that the public bundle has no VINs, plates, ZIPs, stations, technician
@ -159,8 +159,11 @@ cannot establish.
## Presentation checklist ## Presentation checklist
- Start the local dashboard before presenting: `node dashboard/server.mjs`. - Put both computers on the same trusted network, stop any older dashboard
- Open `http://127.0.0.1:4173/#overview` and close unrelated browser tabs. process, and run `node dashboard/server.mjs --lan`.
- On the demo computer, open the printed **Other devices** URL for the shared
Wi-Fi or Ethernet interface with `/#overview` appended. Close unrelated
browser tabs.
- Use a fresh page load to confirm aggregate validation succeeds. - Use a fresh page load to confirm aggregate validation succeeds.
- Keep a screenshot or short recording as a backup, with the sample warning - Keep a screenshot or short recording as a backup, with the sample warning
visible. visible.

View File

@ -69,8 +69,8 @@ is not a second final model and is not used to drive the product.
5. Histogram gradient boosting as a benchmark only. 5. Histogram gradient boosting as a benchmark only.
6. Chronological development evaluation with an explicit one-time 2025 gate. 6. Chronological development evaluation with an explicit one-time 2025 gate.
7. Suppression-reviewed static dashboard assets with fail-closed validation. 7. Suppression-reviewed static dashboard assets with fail-closed validation.
8. Model card, final report, presentation materials, and dashboard-only Bolt 8. Model card, final report, presentation materials, and same-network dashboard
deployment instructions. demo instructions.
## Acceptance criteria ## Acceptance criteria