Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
124 changes: 124 additions & 0 deletions workers/api/migrations/0021_signal_core.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
-- Signal core: the source-agnostic layer that turns any personal data
-- stream into a mintable seed.
--
-- The platform renders art from personal data (body, location, listening,
-- spend, calendar). Writing a bespoke path per source is unbounded work,
-- so every source normalises to one of five SIGNAL SHAPES — time series,
-- interval series, point events, spatial trace, categorical distribution
-- (see src/signals/shapes.ts). Adapters target those five; everything
-- downstream sees nothing else. Adding a source is additive.
--
-- WHAT IS DELIBERATELY ABSENT FROM THIS SCHEMA: there is no column
-- anywhere below that holds raw personal data, a sample series, or a
-- feature vector. That is the point. Personal data is parsed in the
-- browser (file imports) or held only in transit (OAuth sources) and
-- reduced to an irreversible seed; only the seed, a hash commitment to
-- the features, and coarse descriptive metadata are persisted. Adding a
-- payload column here would silently convert this into a health-data
-- store subject to GDPR Article 9 — do not add one.
--
-- The derivation is one-way by construction (src/signals/derive.ts):
-- seed = H(features ‖ salt). A seed cannot be inverted to recover the
-- night, the route, or the heartbeat that produced it.

-- A registered origin of data for one user: either a file the user
-- imported, or an authorised third-party account.
CREATE TABLE IF NOT EXISTS data_sources (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
-- Free-text provider key (apple_health, fitbit, garmin, strava,
-- spotify, manual, ...). Not constrained: the source set is expected to
-- grow continuously and a CHECK here would mean a migration per source.
provider TEXT NOT NULL,
-- file_import | oauth_api. These carry DIFFERENT privacy guarantees and
-- must not be conflated in copy: a file import is parsed client-side and
-- its bytes never reach the Worker, whereas an OAuth response
-- necessarily transits the Worker and is discarded after derivation.
source_class TEXT NOT NULL,
-- The user's own name for it ("Watch", "2019-2026 archive").
label TEXT,
-- active | revoked. Revoking blocks further derivation but never
-- retroactively invalidates derivations already made — a minted token
-- cannot be recalled, and the schema should not pretend otherwise.
status TEXT NOT NULL DEFAULT 'active',
connected_at INTEGER NOT NULL,
revoked_at INTEGER,
created_at INTEGER NOT NULL
);

CREATE INDEX IF NOT EXISTS idx_data_sources_user
ON data_sources(user_id, status, created_at DESC);

-- One normalised signal, extracted from a source over a declared window.
-- Describes the signal; never contains it.
CREATE TABLE IF NOT EXISTS signal_imports (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
source_id INTEGER NOT NULL REFERENCES data_sources(id) ON DELETE CASCADE,
-- One of the five shapes. See src/signals/shapes.ts.
shape TEXT NOT NULL,
-- What the signal is, in the source's own terms ("heart_rate",
-- "sleep_stage", "play_event", "commute"). Descriptive only.
signal_kind TEXT NOT NULL,
-- The window of the person's life this covers, unix seconds.
window_start INTEGER NOT NULL,
window_end INTEGER NOT NULL,
-- Coarse quality descriptors, so the UI can be honest about gaps
-- without retaining the series itself.
sample_count INTEGER NOT NULL DEFAULT 0,
-- 0..10000 basis points of the window actually covered by samples.
-- Integer to keep D1 comparisons exact.
coverage_bp INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL
);

CREATE INDEX IF NOT EXISTS idx_signal_imports_user
ON signal_imports(user_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_signal_imports_source
ON signal_imports(source_id, created_at DESC);

-- The irreversible step: an import becomes a public seed.
CREATE TABLE IF NOT EXISTS derivations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
import_id INTEGER NOT NULL REFERENCES signal_imports(id) ON DELETE CASCADE,
-- sha256 over the canonical feature encoding. A COMMITMENT, not the
-- features: it lets a holder later prove which feature set produced a
-- seed, without the platform retaining anything invertible.
feature_digest TEXT NOT NULL,
-- The public, mintable seed. Hex. Feeds the same slot the sketch lane
-- already mints against (migrations/0015_mint_seed.sql).
seed TEXT NOT NULL,
-- Which salt generation produced this seed, so the salt can be rotated
-- without stranding older derivations.
salt_version INTEGER NOT NULL DEFAULT 1,
-- Replay guard, mirroring token_ledger.idempotency_key.
idempotency_key TEXT NOT NULL,
created_at INTEGER NOT NULL,
UNIQUE(idempotency_key)
);

CREATE INDEX IF NOT EXISTS idx_derivations_user
ON derivations(user_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_derivations_import
ON derivations(import_id);

-- Granular, individually revocable consent. Separate from data_sources
-- because one source can carry several independent permissions and a
-- person must be able to withdraw one without withdrawing the others.
CREATE TABLE IF NOT EXISTS consent_records (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
-- Null for account-wide consent.
source_id INTEGER REFERENCES data_sources(id) ON DELETE CASCADE,
-- derive | publish_artwork | retain_metadata. Note there is no
-- 'retain_raw' scope: raw retention is not an option the system offers,
-- so it is not a consent the user can be asked for.
scope TEXT NOT NULL,
granted_at INTEGER NOT NULL,
revoked_at INTEGER,
created_at INTEGER NOT NULL
);

CREATE INDEX IF NOT EXISTS idx_consent_user_scope
ON consent_records(user_id, scope, revoked_at);
103 changes: 103 additions & 0 deletions workers/api/src/signals/derive.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
/**
* Seed derivation — the one-way door.
*
* `deriveSeed` turns a feature set into the public, mintable seed that
* drives the artwork. It must be:
*
* - DETERMINISTIC: the same features always yield the same seed, or a
* token's provenance claim is meaningless.
* - IRREVERSIBLE: no feature value may be recoverable from a seed. The
* seed is published on-chain and pinned to IPFS forever; if it were
* invertible, minting would be permanent publication of health data.
* - SALTED: a per-deployment secret prevents an attacker from
* brute-forcing plausible feature sets against a public seed. Without
* it, the feature space is small enough (a dozen rounded numbers) that
* a dictionary attack is realistic — hashing alone is NOT enough here.
*
* The salt is versioned so it can be rotated without stranding existing
* derivations: a row records which generation produced it.
*/

import type { Features } from "./features";

/** Fixed-point precision for canonical encoding. */
const PRECISION = 6;

/**
* Current salt generation. Bump alongside SIGNAL_SALT when rotating, and
* whenever the canonical encoding or an existing feature NAME changes —
* all of those alter the seed for identical input, and a stale version
* number would misrepresent how an old seed was produced.
*/
export const SALT_VERSION = 1;

export class DerivationError extends Error {
constructor(
message: string,
public code: string,
) {
super(message);
}
}

/**
* Canonical text encoding of a feature set.
*
* Sorted by name and written at fixed precision so the encoding does not
* depend on extraction order or on floating-point noise: the same night
* parsed on two devices must hash identically, and IEEE-754 arithmetic
* differs in the last bits across engines. Rounding is what makes the
* determinism guarantee real rather than approximate.
*/
export function canonicalEncode(features: Features): string {
if (!features.length) {
throw new DerivationError("cannot derive from an empty feature set", "empty_features");
}
const seen = new Set<string>();
const parts: string[] = [];
for (const f of [...features].sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))) {
if (seen.has(f.name)) {
throw new DerivationError(`duplicate feature name: ${f.name}`, "duplicate_feature");
}
seen.add(f.name);
if (!Number.isFinite(f.value)) {
throw new DerivationError(`feature ${f.name} is not finite`, "invalid_feature");
}
// toFixed then Number-normalise so -0 and 0 encode identically.
const rounded = Number(f.value.toFixed(PRECISION));
parts.push(`${f.name}=${rounded === 0 ? 0 : rounded}`);
}
return parts.join(";");
}

async function sha256Hex(input: string): Promise<string> {
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(input));
return Array.from(new Uint8Array(digest), (b) => b.toString(16).padStart(2, "0")).join("");
}

/**
* Commitment to a feature set: sha256 over the canonical encoding, with
* no salt. Stored so a holder can later prove which features produced a
* seed by re-supplying them — the salt is deliberately excluded so this
* check does not require the platform's secret.
*/
export async function featureDigest(features: Features): Promise<string> {
return sha256Hex(canonicalEncode(features));
}

/**
* The public seed. Salted, so the published value cannot be attacked by
* enumerating plausible feature sets.
*/
export async function deriveSeed(features: Features, salt: string): Promise<string> {
if (!salt || salt.length < 16) {
// Failing closed matters more than convenience: a short or missing
// salt silently removes the only defence against brute-forcing a
// published seed back to someone's health data.
throw new DerivationError(
"signal salt is missing or too short (min 16 chars)",
"salt_unconfigured",
);
}
return sha256Hex(`ga-signal-v${SALT_VERSION}|${salt}|${canonicalEncode(features)}`);
}
Loading
Loading