diff --git a/README.md b/README.md
index b37753a..a7c5db3 100644
--- a/README.md
+++ b/README.md
@@ -31,7 +31,7 @@ Start with the [final report](docs/final_report.md). Supporting deliverables:
- [Private-to-public architecture](docs/architecture.mmd)
- [10-minute presentation outline](docs/presentation_outline.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
[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
population estimate.
-## Publish through Bolt
+### Live demo from another computer
-Do **not** import this full source repository into Bolt. Create a separate
-deployment project or repository containing only the contents of `dashboard/`,
-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.
+When both computers are on the same network, start the LAN demo from the
+repository root. Stop any older dashboard process with `Ctrl-C` first:
-Follow the complete preflight, import, Bolt Hosting, and post-publication checks
-in [docs/bolt_deployment.md](docs/bolt_deployment.md). No publishing action is
-performed by this repository.
+```bash
+node dashboard/server.mjs --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; 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
diff --git a/dashboard/README.md b/dashboard/README.md
index 62ee7f7..c2460d0 100644
--- a/dashboard/README.md
+++ b/dashboard/README.md
@@ -25,6 +25,28 @@ URL; browsers will block the JSON module requests. To use another port:
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:
```bash
@@ -68,28 +90,9 @@ operational connection, or row-level prediction output. VINs, plates, ZIPs,
stations, technician identifiers, raw source JSON, credentials, and operational
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
-not import this source repository into Bolt.
-
-1. Run `npm test` from this directory and confirm every contract check passes.
-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.
+The current delivery path is the same-network live demo. No Bolt project,
+public URL, dependency install, or build step is needed. If public hosting is
+ever reconsidered, publish only a separately reviewed copy of this dashboard
+directory—never the private source repository.
diff --git a/dashboard/js/data.js b/dashboard/js/data.js
index c51ad13..5b6fcae 100644
--- a/dashboard/js/data.js
+++ b/dashboard/js/data.js
@@ -10,6 +10,34 @@ const ENVELOPE_KEYS = Object.freeze([
"population_estimate_allowed",
"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({
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) {
if (!cryptoImplementation?.subtle || typeof cryptoImplementation.subtle.digest !== "function") {
- throw new DataContractError("This browser cannot verify dashboard asset integrity.");
+ return sha256HexFallback(bytes);
}
let digest;
try {
diff --git a/dashboard/package.json b/dashboard/package.json
index 6160fc8..fbe8cf0 100644
--- a/dashboard/package.json
+++ b/dashboard/package.json
@@ -6,6 +6,7 @@
"description": "Dependency-free static dashboard for approved Utah Vehicle Health aggregates.",
"scripts": {
"start": "node server.mjs",
+ "start:lan": "node server.mjs --lan",
"test": "node --test tests/contract.test.mjs"
},
"engines": {
diff --git a/dashboard/server.mjs b/dashboard/server.mjs
index 4cadc66..f4b23d7 100644
--- a/dashboard/server.mjs
+++ b/dashboard/server.mjs
@@ -1,10 +1,12 @@
import { createReadStream, realpathSync, statSync } from "node:fs";
import { createServer } from "node:http";
+import { networkInterfaces } from "node:os";
import path from "node:path";
import { fileURLToPath } from "node: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 TYPES = new Map([
[".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) => {
if (!request.url || !["GET", "HEAD"].includes(request.method || "")) {
response.writeHead(405, { Allow: "GET, HEAD" });
@@ -66,7 +90,7 @@ const server = createServer((request, response) => {
const extension = path.extname(file).toLowerCase();
response.writeHead(200, {
"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",
"Referrer-Policy": "no-referrer",
});
@@ -77,6 +101,22 @@ const server = createServer((request, response) => {
createReadStream(file).on("error", () => response.destroy()).pipe(response);
});
-server.listen(PORT, HOST, () => {
- process.stdout.write(`Utah Vehicle Health dashboard: http://${HOST}:${PORT}\n`);
+server.on("error", (error) => {
+ 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`);
});
diff --git a/dashboard/tests/contract.test.mjs b/dashboard/tests/contract.test.mjs
index 5088be5..8d8c763 100644
--- a/dashboard/tests/contract.test.mjs
+++ b/dashboard/tests/contract.test.mjs
@@ -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", () => {
const manifest = json(REQUIRED_ASSETS.shaManifest);
const expectedNames = Object.entries(REQUIRED_ASSETS)
diff --git a/docs/architecture.mmd b/docs/architecture.mmd
index 2dbc3a2..594b510 100644
--- a/docs/architecture.mmd
+++ b/docs/architecture.mmd
@@ -63,7 +63,7 @@ flowchart TD
end
EXPORT -.-> GUARDRAILS
- SITE --> BOLT[Separate dashboard-only
Bolt project]
+ SITE --> DEMO[Allowlisted local static server
same-network live demo]
subgraph TESTS[Verification]
direction LR
@@ -80,5 +80,5 @@ flowchart TD
class SOURCE,SAMPLE,STAGE,EPISODES,MART,PRIVATE,REPORT private
class LOGISTIC,TREE model
- class PUBLIC,CONTRACT,SITE,VIEWS,BOLT public
+ class PUBLIC,CONTRACT,SITE,VIEWS,DEMO public
class GUARDRAILS warning
diff --git a/docs/bolt_deployment.md b/docs/bolt_deployment.md
index 0026502..21a320c 100644
--- a/docs/bolt_deployment.md
+++ b/docs/bolt_deployment.md
@@ -2,6 +2,11 @@
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
Publish a **separate Bolt project or GitHub repository containing only the
diff --git a/docs/dashboard_spec.md b/docs/dashboard_spec.md
index 069494c..ca0f600 100644
--- a/docs/dashboard_spec.md
+++ b/docs/dashboard_spec.md
@@ -121,13 +121,11 @@ developer console.
- Preserve the warning banner and fail-closed unavailable state on desktop and
mobile layouts.
-## Deployment boundary
+## Demo boundary
-Bolt receives a separate dashboard-only project whose root is the contents of
-`dashboard/`. Do not import or upload the full private-pipeline repository and
-do not rely on a configurable subdirectory working root. The deployable project
-contains no `.env`, private `data/`, model artifacts, SQL, pipeline scripts, or
-repository history outside the dashboard directory.
-
-See [bolt_deployment.md](bolt_deployment.md) for the reviewed handoff and
-post-publication checks.
+The current delivery is a same-network live demo started with
+`node dashboard/server.mjs --lan`. The local server resolves files only beneath
+`dashboard/` and serves only its explicit shell, JavaScript, stylesheet, and
+approved aggregate allowlist. It does not expose `.env`, private `data/`, model
+artifacts, SQL, pipeline scripts, or repository history. Stop the server after
+the demo; no public hosting step is required.
diff --git a/docs/demo_script.md b/docs/demo_script.md
index 02b3e06..55f636c 100644
--- a/docs/demo_script.md
+++ b/docs/demo_script.md
@@ -5,16 +5,22 @@ Target length: **2 minutes 45 seconds**, embedded in the
## Before the audience arrives
-1. From the repository root, run `node dashboard/server.mjs`.
-2. Open `http://127.0.0.1:4173/#overview`.
-3. Confirm the header says the sample aggregates validated.
-4. Confirm the private-sample development-preview banner is visible.
-5. Visit all four routes: Overview, Sample cohorts, Model & benchmark, and Data
+1. Put the presenting computer and demo computer on the same trusted network.
+2. Stop any older dashboard process, then from the repository root run
+ `node dashboard/server.mjs --lan`.
+3. On the demo computer, open the printed **Other devices** URL for the shared
+ 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.
-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,
-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
diff --git a/docs/final_report.md b/docs/final_report.md
index ccd1359..cbffaa5 100644
--- a/docs/final_report.md
+++ b/docs/final_report.md
@@ -172,7 +172,8 @@ The public boundary is intentionally narrow:
private read-only source
-> private local extraction and model pipeline
-> 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,
@@ -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
source database.
-Bolt publication must use a separate project or repository containing only the
-contents of `dashboard/`, with `index.html` at its root. The full source
-repository must not be imported into Bolt. The reviewed procedure is in
-[bolt_deployment.md](bolt_deployment.md).
+The presentation uses `node dashboard/server.mjs --lan` only for the live demo.
+The server has an explicit dashboard-file allowlist and is stopped afterward;
+the prototype is not published to a public host.
## Limitations
@@ -222,4 +222,4 @@ ranking, production model, diagnosis, or individual decision tool.
- [Private-to-public architecture](architecture.mmd)
- [10-minute presentation outline](presentation_outline.md)
- [Dashboard demo script](demo_script.md)
-- [Dashboard-only Bolt deployment](bolt_deployment.md)
+- [Optional public-hosting notes](bolt_deployment.md)
diff --git a/docs/presentation_outline.md b/docs/presentation_outline.md
index 6c9aa8e..896b994 100644
--- a/docs/presentation_outline.md
+++ b/docs/presentation_outline.md
@@ -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 |
| 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 |
-| 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 |
| 9:20-10:00 | 9. Close | Prototype is complete and honest about what it can—and cannot—claim |
@@ -124,7 +124,7 @@ Show:
```text
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
@@ -159,8 +159,11 @@ cannot establish.
## Presentation checklist
-- Start the local dashboard before presenting: `node dashboard/server.mjs`.
-- Open `http://127.0.0.1:4173/#overview` and close unrelated browser tabs.
+- Put both computers on the same trusted network, stop any older dashboard
+ 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.
- Keep a screenshot or short recording as a backup, with the sample warning
visible.
diff --git a/docs/project_charter.md b/docs/project_charter.md
index 2b53980..c9bfcfa 100644
--- a/docs/project_charter.md
+++ b/docs/project_charter.md
@@ -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.
6. Chronological development evaluation with an explicit one-time 2025 gate.
7. Suppression-reviewed static dashboard assets with fail-closed validation.
-8. Model card, final report, presentation materials, and dashboard-only Bolt
- deployment instructions.
+8. Model card, final report, presentation materials, and same-network dashboard
+ demo instructions.
## Acceptance criteria