Compare commits

..
Author SHA1 Message Date
timmy baabeb0f4f fix(privacy): give training-contribution rows a readable layout (#24)
Quality gates / quality (push) Successful in 3m52s
The opt-in row and its tombstone rendered as one run-on line ('Type 4 ·
brownReceipt contrib-…', 'Withdrawn Oct 21, 12:09 PMraw image, …') because
nothing styled .contribution-row: the label and the receipt/artifact line
shared a line and the Withdraw control butted against the text.

Split label and detail into their own lines, right-align the row action,
mute withdrawn tombstones, and keep long receipt ids wrapping inside the
card. Verified on a 390px viewport: queued, withdrawn, and offer rows are
separately legible with no horizontal overflow.
2026-10-21 12:15:25 +00:00
timmy 11c63a8d25 Merge pull request #83: feat: separate opt-in training contribution receipts (#24)
Quality gates / quality (push) Successful in 3m47s
2026-10-21 12:01:52 +00:00
timmy ef22ea8970 feat(training): separate opt-in contribution receipts with withdrawable tombstones
Quality gates / quality (pull_request) Successful in 3m44s
A journal save now records no training contribution. A contribution is a second
consent: the privacy page states consent version training-consent-v1 and the
receipt (contribution id, included fields, never-included fields) before the
confirm control is enabled, the receipt is queued on this device only and copies
no photo bytes, note text, or symptom answers, and withdrawal keeps a tombstone
naming every derived artifact by contribution id. Delete-all clears the receipts
with the ledger.

Closes #24
2026-10-21 10:58:50 +00:00
rockachopa d0b3c10aea Merge pull request 'fix(release): paint demo captions and touch rings on camera and verify the pixels' (#81) from fix/demo-caption-pixels into main 2026-10-19 12:35:29 +00:00
12 changed files with 570 additions and 6 deletions

No files matched your search

+1
View File
@@ -59,6 +59,7 @@ jobs:
npm run test:worker-readiness
npm run test:pattern-summary
npm run test:photo-lifecycle
npm run test:training-contribution
- name: Dependency audit
run: npm audit --audit-level=high
- name: Syntax checks
+65 -2
View File
@@ -7,6 +7,7 @@ import { visionReadiness } from './src/vision-readiness.js';
import { buildPatternSummary } from './src/summary.js';
import { answerFromLedger, buildChatLedger, resolveAgentReply } from './src/grounded-chat.js';
import { photoState, withoutEntry, withoutPhoto } from './src/photo-lifecycle.js';
import { addContribution, consentTerms, contributionEligibility, contributionForEntry, receiptPreview, withdrawContribution } from './src/training-contribution.js';
const runtimeConfig = {
basePath: document.querySelector('meta[name="timmy-base-path"]')?.content || '/',
@@ -16,6 +17,7 @@ const BASE_PATH = runtimeConfig.basePath || '/';
const APP_ROOT = BASE_PATH === '/' ? '/' : `${BASE_PATH}/`;
function appPath(path='') { return `${APP_ROOT}${String(path).replace(/^\/+/, '')}`; }
const STORE = `timmy:${BASE_PATH}:ledger-v1`;
const TRAINING_STORE = `timmy:${BASE_PATH}:training-contributions-v1`;
const LEGACY_STORE = 'timmy-ledger-v1';
if (BASE_PATH === '/') {
const legacyEntries = localStorage.getItem(LEGACY_STORE);
@@ -28,6 +30,7 @@ if (BASE_PATH === '/') {
}
const app = document.querySelector('#app');
let entries = loadEntries();
let contributions = loadContributions();
let view = 'home';
let photoHint = '';
let aiSuggestion = null;
@@ -86,6 +89,10 @@ function refreshFollowUp(){
function esc(value='') { return String(value).replace(/[&<>'"]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;',"'":'&#39;','"':'&quot;'}[c])); }
function loadEntries() { try { return JSON.parse(localStorage.getItem(STORE) || '[]'); } catch { return []; } }
function saveEntries() { localStorage.setItem(STORE, JSON.stringify(entries)); }
// Training contribution receipts are their own store: a journal save never writes here, and a
// withdrawal keeps a tombstone instead of quietly erasing the audit trail.
function loadContributions() { try { return JSON.parse(localStorage.getItem(TRAINING_STORE) || '[]'); } catch { return []; } }
function saveContributions() { localStorage.setItem(TRAINING_STORE, JSON.stringify(contributions)); }
function formatDate(value) { return new Intl.DateTimeFormat(undefined,{month:'short',day:'numeric',hour:'numeric',minute:'2-digit'}).format(new Date(value)); }
function toast(message) { const node=document.createElement('div');node.className='toast';node.textContent=message;document.body.append(node);setTimeout(()=>node.remove(),2400); }
@@ -101,6 +108,8 @@ function bindGlobal(){
document.querySelectorAll('[data-scan]').forEach(btn=>btn.onclick=openPhotoFirst);
document.querySelectorAll('[data-statement-link]').forEach(btn=>btn.onclick=()=>showPatternRecords(btn.dataset.statementLink));
document.querySelectorAll('[data-entry-open]').forEach(btn=>btn.onclick=()=>showEntrySheet(btn.dataset.entryOpen));
document.querySelectorAll('[data-contribute-entry]').forEach(btn=>btn.onclick=()=>showContributionConsent(btn.dataset.contributeEntry));
document.querySelectorAll('[data-withdraw]').forEach(btn=>btn.onclick=()=>withdrawTrainingContribution(btn.dataset.withdraw));
}
function recentList(limit=5){
@@ -245,13 +254,67 @@ async function sendChat(event){
finally{chatBusy=false;timmy()}
}
// Issue #24 — contributing a confirmed photo to training is a second, separate consent. A journal
// save writes nothing here, the consent version and the receipt are on screen before the confirm
// control can be pressed, and a withdrawal keeps a tombstone naming every derived artifact by
// contribution id. Nothing is transmitted in this build: the receipt is queued on the device.
function newContributionId(){
const raw=globalThis.crypto?.randomUUID?.()||`${Date.now().toString(16)}${Math.random().toString(16).slice(2)}`;
return `contrib-${String(raw).replace(/[^a-z0-9]/gi,'').slice(0,12)}`;
}
function trainingConsentSection(){
const terms=consentTerms();
const queued=contributions.filter(receipt=>receipt.state!=='withdrawn').length;
const active=contributions.filter(receipt=>receipt.state!=='withdrawn').map(receipt=>`<div class="contribution-row" data-contribution-state="${esc(receipt.state)}"><div><strong>Type ${receipt.labels.bristolType} · ${esc(receipt.labels.color)}</strong><span>Receipt ${esc(receipt.contributionId)} · consent ${esc(receipt.consentVersion)}</span></div><button class="btn btn-ghost" data-withdraw="${esc(receipt.contributionId)}">Withdraw</button></div>`);
const offerable=entries.filter(entry=>contributionEligibility(entry).eligible&&!contributions.some(receipt=>receipt.entryId===entry.id)).map(entry=>`<div class="contribution-row"><div><strong>${formatDate(entry.occurredAt)}</strong><span>Type ${entry.bristolType} · photo saved</span></div><button class="btn btn-secondary" data-contribute-entry="${esc(entry.id)}">Review opt-in</button></div>`);
const tombstones=contributions.filter(receipt=>receipt.state==='withdrawn').map(receipt=>`<div class="contribution-row withdrawn" data-contribution-state="withdrawn"><div><strong>Withdrawn ${esc(formatDate(receipt.withdrawnAt))}</strong><span data-withdrawn-artifacts>raw image, confirmed label · receipt ${esc(receipt.contributionId)}</span></div></div>`);
const rows=[...active,...offerable].join('');
return `<section class="section card training-consent" data-training-consent><h2>Training contribution</h2><p class="fine">Off by default. Saving a log never contributes it, and nothing is uploaded in this build — a receipt is queued on this device.</p><p class="fine">Consent version <code data-contribution-consent-version>${esc(terms.version)}</code> · <span data-contribution-queued-count>${queued}</span> queued</p>${rows?`<div class="contribution-list">${rows}</div>`:'<p class="fine">No saved photos are available to contribute yet.</p>'}${tombstones.length?`<h3>Withdrawn receipts</h3>${tombstones.join('')}`:''}<p class="fine">Deleting all local data below also clears these receipts.</p></section>`;
}
let contributionSheetKeyHandler=null;
function showContributionConsent(entryId){
const entry=entries.find(item=>item.id===entryId);
const eligibility=contributionEligibility(entry);
if(!eligibility.eligible){toast(eligibility.reason||'This log cannot be contributed.');return}
if(contributionForEntry(contributions,entryId)){toast('This log already has an active contribution receipt.');return}
const terms=consentTerms();
const contributionId=newContributionId();
const consentedAt=new Date().toISOString();
document.querySelector('.sheet-backdrop')?.remove();
const wrap=document.createElement('div');wrap.className='sheet-backdrop';
wrap.innerHTML=`<section class="sheet" role="dialog" aria-modal="true" aria-labelledby="contribution-title" data-contribution-sheet><div class="sheet-handle"></div><div class="sheet-header"><div><span class="eyebrow">Separate consent</span><h2 id="contribution-title">Contribute one photo?</h2></div><button class="icon-btn" id="contribution-cancel" aria-label="Close">×</button></div><div class="consent-card"><p class="fine">Consent version <code data-contribution-version>${esc(terms.version)}</code></p><p class="fine">Receipt <code data-receipt-id>${esc(contributionId)}</code> · ${esc(formatDate(consentedAt))}</p><p class="fine"><strong>Included:</strong> ${esc(terms.includes.join('; '))}.</p><p class="fine"><strong>Never included:</strong> ${esc(terms.excludes.join('; '))}.</p><p class="fine">${esc(terms.transport)}</p></div><label class="check"><input id="training-consent-check" type="checkbox"><span>I have read consent ${esc(terms.version)} and want this one log contributed.</span></label><button class="btn btn-primary btn-wide" id="contribute-confirm" disabled>Contribute this photo and label</button><button class="btn btn-ghost btn-wide section" id="contribution-cancel-bottom">Not now</button></section>`;
document.body.append(wrap);
if(contributionSheetKeyHandler)document.removeEventListener('keydown',contributionSheetKeyHandler);
const close=()=>{wrap.remove();document.removeEventListener('keydown',onKey);if(contributionSheetKeyHandler===onKey)contributionSheetKeyHandler=null};
const onKey=event=>{if(event.key==='Escape')close()};
contributionSheetKeyHandler=onKey;
document.addEventListener('keydown',onKey);
document.querySelector('#contribution-cancel').onclick=close;
document.querySelector('#contribution-cancel-bottom').onclick=close;
wrap.onclick=event=>{if(event.target===wrap)close()};
const check=document.querySelector('#training-consent-check'),confirm=document.querySelector('#contribute-confirm');
check.onchange=()=>{confirm.disabled=!check.checked};
confirm.onclick=()=>{
try{
contributions=addContribution(contributions,receiptPreview(entry,{contributionId,consentedAt}));
saveContributions();close();render();toast('Queued on this device. Nothing was uploaded.');
}catch(error){toast(error.message||'That receipt could not be recorded.')}
};
}
function withdrawTrainingContribution(contributionId){
const result=withdrawContribution(contributions,contributionId,new Date().toISOString());
if(!result.tombstone){toast('That contribution receipt was not found.');return}
contributions=result.contributions;saveContributions();render();toast('Contribution withdrawn. The receipt is kept as a tombstone.');
return result.tombstone;
}
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><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></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>${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([exportLedger(entries)],{type:'application/json'}),a=document.createElement('a');a.href=URL.createObjectURL(blob);a.download='timmy-ledger.json';a.click();URL.revokeObjectURL(a.href);toast('Export created');}
async function importData(e){try{const text=await e.target.files[0].text();entries=importLedger(text);saveEntries();render();toast('Ledger imported')}catch(err){toast(err.message)}}
function deleteData(){if(confirm('Delete every local Timmy entry and photo? This cannot be undone.')){entries=[];localStorage.removeItem(STORE);localStorage.removeItem(LEGACY_STORE);render();toast('Local ledger and photos deleted')}}
function deleteData(){if(confirm('Delete every local Timmy entry and photo? This cannot be undone.')){entries=[];contributions=[];localStorage.removeItem(STORE);localStorage.removeItem(LEGACY_STORE);localStorage.removeItem(TRAINING_STORE);render();toast('Local ledger and photos deleted')}}
function openPhotoFirst(){beginDraft();photoHint='';resetSuggestion();visionStatus=null;showPhotoFirst('pick');loadVisionStatus()}
async function loadVisionStatus(){
+8
View File
@@ -34,6 +34,14 @@ The application’s deterministic urgent-symptom guidance runs before chat and r
Logging must never depend on AI availability. When the vision worker is offline, disabled, or still unknown, the photo-first flow offers no AI action and no analysis-consent prompt, and manual Bristol entry remains one action away from every photo step. A chosen photo and any entered context are retained as an in-session draft with a visible summary and an explicit start over; drafts are deliberately not persisted to storage, so a photo awaiting consent is never written to disk. The service worker never caches analysis requests or image bodies.
### Training contribution consent
Contributing a confirmed photo to training is a second consent, not a side effect of logging. A journal save is never a training contribution; a contribution exists only after the person reads the current consent version and its receipt (contribution id, what is included, what is never included) and accepts it explicitly. The receipt carries the consent version it was accepted under, the confirmed Bristol form and broad color, and no note text, symptom answers, urgency/discomfort ratings, identity, or photo bytes.
Withdrawal is first-class: withdrawal keeps a tombstone naming every derived artifact by contribution id (raw image and confirmed label), so a deletion request can be addressed against the corpus rather than trusted to a promise. Delete-all clears the ledger, saved photos, and contribution receipts together.
This build acknowledges that records the intent and does not transmit a contribution until the corpus pipeline and privacy copy are reviewed in #22 and #25; the section says so in the app rather than implying an upload that does not exist.
### Release authority
Hermes/Timmy is the release authority for routine engineering, test, build, deployment, and release-candidate preparation. Automated evidence must remain reproducible and must not overstate model accuracy or safety.
+3 -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",
"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",
"test:ui": "node tests/ui.acceptance.mjs",
"test:photo": "node tests/photo-first.acceptance.mjs",
"test:mobile-capture": "node tests/mobile-capture.acceptance.mjs",
@@ -17,7 +17,8 @@
"test:pattern-summary": "node tests/pattern-summary.acceptance.mjs",
"test:grounded-chat": "node tests/grounded-chat.acceptance.mjs",
"test:photo-lifecycle": "node tests/photo-lifecycle.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 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/ingest_training_photo.py scripts/build_release.py scripts/deploy_staging.py scripts/build_vision_corpus.py && node --check scripts/record_worker_benchmark.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/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",
"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 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/ingest_training_photo.py scripts/build_release.py scripts/deploy_staging.py scripts/build_vision_corpus.py && node --check scripts/record_worker_benchmark.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/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",
"check:diff": "bash scripts/check_diff.sh",
"start": "node server.mjs",
"test:worker-readiness": "node tests/worker-readiness.acceptance.mjs"
+8 -2
View File
@@ -51,6 +51,9 @@ DEMO_REQUIRED_CAPTIONS = (
"The answer states the count and cites the four confirmed logs it counted",
"Following a citation opens exactly the confirmed log behind that answer",
"Ask something the journal cannot answer and Timmy refuses instead of reassuring",
"Training contribution is off by default: a saved log is never training data",
"The consent version and the receipt are on screen before the contribute button works",
"Withdrawing keeps a tombstone naming every derived artifact by contribution ID",
)
@@ -152,6 +155,7 @@ def main() -> int:
run(["npm", "run", "test:pattern-summary"], tree)
run(["npm", "run", "test:grounded-chat"], tree)
run(["npm", "run", "test:photo-lifecycle"], tree)
run(["npm", "run", "test:training-contribution"], tree)
demo_raw = release_dir / f"timmy-talking-turd-{version}-demo.raw.webm"
caption_receipt = release_dir / f"timmy-talking-turd-{version}-demo-captions.json"
demo_env = dict(server_env)
@@ -199,7 +203,7 @@ def main() -> int:
if not contact_sheet.exists() or contact_sheet.stat().st_size == 0:
raise SystemExit("Release demo contact sheet was not produced")
run(["npm", "audit", "--audit-level=high"], tree)
for file in ("app.js", "server.mjs", "service-worker.js", "src/analysis.js", "src/domain.js", "src/draft.js", "src/suggestion.js", "src/followup.js", "src/vision-readiness.js", "src/hermes-agent-service.js", "src/vision-config.js", "src/vision-service.js", "src/vision-corpus.js", "src/summary.js", "src/photo-lifecycle.js", "scripts/record_worker_benchmark.mjs", "tests/staging.acceptance.mjs", "tests/vision-refusal.acceptance.mjs", "tests/worker-readiness.acceptance.mjs", "tests/pattern-summary.acceptance.mjs", "tests/photo-lifecycle.acceptance.mjs"):
for file in ("app.js", "server.mjs", "service-worker.js", "src/analysis.js", "src/domain.js", "src/draft.js", "src/suggestion.js", "src/followup.js", "src/vision-readiness.js", "src/hermes-agent-service.js", "src/vision-config.js", "src/vision-service.js", "src/vision-corpus.js", "src/summary.js", "src/photo-lifecycle.js", "src/training-contribution.js", "scripts/record_worker_benchmark.mjs", "tests/staging.acceptance.mjs", "tests/vision-refusal.acceptance.mjs", "tests/worker-readiness.acceptance.mjs", "tests/pattern-summary.acceptance.mjs", "tests/photo-lifecycle.acceptance.mjs", "tests/training-contribution.acceptance.mjs"):
run(["node", "--check", file], tree)
run(["bash", "-n", "scripts/bootstrap_selfhost_smolvlm.sh"], tree)
run(["bash", "-n", "scripts/run_selfhost_smolvlm.sh"], tree)
@@ -240,6 +244,7 @@ def main() -> int:
"- Target-GPU benchmark: scripts/record_worker_benchmark.mjs records cold start, warm latency, and VRAM on the target GPU and refuses to write a receipt it did not measure; no GPU measurement is bundled with this release\n"
"- Explainable pattern summaries (issue #34): the home insight card reports confirmed-log counts and form groupings per window, every line names the exact confirmed records behind it, following a citation opens those records, and a window below the minimum says it is too sparse instead of describing a trend; no line explains a cause, rules on food, or offers a health verdict\n"
"- Private photo lifecycle (issue #36): the journal marks a log that holds a saved photo without rendering a thumbnail, showing the image is an explicit request, removing it keeps every confirmed field, deleting a log is a separate two-step decision that takes its photo, and delete-all removes the ledger and every stored photo together\n"
"- Training contribution is a separate opt-in (issue #24): a journal save records no contribution, the consent version and the receipt are on screen before the control can be pressed, the receipt is queued on this device only (this build transmits nothing) and carries no note text, symptom answers, or photo bytes, and a withdrawal keeps a tombstone naming every derived artifact by contribution id\n"
"- Safety: suggestions are observable-field assistance, never diagnosis; user confirmation is required\n"
"- Known limitation: open-weight VLM acceptance is proven, Bristol accuracy is not clinically validated\n"
"- Package excludes model weights, medical-image corpora, raw research pages, generated screenshots, and video production assets\n"
@@ -285,7 +290,7 @@ def main() -> int:
"codec": "h264",
"pixel_format": "yuv420p",
"recorded_from_working_app": True,
"fixture_data": "synthetic-Type-4, deterministic vision suggestion reviewed, confirmed, and corrected to Type 2, user-reported hard-stool follow-up with non-diagnostic self-care and a confirmed black/tarry red flag, the CC0 synthetic vision corpus replaying a non-stool refusal, an unreadable-fields abstention, an out-of-range fail-closed reply, and a blank-plus-out-of-range fail-closed reply, offline vision status, fictional multi-day sample logs driving the explainable pattern summary and its citation sheet, a sparse fictional window showing the insufficient-data copy, a grounded local chat answer citing each counted log and opening the cited record, a refusal that borrows no journal evidence, and a deterministic Hermes chat reply whose positional refs render as sources",
"fixture_data": "synthetic-Type-4, deterministic vision suggestion reviewed, confirmed, and corrected to Type 2, user-reported hard-stool follow-up with non-diagnostic self-care and a confirmed black/tarry red flag, the CC0 synthetic vision corpus replaying a non-stool refusal, an unreadable-fields abstention, an out-of-range fail-closed reply, and a blank-plus-out-of-range fail-closed reply, offline vision status, fictional multi-day sample logs driving the explainable pattern summary and its citation sheet, a sparse fictional window showing the insufficient-data copy, a grounded local chat answer citing each counted log and opening the cited record, a refusal that borrows no journal evidence, and a deterministic Hermes chat reply whose positional refs render as sources, plus the opt-in training contribution path where nothing is queued after ordinary saves, the consent version and receipt precede the confirm control, one receipt is queued on the device only, and withdrawal leaves a tombstone naming each derived artifact",
},
"gates": {
"unit_security": "passed",
@@ -300,6 +305,7 @@ def main() -> int:
"worker_post_load_readiness": "passed",
"longitudinal_pattern_summary": "passed",
"grounded_chat_citations": "passed",
"training_contribution_consent": "passed",
"dependency_audit_high": "passed",
"syntax": "passed",
"secret_scan": "passed",
+46
View File
@@ -477,6 +477,49 @@ await page.getByText(/Your poop\. Your phone\./i).waitFor();
await caption('Every byte-bearing control the model names, in the running app: local ledger, consent-gated upload, portable export, full deletion', 2800);
await indicate('#export');
await caption('Export writes a plain JSON file you hold — the model records it as a data-lifetime boundary, not a server sync', 2600);
// Issue #24 — training contribution is a second, separate consent. The recording shows the section
// reporting nothing queued after ordinary saves, the version and receipt on screen before the
// button can be pressed, one receipt queued on the device only, and a withdrawal tombstone that
// names each derived artifact by contribution id.
await caption('Training contribution is off by default: a saved log is never training data', 2600);
await page.locator('[data-training-consent]').waitFor();
const contributionKey = await page.evaluate(() => `timmy:${document.querySelector('meta[name="timmy-base-path"]')?.content || '/'}:training-contributions-v1`);
const storedContributions = async () => JSON.parse(await page.evaluate(key => localStorage.getItem(key) || '[]', contributionKey));
if ((await page.locator('[data-contribution-queued-count]').innerText()).trim() !== '0') {
throw new Error('a journal save must not queue a training contribution');
}
await indicate('[data-contribute-entry="demo-wipe"]');
await tap('[data-contribute-entry="demo-wipe"]', 600);
await page.locator('[data-contribution-sheet]').waitFor();
const receiptId = (await page.locator('[data-receipt-id]').innerText()).trim();
if (!receiptId) throw new Error('the consent sheet must name the receipt before consent');
const sheetCopy = await page.locator('[data-contribution-sheet]').innerText();
if (!sheetCopy.includes('training-consent-v1')) throw new Error('the consent sheet must state the consent version');
if (!(await page.locator('#contribute-confirm').isDisabled())) throw new Error('the contribute control must stay disabled until the terms are accepted');
if ((await storedContributions()).length !== 0) throw new Error('no receipt may exist before explicit consent');
await caption('The consent version and the receipt are on screen before the contribute button works', 3000);
await page.locator('#training-consent-check').check();
await tap('#contribute-confirm', 700);
await page.locator('[data-contribution-state="queued-local"]').waitFor();
const queuedReceipts = await storedContributions();
if (queuedReceipts.length !== 1 || queuedReceipts[0].consentVersion !== 'training-consent-v1') {
throw new Error('one queued receipt must carry the consent version it was accepted under');
}
if (JSON.stringify(queuedReceipts).includes('data:image')) throw new Error('a receipt must never copy the photo bytes');
await caption('One confirmed log is queued on this device with a receipt you can withdraw — nothing is uploaded', 3200);
await indicate('[data-contribution-state="queued-local"]');
await tap(`[data-withdraw="${receiptId}"]`, 700);
await page.locator('[data-contribution-state="withdrawn"]').waitFor();
const withdrawnReceipts = await storedContributions();
const tombstone = withdrawnReceipts[0];
if (!tombstone || tombstone.state !== 'withdrawn') throw new Error('withdrawal must keep the receipt as a tombstone');
if ((tombstone.artifacts || []).length !== 2) throw new Error('the tombstone must name every derived artifact');
for (const artifact of tombstone.artifacts) {
if (artifact.contributionId !== receiptId) throw new Error('every derived artifact must be addressed by contribution id');
if (artifact.state !== 'withdrawn') throw new Error('every derived artifact must be marked withdrawn');
}
await caption('Withdrawing keeps a tombstone naming every derived artifact by contribution ID', 3000);
await indicate('[data-withdrawn-artifacts]');
await indicate('#delete-all');
await tap('#delete-all', 650);
await page.getByText(/Local ledger and photos deleted/).waitFor();
@@ -485,6 +528,9 @@ if (wiped.raw !== null) throw new Error('delete-all must remove the stored ledge
if (await page.evaluate(() => JSON.stringify(localStorage).includes('data:image'))) {
throw new Error('no photo bytes may survive delete-all');
}
if (await page.evaluate(key => localStorage.getItem(key), contributionKey) !== null) {
throw new Error('delete-all must clear the contribution receipts as well');
}
await caption('Delete all local data clears the ledger and every saved photo in this browser; nothing is kept on a server', 2900);
await tap('[data-view="home"]', 500);
// Issue #37 — a chat answer about the journal must point at the confirmed records behind it. The
+103
View File
@@ -0,0 +1,103 @@
// Issue #24 — contributing a confirmed photo to Timmy's training corpus is a separate decision from
// saving a log. A journal save records nothing here; a contribution exists only after someone reads
// the consent terms and accepts them, and it exists as a receipt they can point a withdrawal at.
// Nothing is transmitted: this build queues the receipt on the device so the corpus pipeline (#25)
// has something auditable to consume later.
export const TRAINING_CONSENT_VERSION = 'training-consent-v1';
const IMAGE_DATA_URL = /^data:image\/[a-z0-9.+-]+(?:;base64)?,[A-Za-z0-9+/=%.-]+$/i;
const CONTRIBUTED_ARTIFACTS = ['raw-image', 'confirmed-label'];
export function consentTerms() {
return {
version: TRAINING_CONSENT_VERSION,
includes: [
'The saved photo bytes for this one log',
'The confirmed Bristol form, broad color, and image quality',
],
excludes: [
'Your note text',
'Your symptom answers',
'Urgency and discomfort ratings',
'Anything identifying you or this device',
],
transport: 'Queued on this device only — nothing is uploaded in this build.',
};
}
export function defaultContributionState() {
return { optedIn: false, receipt: null };
}
export function contributionEligibility(entry) {
if (!entry || !entry.id) return { eligible: false, reason: 'This log has no identifier to attach a receipt to.' };
if (!IMAGE_DATA_URL.test(String(entry.photoDataUrl || ''))) {
return { eligible: false, reason: 'This log has no saved photo to contribute.' };
}
if (!Number.isInteger(entry.bristolType) || entry.bristolType < 1 || entry.bristolType > 7) {
return { eligible: false, reason: 'This log has no confirmed Bristol form to contribute.' };
}
if (!entry.color) return { eligible: false, reason: 'This log has no confirmed color to contribute.' };
return { eligible: true, reason: '' };
}
export function receiptPreview(entry, { contributionId, consentedAt } = {}) {
if (!contributionId) throw new Error('A contribution receipt needs its own identifier.');
if (!consentedAt) throw new Error('A contribution receipt needs the moment of consent.');
const eligibility = contributionEligibility(entry);
if (!eligibility.eligible) throw new Error(eligibility.reason);
return {
contributionId,
entryId: entry.id,
consentVersion: TRAINING_CONSENT_VERSION,
consentedAt,
labels: { bristolType: entry.bristolType, color: entry.color },
state: 'queued-local',
};
}
export function contributionForEntry(contributions, entryId) {
if (!Array.isArray(contributions)) return null;
return contributions.find(item => item.entryId === entryId && item.state !== 'withdrawn') || null;
}
export function addContribution(contributions, receipt) {
const list = Array.isArray(contributions) ? contributions : [];
if (!receipt || receipt.consentVersion !== TRAINING_CONSENT_VERSION) {
throw new Error('A contribution needs the current consent version.');
}
if (contributionForEntry(list, receipt.entryId)) return list;
return [...list, receipt];
}
export function derivedArtifacts(receipt) {
const state = receipt?.state === 'withdrawn' ? 'withdrawn' : 'active';
return CONTRIBUTED_ARTIFACTS.map(artifact => ({
artifact,
contributionId: receipt?.contributionId || '',
state,
}));
}
export function withdrawContribution(contributions, contributionId, withdrawnAt) {
const list = Array.isArray(contributions) ? contributions : [];
const receipt = list.find(item => item.contributionId === contributionId);
if (!receipt) return { contributions: list, tombstone: null };
const withdrawn = {
...receipt,
state: 'withdrawn',
withdrawnAt,
artifacts: derivedArtifacts({ ...receipt, state: 'withdrawn' }),
};
return {
contributions: list.map(item => (item.contributionId === contributionId ? withdrawn : item)),
tombstone: {
contributionId: receipt.contributionId,
entryId: receipt.entryId,
consentVersion: receipt.consentVersion,
consentedAt: receipt.consentedAt,
withdrawnAt,
artifacts: withdrawn.artifacts,
},
};
}
+11
View File
@@ -59,3 +59,14 @@ main{display:block}.eyebrow{font-size:11px;font-weight:800;letter-spacing:.1em;t
.chat-citation{min-height:32px;padding:5px 10px;border:1px solid #bcdfd4;border-radius:999px;background:var(--surface);color:var(--teal);font-weight:800;font-size:11px;cursor:pointer}
.chat-citation:active{background:#e9f5f0}
.chat-unverified{margin:9px 0 0;padding-top:8px;border-top:1px solid rgba(55,40,31,.09);font-size:11px;line-height:1.4;font-weight:700;color:#8a5a12}
/* Issue #24 — an opt-in receipt and its tombstone must read as separate facts, not one run-on line. */
.contribution-list{display:flex;flex-direction:column;margin-top:10px}
.training-consent h3{font-size:13px;margin:16px 0 2px}
.contribution-row{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:11px 0;border-top:1px solid var(--line)}
.contribution-row:first-child{border-top:0}
.contribution-row>div{display:flex;flex-direction:column;gap:3px;min-width:0}
.contribution-row strong{font-size:13px;line-height:1.3}
.contribution-row span{font-size:11px;line-height:1.4;color:var(--muted);overflow-wrap:anywhere}
.contribution-row .btn{min-height:40px;padding:0 14px;font-size:12px;flex:0 0 auto}
.contribution-row.withdrawn strong{color:var(--muted)}
+9
View File
@@ -21,6 +21,15 @@ test('product decision record fixes the AI boundary and release authority', asyn
assert.match(decision, /\.\.\/research\/SELF-HOSTED-STOOL-VISION\.md/);
});
test('the decision record keeps training contribution as its own versioned, withdrawable consent', async () => {
const decision = await readFile(decisionPath, 'utf8');
assert.match(decision, /### Training contribution consent/);
assert.match(decision, /never a training contribution/i);
assert.match(decision, /withdrawal keeps a tombstone naming every derived artifact by contribution id/i);
assert.match(decision, /does not transmit a contribution until the corpus pipeline and privacy copy are reviewed/i);
});
test('Hermes deployment installs a checked-in non-diagnostic workspace policy', async () => {
const [policy, readme] = await Promise.all([readFile(policyPath, 'utf8'), readFile(readmePath, 'utf8')]);
assert.match(policy, /never diagnose/i);
+35
View File
@@ -308,3 +308,38 @@ test('release demo shows readiness waiting for the loaded pinned model before AI
// The caption is a required release gate, so the scene cannot be dropped silently.
assert.match(builder, /Reachable is not loaded: no AI control, no consent, and no upload until the pinned model is resident/);
});
test('release demo shows the training contribution as a separate opt-in receipt that can be withdrawn', async () => {
const demo = await readFile(demoPath, 'utf8');
const builder = await readFile(builderPath, 'utf8');
const app = await readFile(new URL('../app.js', import.meta.url), 'utf8');
// Issue #24: the recording must show the opt-in section, the consent gate, and the withdrawal.
assert.match(demo, /\[data-training-consent\]/);
assert.match(demo, /\[data-contribute-entry="demo-wipe"\]/);
assert.match(demo, /#training-consent-check/);
assert.match(demo, /#contribute-confirm/);
assert.match(demo, /\[data-contribution-state="queued-local"\]/);
assert.match(demo, /data-withdraw=/);
assert.match(demo, /\[data-withdrawn-artifacts\]/);
assert.match(demo, /training-consent-v1/);
// Every claim is measured in the browser before the caption that makes it.
assert.match(demo, /a journal save must not queue a training contribution/);
assert.match(demo, /the contribute control must stay disabled until the terms are accepted/);
assert.match(demo, /no receipt may exist before explicit consent/);
assert.match(demo, /a receipt must never copy the photo bytes/);
assert.match(demo, /every derived artifact must be addressed by contribution id/);
assert.match(demo, /delete-all must clear the contribution receipts as well/);
// The controls are the app's own, not demo-only scaffolding.
assert.match(app, /data-training-consent/);
assert.match(app, /data-contribution-queued-count/);
assert.match(app, /id="training-consent-check"/);
assert.match(app, /id="contribute-confirm" disabled/);
assert.match(app, /data-withdraw=/);
// The captions and the gate key are required, so a later release cannot drop the scenes silently.
assert.match(builder, /Training contribution is off by default: a saved log is never training data/);
assert.match(builder, /The consent version and the receipt are on screen before the contribute button works/);
assert.match(builder, /Withdrawing keeps a tombstone naming every derived artifact by contribution ID/);
assert.match(builder, /"training_contribution_consent": "passed"/);
assert.match(builder, /"test:training-contribution"/);
});
+120
View File
@@ -0,0 +1,120 @@
import { chromium } from 'playwright';
import assert from 'node:assert/strict';
import { mkdir } from 'node:fs/promises';
// Issue #24 — contributing a confirmed photo to training is a separate opt-in. This runs the real
// app in a browser: a journal save must record nothing, the consent sheet must show the version and
// the receipt before the button works, and a withdrawal must leave a tombstone naming every derived
// artifact. The photo is a synthetic 1x1 PNG constant; no real photo is loaded.
const SYNTHETIC_PHOTO = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+ip1sAAAAASUVORK5CYII=';
const CONSENT_VERSION = 'training-consent-v1';
await mkdir('artifacts', { recursive: true });
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext({ viewport: { width: 390, height: 844 }, deviceScaleFactor: 2, serviceWorkers: 'block' });
const page = await context.newPage();
const errors = [];
page.on('console', message => { if (message.type() === 'error') errors.push(message.text()); });
page.on('pageerror', error => errors.push(error.message));
page.on('dialog', dialog => dialog.accept());
await page.goto('http://127.0.0.1:4173', { waitUntil: 'networkidle' });
await page.evaluate(() => localStorage.clear());
await page.reload({ waitUntil: 'networkidle' });
const basePath = () => page.evaluate(() => document.querySelector('meta[name="timmy-base-path"]')?.content || '/');
const storeKey = async suffix => `timmy:${await basePath()}:${suffix}`;
const readStore = async suffix => {
const key = await storeKey(suffix);
const raw = await page.evaluate(storeKeys => localStorage.getItem(storeKeys), key);
return raw === null ? null : JSON.parse(raw);
};
const writeStore = async (suffix, value) => {
const key = await storeKey(suffix);
await page.evaluate(({ storeKey: k, payload }) => localStorage.setItem(k, payload), { storeKey: key, payload: JSON.stringify(value) });
};
// 1. Saving a log through the app's own flow contributes nothing.
await page.locator('[data-log]').first().click();
await page.locator('[data-type="4"]').click();
await page.locator('#next').click();
await page.locator('#note').fill('Synthetic test entry');
await page.locator('#next').click();
await page.locator('#save').click();
await page.getByText(/Private log saved/).waitFor();
assert.equal(await readStore('training-contributions-v1'), null, 'a journal save must not record a training contribution');
// 2. Seed one photo-bearing log and one text-only log for the privacy section.
const seeded = [
{
id: 'demo-photo', occurredAt: new Date(Date.now() - 86400000).toISOString(), bristolType: 4,
color: 'brown', urgency: 1, discomfort: 0, note: 'synthetic', photoDataUrl: SYNTHETIC_PHOTO, symptoms: {},
},
{
id: 'demo-text', occurredAt: new Date(Date.now() - 2 * 86400000).toISOString(), bristolType: 3,
color: 'brown', urgency: 2, discomfort: 1, note: 'no photo here', photoDataUrl: '', symptoms: {},
},
];
await writeStore('ledger-v1', seeded);
await page.reload({ waitUntil: 'networkidle' });
await page.locator('[data-view="calendar"]').first().click();
await page.locator('[data-view="privacy"]').first().click();
await page.getByText(/Your poop\. Your phone\./i).waitFor();
const section = page.locator('[data-training-consent]');
assert.equal(await section.count(), 1, 'the privacy page must offer the training contribution section');
assert.match(await section.innerText(), /off by default/i);
assert.match(await section.innerText(), /nothing is uploaded/i);
assert.equal((await page.locator('[data-contribution-consent-version]').innerText()).trim(), CONSENT_VERSION);
assert.equal((await page.locator('[data-contribution-queued-count]').innerText()).trim(), '0');
assert.equal(await page.locator('[data-contribute-entry="demo-photo"]').count(), 1, 'a saved photo may be contributed');
assert.equal(await page.locator('[data-contribute-entry="demo-text"]').count(), 0, 'a log without photo bytes cannot be contributed');
// 3. The receipt and the consent version are on screen before the confirm control works.
await page.locator('[data-contribute-entry="demo-photo"]').click();
const sheet = page.locator('[data-contribution-sheet]');
await sheet.waitFor();
assert.match(await sheet.innerText(), new RegExp(CONSENT_VERSION));
const receiptId = (await page.locator('[data-receipt-id]').innerText()).trim();
assert.ok(receiptId.length > 0, 'the receipt shown before consent must name its own id');
assert.match(await sheet.innerText(), /photo bytes/i);
assert.match(await sheet.innerText(), /note text/i);
assert.equal(await page.locator('#contribute-confirm').isDisabled(), true, 'the contribution button stays disabled until the terms are accepted');
assert.equal(await readStore('training-contributions-v1'), null, 'no receipt may be written before explicit consent');
await page.screenshot({ path: 'artifacts/training-contribution-consent-mobile.png' });
// 4. Consent records exactly one queued-on-device receipt.
await page.locator('#training-consent-check').check();
await page.locator('#contribute-confirm').click();
await page.getByText(/Queued on this device/i).first().waitFor();
const queued = await readStore('training-contributions-v1');
assert.equal(queued.length, 1);
assert.equal(queued[0].entryId, 'demo-photo');
assert.equal(queued[0].consentVersion, CONSENT_VERSION);
assert.equal(queued[0].contributionId, receiptId);
assert.equal(queued[0].state, 'queued-local');
assert.equal(JSON.stringify(queued).includes('data:image'), false, 'the receipt must not copy photo bytes');
assert.equal(JSON.stringify(queued).includes('synthetic'), false, 'the receipt must not copy note text');
await page.locator('[data-contribution-state="queued-local"]').waitFor();
assert.equal((await page.locator('[data-contribution-queued-count]').innerText()).trim(), '1');
await page.screenshot({ path: 'artifacts/training-contribution-queued-mobile.png' });
// 5. Withdrawal leaves a tombstone that addresses every derived artifact by contribution id.
await page.locator(`[data-withdraw="${receiptId}"]`).click();
await page.locator('[data-contribution-state="withdrawn"]').waitFor();
const withdrawn = await readStore('training-contributions-v1');
assert.equal(withdrawn.length, 1, 'the withdrawn receipt is kept as a tombstone, not silently erased');
assert.equal(withdrawn[0].state, 'withdrawn');
assert.ok(withdrawn[0].withdrawnAt, 'the tombstone records when the withdrawal happened');
assert.deepEqual(withdrawn[0].artifacts.map(artifact => artifact.artifact).sort(), ['confirmed-label', 'raw-image']);
for (const artifact of withdrawn[0].artifacts) {
assert.equal(artifact.contributionId, receiptId);
assert.equal(artifact.state, 'withdrawn');
}
assert.match(await page.locator('[data-withdrawn-artifacts]').innerText(), /raw image/i);
assert.equal((await page.locator('[data-contribution-queued-count]').innerText()).trim(), '0');
await page.screenshot({ path: 'artifacts/training-contribution-withdrawn-mobile.png' });
assert.deepEqual(errors, []);
await context.close();
await browser.close();
console.log('PASS training contribution stays opt-in, shows its consent version before commit, and withdrawal names every derived artifact');
+161
View File
@@ -0,0 +1,161 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
TRAINING_CONSENT_VERSION,
addContribution,
consentTerms,
contributionEligibility,
contributionForEntry,
defaultContributionState,
derivedArtifacts,
receiptPreview,
withdrawContribution,
} from '../src/training-contribution.js';
// Issue #24 — contributing a photo to Timmy's training corpus is a separate decision from saving
// a log. Nothing here is transmitted: the receipt is what a person could carry to a corpus pipeline
// later, and the withdrawal tombstone is what names the artifacts a deletion has to reach.
const PHOTO = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+ip1sAAAAASUVORK5CYII=';
const entry = (overrides = {}) => ({
id: 'log-1',
occurredAt: '2026-10-14T07:20:00.000Z',
bristolType: 4,
color: 'brown',
urgency: 1,
discomfort: 0,
note: 'after lunch',
photoDataUrl: PHOTO,
symptoms: { blood: false },
...overrides,
});
const preview = (target = entry(), id = 'contrib-1') => receiptPreview(target, {
contributionId: id,
consentedAt: '2026-10-21T10:00:00.000Z',
});
test('saving a journal entry contributes nothing by default', () => {
assert.deepEqual(defaultContributionState(), { optedIn: false, receipt: null });
});
test('an entry with no saved photo bytes cannot be contributed', () => {
const unavailable = contributionEligibility(entry({ photoDataUrl: '' }));
assert.equal(unavailable.eligible, false);
assert.match(unavailable.reason, /no saved photo/i);
assert.equal(contributionEligibility(entry()).eligible, true);
});
test('the consent terms name the version and what is never shared', () => {
const terms = consentTerms();
assert.equal(terms.version, TRAINING_CONSENT_VERSION);
assert.match(terms.version, /^training-consent-/);
assert.ok(terms.includes.some(item => /photo/i.test(item)));
assert.ok(terms.excludes.some(item => /note/i.test(item)));
assert.ok(terms.excludes.some(item => /symptom/i.test(item)));
// This build is honest about transport: a receipt is queued on the device, not uploaded.
assert.match(terms.transport, /nothing is uploaded/i);
});
test('the receipt shown before consent carries the version, the id, and no free text from the log', () => {
const receipt = preview();
assert.equal(receipt.contributionId, 'contrib-1');
assert.equal(receipt.consentVersion, TRAINING_CONSENT_VERSION);
assert.equal(receipt.consentedAt, '2026-10-21T10:00:00.000Z');
assert.deepEqual(receipt.labels, { bristolType: 4, color: 'brown' });
assert.equal(receipt.state, 'queued-local');
// The confirmed note and symptom flags stay in the journal; they are not part of a contribution.
const serialized = JSON.stringify(receipt);
assert.equal(serialized.includes('after lunch'), false);
assert.equal(serialized.includes('"blood"'), false);
assert.equal(serialized.includes('data:image'), false);
});
test('a receipt cannot be recorded without the current consent version', () => {
assert.throws(
() => addContribution([], { ...preview(), consentVersion: 'training-consent-v0' }),
/consent version/i,
);
});
test('recording a contribution returns a new list and leaves the ledger it was given alone', () => {
const before = [];
const after = addContribution(before, preview());
assert.equal(before.length, 0);
assert.equal(after.length, 1);
assert.equal(after[0].contributionId, 'contrib-1');
assert.equal(contributionForEntry(after, 'log-1')?.contributionId, 'contrib-1');
});
test('the same log cannot be contributed twice while its receipt is still active', () => {
const first = addContribution([], preview());
const second = addContribution(first, preview(entry(), 'contrib-2'));
assert.equal(second.length, 1);
assert.equal(second[0].contributionId, 'contrib-1');
});
test('withdrawal keeps a tombstone that names every derived artifact by contribution id', () => {
const contributions = addContribution([], preview());
const artifacts = derivedArtifacts(contributions[0]);
const { contributions: after, tombstone } = withdrawContribution(
contributions, 'contrib-1', '2026-10-21T11:00:00.000Z',
);
assert.equal(after[0].state, 'withdrawn');
assert.equal(after[0].withdrawnAt, '2026-10-21T11:00:00.000Z');
assert.equal(tombstone.contributionId, 'contrib-1');
assert.deepEqual(
tombstone.artifacts.map(artifact => artifact.artifact).sort(),
artifacts.map(artifact => artifact.artifact).sort(),
);
for (const artifact of tombstone.artifacts) {
assert.equal(artifact.contributionId, 'contrib-1');
assert.equal(artifact.state, 'withdrawn');
}
});
test('the derived artifacts are the image bytes and the confirmed label, addressed by contribution id', () => {
const artifacts = derivedArtifacts(preview());
assert.deepEqual(artifacts.map(artifact => artifact.artifact).sort(), ['confirmed-label', 'raw-image']);
for (const artifact of artifacts) {
assert.equal(artifact.contributionId, 'contrib-1');
assert.equal(artifact.state, 'active');
}
});
test('withdrawing one contribution never touches another receipt', () => {
const list = [
...addContribution([], preview()),
...addContribution([], preview(entry({ id: 'log-2' }), 'contrib-2')),
];
const { contributions: after } = withdrawContribution(list, 'contrib-1', '2026-10-21T11:00:00.000Z');
assert.equal(after.find(item => item.contributionId === 'contrib-1').state, 'withdrawn');
assert.equal(after.find(item => item.contributionId === 'contrib-2').state, 'queued-local');
});
test('withdrawing an unknown contribution id changes nothing and reports no tombstone', () => {
const list = addContribution([], preview());
const { contributions: after, tombstone } = withdrawContribution(list, 'missing', '2026-10-21T11:00:00.000Z');
assert.equal(after, list);
assert.equal(tombstone, null);
});
test('a withdrawn log can be contributed again under a fresh receipt', () => {
const { contributions: withdrawn } = withdrawContribution(
addContribution([], preview()), 'contrib-1', '2026-10-21T11:00:00.000Z',
);
const again = addContribution(withdrawn, preview(entry(), 'contrib-2'));
assert.equal(again.length, 2);
assert.equal(contributionForEntry(again, 'log-1').contributionId, 'contrib-2');
assert.equal(contributionForEntry(again, 'log-1').state, 'queued-local');
});