feat(api): unified Profile schema + consent backfill (ADR-0014 step 1-2)

Adds user_preferences, user_consents, user_contexts and the tone /
tip_kinds_json columns on users. Backfills consent_given=1 rows into
user_consents as data:core; INSERT OR IGNORE keeps it idempotent and
respects later revocations.

Migration body moves to db/migrations.ts so tests can apply it to a
fresh in-memory handle without opening the prod DB on import.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-05 10:28:47 +00:00
parent d454a0a8bf
commit 5d43339616
5 changed files with 410 additions and 168 deletions

View File

@@ -7,12 +7,50 @@ export const users = sqliteTable('users', {
image: text('image'),
googleId: text('google_id').unique(),
role: text('role').notNull().default('user'), // 'user' | 'admin'
// Legacy single-bit consent. Superseded by user_consents (consent_key='data:core').
// Kept for one release per ADR-0014 migration plan; reads consult both, writes go to user_consents only.
consentGiven: integer('consent_given', { mode: 'boolean' }).notNull().default(false),
consentAt: text('consent_at'),
// Stable globals (ADR-0014). Per-agent prefs land in user_preferences instead.
tone: text('tone'), // 'direct' | 'gentle' | 'motivational'
tipKindsJson: text('tip_kinds_json'), // JSON array of allowed tip kinds; null = all
createdAt: text('created_at').notNull(),
deletedAt: text('deleted_at'),
});
// ── Unified Profile model (ADR-0014) ────────────────────────────────────────
// Open-ended per-scope preferences. `scope` is 'orchestrator' or 'agent:<id>';
// the agent's pref_schema (from its manifest) validates value_json on read.
// `source='inferred'` is written by the inference framework (#111); never
// overwrites a `source='user'` row.
export const userPreferences = sqliteTable('user_preferences', {
userId: text('user_id').notNull().references(() => users.id),
scope: text('scope').notNull(), // 'orchestrator' | 'agent:<id>'
key: text('key').notNull(),
valueJson: text('value_json').notNull(),
source: text('source').notNull().default('user'), // 'user' | 'inferred'
updatedAt: text('updated_at').notNull(),
});
// Per-key consent. Revocation writes `revoked_at`; rows are never deleted
// so audits stay clean. `revoked_at IS NULL` = currently active.
export const userConsents = sqliteTable('user_consents', {
userId: text('user_id').notNull().references(() => users.id),
consentKey: text('consent_key').notNull(), // 'data:core' | 'data:todoist' | 'agent:<id>' | …
grantedAt: text('granted_at').notNull(),
revokedAt: text('revoked_at'),
});
// User-named contexts (work / home / vacation). M2 ships manual toggle only;
// auto-inference is per-agent (#112#116).
export const userContexts = sqliteTable('user_contexts', {
userId: text('user_id').notNull().references(() => users.id),
name: text('name').notNull(),
active: integer('active', { mode: 'boolean' }).notNull().default(false),
scheduleJson: text('schedule_json'), // optional: when active
createdAt: text('created_at').notNull(),
});
export const integrationTokens = sqliteTable('integration_tokens', {
id: text('id').primaryKey(),
userId: text('user_id').notNull().references(() => users.id),