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
8 changes: 4 additions & 4 deletions docs/contributing/architecture/values-retirement-runbook.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,10 +84,10 @@ Goal: the platform stops _creating_ reasons to use values, and the one security
smell leaves the readable store.

1. **Move onboarding dismiss off values.** Add
`users.onboarding_checklist_dismissed_at` (nullable ISO timestamp). Backfill
from `onboardingChecklistDismissed`, then stop reading/writing that value
name. The operator account plus six others drop off values without a user
action.
`users.onboarding_checklist_dismissed_at` (nullable ISO timestamp). Migration
`0015` backfills from `onboardingChecklistDismissed` and deletes those rows.
Reads copy any leftover value onto the column; writes go to the column only.
The operator account plus six others drop off values without a user action.
2. **Move bearer-token-like rows to a secret** (operator). Values entity detail
prints the stored string; bearer tokens must not live there.
3. **Tell agents in server instructions.** Keep a `retiringPrimitiveNotices`
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
-- Move onboarding checklist dismissal off leftover user values.
-- Backfill from onboardingChecklistDismissed, then delete those user-scope rows.
-- Session/app leftover rows stay intact. The stored value is an ISO timestamp;
-- fall back to the row's updated_at.

ALTER TABLE users ADD COLUMN onboarding_checklist_dismissed_at TEXT;

UPDATE users
SET onboarding_checklist_dismissed_at = (
SELECT COALESCE(NULLIF(TRIM(ve.value), ''), ve.updated_at)
FROM value_buckets vb
INNER JOIN value_entries ve ON ve.bucket_id = vb.id
WHERE vb.user_id = users.stable_user_id
AND vb.scope = 'user'
AND ve.name = 'onboardingChecklistDismissed'
LIMIT 1
)
WHERE onboarding_checklist_dismissed_at IS NULL
AND EXISTS (
SELECT 1
FROM value_buckets vb
INNER JOIN value_entries ve ON ve.bucket_id = vb.id
WHERE vb.user_id = users.stable_user_id
AND vb.scope = 'user'
AND ve.name = 'onboardingChecklistDismissed'
);

DELETE FROM value_entries
WHERE name = 'onboardingChecklistDismissed'
AND bucket_id IN (
SELECT id
FROM value_buckets
WHERE scope = 'user'
);
101 changes: 101 additions & 0 deletions packages/worker/src/mcp/onboarding-checklist-migration.node.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { readFileSync, readdirSync } from 'node:fs'
import { DatabaseSync } from 'node:sqlite'
import { expect, test } from 'vitest'

const migrationsDirectory = new URL('../../migrations/', import.meta.url)
const migrationFileName = '0015-users-onboarding-checklist-dismissed-at.sql'
const userId = 'a'.repeat(64)

function applyMigrationsBeforeOnboardingColumn(db: DatabaseSync) {
for (const fileName of readdirSync(migrationsDirectory)
.filter(
(fileName) => fileName.endsWith('.sql') && fileName < migrationFileName,
)
.sort()) {
db.exec(readFileSync(new URL(fileName, migrationsDirectory), 'utf8'))
}
}

function applyOnboardingColumnMigration(db: DatabaseSync) {
db.exec(readFileSync(new URL(migrationFileName, migrationsDirectory), 'utf8'))
}

test('0015 backfills user dismissals and leaves non-user leftover rows intact', () => {
const db = new DatabaseSync(':memory:')
applyMigrationsBeforeOnboardingColumn(db)

db.prepare(
`INSERT INTO users (username, email, password_hash, email_verified_at, stable_user_id)
VALUES (?, ?, ?, ?, ?)`,
).run(
'user-onboarding',
'user-onboarding@example.test',
'test-password-hash',
'2026-08-01T00:00:00.000Z',
userId,
)
db.prepare(
`INSERT INTO value_buckets (id, user_id, scope, binding_key, created_at, updated_at)
VALUES (?, ?, ?, '', ?, ?)`,
).run(
'vb-user',
userId,
'user',
'2026-08-01T00:00:00.000Z',
'2026-08-01T00:00:00.000Z',
)
db.prepare(
`INSERT INTO value_buckets (id, user_id, scope, binding_key, created_at, updated_at)
VALUES (?, ?, ?, '', ?, ?)`,
).run(
'vb-session',
userId,
'session',
'2026-08-01T00:00:00.000Z',
'2026-08-01T00:00:00.000Z',
)
db.prepare(
`INSERT INTO value_entries (bucket_id, name, description, value, created_at, updated_at)
VALUES (?, 'onboardingChecklistDismissed', '', ?, ?, ?)`,
).run(
'vb-user',
'2026-08-01T12:00:00.000Z',
'2026-08-01T12:00:00.000Z',
'2026-08-01T12:00:00.000Z',
)
db.prepare(
`INSERT INTO value_entries (bucket_id, name, description, value, created_at, updated_at)
VALUES (?, 'onboardingChecklistDismissed', '', ?, ?, ?)`,
).run(
'vb-session',
'keep-session',
'2026-08-01T12:00:00.000Z',
'2026-08-01T12:00:00.000Z',
)

applyOnboardingColumnMigration(db)

const dismissedAt = db
.prepare(
`SELECT onboarding_checklist_dismissed_at
FROM users
WHERE stable_user_id = ?`,
)
.get(userId) as { onboarding_checklist_dismissed_at: string | null }
expect(dismissedAt.onboarding_checklist_dismissed_at).toBe(
'2026-08-01T12:00:00.000Z',
)

const leftover = db
.prepare(
`SELECT ve.bucket_id, ve.value, vb.scope
FROM value_entries ve
INNER JOIN value_buckets vb ON vb.id = ve.bucket_id
WHERE ve.name = 'onboardingChecklistDismissed'
ORDER BY vb.scope`,
)
.all() as Array<{ bucket_id: string; value: string; scope: string }>
expect(leftover).toEqual([
{ bucket_id: 'vb-session', value: 'keep-session', scope: 'session' },
])
})
64 changes: 63 additions & 1 deletion packages/worker/src/mcp/onboarding-checklist.node.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,37 @@ function createEnv() {

const userId = 'a'.repeat(64)

async function seedUser(db: D1Database, stableUserId = userId) {
await db
.prepare(
`INSERT INTO users (username, email, password_hash, email_verified_at, stable_user_id)
VALUES (?, ?, ?, ?, ?)`,
)
.bind(
`user-${stableUserId.slice(0, 8)}`,
`${stableUserId.slice(0, 8)}@example.test`,
'test-password-hash',
new Date().toISOString(),
stableUserId,
)
.run()
}

async function readDismissedAt(db: D1Database, stableUserId = userId) {
const row = await db
.prepare(
`SELECT onboarding_checklist_dismissed_at
FROM users
WHERE stable_user_id = ?`,
)
.bind(stableUserId)
.first<{ onboarding_checklist_dismissed_at: string | null }>()
return row?.onboarding_checklist_dismissed_at ?? null
}

test('checklist derives from stored signals, fails open on missing bindings, and dismissal round-trips', async () => {
const { env } = createEnv()
await seedUser(env.APP_DB)

const fresh = await deriveOnboardingChecklist({
env,
Expand Down Expand Up @@ -97,14 +126,16 @@ test('checklist derives from stored signals, fails open on missing bindings, and
expect(doneById['connect-integration']).toBe(true)
expect(progressed.complete).toBe(false)

// Dismissal is a user-scoped value that round-trips.
// Dismissal writes the users column and round-trips without a leftover value.
expect(await readOnboardingChecklistDismissed({ env, userId })).toBe(false)
await dismissOnboardingChecklist({ env, userId })
expect(await readOnboardingChecklistDismissed({ env, userId })).toBe(true)
expect(await readDismissedAt(env.APP_DB)).toMatch(/^\d{4}-\d{2}-\d{2}T/)
})

test('search onboarding notice points at /onboarding and goes quiet after dismissal', async () => {
const { env } = createEnv()
await seedUser(env.APP_DB)

const notice = await buildOnboardingSearchNotice({
env,
Expand Down Expand Up @@ -144,3 +175,34 @@ test('first-win Send is done when email_send meter counts even without mailbox',
await userHasSentWelcomeEmail({ env, userId: 'b'.repeat(64), now }),
).toBe(false)
})

test('leftover onboardingChecklistDismissed value copies onto the users column', async () => {
const { env } = createEnv()
await seedUser(env.APP_DB)
await env.APP_DB.prepare(
`INSERT INTO value_buckets (id, user_id, scope, binding_key, created_at, updated_at)
VALUES (?, ?, 'user', '', ?, ?)`,
)
.bind(
'vb-onboarding',
userId,
'2026-08-01T00:00:00.000Z',
'2026-08-01T00:00:00.000Z',
)
.run()
await env.APP_DB.prepare(
`INSERT INTO value_entries (bucket_id, name, description, value, created_at, updated_at)
VALUES (?, 'onboardingChecklistDismissed', '', ?, ?, ?)`,
)
.bind(
'vb-onboarding',
'2026-08-01T12:00:00.000Z',
'2026-08-01T12:00:00.000Z',
'2026-08-01T12:00:00.000Z',
)
.run()

expect(await readDismissedAt(env.APP_DB)).toBe(null)
expect(await readOnboardingChecklistDismissed({ env, userId })).toBe(true)
expect(await readDismissedAt(env.APP_DB)).toBe('2026-08-01T12:00:00.000Z')
})
66 changes: 50 additions & 16 deletions packages/worker/src/mcp/onboarding-checklist.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,17 @@ import {
} from '#worker/entitlements/service.ts'
import { listIntegrations } from '#worker/integrations/service.ts'
import { listMemoriesByUserId } from '#mcp/memory/repo.ts'
import { getValue, saveValue } from '#mcp/values/service.ts'
import { getValue } from '#mcp/values/service.ts'
import {
type OnboardingChecklistItem,
type OnboardingChecklistItemId,
} from '#universal/onboarding-checklist-types.ts'

/**
* Derived onboarding progress. Every item is computed from data the platform
* already stores — the only persisted state is the dismissal, kept as a
* user-scoped value so it survives across devices and agents.
* already stores. Dismissal lives on `users.onboarding_checklist_dismissed_at`.
* Leftover `onboardingChecklistDismissed` values are copied on read until
* migration 0015 deletes those rows.
*/

export type { OnboardingChecklistItem, OnboardingChecklistItemId }
Expand Down Expand Up @@ -122,31 +123,64 @@ export async function readOnboardingChecklistDismissed(input: {
userId: string
}): Promise<boolean> {
try {
const value = await getValue({
const column = await readDismissedAtColumn(input.env.APP_DB, input.userId)
if (column) return true

const leftover = await getValue({
env: input.env,
userId: input.userId,
storageContext: userScopedStorageContext,
scope: 'user',
name: onboardingChecklistDismissedValueName,
})
return value !== null
if (!leftover) return false

const dismissedAt =
leftover.value.trim() || leftover.updatedAt || new Date().toISOString()
await writeDismissedAtColumn(input.env.APP_DB, input.userId, dismissedAt)
return true
} catch {
return false
}
}

export async function dismissOnboardingChecklist(input: {
env: Pick<Env, 'APP_DB'> & Parameters<typeof saveValue>[0]['env']
env: Pick<Env, 'APP_DB'>
userId: string
}): Promise<void> {
await saveValue({
env: input.env,
userId: input.userId,
storageContext: userScopedStorageContext,
scope: 'user',
name: onboardingChecklistDismissedValueName,
value: new Date().toISOString(),
description:
'Set when the onboarding checklist is dismissed; delete this value to bring the checklist back.',
})
await writeDismissedAtColumn(
input.env.APP_DB,
input.userId,
new Date().toISOString(),
)
}

async function readDismissedAtColumn(db: D1Database, userId: string) {
const row = await db
.prepare(
`SELECT onboarding_checklist_dismissed_at
FROM users
WHERE stable_user_id = ?
LIMIT 1`,
)
.bind(userId)
.first<{ onboarding_checklist_dismissed_at: string | null }>()
const dismissedAt = row?.onboarding_checklist_dismissed_at?.trim()
return dismissedAt ? dismissedAt : null
}

async function writeDismissedAtColumn(
db: D1Database,
userId: string,
dismissedAt: string,
) {
await db
.prepare(
`UPDATE users
SET onboarding_checklist_dismissed_at = ?,
updated_at = CURRENT_TIMESTAMP
WHERE stable_user_id = ?`,
)
.bind(dismissedAt, userId)
.run()
}
2 changes: 1 addition & 1 deletion packages/worker/src/mcp/tools/search-onboarding-notice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {
* One-line onboarding reminder appended to `search` notices, at most once per
* conversation (the runner tracks shown conversations in agent state) and
* only while the derived checklist is incomplete and undismissed. When the
* checklist completes, the dismissal value is written automatically so
* checklist completes, the dismissal column is written automatically so
* established accounts stop paying the derivation on new conversations.
*/

Expand Down
9 changes: 6 additions & 3 deletions packages/worker/src/users-test-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export type UsersTestSchemaColumn =
| 'bio'
| 'avatar_key'
| 'profile_visibility'
| 'onboarding_checklist_dismissed_at'

type UsersColumnDefinition = {
/** Definition used by the fresh `CREATE TABLE users`. */
Expand Down Expand Up @@ -76,9 +77,10 @@ const alwaysAdditiveColumns: Record<string, UsersColumnDefinition> = {

/**
* Opt-in columns. `account_type` mirrors migration 0072, the Stripe columns
* mirror 0066, `email_verified_at` mirrors 0046, and the profile columns mirror
* the community social migration. `CHECK` constraints are dropped from the
* alter forms to match what the migrations do for preexisting tables.
* mirror 0066, `email_verified_at` mirrors 0046, the profile columns mirror
* the community social migration, and `onboarding_checklist_dismissed_at`
* mirrors 0015. `CHECK` constraints are dropped from the alter forms to match
* what the migrations do for preexisting tables.
*/
const optionalColumns: Record<UsersTestSchemaColumn, UsersColumnDefinition> = {
email_verified_at: { create: 'TEXT' },
Expand All @@ -95,6 +97,7 @@ const optionalColumns: Record<UsersTestSchemaColumn, UsersColumnDefinition> = {
create: `TEXT NOT NULL DEFAULT 'public' CHECK (profile_visibility IN ('public', 'private'))`,
alter: `TEXT NOT NULL DEFAULT 'public'`,
},
onboarding_checklist_dismissed_at: { create: 'TEXT' },
}

export async function ensureUsersTestSchema(input: {
Expand Down
4 changes: 4 additions & 0 deletions tools/migration-ledger.json
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,10 @@
{
"filename": "0014-drop-remote-connector-settings.sql",
"sha256": "5a170f05dffc793f94b01bffdbfb62eb11359ed54db768f5a121715317fa5536"
},
{
"filename": "0015-users-onboarding-checklist-dismissed-at.sql",
"sha256": "b3e5b3c168bdebe0f900d30e3d781f874b4850b975bf5ea0d2278114cd6d58ca"
}
]
}
Loading