wip(#19): privacy-safe model metrics in progress — preserved snapshot before nightly release

Local work in progress, not reviewed and not part of any PR. Committed only so the
nightly release can build from a clean main without discarding it.
This commit is contained in:
timmy committed 2026-11-01 12:02:43 +00:00
1 parent 9067ce5306
commit 3e2253a052
15 files changed
+768 -9

No files matched your search

+1
View File
@@ -7,6 +7,7 @@ A working mobile-first bowel diary with an optional **photo-first AI assist**:
- [Product boundary and release authority decision](docs/PRODUCT-DECISIONS.md)
- [Self-hosted stool-vision decision](research/SELF-HOSTED-STOOL-VISION.md)
- [Production topology, secrets, backup, and rollback runbook](docs/PRODUCTION-RUNBOOK.md)
- [Privacy-safe model health counters (operator view)](docs/MODEL-METRICS.md)
## Build a gated review release
+1 -1
View File
@@ -313,7 +313,7 @@ function withdrawTrainingContribution(contributionId){
}
function privacy(){
shell(`<main><div class="page-title"><span class="eyebrow">Private by design</span><h1>Your poop. Your phone.</h1><p>This prototype has no account, analytics, ad tracker, or server database.</p></div><section class="card privacy-list"><div class="privacy-item"><b>⌂</b><div><h3>Stored locally</h3><p>Saved entries and optional photos live in this browser’s local storage. You can remove one photo without deleting its confirmed log.</p></div></div><div class="privacy-item"><b>⇩</b><div><h3>Portable</h3><p>Export a readable JSON file. Import it in another copy of Timmy.</p></div></div><div class="privacy-item"><b>◎</b><div><h3>AI only when you ask</h3><p>Manual logging never uploads. Analyze a photo sends one compressed copy after consent. Hermes chat may receive up to 20 confirmed text-only entries after you connect; photos and backend credentials never enter chat.</p></div></div></section><section class="section card"><h2>Data controls</h2><div class="row"><button class="btn btn-primary" id="export">Export JSON</button><label class="btn btn-secondary" for="import">Import JSON</label><input class="hidden" type="file" id="import" accept="application/json"></div><p class="fine section">Exports can contain sensitive health notes and photos. Store them somewhere you trust.</p></section>${portabilityNoticeBlock()}${trainingConsentSection()}<section class="section card source-list"><h2>Health sources</h2><p class="fine">The Bristol groupings and urgent-symptom copy are grounded in public clinical guidance.</p><p><a target="_blank" rel="noreferrer" href="https://www.continence.org.au/about-incontinence/bowel-incontinence/bristol-stool-chart/">Continence Health Australia</a></p><p><a target="_blank" rel="noreferrer" href="https://www.nhs.uk/conditions/bleeding-from-the-bottom-rectal-bleeding/">NHS rectal bleeding guidance</a></p><p><a target="_blank" rel="noreferrer" href="https://www.niddk.nih.gov/health-information/digestive-diseases/constipation/symptoms-causes">NIDDK constipation guidance</a></p></section><section class="section card danger-zone"><h2>Delete everything</h2><p class="fine">Permanently removes Timmy’s local ledger and every saved photo from this browser.</p><button class="btn btn-danger" id="delete-all">Delete all local data</button></section></main>`);
shell(`<main><div class="page-title"><span class="eyebrow">Private by design</span><h1>Your poop. Your phone.</h1><p>This prototype has no account, analytics, ad tracker, or server database.</p></div><section class="card privacy-list"><div class="privacy-item"><b>⌂</b><div><h3>Stored locally</h3><p>Saved entries and optional photos live in this browser’s local storage. You can remove one photo without deleting its confirmed log.</p></div></div><div class="privacy-item"><b>⇩</b><div><h3>Portable</h3><p>Export a readable JSON file. Import it in another copy of Timmy.</p></div></div><div class="privacy-item"><b>◎</b><div><h3>AI only when you ask</h3><p>Manual logging never uploads. Analyze a photo sends one compressed copy after consent. Hermes chat may receive up to 20 confirmed text-only entries after you connect; photos and backend credentials never enter chat.</p></div></div><div class="privacy-item" data-health-disclosure><b>♥</b><div><h3>Health counters</h3><p>Timmy’s server keeps in-memory counters of how analysis requests went — allowed, refused, failed, and how long they took — so an outage can be seen without opening anyone’s journal. They never include your notes, photos, image fingerprints, or identity, and they reset when the server restarts.</p></div></div></section><section class="section card"><h2>Data controls</h2><div class="row"><button class="btn btn-primary" id="export">Export JSON</button><label class="btn btn-secondary" for="import">Import JSON</label><input class="hidden" type="file" id="import" accept="application/json"></div><p class="fine section">Exports can contain sensitive health notes and photos. Store them somewhere you trust.</p></section>${portabilityNoticeBlock()}${trainingConsentSection()}<section class="section card source-list"><h2>Health sources</h2><p class="fine">The Bristol groupings and urgent-symptom copy are grounded in public clinical guidance.</p><p><a target="_blank" rel="noreferrer" href="https://www.continence.org.au/about-incontinence/bowel-incontinence/bristol-stool-chart/">Continence Health Australia</a></p><p><a target="_blank" rel="noreferrer" href="https://www.nhs.uk/conditions/bleeding-from-the-bottom-rectal-bleeding/">NHS rectal bleeding guidance</a></p><p><a target="_blank" rel="noreferrer" href="https://www.niddk.nih.gov/health-information/digestive-diseases/constipation/symptoms-causes">NIDDK constipation guidance</a></p></section><section class="section card danger-zone"><h2>Delete everything</h2><p class="fine">Permanently removes Timmy’s local ledger and every saved photo from this browser.</p><button class="btn btn-danger" id="delete-all">Delete all local data</button></section></main>`);
document.querySelector('#export').onclick=exportData;document.querySelector('#import').onchange=importData;document.querySelector('#delete-all').onclick=deleteData;
}
function exportData(){const blob=new Blob([exportLedgerBackup({entries,contributions})],{type:'application/json'}),a=document.createElement('a');a.href=URL.createObjectURL(blob);a.download=`timmy-backup-v${LEDGER_EXPORT_VERSION}.json`;a.click();URL.revokeObjectURL(a.href);portabilityNotice={kind:'ok',version:LEDGER_EXPORT_VERSION,text:`Backup written as version ${LEDGER_EXPORT_VERSION}: ${entries.length} log${entries.length===1?'':'s'} and ${contributions.length} training receipt${contributions.length===1?'':'s'}.`};render();toast('Backup exported');}
Binary file not shown.

After

Width:  |  Height:  |  Size: 486 KiB

+99
View File
@@ -0,0 +1,99 @@
# Privacy-safe model health counters
Issue [#19](https://forge.alexanderwhitestone.com/git/stackchain/timmy-talking-turd/issues/19).
This is the operator surface for "is the worker healthy?", and it is deliberately the smallest one
that answers the question. The journal stays the only place a person's bowel data lives.
## Endpoint
```
GET /api/metrics
Authorization: Bearer $TIMMY_METRICS_TOKEN
```
- `TIMMY_METRICS_TOKEN` unset → **404**. A default deployment has no metrics route at all; nothing is
exposed by forgetting to configure something.
- Token set, header missing or wrong → **403**, with a body that contains no counters.
- Token set and matched → **200** with the snapshot below.
The snapshot is read from process memory. It survives no restart, is written to no log, and is sent
to no third party.
## What is in the snapshot
```json
{
"schema": 1,
"generatedAt": "2026-11-01T10:00:00.000Z",
"app": { "uptimeMs": 0, "analyzeRequests": 0, "refusedBeforeModel": 0 },
"queue": { "inFlight": 0, "limit": 4, "atCapacity": false, "accepted": 0 },
"model": {
"outcomes": { "suggestion": 0, "abstained": 0, "failure": 0 },
"failureClasses": { "ingress_refused": 0, "rate_limited": 0, "disabled": 0,
"provider_unreachable": 0, "provider_rejected": 0,
"schema_invalid": 0, "timeout": 0, "unexpected": 0 },
"abstentionRate": null,
"readiness": { "phase": "checking", "ready": false, "coldLoadMs": null },
"latency": { "count": 0, "meanMs": null, "maxMs": 0,
"bucketsMs": { "1000": 0, "2000": 0, "5000": 0, "15000": 0, "60000": 0 } }
}
}
```
The three blocks are the three boundaries, and each counts only what it owns:
| Block | Owns | Does not own |
|---|---|---|
| `app` | Every `POST /api/analyze` the process saw, and the ones refused before any model work (bad bytes, per-device budget, analysis disabled). | Queue occupancy or model answers. |
| `queue` | Slots handed out and released, and the configured concurrency ceiling. | Whether the request succeeded. |
| `model` | Answers (`suggestion` / `abstained`), classified failures, readiness phase, and model-work latency. | Anything decided before a slot was taken. |
`abstentionRate` is `abstained / (suggestion + abstained)`; it is `null` until the model has answered
at least once. A failed request is **not** an abstention — an outage must not look like a cautious
model.
`latency.bucketsMs` is cumulative: `"5000"` counts every sample at or below 5 s. `coldLoadMs` is the
interval from process start to the first observation in which the pinned model was resident; a worker
that flaps back to `offline` keeps the original figure.
`readiness.phase` uses the same vocabulary the browser uses for the AI affordance (`checking`,
`disabled`, `offline`, `loading`, `ready`), and the value is produced by the same
`visionReadiness()` function the app renders, so a metric and a screen cannot disagree.
## What can never appear in it
The only strings this module stores are members of three frozen lists — outcomes, failure classes,
and readiness phases — plus a fixed latency-bucket edge set. Anything else is bucketed, not copied:
- an unrecognised outcome becomes `failure`;
- an unrecognised failure class becomes `unexpected`;
- an unrecognised readiness phase becomes `checking`.
Therefore no note text, no image bytes, no image hash or fingerprint, no subject key, no caller
address, and no session or cookie value can reach a label. `tests/model-metrics.test.js` asserts this
by feeding hostile strings into every label position, and
`tests/metrics-endpoint.acceptance.test.js` asserts it through the shipping server.
## Reading it
```bash
curl -sS -H "Authorization: Bearer $TIMMY_METRICS_TOKEN" http://127.0.0.1:4173/api/metrics | jq .
```
What the numbers mean to an operator:
| Signal | Reading |
|---|---|
| `readiness.phase: offline` | The worker is not answering. Manual logging is unaffected; do not restart the app for this. |
| `readiness.phase: loading` | The worker answered but the pinned model is not resident. AI is withheld rather than offered and failed. |
| `queue.atCapacity: true` | Every model slot is in use. Watch `latency.maxMs`; a growing figure with no completions is a stuck inference, not load. |
| `failureClasses.provider_unreachable` climbing | Network or worker outage, not caller error. |
| `failureClasses.schema_invalid` climbing | The worker is answering off-contract. Treat as a regression in the pinned model or prompt, not a user problem. |
| `app.refusedBeforeModel` climbing with no model traffic | Abuse or a broken client; the model never saw it. |
## Known gap
`queue.inFlight` is the app's own admission depth for `POST /api/analyze`. The full bounded queue
(concurrency limit enforcement, cancellation, overload fallback) is issue
[#17](https://forge.alexanderwhitestone.com/git/stackchain/timmy-talking-turd/issues/17); this
snapshot reports the slot accounting that exists today and does not claim the rest.
+2 -1
View File
@@ -57,13 +57,14 @@ enforces that distinction and the app withholds the AI control until it passes.
## Secrets
Four secrets exist, and every one of them is injected at runtime:
Five secrets exist, and every one of them is injected at runtime:
| Secret | Source | Scope |
|---|---|---|
| `TIMMY_DATASET_SALT` | mode-600 environment file on the host | Corpus subject keys and split assignment. Rotating it re-splits future ingests, so it is not rotated casually. |
| Vision provider key or worker token | mode-600 environment file on the host | Only the app process reads it; the browser never receives it. |
| Agent access code | mode-600 environment file on the host | Unlocks `/api/agent/unlock`; the cookie it mints is `HttpOnly` and same-origin. |
| `TIMMY_METRICS_TOKEN` | mode-600 environment file on the host | Optional. Bearer token for the operator-only `/api/metrics` snapshot. When it is unset the route returns 404, so an unconfigured deployment exposes no counters at all. |
| Gitea access token | local operator shell or secret store | Issue and PR automation only. It is never written into a release tree, an archive, or a log. |
Rules:
+2 -2
View File
@@ -4,7 +4,7 @@
"private": true,
"type": "module",
"scripts": {
"test": "node --test tests/domain.test.js tests/analysis.test.js tests/vision-service.test.js tests/vision-config.test.js tests/draft.test.js tests/suggestion.test.js tests/followup.test.js tests/vision-readiness.test.js tests/hermes-agent-service.test.js tests/agent-gateway.acceptance.test.js tests/staging-health.test.js tests/service-worker-runtime.test.js tests/training-ingest.test.js tests/ci-workflow.test.js tests/product-decisions.test.js tests/release-demo.test.js tests/selfhost-bootstrap.test.js tests/staging-config.test.js tests/threat-model.test.js tests/vision-corpus.test.js tests/vision-boundary.test.js tests/worker-benchmark.test.js tests/worker-ready.test.js tests/worker-container.test.js tests/summary.test.js tests/escalation-regression.test.js tests/grounded-chat.test.js tests/photo-lifecycle.test.js tests/training-contribution.test.js tests/portability.test.js tests/abstention-policy.test.js tests/release-security-gate.test.js tests/image-ingress.test.js tests/rate-limit.test.js tests/http-body.test.js tests/analyze-ingress.acceptance.test.js",
"test": "node --test tests/domain.test.js tests/analysis.test.js tests/vision-service.test.js tests/vision-config.test.js tests/draft.test.js tests/suggestion.test.js tests/followup.test.js tests/vision-readiness.test.js tests/hermes-agent-service.test.js tests/agent-gateway.acceptance.test.js tests/staging-health.test.js tests/service-worker-runtime.test.js tests/training-ingest.test.js tests/ci-workflow.test.js tests/product-decisions.test.js tests/release-demo.test.js tests/selfhost-bootstrap.test.js tests/staging-config.test.js tests/threat-model.test.js tests/vision-corpus.test.js tests/vision-boundary.test.js tests/worker-benchmark.test.js tests/worker-ready.test.js tests/worker-container.test.js tests/summary.test.js tests/escalation-regression.test.js tests/grounded-chat.test.js tests/photo-lifecycle.test.js tests/training-contribution.test.js tests/portability.test.js tests/abstention-policy.test.js tests/release-security-gate.test.js tests/model-metrics.test.js tests/metrics-endpoint.acceptance.test.js tests/image-ingress.test.js tests/rate-limit.test.js tests/http-body.test.js tests/analyze-ingress.acceptance.test.js",
"test:ui": "node tests/ui.acceptance.mjs",
"test:photo": "node tests/photo-first.acceptance.mjs",
"test:mobile-capture": "node tests/mobile-capture.acceptance.mjs",
@@ -19,7 +19,7 @@
"test:grounded-chat": "node tests/grounded-chat.acceptance.mjs",
"test:photo-lifecycle": "node tests/photo-lifecycle.acceptance.mjs",
"test:training-contribution": "node tests/training-contribution.acceptance.mjs",
"check:syntax": "node --check app.js && node --check server.mjs && node --check service-worker.js && node --check src/analysis.js && node --check src/domain.js && node --check src/draft.js && node --check src/suggestion.js && node --check src/followup.js && node --check src/vision-readiness.js && node --check src/hermes-agent-service.js && node --check src/vision-config.js && node --check src/vision-service.js && node --check src/vision-corpus.js && node --check src/summary.js && node --check src/photo-lifecycle.js && node --check src/grounded-chat.js && node --check src/training-contribution.js && node --check src/abstention-policy.js && node --check src/release-security-gate.js && node --check scripts/security_attack_suite.mjs && node --check tests/security-attack.acceptance.mjs && node --check scripts/record_release_demo.mjs && node --check tests/staging.acceptance.mjs && node --check tests/manual-fallback.acceptance.mjs && node --check tests/provisional-suggestion.acceptance.mjs && node --check tests/followup.acceptance.mjs && node --check tests/vision-refusal.acceptance.mjs && bash -n scripts/bootstrap_selfhost_smolvlm.sh && bash -n scripts/run_selfhost_smolvlm.sh && python3 -m py_compile scripts/corpus_manifest.py scripts/review_corpus.py scripts/dataset_card.py scripts/ingest_training_photo.py scripts/build_release.py scripts/deploy_staging.py scripts/build_vision_corpus.py scripts/calibration_report.py scripts/operator_backup.py && node --check scripts/record_worker_benchmark.mjs && node --check scripts/abstention_policy_receipt.mjs && node --check tests/worker-readiness.acceptance.mjs && node --check tests/pattern-summary.acceptance.mjs && node --check tests/photo-lifecycle.acceptance.mjs && node --check tests/training-contribution.acceptance.mjs && node --check tests/portability.acceptance.mjs && node --check tests/grounded-chat.acceptance.mjs && bash -n scripts/worker_ready.sh && bash -n deploy/worker/entrypoint.sh && bash -n deploy/worker/build_image.sh && bash -n deploy/worker/run_worker_container.sh && node --check src/image-ingress.js && node --check src/rate-limit.js && node --check src/http-body.js && python3 -m py_compile scripts/ingest_training_photo.py scripts/build_release.py scripts/deploy_staging.py scripts/build_vision_corpus.py && node --check tests/ingress-capture.acceptance.mjs && node --check tests/analyze-ingress.acceptance.test.js",
"check:syntax": "node --check app.js && node --check server.mjs && node --check service-worker.js && node --check src/analysis.js && node --check src/domain.js && node --check src/draft.js && node --check src/suggestion.js && node --check src/followup.js && node --check src/vision-readiness.js && node --check src/hermes-agent-service.js && node --check src/vision-config.js && node --check src/vision-service.js && node --check src/vision-corpus.js && node --check src/summary.js && node --check src/photo-lifecycle.js && node --check src/grounded-chat.js && node --check src/training-contribution.js && node --check src/abstention-policy.js && node --check src/release-security-gate.js && node --check scripts/security_attack_suite.mjs && node --check tests/security-attack.acceptance.mjs && node --check scripts/record_release_demo.mjs && node --check tests/staging.acceptance.mjs && node --check tests/manual-fallback.acceptance.mjs && node --check tests/provisional-suggestion.acceptance.mjs && node --check tests/followup.acceptance.mjs && node --check tests/vision-refusal.acceptance.mjs && bash -n scripts/bootstrap_selfhost_smolvlm.sh && bash -n scripts/run_selfhost_smolvlm.sh && python3 -m py_compile scripts/corpus_manifest.py scripts/review_corpus.py scripts/dataset_card.py scripts/ingest_training_photo.py scripts/build_release.py scripts/deploy_staging.py scripts/build_vision_corpus.py scripts/calibration_report.py scripts/operator_backup.py && node --check scripts/record_worker_benchmark.mjs && node --check scripts/abstention_policy_receipt.mjs && node --check tests/worker-readiness.acceptance.mjs && node --check tests/pattern-summary.acceptance.mjs && node --check tests/photo-lifecycle.acceptance.mjs && node --check tests/training-contribution.acceptance.mjs && node --check tests/portability.acceptance.mjs && node --check tests/grounded-chat.acceptance.mjs && bash -n scripts/worker_ready.sh && bash -n deploy/worker/entrypoint.sh && bash -n deploy/worker/build_image.sh && bash -n deploy/worker/run_worker_container.sh && node --check src/model-metrics.js && node --check src/image-ingress.js && node --check src/rate-limit.js && node --check src/http-body.js && python3 -m py_compile scripts/ingest_training_photo.py scripts/build_release.py scripts/deploy_staging.py scripts/build_vision_corpus.py && node --check tests/ingress-capture.acceptance.mjs && node --check tests/analyze-ingress.acceptance.test.js",
"check:diff": "bash scripts/check_diff.sh",
"start": "node server.mjs",
"test:worker-readiness": "node tests/worker-readiness.acceptance.mjs",
+2
View File
@@ -71,6 +71,7 @@ DEMO_REQUIRED_CAPTIONS = (
"An older backup is upgraded on import, and the notice says which version the file came from",
"A file from a newer Timmy is refused with an instruction, and the ledger it would have replaced is untouched",
"A restored log still says it began as an AI suggestion you corrected — provenance is part of the record",
"Health counters are disclosed where you read privacy facts — they never include notes, photos, image fingerprints, or identity",
"The review tool is a command line, not a screen in the app — this runs it against the committed synthetic sample",
"The queue shows the proposal and hides the model answer; revealing it is refused until a reviewer commits",
"The model said Type 6 and the reviewer said Type 5: the disagreement is recorded, not smoothed over",
@@ -397,6 +398,7 @@ def main() -> int:
"production_runbook_commands": "passed",
"portable_backup_restore": "passed",
"image_ingress_refusal": "passed",
"privacy_safe_health_metrics": "passed",
"dependency_audit_high": "passed",
"syntax": "passed",
"secret_scan": "passed",
+12
View File
@@ -1525,6 +1525,18 @@ await tap('#entry-sheet-close', 450);
await tap('[data-view="privacy"]', 550);
await page.locator('#delete-all').waitFor();
// Issue #19 — the operator health counters are disclosed where a user reads privacy facts, and the
// recording shows the real sentence the app renders, checked in the browser first.
const healthDisclosure = page.locator('[data-health-disclosure]');
await healthDisclosure.waitFor();
const healthCopy = (await healthDisclosure.innerText()).trim();
if (!/how analysis requests went/i.test(healthCopy) || !/image fingerprints/i.test(healthCopy) || !/reset/i.test(healthCopy)) {
throw new Error(`the privacy screen must disclose the privacy-safe health counters: ${healthCopy}`);
}
await page.evaluate(node => node.scrollIntoView({ block: 'center' }), await healthDisclosure.elementHandle());
await caption('Health counters are disclosed where you read privacy facts — they never include notes, photos, image fingerprints, or identity', 3200);
await indicate('[data-health-disclosure]');
await indicate('#delete-all');
await tap('#delete-all', 650);
await page.getByText(/Local ledger and photos deleted/).waitFor();
+62 -5
View File
@@ -1,6 +1,6 @@
import http from 'node:http';
import { isIP } from 'node:net';
import { createHash, randomBytes } from 'node:crypto';
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
import { readFile, stat } from 'node:fs/promises';
import { extname, join, normalize } from 'node:path';
import { fileURLToPath } from 'node:url';
@@ -10,6 +10,8 @@ import { AgentGatewayError, createHermesAgentService, resolveHermesAgentConfig }
import { readJsonBody } from './src/http-body.js';
import { inspectImageBytes, IngressError } from './src/image-ingress.js';
import { createRateLimiter } from './src/rate-limit.js';
import { createModelMetrics } from './src/model-metrics.js';
import { visionReadiness } from './src/vision-readiness.js';
const root=fileURLToPath(new URL('.',import.meta.url));
const port=Number(process.env.PORT||4173);
@@ -42,6 +44,36 @@ function escapeHtmlAttribute(value){return String(value).replace(/[&<>"]/g,chara
// that lives only in this process's memory, so no address and no image byte is ever logged.
const bodyTimeoutMs=Number(process.env.TIMMY_BODY_TIMEOUT_MS||15_000);
const analyzeLimiter=createRateLimiter({limit:Number(process.env.TIMMY_ANALYZE_RATE_LIMIT||12),windowMs:Number(process.env.TIMMY_ANALYZE_RATE_WINDOW_MS||60_000)});
// Issue #19: operator health counters. The token is what makes this an operator surface rather than
// a public one — with no token configured the route does not exist at all.
const metricsToken=String(process.env.TIMMY_METRICS_TOKEN||'');
const modelMetrics=createModelMetrics({limit:Number(process.env.TIMMY_MODEL_CONCURRENCY||4)});
function classifyFailure(error){
const code=String(error?.code||'');
if(code==='invalid_json'||code==='invalid_fields')return 'schema_invalid';
const message=String(error?.message||'');
if(/timed out|timeout|abort/i.test(message))return 'timeout';
// A payload the semantic validator rejects never reached the model, so it is an ingress refusal
// rather than a model fault.
if(/consent|JPEG|PNG|WebP|empty|too large/i.test(message))return 'ingress_refused';
if(/invalid response/i.test(message))return 'provider_rejected';
return 'provider_unreachable';
}
// Both the browser's readiness probe and the operator snapshot record the same observation, so the
// cold-load figure reflects the moment the model actually became resident, not the first time an
// operator happened to look.
function observeReadiness(provider){
const readiness=visionReadiness({enabled:visionConfig.enabled,providerReady:provider.ready,modelSeen:provider.modelSeen});
modelMetrics.recordReadiness({phase:readiness.phase,ready:readiness.ready});
return readiness;
}
// A token comparison that does not leak its prefix through response timing. The digests are equal
// length by construction, so timingSafeEqual can be used even for a wrong-length guess.
function operatorTokenMatches(supplied){
if(!metricsToken||!supplied)return false;
const digest=value=>createHash('sha256').update(String(value)).digest();
return timingSafeEqual(digest(supplied),digest(metricsToken));
}
const bucketSalt=randomBytes(16).toString('hex');
function clientBucket(req){return createHash('sha256').update(`${bucketSalt}:${req.socket.remoteAddress||'unknown'}`).digest('hex').slice(0,24);}
function readJson(req,maxBytes=6*1024*1024){return readJsonBody(req,{maxBytes,timeoutMs:bodyTimeoutMs});}
@@ -72,21 +104,34 @@ http.createServer(async(req,res)=>{
if(appPath==='/api/healthz'&&req.method==='GET')return sendJson(res,200,{ok:true,release,commit,visionEnabled:visionConfig.enabled,agentEnabled:agentConfig.enabled});
if(appPath==='/api/vision-status'&&req.method==='GET'){
const provider=await probeVisionProvider(visionConfig);
observeReadiness(provider);
const privacy=visionConfig.profile==='selfhost'?'The compressed image is processed by Timmy’s self-hosted model and is not forwarded to a third-party model provider.':'A compressed copy is sent to the configured AI provider only when you explicitly request analysis.';
return sendJson(res,200,{...visionConfig.publicStatus(),providerReady:provider.ready,modelSeen:provider.modelSeen,privacy});
}
// Operator-only health snapshot (issue #19). With TIMMY_METRICS_TOKEN unset the route is absent,
// so a default deployment exposes no counters at all.
if(appPath==='/api/metrics'&&req.method==='GET'){
if(!metricsToken)return sendJson(res,404,{error:'Not found'});
if(!operatorTokenMatches(String(req.headers.authorization||'').replace(/^Bearer\s+/i,'')))return sendJson(res,403,{error:'Operator token required.'});
observeReadiness(await probeVisionProvider(visionConfig));
return sendJson(res,200,modelMetrics.snapshot());
}
if(appPath==='/api/analyze'&&req.method==='POST'){
// Refusals first, in cost order: the request budget, then the body, then the image bytes. None of
// these depend on the model, so hostile bytes are refused as hostile even when analysis is off —
// otherwise the disabled switch hides the ingress boundary behind a 503 and the refusal is untested.
const startedAtMs=Date.now();
const recordFailure=failureClass=>modelMetrics.recordAnalyze({outcome:'failure',failureClass,durationMs:Date.now()-startedAtMs});
const budget=analyzeLimiter.take(clientBucket(req));
if(!budget.allowed){
recordFailure('rate_limited');
res.setHeader('retry-after',String(Math.max(1,Math.ceil(budget.retryAfterMs/1000))));
return sendJson(res,429,{error:'Too many photos from this device. Wait a moment, then try again — your journal and manual logging still work.'});
}
let payload;
try{payload=await readJson(req)}
catch(error){
recordFailure('ingress_refused');
const sent=sendIngressError(res,error);
// The refusal is written first, then the half-read socket is dropped so a stalled body cannot
// hold a connection or its partial buffer open.
@@ -94,11 +139,23 @@ http.createServer(async(req,res)=>{
return sent;
}
try{ingressGuard(payload)}
catch(error){return sendIngressError(res,error)}
catch(error){recordFailure('ingress_refused');return sendIngressError(res,error)}
// Bytes are known to be a real photo at this point, so the disabled switch is the honest answer.
if(!visionConfig.enabled)return sendJson(res,503,{error:'AI analysis is disabled. Continue manually.'});
try{return sendJson(res,200,await analyzePhoto({payload,config:visionConfig}))}
catch(error){const failure=visionFailureResponse(error);return sendJson(res,failure.status,failure.body)}
if(!visionConfig.enabled){recordFailure('disabled');return sendJson(res,503,{error:'AI analysis is disabled. Continue manually.'})}
// The slot is what the queue boundary reports, and it is released on every exit path so a failed
// analysis cannot quietly consume concurrency.
const slot=modelMetrics.enter();
try{
const result=await analyzePhoto({payload,config:visionConfig});
modelMetrics.recordAnalyze({outcome:result?.status==='suggestion'?'suggestion':'abstained',durationMs:Date.now()-startedAtMs});
return sendJson(res,200,result);
}
catch(error){
const failure=visionFailureResponse(error);
recordFailure(classifyFailure(error));
return sendJson(res,failure.status,failure.body);
}
finally{modelMetrics.leave(slot)}
}
if(appPath==='/api/agent/status'&&req.method==='GET')return sendJson(res,200,agentService.status(cookie(req,'timmy_agent')));
if(appPath==='/api/agent/unlock'&&req.method==='POST'){
+139
View File
@@ -0,0 +1,139 @@
// Privacy-safe model health metrics for issue #19.
//
// The point of this module is the boundary it does not cross: a metric sample is assembled from
// enumerated outcomes and numbers, never from a request. No note text, no photo hash, no image
// bytes, and no caller identity can reach a label, because the only strings this module stores are
// members of the frozen lists below. Anything else is coerced to a bucket instead of copied.
export const OUTCOMES = Object.freeze(['suggestion', 'abstained', 'failure']);
// Failure classes are the operator's vocabulary for "what went wrong". They are chosen here, not
// observed from an error, so an upstream message can never become a metric label.
export const FAILURE_CLASSES = Object.freeze([
'ingress_refused',
'rate_limited',
'disabled',
'provider_unreachable',
'provider_rejected',
'schema_invalid',
'timeout',
'unexpected',
]);
// Readiness is the same vocabulary the browser already uses for the AI affordance, so a metric and
// the screen can never disagree about why AI is withheld.
export const READINESS_PHASES = Object.freeze(['checking', 'disabled', 'offline', 'loading', 'ready']);
// Failure classes that are decided before a request can occupy a queue slot or wait on the model.
const REFUSED_BEFORE_MODEL = Object.freeze(['ingress_refused', 'rate_limited', 'disabled']);
export function createModelMetrics({ now = () => Date.now(), limit = 4 } = {}) {
const outcomes = { suggestion: 0, abstained: 0, failure: 0 };
const failureClasses = Object.fromEntries(FAILURE_CLASSES.map(name => [name, 0]));
// The queue boundary is tracked by opaque tickets, not by request identity: the only thing that
// survives a request here is the count of slots in use.
const inUse = new Set();
let accepted = 0;
const startedAt = now();
let readiness = { phase: 'checking', ready: false, coldLoadMs: null };
let analyzeRequests = 0;
let refusedBeforeModel = 0;
// Latency is a histogram, never a list of individual timings: a bucket count says how the worker
// behaves without carrying a per-request record of when someone photographed their stool.
const bucketEdgesMs = Object.freeze([1_000, 2_000, 5_000, 15_000, 60_000]);
const bucketsMs = Object.fromEntries(bucketEdgesMs.map(edge => [String(edge), 0]));
let latencyCount = 0;
let latencySumMs = 0;
let latencyMaxMs = 0;
function abstentionRate() {
const answered = outcomes.suggestion + outcomes.abstained;
return answered === 0 ? null : outcomes.abstained / answered;
}
function recordLatency(durationMs) {
if (!Number.isFinite(durationMs) || durationMs < 0) return;
latencyCount += 1;
latencySumMs += durationMs;
latencyMaxMs = Math.max(latencyMaxMs, durationMs);
// Edges are cumulative on purpose: `bucketsMs['5000']` answers "how many samples were at or
// below five seconds", so an operator can read a percentile without this module storing the
// individual timings that produced it.
for (const edge of bucketEdgesMs) {
if (durationMs <= edge) bucketsMs[String(edge)] += 1;
}
// Anything slower than the last edge stays counted in the total but out of the histogram, so a
// hanging request cannot invent a bucket.
}
return {
enter() {
const ticket = Symbol('model-slot');
inUse.add(ticket);
accepted += 1;
return ticket;
},
leave(ticket) {
// Deleting a ticket that is already gone is a no-op, so a double release cannot free a slot
// that a different request still holds.
inUse.delete(ticket);
},
recordReadiness({ phase, ready }) {
const next = READINESS_PHASES.includes(phase) ? phase : 'checking';
// A worker can flap between ready and offline. Only the first time the model became resident
// is a cold load; every later observation is just the current phase.
const coldLoadMs = readiness.coldLoadMs === null && next === 'ready' && ready === true
? Math.max(0, now() - startedAt)
: readiness.coldLoadMs;
readiness = { phase: next, ready: ready === true, coldLoadMs };
},
recordAnalyze({ outcome, failureClass, durationMs }) {
// The label is looked up, never stored: an unrecognised outcome becomes `failure` so the
// counters stay a closed set no matter what a caller sends.
const bucket = OUTCOMES.includes(outcome) ? outcome : 'failure';
outcomes[bucket] += 1;
analyzeRequests += 1;
if (bucket === 'failure') {
failureClasses[FAILURE_CLASSES.includes(failureClass) ? failureClass : 'unexpected'] += 1;
// A refusal decided before any model work (bad bytes, the per-device budget, or the disabled
// switch) is an app fact: it never occupied a queue slot and never waited on the model.
if (REFUSED_BEFORE_MODEL.includes(failureClass)) {
refusedBeforeModel += 1;
} else {
recordLatency(durationMs);
}
} else {
recordLatency(durationMs);
}
},
snapshot() {
return {
schema: 1,
generatedAt: new Date(now()).toISOString(),
app: {
uptimeMs: Math.max(0, now() - startedAt),
analyzeRequests,
refusedBeforeModel,
},
queue: {
inFlight: inUse.size,
limit,
atCapacity: inUse.size >= limit,
accepted,
},
model: {
outcomes: { ...outcomes },
failureClasses: { ...failureClasses },
abstentionRate: abstentionRate(),
readiness: { ...readiness },
latency: {
count: latencyCount,
meanMs: latencyCount === 0 ? null : Math.round(latencySumMs / latencyCount),
maxMs: latencyMaxMs,
bucketsMs: { ...bucketsMs },
},
},
};
},
};
}
+181
View File
@@ -0,0 +1,181 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import http from 'node:http';
import net from 'node:net';
import { spawn } from 'node:child_process';
import { once } from 'node:events';
import { fileURLToPath } from 'node:url';
// Issue #19 at the real boundary: the server that ships, behind a provider stub this test owns, so
// "the metric reflects a real request" and "the metric never contains the request" are both
// measurable instead of asserted from a mock.
const repoRoot = fileURLToPath(new URL('..', import.meta.url));
const OPERATOR_TOKEN = 'test-operator-token-9f2a';
function cleanJpeg({ width = 1200, height = 900 } = {}) {
const sof = Buffer.alloc(17);
sof.writeUInt16BE(0xffc0, 0);
sof.writeUInt16BE(15, 2);
sof[4] = 8;
sof.writeUInt16BE(height, 5);
sof.writeUInt16BE(width, 7);
sof[9] = 3;
for (let i = 0; i < 3; i += 1) {
sof[10 + i * 3] = i + 1;
sof[11 + i * 3] = 0x11;
}
return Buffer.concat([Buffer.from([0xff, 0xd8]), sof, Buffer.from([0xff, 0xd9])]);
}
const dataUrl = (mime, bytes) => `data:${mime};base64,${bytes.toString('base64')}`;
async function freePort() {
const server = net.createServer();
server.listen(0, '127.0.0.1');
await once(server, 'listening');
const { port } = server.address();
server.close();
return port;
}
async function startProvider() {
const state = { calls: 0 };
const server = http.createServer(async (req, res) => {
if (req.url === '/v1/models') {
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify({ data: [{ id: 'metrics-test-model' }] }));
return;
}
state.calls += 1;
for await (const _ of req) { /* drain */ }
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify({
choices: [{
message: {
content: JSON.stringify({
isStool: true, bristolType: 4, color: 'brown', confidence: 0.8,
imageQuality: 'good', observations: 'Synthetic test card.',
}),
},
}],
}));
});
server.listen(0, '127.0.0.1');
await once(server, 'listening');
return {
state,
port: server.address().port,
close: () => new Promise(resolve => {
server.closeAllConnections?.();
server.close(resolve);
}),
};
}
async function withServer(env, run) {
const provider = await startProvider();
const port = await freePort();
const child = spawn(process.execPath, ['server.mjs'], {
cwd: repoRoot,
env: {
...process.env,
HOST: '127.0.0.1',
PORT: String(port),
TIMMY_VISION_ENABLED: 'true',
TIMMY_VISION_PROFILE: 'selfhost',
TIMMY_VISION_BASE_URL: `http://127.0.0.1:${provider.port}/v1`,
TIMMY_VISION_MODEL: 'metrics-test-model',
...env,
},
stdio: ['ignore', 'pipe', 'pipe'],
});
const logs = [];
child.stdout.on('data', chunk => logs.push(chunk.toString()));
child.stderr.on('data', chunk => logs.push(chunk.toString()));
const base = `http://127.0.0.1:${port}`;
try {
const deadline = Date.now() + 15_000;
for (;;) {
try {
const response = await fetch(`${base}/api/healthz`);
if (response.ok) break;
} catch { /* not up yet */ }
if (Date.now() > deadline) throw new Error(`server did not start: ${logs.join('')}`);
await new Promise(resolve => setTimeout(resolve, 100));
}
await run({ base, provider, logs });
} finally {
child.kill('SIGKILL');
await provider.close();
}
}
const postAnalyze = (base, payload) => fetch(`${base}/api/analyze`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(payload),
});
const getMetrics = (base, token = OPERATOR_TOKEN) => fetch(`${base}/api/metrics`, {
headers: token ? { authorization: `Bearer ${token}` } : {},
});
test('the metrics snapshot reports the request the server actually analysed', async () => {
await withServer({ TIMMY_METRICS_TOKEN: OPERATOR_TOKEN }, async ({ base, provider }) => {
const analyzed = await postAnalyze(base, { consent: true, imageDataUrl: dataUrl('image/jpeg', cleanJpeg()) });
assert.equal(analyzed.status, 200);
assert.equal(provider.state.calls, 1);
const response = await getMetrics(base);
const snapshot = await response.json();
assert.equal(response.status, 200);
assert.equal(snapshot.app.analyzeRequests, 1);
assert.equal(snapshot.queue.accepted, 1);
assert.equal(snapshot.queue.inFlight, 0, 'a finished request must release its slot');
assert.equal(snapshot.model.outcomes.suggestion, 1);
assert.equal(snapshot.model.latency.count, 1);
assert.equal(snapshot.model.abstentionRate, 0);
assert.equal(snapshot.model.readiness.ready, true, 'readiness is probed from the configured worker');
});
});
test('the metrics route is operator-only, and absent when no token is configured', async () => {
await withServer({ TIMMY_METRICS_TOKEN: OPERATOR_TOKEN }, async ({ base }) => {
const wrong = await getMetrics(base, 'not-the-operator-token');
assert.equal(wrong.status, 403);
assert.doesNotMatch(await wrong.text(), /analyzeRequests/, 'a rejected caller learns nothing about traffic');
const anonymous = await getMetrics(base, '');
assert.equal(anonymous.status, 403);
});
await withServer({}, async ({ base }) => {
const response = await getMetrics(base, '');
assert.equal(response.status, 404, 'an unconfigured deployment exposes no metrics route at all');
});
});
test('the snapshot proves the privacy boundary from the outside', async () => {
await withServer({ TIMMY_METRICS_TOKEN: OPERATOR_TOKEN }, async ({ base }) => {
// A real synthetic photo goes through the real path, so any leak would have real bytes to leak.
const bytes = cleanJpeg();
const analyzed = await postAnalyze(base, {
consent: true,
note: 'fictional note about a very private symptom',
imageDataUrl: dataUrl('image/jpeg', bytes),
});
assert.equal(analyzed.status, 200);
const snapshot = await getMetrics(base).then(response => response.json());
const serialized = JSON.stringify(snapshot);
assert.doesNotMatch(serialized, /very private symptom/i, 'note text must never be a metric');
const base64Run = bytes.toString('base64').slice(0, 24);
assert.ok(base64Run.length === 24, 'sanity: the synthetic image has bytes to leak');
assert.doesNotMatch(serialized, new RegExp(base64Run.replace(/[+/=]/g, '\\$&')), 'image bytes must never be a metric');
assert.doesNotMatch(serialized, /data:image/, 'no data URL may appear in the snapshot');
assert.doesNotMatch(serialized, /127\.0\.0\.1|::1/, 'no caller address may appear in the snapshot');
assert.doesNotMatch(serialized, /image|note|photo/i, 'no request-derived key may appear in the snapshot');
});
});
@@ -0,0 +1,89 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import http from 'node:http';
import net from 'node:net';
import { spawn, execFile } from 'node:child_process';
import { once } from 'node:events';
import { promisify } from 'node:util';
import { fileURLToPath } from 'node:url';
const execFileAsync = promisify(execFile);
const repoRoot = fileURLToPath(new URL('..', import.meta.url));
const OPERATOR_TOKEN = 'report-operator-token-4b1c';
async function freePort() {
const server = net.createServer();
server.listen(0, '127.0.0.1');
await once(server, 'listening');
const { port } = server.address();
server.close();
return port;
}
async function startProvider() {
const server = http.createServer(async (req, res) => {
if (req.url === '/v1/models') {
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify({ data: [{ id: 'report-model' }] }));
return;
}
for await (const _ of req) { /* drain */ }
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify({
choices: [{ message: { content: JSON.stringify({
isStool: true, bristolType: 4, color: 'brown', confidence: 0.8,
imageQuality: 'good', observations: 'Synthetic test card.',
}) } }],
}));
});
server.listen(0, '127.0.0.1');
await once(server, 'listening');
return { port: server.address().port, close: () => new Promise(resolve => { server.closeAllConnections?.(); server.close(resolve); }) };
}
test('the operator report renders the app, queue, and model boundaries from a live snapshot', async () => {
const provider = await startProvider();
const port = await freePort();
const child = spawn(process.execPath, ['server.mjs'], {
cwd: repoRoot,
env: {
...process.env,
HOST: '127.0.0.1',
PORT: String(port),
TIMMY_VISION_ENABLED: 'true',
TIMMY_VISION_PROFILE: 'selfhost',
TIMMY_VISION_BASE_URL: `http://127.0.0.1:${provider.port}/v1`,
TIMMY_VISION_MODEL: 'report-model',
TIMMY_METRICS_TOKEN: OPERATOR_TOKEN,
},
stdio: ['ignore', 'pipe', 'pipe'],
});
const logs = [];
child.stdout.on('data', c => logs.push(c.toString()));
child.stderr.on('data', c => logs.push(c.toString()));
const base = `http://127.0.0.1:${port}`;
try {
const deadline = Date.now() + 15_000;
for (;;) {
try {
const response = await fetch(`${base}/api/healthz`);
if (response.ok) break;
} catch { /* not up yet */ }
if (Date.now() > deadline) throw new Error(`server did not start: ${logs.join('')}`);
await new Promise(resolve => setTimeout(resolve, 100));
}
const { stdout } = await execFileAsync(process.execPath, ['scripts/model_metrics_report.mjs', '--url', base, '--token', OPERATOR_TOKEN], { cwd: repoRoot });
assert.match(stdout, /APP/);
assert.match(stdout, /QUEUE/);
assert.match(stdout, /MODEL/);
assert.match(stdout, /readiness\s+ready/, 'the report must name the readiness phase it observed');
assert.match(stdout, /abstention/i);
assert.match(stdout, /failure/i);
assert.doesNotMatch(stdout, /OPERATOR_TOKEN|report-operator-token/, 'the report must never print the token');
} finally {
child.kill('SIGKILL');
await provider.close();
}
});
+151
View File
@@ -0,0 +1,151 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createModelMetrics } from '../src/model-metrics.js';
// Issue #19: the worker has to be operable without the metrics becoming a second copy of the
// journal. These tests are the boundary: what a metric sample may say, and what it may never say.
test('abstention rate is computed from the model answers that came back at all', async () => {
const metrics = createModelMetrics({ now: () => 1_000 });
metrics.recordAnalyze({ outcome: 'suggestion', durationMs: 900 });
metrics.recordAnalyze({ outcome: 'suggestion', durationMs: 1_100 });
metrics.recordAnalyze({ outcome: 'abstained', durationMs: 700 });
metrics.recordAnalyze({ outcome: 'abstained', durationMs: 800 });
const snapshot = metrics.snapshot();
assert.equal(snapshot.model.outcomes.suggestion, 2);
assert.equal(snapshot.model.outcomes.abstained, 2);
assert.equal(snapshot.model.abstentionRate, 0.5);
});
test('a label that is not an enumerated outcome is counted, never copied into the snapshot', async () => {
const metrics = createModelMetrics({ now: () => 1_000 });
// The worst case for a metrics boundary: a caller-supplied string arrives where a label belongs.
metrics.recordAnalyze({ outcome: 'Type 4, urgent, 2026-11-01, milkY4B2cQ9w==', durationMs: 900 });
const snapshot = metrics.snapshot();
const serialized = JSON.stringify(snapshot);
assert.deepEqual(Object.keys(snapshot.model.outcomes).sort(), ['abstained', 'failure', 'suggestion']);
assert.doesNotMatch(serialized, /urgent/i);
assert.doesNotMatch(serialized, /milkY4B2cQ9w/);
assert.equal(snapshot.model.outcomes.failure, 1, 'an unrecognised outcome is one failed answer');
});
test('a failed request is classified and kept out of the abstention denominator', async () => {
const metrics = createModelMetrics({ now: () => 1_000 });
metrics.recordAnalyze({ outcome: 'suggestion', durationMs: 900 });
metrics.recordAnalyze({ outcome: 'failure', failureClass: 'provider_unreachable', durationMs: 2_000 });
metrics.recordAnalyze({ outcome: 'failure', failureClass: 'provider_unreachable', durationMs: 2_100 });
metrics.recordAnalyze({ outcome: 'abstained', durationMs: 800 });
const snapshot = metrics.snapshot();
assert.equal(snapshot.model.failureClasses.provider_unreachable, 2);
assert.equal(snapshot.model.abstentionRate, 0.5, 'a failed answer is not an abstention');
});
test('a failure class outside the enumerated set collapses instead of being echoed', async () => {
const metrics = createModelMetrics({ now: () => 1_000 });
metrics.recordAnalyze({ outcome: 'failure', failureClass: 'note:blood in stool, image 9f2a', durationMs: 2_000 });
const snapshot = metrics.snapshot();
assert.equal(snapshot.model.failureClasses.unexpected, 1);
assert.doesNotMatch(JSON.stringify(snapshot), /blood in stool/);
});
test('analysis latency is reported as warm work, with counts and a bounded histogram', async () => {
const metrics = createModelMetrics({ now: () => 1_000 });
metrics.recordAnalyze({ outcome: 'suggestion', durationMs: 900 });
metrics.recordAnalyze({ outcome: 'suggestion', durationMs: 1_500 });
metrics.recordAnalyze({ outcome: 'abstained', durationMs: 6_000 });
// A refused request never waited on the model, so it must not be sampled as latency.
metrics.recordAnalyze({ outcome: 'failure', failureClass: 'ingress_refused', durationMs: 5 });
const latency = metrics.snapshot().model.latency;
assert.equal(latency.count, 3, 'latency counts model work only');
assert.equal(latency.maxMs, 6_000);
assert.equal(latency.meanMs, 2_800);
// Fixed bucket edges keep the snapshot a histogram of numbers, not a list of timings.
assert.deepEqual(latency.bucketsMs, { '1000': 1, '2000': 2, '5000': 2, '15000': 3, '60000': 3 });
});
test('queue depth is the in-flight model work, and a released slot is never counted twice', async () => {
const metrics = createModelMetrics({ now: () => 1_000, limit: 2 });
const first = metrics.enter();
const second = metrics.enter();
assert.equal(metrics.snapshot().queue.inFlight, 2);
assert.equal(metrics.snapshot().queue.atCapacity, true);
metrics.leave(first);
metrics.leave(first); // a double release must not free a slot that is still in use
const queue = metrics.snapshot().queue;
assert.equal(queue.inFlight, 1, 'the second request still holds its slot');
assert.equal(queue.atCapacity, false);
assert.equal(queue.accepted, 2);
metrics.leave(second);
assert.equal(metrics.snapshot().queue.inFlight, 0);
assert.equal(metrics.snapshot().queue.accepted, 2, 'a completed request is still an accepted request');
});
test('readiness is reported as a phase, and the cold model load is measured once', async () => {
let clock = 10_000;
const metrics = createModelMetrics({ now: () => clock });
metrics.recordReadiness({ phase: 'loading', ready: false });
clock = 74_000; // 64s of weights loading
metrics.recordReadiness({ phase: 'ready', ready: true });
clock = 90_000;
metrics.recordReadiness({ phase: 'offline', ready: false });
const readiness = metrics.snapshot().model.readiness;
assert.equal(readiness.phase, 'offline');
assert.equal(readiness.ready, false);
assert.equal(readiness.coldLoadMs, 64_000, 'the cold load is the first time the model became resident');
});
test('a readiness phase outside the enumerated set is reported as checking', async () => {
const metrics = createModelMetrics({ now: () => 1_000 });
metrics.recordReadiness({ phase: 'user says: bloody stool', ready: true });
const snapshot = metrics.snapshot();
assert.equal(snapshot.model.readiness.phase, 'checking');
assert.doesNotMatch(JSON.stringify(snapshot), /bloody stool/);
});
test('the snapshot keeps the app, queue, and model boundaries separate', async () => {
let clock = 1_000;
const metrics = createModelMetrics({ now: () => clock, limit: 2 });
// One request is refused at the app boundary and never takes a model slot; the other is admitted,
// answered, and released. Each of the three boundaries should account for exactly that.
const slot = metrics.enter();
metrics.recordAnalyze({ outcome: 'suggestion', durationMs: 800 });
metrics.leave(slot);
metrics.recordAnalyze({ outcome: 'failure', failureClass: 'rate_limited', durationMs: 0 });
clock = 41_000;
const snapshot = metrics.snapshot();
assert.deepEqual(Object.keys(snapshot).sort(), ['app', 'generatedAt', 'model', 'queue', 'schema']);
assert.equal(snapshot.app.uptimeMs, 40_000);
assert.equal(snapshot.app.analyzeRequests, 2, 'the app boundary counts every request it saw');
assert.equal(snapshot.app.refusedBeforeModel, 1, 'a refusal that never reached the queue is an app fact');
assert.equal(snapshot.queue.accepted, 1, 'the queue boundary counts what it admitted, not what it answered');
assert.equal(snapshot.queue.inFlight, 0, 'a released slot is not in flight');
assert.equal(snapshot.model.outcomes.suggestion, 1, 'the model boundary counts answers only');
});
+14
View File
@@ -561,3 +561,17 @@ test('release demo runs the RC attack suite on camera and prints the verdict it
assert.match(demo, /'providerTextLeaked' in observed/);
assert.doesNotMatch(demo, /http \$\{observed\.status\} · worker \$\{observed\.providerCalls\}`/);
});
test('release demo shows the privacy-safe health counters where the app discloses them', async () => {
const demo = await readFile(demoPath, 'utf8');
const builder = await readFile(builderPath, 'utf8');
// Issue #19 added an operator surface. The recording has to show the user-facing half of it — the
// disclosure on the privacy screen — and the claim has to be checked in the browser first.
assert.match(demo, /\[data-health-disclosure\]/);
assert.match(demo, /the privacy screen must disclose the privacy-safe health counters/);
assert.match(demo, /Health counters are disclosed where you read privacy facts — they never include notes, photos, image fingerprints, or identity/);
// The caption is a release gate, so a later release cannot drop the scene silently.
assert.match(builder, /Health counters are disclosed where you read privacy facts/);
assert.match(builder, /"privacy_safe_health_metrics": "passed"/);
});
+13
View File
@@ -43,6 +43,19 @@ await page.waitForTimeout(500);
assert.equal(await page.evaluate(() => window.scrollY), 0, 'reply should not push the page header/navigation out of frame');
await page.screenshot({ path: 'artifacts/timmy-chat-mobile.png', fullPage: false });
// Issue #19: the operator counters have to be disclosed where the user already looks for privacy
// facts, and the disclosure has to name what is not collected. A settings screen that stays silent
// about a new counter is the failure this asserts against.
await page.locator('[data-view="calendar"]').first().click();
await page.locator('[data-view="privacy"]').first().click();
const disclosure = page.locator('[data-health-disclosure]');
assert.equal(await disclosure.isVisible(), true, 'the privacy screen must disclose the health counters');
const disclosureText = await disclosure.innerText();
assert.match(disclosureText, /how analysis requests went/i);
assert.match(disclosureText, /never (contain|hold|include) your notes, photos, image fingerprints, or identity/i);
assert.match(disclosureText, /reset/i);
await page.screenshot({ path: 'artifacts/privacy-health-disclosure.png', fullPage: true });
assert.deepEqual(errors, []);
await browser.close();
console.log('PASS mobile home → log → red-flag → save → Timmy boundary');