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
This commit is contained in:
timmy committed 2026-10-21 10:58:50 +00:00
1 parent d0b3c10aea
commit ef22ea8970
11 files changed
+559 -6

No files matched your search

+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,
},
};
}