diff --git a/packages/core/src/db.test.ts b/packages/core/src/db.test.ts index ed0ff17c..0b310005 100644 --- a/packages/core/src/db.test.ts +++ b/packages/core/src/db.test.ts @@ -1047,6 +1047,15 @@ describe("ensureAdditiveSchemaCompatibility schema-compat gate", () => { expect(tableExists(db, "share_operation_projects")).toBe(true); expect(tableExists(db, "share_operation_steps")).toBe(true); expect(tableExists(db, "recipient_policy_review_resolutions")).toBe(true); + for (const table of [ + "policy_teams", + "policy_team_memberships", + "identity_devices", + "project_recipients", + ]) { + expect(tableExists(db, table)).toBe(true); + } + expect(tableExists(db, "identities")).toBe(false); expect(appliedSchemaVersion(db)).toBe(SCHEMA_VERSION); }); @@ -1071,6 +1080,16 @@ describe("ensureAdditiveSchemaCompatibility schema-compat gate", () => { expect(tableExists(previous, table)).toBe(true); } expect(tableExists(previous, "recipient_policy_review_resolutions")).toBe(true); + for (const table of [ + "policy_teams", + "policy_team_memberships", + "identity_devices", + "project_recipients", + ]) { + expect(tableExists(previous, table)).toBe(true); + } + expect(tableExists(previous, "identities")).toBe(false); + expect(getSchemaVersion(previous)).toBe(SCHEMA_VERSION); expect(columnExists(previous, "share_operations", "pending_person_operation_id")).toBe(true); expect(columnExists(previous, "share_operations", "recipient_device_id")).toBe(true); expect(columnExists(previous, "share_operations", "bootstrap_grant_id")).toBe(true); diff --git a/packages/core/src/db.ts b/packages/core/src/db.ts index 5e230700..1c3d6002 100644 --- a/packages/core/src/db.ts +++ b/packages/core/src/db.ts @@ -32,7 +32,7 @@ import { canAutoBootstrapSchema, ensureSchemaBootstrapped } from "./schema-boots export type { DatabaseType as Database }; /** Current schema version this TS runtime was built against. */ -export const SCHEMA_VERSION = 11; +export const SCHEMA_VERSION = 12; /** * Minimum schema version the TS runtime can operate with. @@ -741,6 +741,65 @@ export function ensureAdditiveSchemaCompatibility(db: DatabaseType): void { resolved_at TEXT NOT NULL, PRIMARY KEY (review_item_id, source_fingerprint) ); + CREATE TABLE IF NOT EXISTS policy_teams ( + team_id TEXT PRIMARY KEY NOT NULL, + display_name TEXT NOT NULL, + status TEXT NOT NULL, + provenance TEXT NOT NULL, + revision TEXT NOT NULL, + migration_state TEXT NOT NULL, + source_fingerprint TEXT, + idempotency_key TEXT NOT NULL UNIQUE, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS policy_team_memberships ( + team_id TEXT NOT NULL, + identity_id TEXT NOT NULL, + role TEXT NOT NULL, + status TEXT NOT NULL, + provenance TEXT NOT NULL, + revision TEXT NOT NULL, + migration_state TEXT NOT NULL, + source_fingerprint TEXT, + idempotency_key TEXT NOT NULL UNIQUE, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + PRIMARY KEY (team_id, identity_id) + ); + CREATE TABLE IF NOT EXISTS identity_devices ( + device_id TEXT PRIMARY KEY NOT NULL, + identity_id TEXT NOT NULL, + display_name TEXT NOT NULL, + status TEXT NOT NULL, + provenance TEXT NOT NULL, + revision TEXT NOT NULL, + migration_state TEXT NOT NULL, + source_fingerprint TEXT, + idempotency_key TEXT NOT NULL UNIQUE, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS project_recipients ( + canonical_project_identity TEXT NOT NULL, + recipient_kind TEXT NOT NULL, + recipient_id TEXT NOT NULL, + status TEXT NOT NULL, + provenance TEXT NOT NULL, + policy_revision TEXT NOT NULL, + migration_state TEXT NOT NULL, + source_fingerprint TEXT, + idempotency_key TEXT NOT NULL UNIQUE, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + PRIMARY KEY (canonical_project_identity, recipient_kind, recipient_id) + ); + CREATE INDEX IF NOT EXISTS idx_policy_team_memberships_identity_status + ON policy_team_memberships(identity_id, status); + CREATE INDEX IF NOT EXISTS idx_identity_devices_identity_status + ON identity_devices(identity_id, status); + CREATE INDEX IF NOT EXISTS idx_project_recipients_project_status + ON project_recipients(canonical_project_identity, status); CREATE TABLE IF NOT EXISTS share_operations ( operation_id TEXT PRIMARY KEY NOT NULL, state TEXT NOT NULL, @@ -1301,6 +1360,16 @@ export function ensureAdditiveSchemaCompatibility(db: DatabaseType): void { } } + const recipientPolicyTablesReady = [ + "policy_teams", + "policy_team_memberships", + "identity_devices", + "project_recipients", + ].every((table) => tableExists(db, table)); + const currentVersion = getSchemaVersion(db); + if (recipientPolicyTablesReady && currentVersion > 0 && currentVersion < SCHEMA_VERSION) { + db.pragma(`user_version = ${SCHEMA_VERSION}`); + } markSchemaCompatApplied(db); } try { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 9d766066..32dace67 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -588,8 +588,21 @@ export type { RecipientPolicyTeamV1, } from "./recipient-policy-contract.js"; export { RECIPIENT_POLICY_CONTRACT_VERSION } from "./recipient-policy-contract.js"; +export type { RecipientPolicyIntentGraphV1 } from "./recipient-policy-intent.js"; +export { listRecipientPolicyIntent } from "./recipient-policy-intent.js"; +export type { + RecipientPolicyMigrationOptions, + RecipientPolicyMigrationProjectResultV1, + RecipientPolicyMigrationProjectStatus, + RecipientPolicyMigrationResultV1, +} from "./recipient-policy-migration.js"; +export { + deterministicPolicyTeamId, + migrateRecipientPolicyIntent, +} from "./recipient-policy-migration.js"; export type { RecipientPolicyActionableReviewItemV1, + RecipientPolicyDerivedReviewState, RecipientPolicyReviewActionOptionV1, RecipientPolicyReviewBulkResultV1, RecipientPolicyReviewContext, @@ -599,6 +612,7 @@ export type { RecipientPolicyReviewResolveStatusV1, } from "./recipient-policy-review.js"; export { + deriveRecipientPolicyReviewState, listRecipientPolicyReview, recipientPolicyReviewSourceFingerprint, resolveRecipientPolicyReview, @@ -614,9 +628,17 @@ export { clearMemoryRefs, normalizeConcept, populateMemoryRefs } from "./ref-pop export type { RefQueryOptions, RefQueryResult } from "./ref-queries.js"; export { findByConcept, findByFile } from "./ref-queries.js"; export type { + IdentityDevice, MaintenanceJob, + NewIdentityDevice, NewMaintenanceJob, + NewPolicyTeam, + NewPolicyTeamMembership, + NewProjectRecipient, NewRecipientPolicyReviewResolution, + PolicyTeam, + PolicyTeamMembership, + ProjectRecipient, RecipientPolicyReviewResolution, } from "./schema.js"; export * as schema from "./schema.js"; diff --git a/packages/core/src/recipient-policy-contract.test.ts b/packages/core/src/recipient-policy-contract.test.ts index cafd0661..1f4b4bfa 100644 --- a/packages/core/src/recipient-policy-contract.test.ts +++ b/packages/core/src/recipient-policy-contract.test.ts @@ -11,6 +11,7 @@ import { type RecipientPolicyTeamMembershipV1, type RecipientPolicyTeamV1, } from "./recipient-policy-contract.js"; +import type { RecipientPolicyIntentGraphV1 } from "./recipient-policy-intent.js"; const PERSONAL_IDENTITY = { version: RECIPIENT_POLICY_CONTRACT_VERSION, @@ -162,6 +163,19 @@ describe("recipient policy V1 contract", () => { "scopeId" | "deviceId" | "trusted" | "projectFilters" >; expectTypeOf().toEqualTypeOf(); + type ForbiddenGraphKey = Extract< + keyof RecipientPolicyIntentGraphV1, + | "scopes" + | "groupIds" + | "addresses" + | "keys" + | "fingerprints" + | "epochs" + | "cursors" + | "filters" + | "payloads" + >; + expectTypeOf().toEqualTypeOf(); }); it("makes keeping current setup an actionable outcome", () => { diff --git a/packages/core/src/recipient-policy-intent.ts b/packages/core/src/recipient-policy-intent.ts new file mode 100644 index 00000000..0dcb9755 --- /dev/null +++ b/packages/core/src/recipient-policy-intent.ts @@ -0,0 +1,127 @@ +import type { Database } from "./db.js"; +import { + RECIPIENT_POLICY_CONTRACT_VERSION, + type RecipientPolicyContractVersion, + type RecipientPolicyIdentityDeviceV1, + type RecipientPolicyIdentityV1, + type RecipientPolicyProjectRecipientV1, + type RecipientPolicyTeamMembershipV1, + type RecipientPolicyTeamV1, +} from "./recipient-policy-contract.js"; + +export interface RecipientPolicyIntentGraphV1 { + version: RecipientPolicyContractVersion; + identities: RecipientPolicyIdentityV1[]; + teams: RecipientPolicyTeamV1[]; + teamMemberships: RecipientPolicyTeamMembershipV1[]; + identityDevices: RecipientPolicyIdentityDeviceV1[]; + projectRecipients: RecipientPolicyProjectRecipientV1[]; +} + +function identityStatus(value: string): RecipientPolicyIdentityV1["status"] { + return value === "pending" || value === "merged" ? value : "active"; +} + +export function listRecipientPolicyIntent(db: Database): RecipientPolicyIntentGraphV1 { + const identities = db + .prepare( + `SELECT actor_id, display_name, is_local, status, merged_into_actor_id + FROM actors WHERE status <> 'deactivated' + ORDER BY display_name, actor_id`, + ) + .all() + .map((row): RecipientPolicyIdentityV1 => { + const value = row as Record; + return { + version: RECIPIENT_POLICY_CONTRACT_VERSION, + identityId: String(value.actor_id ?? ""), + displayName: String(value.display_name ?? ""), + kind: "other", + verification: "local", + status: identityStatus(String(value.status ?? "active")), + mergedIntoIdentityId: + typeof value.merged_into_actor_id === "string" && value.merged_into_actor_id + ? value.merged_into_actor_id + : null, + }; + }); + const teams = db + .prepare( + "SELECT team_id, display_name, status FROM policy_teams ORDER BY display_name, team_id", + ) + .all() + .map((row): RecipientPolicyTeamV1 => { + const value = row as Record; + return { + version: RECIPIENT_POLICY_CONTRACT_VERSION, + teamId: String(value.team_id ?? ""), + displayName: String(value.display_name ?? ""), + status: value.status === "archived" ? "archived" : "active", + }; + }); + const teamMemberships = db + .prepare( + `SELECT team_id, identity_id, role, status FROM policy_team_memberships + ORDER BY team_id, identity_id`, + ) + .all() + .map((row): RecipientPolicyTeamMembershipV1 => { + const value = row as Record; + const status = String(value.status ?? "active"); + return { + version: RECIPIENT_POLICY_CONTRACT_VERSION, + teamId: String(value.team_id ?? ""), + identityId: String(value.identity_id ?? ""), + role: value.role === "admin" ? "admin" : "member", + status: status === "pending" || status === "revoked" ? status : "active", + }; + }); + const identityDevices = db + .prepare( + `SELECT identity_id, device_id, display_name, status FROM identity_devices + ORDER BY identity_id, device_id`, + ) + .all() + .map((row): RecipientPolicyIdentityDeviceV1 => { + const value = row as Record; + return { + version: RECIPIENT_POLICY_CONTRACT_VERSION, + identityId: String(value.identity_id ?? ""), + deviceId: String(value.device_id ?? ""), + displayName: String(value.display_name ?? ""), + status: value.status === "revoked" ? "revoked" : "active", + }; + }); + const projectRecipients = db + .prepare( + `SELECT canonical_project_identity, recipient_kind, recipient_id, status, + provenance, policy_revision + FROM project_recipients + ORDER BY canonical_project_identity, recipient_kind, recipient_id`, + ) + .all() + .map((row): RecipientPolicyProjectRecipientV1 => { + const value = row as Record; + const base = { + version: RECIPIENT_POLICY_CONTRACT_VERSION, + canonicalProjectIdentity: String(value.canonical_project_identity ?? ""), + intentSource: + value.provenance === "exact_project_invite" + ? ("legacy_project_invite" as const) + : ("migration" as const), + policyRevision: String(value.policy_revision ?? ""), + status: value.status === "revoked" ? ("revoked" as const) : ("active" as const), + }; + return value.recipient_kind === "team" + ? { ...base, recipientKind: "team", teamId: String(value.recipient_id ?? "") } + : { ...base, recipientKind: "identity", identityId: String(value.recipient_id ?? "") }; + }); + return { + version: RECIPIENT_POLICY_CONTRACT_VERSION, + identities, + teams, + teamMemberships, + identityDevices, + projectRecipients, + }; +} diff --git a/packages/core/src/recipient-policy-migration.test.ts b/packages/core/src/recipient-policy-migration.test.ts new file mode 100644 index 00000000..52336ee1 --- /dev/null +++ b/packages/core/src/recipient-policy-migration.test.ts @@ -0,0 +1,1031 @@ +import Database from "better-sqlite3"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { listLegacyRecipientPolicyProjections } from "./legacy-recipient-policy-projection.js"; +import { listRecipientPolicyIntent } from "./recipient-policy-intent.js"; +import { + deterministicPolicyTeamId, + migrateRecipientPolicyIntent, +} from "./recipient-policy-migration.js"; +import { + listRecipientPolicyReview, + resolveRecipientPolicyReview, +} from "./recipient-policy-review.js"; +import { shareProjectSetDigest } from "./share-operation.js"; +import { initTestSchema } from "./test-utils.js"; + +const NOW = "2026-07-21T12:00:00.000Z"; +const LOCAL_ACTOR_ID = "identity-personal"; +const LOCAL_DEVICE_ID = "device-local"; +const context = { + localActorId: LOCAL_ACTOR_ID, + localDeviceId: LOCAL_DEVICE_ID, + now: () => NOW, +}; + +function insertActor( + db: InstanceType, + actorId: string, + displayName: string, + isLocal = false, +): void { + db.prepare( + `INSERT INTO actors(actor_id, display_name, is_local, status, created_at, updated_at) + VALUES (?, ?, ?, 'active', ?, ?)`, + ).run(actorId, displayName, isLocal ? 1 : 0, NOW, NOW); +} + +function insertProject( + db: InstanceType, + input: { projectId: string; displayName: string; scopeId?: string }, +): void { + const sessionId = Number( + db + .prepare( + `INSERT INTO sessions(started_at, cwd, project, git_remote, git_branch) + VALUES (?, ?, ?, ?, 'main')`, + ) + .run(NOW, `/workspace/${input.displayName}`, input.displayName, input.projectId) + .lastInsertRowid, + ); + db.prepare( + `INSERT INTO memory_items( + session_id, kind, title, body_text, active, created_at, updated_at, + visibility, project, scope_id + ) VALUES (?, 'discovery', ?, 'body', 1, ?, ?, 'shared', ?, ?)`, + ).run( + sessionId, + input.displayName, + NOW, + NOW, + input.displayName, + input.scopeId ?? "local-default", + ); +} + +function insertScope( + db: InstanceType, + input: { + scopeId: string; + projectId: string; + kind?: string; + label?: string; + coordinatorId?: string | null; + groupId?: string | null; + }, +): void { + db.prepare( + `INSERT INTO replication_scopes( + scope_id, label, kind, authority_type, coordinator_id, group_id, + membership_epoch, status, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, 1, 'active', ?, ?)`, + ).run( + input.scopeId, + input.label ?? input.scopeId, + input.kind ?? "managed_project", + input.coordinatorId ? "coordinator" : "local", + input.coordinatorId ?? null, + input.groupId ?? null, + NOW, + NOW, + ); + db.prepare( + `INSERT INTO project_scope_mappings( + workspace_identity, project_pattern, scope_id, priority, source, created_at, updated_at + ) VALUES (?, ?, ?, 1000, 'test', ?, ?)`, + ).run(input.projectId, input.projectId, input.scopeId, NOW, NOW); +} + +function assignDevice( + db: InstanceType, + input: { scopeId: string; deviceId: string; actorId: string; displayName?: string }, +): void { + db.prepare( + `INSERT INTO sync_peers(peer_device_id, name, actor_id, addresses_json, created_at) + VALUES (?, ?, ?, '["private-address"]', ?)`, + ).run(input.deviceId, input.displayName ?? input.deviceId, input.actorId, NOW); + db.prepare( + `INSERT INTO scope_memberships( + scope_id, device_id, role, status, membership_epoch, updated_at + ) VALUES (?, ?, 'member', 'active', 1, ?)`, + ).run(input.scopeId, input.deviceId, NOW); +} + +function insertLinkedOperation( + db: InstanceType, + input: { + operationId: string; + projectId: string; + displayName: string; + recipientActorId: string; + digestOverride?: string; + inviterActorId?: string; + accepted?: boolean; + }, +): void { + const projects = [ + { + canonicalIdentity: input.projectId, + displayName: input.displayName, + identitySource: "git_remote", + existingMemoryCount: 1, + }, + ]; + const reviewedDigest = input.digestOverride ?? shareProjectSetDigest(projects); + db.prepare( + `INSERT INTO share_operations( + operation_id, state, inviter_actor_id, inviter_device_ids_json, person_id, + person_kind, teammate_name, history_policy, reviewed_project_set_digest, + coordinator_group_id, invite_token_digest, invite_expires_at, + recipient_actor_id, recipient_device_id, acceptance_consumed_at, created_at, updated_at + ) VALUES (?, 'active', ?, ?, ?, 'existing', ?, 'existing_and_future', ?, + 'coordinator-group-only', ?, '2099-01-01T00:00:00.000Z', ?, ?, ?, ?, ?)`, + ).run( + input.operationId, + input.inviterActorId ?? LOCAL_ACTOR_ID, + JSON.stringify([LOCAL_DEVICE_ID]), + input.recipientActorId, + input.recipientActorId, + reviewedDigest, + `invite-${input.operationId}`, + input.recipientActorId, + `device-${input.recipientActorId}`, + input.accepted === false ? null : NOW, + NOW, + NOW, + ); + db.prepare( + `INSERT INTO share_operation_projects( + operation_id, canonical_project_identity, display_name, identity_source, + existing_memory_count, ordinal + ) VALUES (?, ?, ?, 'git_remote', 1, 0)`, + ).run(input.operationId, input.projectId, input.displayName); +} + +function protectedSnapshot(db: InstanceType): string { + const tables = [ + "replication_scopes", + "project_scope_mappings", + "scope_memberships", + "memory_items", + "replication_ops", + "replication_cursors", + "sync_peers", + "share_operations", + "share_operation_projects", + ]; + return JSON.stringify( + Object.fromEntries( + tables.map((table) => [table, db.prepare(`SELECT * FROM ${table} ORDER BY rowid`).all()]), + ), + ); +} + +describe("recipient policy intent migration", () => { + let db: InstanceType; + + beforeEach(() => { + db = new Database(":memory:"); + initTestSchema(db); + insertActor(db, LOCAL_ACTOR_ID, "Personal", true); + db.prepare( + `INSERT INTO sync_device(device_id, public_key, fingerprint, created_at) + VALUES (?, 'local-key', 'transport-fingerprint', ?)`, + ).run(LOCAL_DEVICE_ID, NOW); + }); + + afterEach(() => db.close()); + + function exactFixture(input?: { + projectId?: string; + displayName?: string; + recipientActorId?: string; + digestOverride?: string; + }): { projectId: string; recipientActorId: string; deviceId: string } { + const projectId = input?.projectId ?? "https://git.example.invalid/acme/api.git"; + const displayName = input?.displayName ?? "api"; + const recipientActorId = input?.recipientActorId ?? "identity-work"; + const scopeId = `scope-${recipientActorId}`; + const deviceId = `device-${recipientActorId}`; + if (!db.prepare("SELECT 1 FROM actors WHERE actor_id = ?").get(recipientActorId)) { + insertActor( + db, + recipientActorId, + recipientActorId === "identity-work" ? "Work" : "Recipient", + ); + } + insertProject(db, { projectId, displayName, scopeId }); + insertScope(db, { scopeId, projectId }); + assignDevice(db, { scopeId, deviceId, actorId: recipientActorId, displayName: "Work laptop" }); + insertLinkedOperation(db, { + operationId: `operation-${scopeId}`, + projectId, + displayName, + recipientActorId, + digestOverride: input?.digestOverride, + }); + return { projectId, recipientActorId, deviceId }; + } + + function reviewDeviceFixture(input: { + projectId: string; + displayName: string; + unassignedDeviceId: string; + recipientActorId?: string; + }): { projectId: string; recipientActorId: string; unassignedDeviceId: string } { + const recipientActorId = input.recipientActorId ?? "identity-review-recipient"; + const scopeId = `scope-${recipientActorId}`; + if (!db.prepare("SELECT 1 FROM actors WHERE actor_id = ?").get(recipientActorId)) { + insertActor(db, recipientActorId, "Review recipient"); + } + insertProject(db, { + projectId: input.projectId, + displayName: input.displayName, + scopeId, + }); + insertScope(db, { scopeId, projectId: input.projectId }); + assignDevice(db, { + scopeId, + deviceId: `device-${recipientActorId}`, + actorId: recipientActorId, + displayName: "Assigned laptop", + }); + if ( + !db.prepare("SELECT 1 FROM sync_peers WHERE peer_device_id = ?").get(input.unassignedDeviceId) + ) { + db.prepare( + `INSERT INTO sync_peers(peer_device_id, name, actor_id, created_at) + VALUES (?, 'Unassigned laptop', NULL, ?)`, + ).run(input.unassignedDeviceId, NOW); + } + db.prepare( + `INSERT INTO scope_memberships(scope_id, device_id, status, membership_epoch, updated_at) + VALUES (?, ?, 'active', 1, ?)`, + ).run(scopeId, input.unassignedDeviceId, NOW); + return { + projectId: input.projectId, + recipientActorId, + unassignedDeviceId: input.unassignedDeviceId, + }; + } + + it("revalidates exact operation digests, writes direct intent, and replays idempotently", () => { + const fixture = exactFixture(); + const protectedBefore = protectedSnapshot(db); + const actorsBefore = JSON.stringify(db.prepare("SELECT * FROM actors ORDER BY actor_id").all()); + + const first = migrateRecipientPolicyIntent(db, context); + const second = migrateRecipientPolicyIntent(db, context); + const intent = listRecipientPolicyIntent(db); + + expect(first.results).toContainEqual( + expect.objectContaining({ + canonicalProjectIdentity: fixture.projectId, + status: "migrated", + }), + ); + expect(second.results).toContainEqual( + expect.objectContaining({ status: "unchanged", idempotent: true, writeCount: 0 }), + ); + expect(intent.projectRecipients).toContainEqual( + expect.objectContaining({ + canonicalProjectIdentity: fixture.projectId, + recipientKind: "identity", + identityId: fixture.recipientActorId, + }), + ); + expect(intent.identityDevices).toContainEqual( + expect.objectContaining({ + deviceId: fixture.deviceId, + identityId: fixture.recipientActorId, + }), + ); + expect(protectedSnapshot(db)).toBe(protectedBefore); + expect(JSON.stringify(db.prepare("SELECT * FROM actors ORDER BY actor_id").all())).toBe( + actorsBefore, + ); + }); + + it("applies fingerprint-bound attach-device intent without automatic operation evidence", () => { + const fixture = reviewDeviceFixture({ + projectId: "https://git.example.invalid/acme/attach-device.git", + displayName: "attach-device", + unassignedDeviceId: "device-unassigned", + }); + const protectedBefore = protectedSnapshot(db); + const item = listRecipientPolicyReview(db, context).reviewItems.find((candidate) => + candidate.options.some((option) => option.decision === "attach_device_to_identity"), + ); + if (!item) throw new Error("attach-device review item missing"); + resolveRecipientPolicyReview(db, context, { + reviewItemId: item.reviewItemId, + sourceFingerprint: item.sourceFingerprint, + decision: "attach_device_to_identity", + decisionInput: { + deviceId: fixture.unassignedDeviceId, + identityId: fixture.recipientActorId, + }, + }); + + const first = migrateRecipientPolicyIntent(db, context); + const retry = migrateRecipientPolicyIntent(db, context); + const intent = listRecipientPolicyIntent(db); + const recipientMetadata = db + .prepare( + `SELECT provenance, source_fingerprint FROM project_recipients + WHERE canonical_project_identity = ? AND recipient_kind = 'identity' AND recipient_id = ?`, + ) + .get(fixture.projectId, fixture.recipientActorId); + + expect(db.prepare("SELECT COUNT(*) FROM share_operations").pluck().get()).toBe(0); + expect(first.results).toContainEqual( + expect.objectContaining({ + canonicalProjectIdentity: fixture.projectId, + status: "migrated", + }), + ); + expect(retry.results).toContainEqual( + expect.objectContaining({ + canonicalProjectIdentity: fixture.projectId, + status: "unchanged", + writeCount: 0, + idempotent: true, + }), + ); + expect(intent.identityDevices).toContainEqual( + expect.objectContaining({ + deviceId: fixture.unassignedDeviceId, + identityId: fixture.recipientActorId, + }), + ); + expect(intent.projectRecipients).toContainEqual( + expect.objectContaining({ + canonicalProjectIdentity: fixture.projectId, + identityId: fixture.recipientActorId, + }), + ); + expect(recipientMetadata).toEqual({ + provenance: "review_resolution", + source_fingerprint: item.sourceFingerprint, + }); + expect(protectedSnapshot(db)).toBe(protectedBefore); + }); + + it("blocks a digest mismatch without a partial graph write", () => { + const fixture = exactFixture({ digestOverride: "not-the-reviewed-digest" }); + + const result = migrateRecipientPolicyIntent(db, context); + + expect(result.results).toContainEqual( + expect.objectContaining({ + canonicalProjectIdentity: fixture.projectId, + status: "blocked", + errorCode: "reviewed_project_set_digest_mismatch", + }), + ); + expect(db.prepare("SELECT COUNT(*) FROM project_recipients").pluck().get()).toBe(0); + expect(db.prepare("SELECT COUNT(*) FROM identity_devices").pluck().get()).toBe(0); + }); + + it("ignores non-local and unaccepted operations when applying valid exact-project evidence", () => { + const fixture = exactFixture(); + for (const operation of [ + { operationId: "operation-non-local", inviterActorId: "identity-other" }, + { operationId: "operation-unaccepted", accepted: false }, + ]) { + insertLinkedOperation(db, { + ...operation, + projectId: fixture.projectId, + displayName: "api", + recipientActorId: fixture.recipientActorId, + digestOverride: "invalid-ignored-digest", + }); + } + + const result = migrateRecipientPolicyIntent(db, context); + + expect(result.results).toContainEqual( + expect.objectContaining({ + canonicalProjectIdentity: fixture.projectId, + status: "migrated", + errorCode: null, + }), + ); + expect(listRecipientPolicyIntent(db).projectRecipients).toContainEqual( + expect.objectContaining({ + canonicalProjectIdentity: fixture.projectId, + identityId: fixture.recipientActorId, + }), + ); + }); + + it("performs no writes in dry-run mode", () => { + exactFixture(); + const before = protectedSnapshot(db); + + const result = migrateRecipientPolicyIntent(db, context, { dryRun: true }); + + expect(result.dryRun).toBe(true); + expect(result.results).toContainEqual( + expect.objectContaining({ status: "would_migrate", writeCount: 0, idempotent: false }), + ); + expect(db.prepare("SELECT COUNT(*) FROM project_recipients").pluck().get()).toBe(0); + expect(db.prepare("SELECT COUNT(*) FROM identity_devices").pluck().get()).toBe(0); + expect(protectedSnapshot(db)).toBe(before); + }); + + it("fails closed when one device is already assigned to another Identity", () => { + const fixture = exactFixture(); + const metadata = { + revision: "existing-revision", + idempotency: "existing-idempotency", + }; + db.prepare( + `INSERT INTO identity_devices( + device_id, identity_id, display_name, status, provenance, revision, + migration_state, source_fingerprint, idempotency_key, created_at, updated_at + ) VALUES (?, ?, 'Conflicting device', 'active', 'user', ?, 'projected', NULL, ?, ?, ?)`, + ).run(fixture.deviceId, LOCAL_ACTOR_ID, metadata.revision, metadata.idempotency, NOW, NOW); + + const result = migrateRecipientPolicyIntent(db, context); + + expect(result.results).toContainEqual( + expect.objectContaining({ status: "blocked", errorCode: "device_identity_conflict" }), + ); + expect(db.prepare("SELECT COUNT(*) FROM project_recipients").pluck().get()).toBe(0); + }); + + it("keeps Personal and Work actor IDs and same-name canonical Projects isolated", () => { + const personalProject = exactFixture({ + projectId: "https://git.example.invalid/personal/api.git", + displayName: "api", + recipientActorId: LOCAL_ACTOR_ID, + }); + const workProject = exactFixture({ + projectId: "https://git.example.invalid/work/api.git", + displayName: "api", + recipientActorId: "identity-work", + }); + + migrateRecipientPolicyIntent(db, context); + const recipients = listRecipientPolicyIntent(db).projectRecipients; + + expect(recipients).toContainEqual( + expect.objectContaining({ + canonicalProjectIdentity: personalProject.projectId, + identityId: LOCAL_ACTOR_ID, + }), + ); + expect(recipients).toContainEqual( + expect.objectContaining({ + canonicalProjectIdentity: workProject.projectId, + identityId: "identity-work", + }), + ); + expect(recipients).not.toContainEqual( + expect.objectContaining({ + canonicalProjectIdentity: personalProject.projectId, + identityId: "identity-work", + }), + ); + }); + + it("requires a current review resolution and applies a local Identity recommendation", () => { + const projectId = "https://git.example.invalid/personal/notes.git"; + insertProject(db, { projectId, displayName: "notes" }); + + const missing = migrateRecipientPolicyIntent(db, context); + expect(missing.results).toContainEqual( + expect.objectContaining({ status: "skipped", errorCode: "review_resolution_missing" }), + ); + const item = listRecipientPolicyReview(db, context).reviewItems[0]; + if (!item) throw new Error("review item missing"); + resolveRecipientPolicyReview(db, context, { + reviewItemId: item.reviewItemId, + sourceFingerprint: item.sourceFingerprint, + decision: "apply_recommendation", + }); + + const result = migrateRecipientPolicyIntent(db, context); + + expect(result.results).toContainEqual(expect.objectContaining({ status: "migrated" })); + expect(listRecipientPolicyIntent(db).projectRecipients).toContainEqual( + expect.objectContaining({ + canonicalProjectIdentity: projectId, + identityId: LOCAL_ACTOR_ID, + }), + ); + }); + + it("applies recipient choices against the exact device-scoped review preview", () => { + const projectId = "https://git.example.invalid/acme/scoped-review.git"; + const scopeId = "scope-scoped-review"; + insertActor(db, "identity-assigned", "Assigned recipient"); + insertProject(db, { projectId, displayName: "scoped-review", scopeId }); + insertScope(db, { scopeId, projectId }); + assignDevice(db, { + scopeId, + deviceId: "device-assigned", + actorId: "identity-assigned", + }); + db.prepare( + `INSERT INTO sync_peers(peer_device_id, name, actor_id, created_at) + VALUES ('device-unassigned', 'Unassigned laptop', NULL, ?)`, + ).run(NOW); + db.prepare( + `INSERT INTO scope_memberships(scope_id, device_id, status, membership_epoch, updated_at) + VALUES (?, 'device-unassigned', 'active', 1, ?)`, + ).run(scopeId, NOW); + const item = listRecipientPolicyReview(db, context).reviewItems.find((candidate) => + candidate.options.some( + (option) => + option.decision === "choose_recipients" && + option.preview.effectiveDevices.some((device) => device.deviceId === "device-unassigned"), + ), + ); + if (!item) throw new Error("device-scoped review item missing"); + resolveRecipientPolicyReview(db, context, { + reviewItemId: item.reviewItemId, + sourceFingerprint: item.sourceFingerprint, + decision: "choose_recipients", + decisionInput: { recipientIds: ["identity-assigned"] }, + }); + + const result = migrateRecipientPolicyIntent(db, context); + + expect(result.results).toContainEqual( + expect.objectContaining({ + canonicalProjectIdentity: projectId, + status: "migrated", + errorCode: null, + }), + ); + expect(listRecipientPolicyIntent(db).projectRecipients).toContainEqual( + expect.objectContaining({ + canonicalProjectIdentity: projectId, + identityId: "identity-assigned", + }), + ); + }); + + it("keeps reviewed preserve-current Projects on legacy enforcement", () => { + const projectId = "https://git.example.invalid/acme/preserve-current.git"; + const scopeId = "scope-preserve-current"; + insertActor(db, "identity-assigned", "Assigned recipient"); + insertProject(db, { projectId, displayName: "preserve-current", scopeId }); + insertScope(db, { scopeId, projectId }); + assignDevice(db, { + scopeId, + deviceId: "device-assigned", + actorId: "identity-assigned", + }); + db.prepare( + `INSERT INTO sync_peers(peer_device_id, name, actor_id, created_at) + VALUES ('device-unassigned', 'Unassigned laptop', NULL, ?)`, + ).run(NOW); + db.prepare( + `INSERT INTO scope_memberships(scope_id, device_id, status, membership_epoch, updated_at) + VALUES (?, 'device-unassigned', 'active', 1, ?)`, + ).run(scopeId, NOW); + const item = listRecipientPolicyReview(db, context).reviewItems.find((candidate) => + candidate.options.some( + (option) => + option.decision === "preserve_current_access" && + option.preview.effectiveDevices.some((device) => device.deviceId === "device-unassigned"), + ), + ); + if (!item) throw new Error("device-scoped preserve-current review item missing"); + resolveRecipientPolicyReview(db, context, { + reviewItemId: item.reviewItemId, + sourceFingerprint: item.sourceFingerprint, + decision: "preserve_current_access", + }); + + const result = migrateRecipientPolicyIntent(db, context); + + expect(result.results).toContainEqual( + expect.objectContaining({ + canonicalProjectIdentity: projectId, + status: "skipped", + writeCount: 0, + idempotent: true, + errorCode: "review_preserves_legacy_access", + }), + ); + expect(db.prepare("SELECT COUNT(*) FROM project_recipients").pluck().get()).toBe(0); + expect(db.prepare("SELECT COUNT(*) FROM identity_devices").pluck().get()).toBe(0); + }); + + it("lets preserve-current dominate automatic evidence and sibling review choices", () => { + const fixture = exactFixture({ digestOverride: "stale-automatic-evidence" }); + const scopeId = `scope-${fixture.recipientActorId}`; + for (const deviceId of ["device-unassigned-a", "device-unassigned-z"]) { + db.prepare( + `INSERT INTO sync_peers(peer_device_id, name, actor_id, created_at) + VALUES (?, ?, NULL, ?)`, + ).run(deviceId, deviceId, NOW); + db.prepare( + `INSERT INTO scope_memberships(scope_id, device_id, status, membership_epoch, updated_at) + VALUES (?, ?, 'active', 1, ?)`, + ).run(scopeId, deviceId, NOW); + } + const items = listRecipientPolicyReview(db, context).reviewItems; + const itemFor = (deviceId: string) => + items.find((item) => + item.options.some((option) => + option.preview.effectiveDevices.some((device) => device.deviceId === deviceId), + ), + ); + const chooseItem = itemFor("device-unassigned-a"); + const preserveItem = itemFor("device-unassigned-z"); + if (!chooseItem || !preserveItem) throw new Error("device-scoped review items missing"); + resolveRecipientPolicyReview(db, context, { + reviewItemId: chooseItem.reviewItemId, + sourceFingerprint: chooseItem.sourceFingerprint, + decision: "choose_recipients", + decisionInput: { recipientIds: [fixture.recipientActorId] }, + }); + resolveRecipientPolicyReview(db, context, { + reviewItemId: preserveItem.reviewItemId, + sourceFingerprint: preserveItem.sourceFingerprint, + decision: "preserve_current_access", + }); + + const result = migrateRecipientPolicyIntent(db, context); + + expect(result.results).toContainEqual( + expect.objectContaining({ + canonicalProjectIdentity: fixture.projectId, + status: "skipped", + errorCode: "review_preserves_legacy_access", + }), + ); + expect(db.prepare("SELECT COUNT(*) FROM project_recipients").pluck().get()).toBe(0); + expect(db.prepare("SELECT COUNT(*) FROM identity_devices").pluck().get()).toBe(0); + }); + + it("treats durable keep-current review outcomes as migration no-ops", () => { + insertProject(db, { + projectId: "https://git.example.invalid/personal/keep.git", + displayName: "keep", + }); + const item = listRecipientPolicyReview(db, context).reviewItems[0]; + if (!item) throw new Error("review item missing"); + resolveRecipientPolicyReview(db, context, { + reviewItemId: item.reviewItemId, + sourceFingerprint: item.sourceFingerprint, + decision: "keep_current_setup", + }); + + const result = migrateRecipientPolicyIntent(db, context); + + expect(result.results).toContainEqual( + expect.objectContaining({ status: "unchanged", writeCount: 0, idempotent: true }), + ); + expect(db.prepare("SELECT COUNT(*) FROM project_recipients").pluck().get()).toBe(0); + }); + + it("never writes diagnostic-only blocked Projects", () => { + const scopeId = "ambiguous-scope"; + for (const projectId of [ + "https://git.example.invalid/acme/blocked-one.git", + "https://git.example.invalid/acme/blocked-two.git", + ]) { + insertProject(db, { projectId, displayName: "blocked", scopeId }); + if (!db.prepare("SELECT 1 FROM replication_scopes WHERE scope_id = ?").get(scopeId)) { + insertScope(db, { + scopeId, + projectId, + kind: "team", + coordinatorId: "coordinator", + groupId: "group", + }); + } else { + db.prepare( + `INSERT INTO project_scope_mappings( + workspace_identity, project_pattern, scope_id, priority, source, created_at, updated_at + ) VALUES (?, ?, ?, 1000, 'test', ?, ?)`, + ).run(projectId, projectId, scopeId, NOW, NOW); + } + } + + const result = migrateRecipientPolicyIntent(db, context); + + expect(result.results.every((entry) => entry.status === "skipped")).toBe(true); + expect(db.prepare("SELECT COUNT(*) FROM policy_teams").pluck().get()).toBe(0); + expect(db.prepare("SELECT COUNT(*) FROM project_recipients").pluck().get()).toBe(0); + }); + + it("skips stale resolved review rows", () => { + insertProject(db, { + projectId: "https://git.example.invalid/personal/stale.git", + displayName: "stale", + }); + const item = listRecipientPolicyReview(db, context).reviewItems[0]; + if (!item) throw new Error("review item missing"); + resolveRecipientPolicyReview(db, context, { + reviewItemId: item.reviewItemId, + sourceFingerprint: item.sourceFingerprint, + decision: "apply_recommendation", + }); + db.prepare( + `INSERT INTO replication_scopes( + scope_id, label, kind, authority_type, membership_epoch, status, created_at, updated_at + ) VALUES ('local-default', 'Local only', 'system', 'local', 0, 'active', ?, ?)`, + ).run(NOW, NOW); + db.prepare( + `INSERT INTO actors(actor_id, display_name, is_local, status, created_at, updated_at) + VALUES ('identity-change', 'Changed', 0, 'active', ?, ?)`, + ).run(NOW, NOW); + db.prepare( + `INSERT INTO sync_peers(peer_device_id, name, actor_id, created_at) + VALUES ('device-change', 'Changed', 'identity-change', ?)`, + ).run(NOW); + db.prepare( + `INSERT INTO scope_memberships(scope_id, device_id, status, membership_epoch, updated_at) + VALUES ('local-default', 'device-change', 'active', 1, ?)`, + ).run(NOW); + + const result = migrateRecipientPolicyIntent(db, context); + + expect(result.results).toContainEqual( + expect.objectContaining({ status: "skipped", errorCode: "review_resolution_stale" }), + ); + expect(db.prepare("SELECT COUNT(*) FROM project_recipients").pluck().get()).toBe(0); + }); + + it("reuses one created Identity for the same unassigned device across Projects", () => { + const firstFixture = reviewDeviceFixture({ + projectId: "https://git.example.invalid/acme/first.git", + displayName: "first", + unassignedDeviceId: "device-new-identity", + }); + const firstItem = listRecipientPolicyReview(db, context).reviewItems.find((candidate) => + candidate.options.some( + (option) => + option.decision === "create_identity" && + option.preview.projects.some( + (project) => project.canonicalIdentity === firstFixture.projectId, + ), + ), + ); + if (!firstItem) throw new Error("first create-identity review item missing"); + resolveRecipientPolicyReview(db, context, { + reviewItemId: firstItem.reviewItemId, + sourceFingerprint: firstItem.sourceFingerprint, + decision: "create_identity", + decisionInput: { deviceId: "device-new-identity", displayName: "Separate Identity" }, + }); + + const first = migrateRecipientPolicyIntent(db, context); + const firstDevice = listRecipientPolicyIntent(db).identityDevices.find( + (candidate) => candidate.deviceId === "device-new-identity", + ); + const actor = firstDevice + ? db + .prepare("SELECT display_name, is_local, status FROM actors WHERE actor_id = ?") + .get(firstDevice.identityId) + : null; + const firstRecipientMetadata = firstDevice + ? db + .prepare( + `SELECT provenance, source_fingerprint FROM project_recipients + WHERE canonical_project_identity = ? AND recipient_kind = 'identity' AND recipient_id = ?`, + ) + .get(firstFixture.projectId, firstDevice.identityId) + : null; + + expect(first.results).toContainEqual( + expect.objectContaining({ + canonicalProjectIdentity: firstFixture.projectId, + status: "migrated", + }), + ); + expect(firstDevice?.identityId).toMatch(/^policy-identity-v1:/u); + expect(actor).toEqual({ display_name: "Separate Identity", is_local: 0, status: "active" }); + expect(firstRecipientMetadata).toEqual({ + provenance: "review_resolution", + source_fingerprint: firstItem.sourceFingerprint, + }); + expect(listRecipientPolicyIntent(db).projectRecipients).toContainEqual( + expect.objectContaining({ + canonicalProjectIdentity: firstFixture.projectId, + identityId: firstDevice?.identityId, + }), + ); + + const secondFixture = reviewDeviceFixture({ + projectId: "https://git.example.invalid/acme/second.git", + displayName: "second", + unassignedDeviceId: "device-new-identity", + recipientActorId: "identity-second-project", + }); + const secondItem = listRecipientPolicyReview(db, context).reviewItems.find((candidate) => + candidate.options.some( + (option) => + option.decision === "create_identity" && + option.preview.projects.some( + (project) => project.canonicalIdentity === secondFixture.projectId, + ), + ), + ); + if (!secondItem) throw new Error("second create-identity review item missing"); + resolveRecipientPolicyReview(db, context, { + reviewItemId: secondItem.reviewItemId, + sourceFingerprint: secondItem.sourceFingerprint, + decision: "create_identity", + decisionInput: { deviceId: "device-new-identity", displayName: "Other Project Name" }, + }); + + const second = migrateRecipientPolicyIntent(db, context); + const retry = migrateRecipientPolicyIntent(db, context); + const matchingDevices = listRecipientPolicyIntent(db).identityDevices.filter( + (candidate) => candidate.deviceId === "device-new-identity", + ); + const projectRecipients = listRecipientPolicyIntent(db).projectRecipients; + + expect(db.prepare("SELECT COUNT(*) FROM share_operations").pluck().get()).toBe(0); + expect(second.results).toContainEqual( + expect.objectContaining({ + canonicalProjectIdentity: secondFixture.projectId, + status: "migrated", + errorCode: null, + }), + ); + expect(second.results).not.toContainEqual( + expect.objectContaining({ + canonicalProjectIdentity: secondFixture.projectId, + errorCode: "device_identity_conflict", + }), + ); + expect(matchingDevices).toHaveLength(1); + expect(matchingDevices[0]?.identityId).toBe(firstDevice?.identityId); + expect(projectRecipients).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + canonicalProjectIdentity: firstFixture.projectId, + identityId: firstDevice?.identityId, + }), + expect.objectContaining({ + canonicalProjectIdentity: secondFixture.projectId, + identityId: firstDevice?.identityId, + }), + ]), + ); + expect( + db + .prepare("SELECT COUNT(*) FROM actors WHERE actor_id LIKE 'policy-identity-v1:%'") + .pluck() + .get(), + ).toBe(1); + expect(retry.results).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + canonicalProjectIdentity: firstFixture.projectId, + status: "unchanged", + writeCount: 0, + idempotent: true, + }), + expect.objectContaining({ + canonicalProjectIdentity: secondFixture.projectId, + status: "unchanged", + writeCount: 0, + idempotent: true, + }), + ]), + ); + }); + + it("rolls back a created actor and device when its Project recipient conflicts", () => { + const fixture = reviewDeviceFixture({ + projectId: "https://git.example.invalid/acme/create-rollback.git", + displayName: "create-rollback", + unassignedDeviceId: "device-create-rollback", + }); + const item = listRecipientPolicyReview(db, context).reviewItems.find((candidate) => + candidate.options.some((option) => option.decision === "create_identity"), + ); + if (!item) throw new Error("create-identity review item missing"); + resolveRecipientPolicyReview(db, context, { + reviewItemId: item.reviewItemId, + sourceFingerprint: item.sourceFingerprint, + decision: "create_identity", + decisionInput: { + deviceId: fixture.unassignedDeviceId, + displayName: "Rollback Identity", + }, + }); + const first = migrateRecipientPolicyIntent(db, context); + const identityId = db + .prepare("SELECT identity_id FROM identity_devices WHERE device_id = ?") + .pluck() + .get(fixture.unassignedDeviceId) as string; + expect(first.results).toContainEqual(expect.objectContaining({ status: "migrated" })); + + db.prepare("DELETE FROM identity_devices WHERE device_id = ?").run(fixture.unassignedDeviceId); + db.prepare("DELETE FROM actors WHERE actor_id = ?").run(identityId); + db.prepare( + `UPDATE project_recipients SET status = 'revoked' + WHERE canonical_project_identity = ? AND recipient_kind = 'identity' AND recipient_id = ?`, + ).run(fixture.projectId, identityId); + + const retry = migrateRecipientPolicyIntent(db, context); + + expect(retry.results).toContainEqual( + expect.objectContaining({ + canonicalProjectIdentity: fixture.projectId, + status: "blocked", + writeCount: 0, + errorCode: "intent_conflict", + }), + ); + expect(db.prepare("SELECT 1 FROM actors WHERE actor_id = ?").get(identityId)).toBeUndefined(); + expect( + db + .prepare("SELECT 1 FROM identity_devices WHERE device_id = ?") + .get(fixture.unassignedDeviceId), + ).toBeUndefined(); + }); + + it("mints a policy Team distinct from a coordinator group and projects memberships", () => { + const projectId = "https://git.example.invalid/acme/team-docs.git"; + const scopeId = "legacy-team-scope"; + insertActor(db, "identity-member", "Member"); + insertProject(db, { projectId, displayName: "docs", scopeId }); + insertScope(db, { + scopeId, + projectId, + kind: "team", + label: "Docs Team", + coordinatorId: "coordinator-private", + groupId: "coordinator-group-private", + }); + assignDevice(db, { + scopeId, + deviceId: "device-member", + actorId: "identity-member", + }); + insertActor(db, "identity-second-member", "Second member"); + assignDevice(db, { + scopeId, + deviceId: "device-second-member", + actorId: "identity-second-member", + }); + const projection = listLegacyRecipientPolicyProjections(db, context)[0]; + const teamCandidate = projection?.teamCandidates[0]; + const item = listRecipientPolicyReview(db, context).reviewItems[0]; + if (!teamCandidate || !item) throw new Error("team review fixture incomplete"); + resolveRecipientPolicyReview(db, context, { + reviewItemId: item.reviewItemId, + sourceFingerprint: item.sourceFingerprint, + decision: "choose_recipients", + decisionInput: { recipientIds: [teamCandidate.teamCandidateId] }, + }); + const previewJson = db + .prepare( + `SELECT preview_json FROM recipient_policy_review_resolutions + WHERE review_item_id = ? AND source_fingerprint = ?`, + ) + .pluck() + .get(item.reviewItemId, item.sourceFingerprint) as string; + const preview = JSON.parse(previewJson) as { effectiveDevices: unknown[] }; + db.prepare( + `UPDATE recipient_policy_review_resolutions SET preview_json = ? + WHERE review_item_id = ? AND source_fingerprint = ?`, + ).run( + JSON.stringify({ ...preview, effectiveDevices: preview.effectiveDevices.slice(0, 1) }), + item.reviewItemId, + item.sourceFingerprint, + ); + const stalePreview = migrateRecipientPolicyIntent(db, context); + expect(stalePreview.results).toContainEqual( + expect.objectContaining({ status: "blocked", errorCode: "review_preview_stale" }), + ); + expect(db.prepare("SELECT COUNT(*) FROM policy_teams").pluck().get()).toBe(0); + db.prepare( + `UPDATE recipient_policy_review_resolutions SET preview_json = ? + WHERE review_item_id = ? AND source_fingerprint = ?`, + ).run(previewJson, item.reviewItemId, item.sourceFingerprint); + + migrateRecipientPolicyIntent(db, context); + const intent = listRecipientPolicyIntent(db); + const teamId = deterministicPolicyTeamId(teamCandidate.teamCandidateId); + + expect(teamId).not.toBe("coordinator-group-private"); + expect(intent.teams).toContainEqual( + expect.objectContaining({ teamId, displayName: "Docs Team" }), + ); + expect(intent.teamMemberships).toContainEqual( + expect.objectContaining({ teamId, identityId: "identity-member" }), + ); + expect(intent.teamMemberships).toContainEqual( + expect.objectContaining({ teamId, identityId: "identity-second-member" }), + ); + expect(intent.projectRecipients).toContainEqual( + expect.objectContaining({ + canonicalProjectIdentity: projectId, + recipientKind: "team", + teamId, + }), + ); + }); +}); diff --git a/packages/core/src/recipient-policy-migration.ts b/packages/core/src/recipient-policy-migration.ts new file mode 100644 index 00000000..2f9f2297 --- /dev/null +++ b/packages/core/src/recipient-policy-migration.ts @@ -0,0 +1,812 @@ +import { createHash } from "node:crypto"; +import type { Database } from "./db.js"; +import { + type LegacyRecipientPolicyProjectionV1, + listLegacyRecipientPolicyProjections, +} from "./legacy-recipient-policy-projection.js"; +import { RECIPIENT_POLICY_CONTRACT_VERSION } from "./recipient-policy-contract.js"; +import type { + RecipientPolicyActionableReviewItemV1, + RecipientPolicyReviewContext, +} from "./recipient-policy-review.js"; +import { deriveRecipientPolicyReviewState } from "./recipient-policy-review.js"; +import { shareProjectSetDigest } from "./share-operation.js"; + +export interface RecipientPolicyMigrationOptions { + dryRun?: boolean; +} + +export type RecipientPolicyMigrationProjectStatus = + | "migrated" + | "would_migrate" + | "unchanged" + | "skipped" + | "blocked"; + +export interface RecipientPolicyMigrationProjectResultV1 { + canonicalProjectIdentity: string; + status: RecipientPolicyMigrationProjectStatus; + writeCount: number; + idempotent: boolean; + errorCode: string | null; +} + +export interface RecipientPolicyMigrationResultV1 { + version: typeof RECIPIENT_POLICY_CONTRACT_VERSION; + dryRun: boolean; + results: RecipientPolicyMigrationProjectResultV1[]; +} + +interface StoredResolution { + review_item_id: string; + source_fingerprint: string; + decision: string; + decision_input_json: string; + preview_json: string; +} + +interface IntentRow { + table: "policy_teams" | "policy_team_memberships" | "identity_devices" | "project_recipients"; + key: Record; + values: Record; +} + +interface ActorRow { + actorId: string; + displayName: string; +} + +interface ProjectPlan { + rows: IntentRow[]; + actors: ActorRow[]; + hadApplicableEvidence: boolean; +} + +const VALID_LINKED_OPERATION_STATES = new Set([ + "accepted", + "provisioning", + "initial_sync", + "active", + "needs_attention", +]); + +const NO_OP_DECISIONS = new Set([ + "keep_current_setup", + "reject_suggestion", + "keep_project_local", + "keep_identities_separate", + "remove_stale_device", +]); + +function canonicalJson(value: unknown): string { + if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`; + if (value && typeof value === "object") { + return `{${Object.entries(value as Record) + .toSorted(([left], [right]) => left.localeCompare(right)) + .map(([key, child]) => `${JSON.stringify(key)}:${canonicalJson(child)}`) + .join(",")}}`; + } + return JSON.stringify(value) ?? "null"; +} + +function digest(prefix: string, value: unknown): string { + return `${prefix}:${createHash("sha256").update(canonicalJson(value)).digest("hex")}`; +} + +export function deterministicPolicyTeamId(teamCandidateId: string): string { + return digest("policy-team-v1", teamCandidateId); +} + +function relationshipMetadata( + kind: string, + identity: unknown, +): { + revision: string; + idempotencyKey: string; +} { + return { + revision: digest(`recipient-policy-${kind}-revision-v1`, identity), + idempotencyKey: digest(`recipient-policy-${kind}-idempotency-v1`, identity), + }; +} + +function baseValues(input: { + provenance: string; + revision: string; + idempotencyKey: string; + sourceFingerprint?: string | null; + now: string; +}): Record & { revision: string } { + return { + status: "active", + provenance: input.provenance, + migration_state: "projected", + source_fingerprint: input.sourceFingerprint ?? null, + idempotency_key: input.idempotencyKey, + created_at: input.now, + updated_at: input.now, + revision: input.revision, + }; +} + +function projectRecipientRow(input: { + projectId: string; + recipientKind: "identity" | "team"; + recipientId: string; + provenance: string; + sourceFingerprint?: string | null; + now: string; +}): IntentRow { + const identity = [input.projectId, input.recipientKind, input.recipientId]; + const metadata = relationshipMetadata("project-recipient", identity); + const values = baseValues({ + provenance: input.provenance, + revision: metadata.revision, + idempotencyKey: metadata.idempotencyKey, + sourceFingerprint: input.sourceFingerprint, + now: input.now, + }); + const { revision, ...withoutRevision } = values; + return { + table: "project_recipients", + key: { + canonical_project_identity: input.projectId, + recipient_kind: input.recipientKind, + recipient_id: input.recipientId, + }, + values: { ...withoutRevision, policy_revision: revision }, + }; +} + +function identityDeviceRow(input: { + deviceId: string; + identityId: string; + displayName: string; + provenance: string; + sourceFingerprint?: string | null; + now: string; +}): IntentRow { + const metadata = relationshipMetadata("identity-device", [input.deviceId, input.identityId]); + return { + table: "identity_devices", + key: { device_id: input.deviceId }, + values: { + identity_id: input.identityId, + display_name: input.displayName, + ...baseValues({ + provenance: input.provenance, + revision: metadata.revision, + idempotencyKey: metadata.idempotencyKey, + sourceFingerprint: input.sourceFingerprint, + now: input.now, + }), + }, + }; +} + +function teamRows(input: { + projectId: string; + teamCandidateId: string; + displayName: string; + members: string[]; + sourceFingerprint: string; + now: string; +}): IntentRow[] { + const teamId = deterministicPolicyTeamId(input.teamCandidateId); + const teamMetadata = relationshipMetadata("team", teamId); + const rows: IntentRow[] = [ + { + table: "policy_teams", + key: { team_id: teamId }, + values: { + display_name: input.displayName, + ...baseValues({ + provenance: "reviewed_team_candidate", + revision: teamMetadata.revision, + idempotencyKey: teamMetadata.idempotencyKey, + sourceFingerprint: input.sourceFingerprint, + now: input.now, + }), + }, + }, + ]; + for (const identityId of input.members.toSorted()) { + const metadata = relationshipMetadata("team-membership", [teamId, identityId]); + rows.push({ + table: "policy_team_memberships", + key: { team_id: teamId, identity_id: identityId }, + values: { + role: "member", + ...baseValues({ + provenance: "reviewed_team_candidate", + revision: metadata.revision, + idempotencyKey: metadata.idempotencyKey, + sourceFingerprint: input.sourceFingerprint, + now: input.now, + }), + }, + }); + } + rows.push( + projectRecipientRow({ + projectId: input.projectId, + recipientKind: "team", + recipientId: teamId, + provenance: "reviewed_team_candidate", + sourceFingerprint: input.sourceFingerprint, + now: input.now, + }), + ); + return rows; +} + +function projectIdForReviewItem(item: { + options: Array<{ preview: { projects: Array<{ canonicalIdentity: string }> } }>; +}): string | null { + return item.options[0]?.preview.projects[0]?.canonicalIdentity ?? null; +} + +function parseDecisionInput(json: string): Record | null { + try { + const value = JSON.parse(json) as unknown; + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : null; + } catch { + return null; + } +} + +function assignedIdentityIdsFromPreview(value: unknown): string[] | null { + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + const preview = value as Record; + if (!Array.isArray(preview.effectiveDevices)) return null; + const identityIds = new Set(); + for (const value of preview.effectiveDevices) { + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + const device = value as Record; + if (device.assignment === "assigned" && typeof device.identityId === "string") { + identityIds.add(device.identityId); + } + } + return [...identityIds].toSorted(); +} + +function addProjectedDevices( + plan: ProjectPlan, + projection: LegacyRecipientPolicyProjectionV1, + provenance: string, + sourceFingerprint: string | null, + now: string, +): void { + for (const device of projection.effectiveDevices) { + if (device.assignment !== "assigned" || !device.identityId) continue; + plan.rows.push( + identityDeviceRow({ + deviceId: device.deviceId, + identityId: device.identityId, + displayName: device.displayName, + provenance, + sourceFingerprint, + now, + }), + ); + } +} + +function addAutomaticOperationEvidence( + db: Database, + plan: ProjectPlan, + projection: LegacyRecipientPolicyProjectionV1, + localActorId: string, + now: string, +): string | null { + if (projection.enforcement.state !== "managed_exact_project") return null; + const operations = db + .prepare( + `SELECT o.operation_id, o.state, o.recipient_actor_id, o.recipient_device_id, + o.reviewed_project_set_digest + FROM share_operations o + JOIN share_operation_projects p ON p.operation_id = o.operation_id + WHERE p.canonical_project_identity = ? + AND o.inviter_actor_id = ? + AND o.recipient_actor_id IS NOT NULL + AND o.acceptance_consumed_at IS NOT NULL + AND TRIM(o.acceptance_consumed_at) <> '' + ORDER BY o.created_at, o.operation_id`, + ) + .all(projection.project.canonicalIdentity, localActorId) as Array<{ + operation_id: string; + state: string; + recipient_actor_id: string; + recipient_device_id: string | null; + reviewed_project_set_digest: string; + }>; + for (const operation of operations) { + if (!VALID_LINKED_OPERATION_STATES.has(operation.state)) continue; + const projects = db + .prepare( + `SELECT canonical_project_identity, display_name, identity_source, existing_memory_count + FROM share_operation_projects WHERE operation_id = ? + ORDER BY canonical_project_identity`, + ) + .all(operation.operation_id) + .map((row) => { + const value = row as Record; + return { + canonicalIdentity: String(value.canonical_project_identity ?? ""), + displayName: String(value.display_name ?? ""), + identitySource: String(value.identity_source ?? ""), + existingMemoryCount: Number(value.existing_memory_count ?? -1), + }; + }); + if ( + projects.length === 0 || + shareProjectSetDigest(projects) !== operation.reviewed_project_set_digest + ) { + return "reviewed_project_set_digest_mismatch"; + } + const validCandidate = projection.identityCandidates.some( + (candidate) => + candidate.identityId === operation.recipient_actor_id && + candidate.provenance.includes("exact_project_invite"), + ); + const actorExists = Boolean( + db + .prepare("SELECT 1 FROM actors WHERE actor_id = ? AND status IN ('active', 'pending')") + .get(operation.recipient_actor_id), + ); + const linkedDevice = operation.recipient_device_id + ? projection.effectiveDevices.find( + (device) => + device.deviceId === operation.recipient_device_id && + device.assignment === "assigned" && + device.identityId === operation.recipient_actor_id, + ) + : null; + if (!validCandidate || !actorExists || !linkedDevice) return "linked_identity_invalid"; + plan.hadApplicableEvidence = true; + plan.rows.push( + projectRecipientRow({ + projectId: projection.project.canonicalIdentity, + recipientKind: "identity", + recipientId: operation.recipient_actor_id, + provenance: "exact_project_invite", + now, + }), + ); + addProjectedDevices(plan, projection, "managed_exact_project", null, now); + } + return null; +} + +function addReviewDecision( + db: Database, + plan: ProjectPlan, + projection: LegacyRecipientPolicyProjectionV1, + currentItem: RecipientPolicyActionableReviewItemV1, + resolution: StoredResolution, + now: string, +): string | null { + plan.hadApplicableEvidence = true; + const currentOption = currentItem.options.find( + (option) => option.decision === resolution.decision, + ); + const reviewedPreview = parseDecisionInput(resolution.preview_json); + if ( + !currentOption || + !reviewedPreview || + canonicalJson(reviewedPreview) !== canonicalJson(currentOption.preview) + ) { + return "review_preview_stale"; + } + if (resolution.decision === "preserve_current_access") { + return "review_preserves_legacy_access"; + } + if (NO_OP_DECISIONS.has(resolution.decision)) return null; + const input = parseDecisionInput(resolution.decision_input_json); + if (!input) return "review_decision_input_invalid"; + if (resolution.decision === "apply_recommendation") { + const localCandidates = projection.identityCandidates.filter((candidate) => candidate.isLocal); + if (localCandidates.length !== 1) return "review_recommendation_invalid"; + plan.rows.push( + projectRecipientRow({ + projectId: projection.project.canonicalIdentity, + recipientKind: "identity", + recipientId: localCandidates[0]?.identityId ?? "", + provenance: "review_resolution", + sourceFingerprint: resolution.source_fingerprint, + now, + }), + ); + addProjectedDevices(plan, projection, "review_resolution", resolution.source_fingerprint, now); + return null; + } + if (resolution.decision === "choose_recipients") { + const recipientIds = Array.isArray(input.recipientIds) ? input.recipientIds : []; + if ( + recipientIds.length === 0 || + recipientIds.some((id) => typeof id !== "string") || + new Set(recipientIds).size !== recipientIds.length + ) { + return "review_decision_input_invalid"; + } + const identities = new Map( + projection.identityCandidates.map((candidate) => [candidate.identityId, candidate]), + ); + const teams = new Map( + projection.teamCandidates.map((candidate) => [candidate.teamCandidateId, candidate]), + ); + const reviewedAssignedMembers = assignedIdentityIdsFromPreview(currentOption.preview); + if (!reviewedAssignedMembers) return "review_preview_stale"; + for (const recipientId of recipientIds as string[]) { + if (identities.has(recipientId)) { + plan.rows.push( + projectRecipientRow({ + projectId: projection.project.canonicalIdentity, + recipientKind: "identity", + recipientId, + provenance: "review_resolution", + sourceFingerprint: resolution.source_fingerprint, + now, + }), + ); + continue; + } + const team = teams.get(recipientId); + if (!team) return "review_recipient_stale"; + plan.rows.push( + ...teamRows({ + projectId: projection.project.canonicalIdentity, + teamCandidateId: team.teamCandidateId, + displayName: team.displayName, + members: reviewedAssignedMembers, + sourceFingerprint: resolution.source_fingerprint, + now, + }), + ); + } + addProjectedDevices(plan, projection, "review_resolution", resolution.source_fingerprint, now); + return null; + } + if (resolution.decision === "attach_device_to_identity") { + const deviceId = typeof input.deviceId === "string" ? input.deviceId : ""; + const identityId = typeof input.identityId === "string" ? input.identityId : ""; + const device = projection.effectiveDevices.find( + (candidate) => candidate.deviceId === deviceId && candidate.assignment === "unassigned", + ); + if ( + !device || + !projection.identityCandidates.some((candidate) => candidate.identityId === identityId) + ) + return "review_decision_input_stale"; + plan.rows.push( + identityDeviceRow({ + deviceId, + identityId, + displayName: device.displayName, + provenance: "review_resolution", + sourceFingerprint: resolution.source_fingerprint, + now, + }), + projectRecipientRow({ + projectId: projection.project.canonicalIdentity, + recipientKind: "identity", + recipientId: identityId, + provenance: "review_resolution", + sourceFingerprint: resolution.source_fingerprint, + now, + }), + ); + return null; + } + if (resolution.decision === "create_identity") { + const deviceId = typeof input.deviceId === "string" ? input.deviceId : ""; + const displayName = typeof input.displayName === "string" ? input.displayName.trim() : ""; + const device = projection.effectiveDevices.find( + (candidate) => candidate.deviceId === deviceId && candidate.assignment === "unassigned", + ); + if (!device || !displayName || displayName.length > 80 || input.displayName !== displayName) + return "review_decision_input_stale"; + const existingIdentityId = db + .prepare("SELECT identity_id FROM identity_devices WHERE device_id = ?") + .pluck() + .get(deviceId) as string | undefined; + // Device assignment is global; Project/review inputs must not mint another Identity. + const actorId = existingIdentityId ?? digest("policy-identity-v1", { deviceId }); + if (!existingIdentityId) plan.actors.push({ actorId, displayName }); + plan.rows.push( + identityDeviceRow({ + deviceId, + identityId: actorId, + displayName: device.displayName, + provenance: "review_resolution", + sourceFingerprint: resolution.source_fingerprint, + now, + }), + projectRecipientRow({ + projectId: projection.project.canonicalIdentity, + recipientKind: "identity", + recipientId: actorId, + provenance: "review_resolution", + sourceFingerprint: resolution.source_fingerprint, + now, + }), + ); + return null; + } + return "review_decision_unsupported"; +} + +function rowWhere(key: Record): { clause: string; parameters: string[] } { + const entries = Object.entries(key); + return { + clause: entries.map(([column]) => `${column} = ?`).join(" AND "), + parameters: entries.map(([, value]) => value), + }; +} + +function validateOrWriteActor(db: Database, actor: ActorRow, now: string, write: boolean): boolean { + const existing = db + .prepare( + "SELECT display_name, is_local, status, merged_into_actor_id FROM actors WHERE actor_id = ?", + ) + .get(actor.actorId) as + | { + display_name: string; + is_local: number; + status: string; + merged_into_actor_id: string | null; + } + | undefined; + if (existing) { + if ( + existing.display_name !== actor.displayName || + existing.is_local !== 0 || + existing.status !== "active" || + existing.merged_into_actor_id !== null + ) { + throw new Error("identity_conflict"); + } + return false; + } + if (write) { + db.prepare( + `INSERT INTO actors(actor_id, display_name, is_local, status, merged_into_actor_id, created_at, updated_at) + VALUES (?, ?, 0, 'active', NULL, ?, ?)`, + ).run(actor.actorId, actor.displayName, now, now); + } + return true; +} + +function validateOrWriteRow(db: Database, row: IntentRow, write: boolean): boolean { + const where = rowWhere(row.key); + const existing = db + .prepare(`SELECT * FROM ${row.table} WHERE ${where.clause}`) + .get(...where.parameters) as Record | undefined; + if (existing) { + const relationshipColumns = + row.table === "identity_devices" + ? ["identity_id", "status", "revision"] + : row.table === "project_recipients" + ? ["status", "policy_revision"] + : row.table === "policy_team_memberships" + ? ["role", "status", "revision"] + : ["status", "revision"]; + if (relationshipColumns.some((column) => existing[column] !== row.values[column])) { + throw new Error( + row.table === "identity_devices" ? "device_identity_conflict" : "intent_conflict", + ); + } + return false; + } + const columns = [...Object.keys(row.key), ...Object.keys(row.values)]; + const values = [...Object.values(row.key), ...Object.values(row.values)]; + if (write) { + db.prepare( + `INSERT INTO ${row.table}(${columns.join(", ")}) VALUES (${columns.map(() => "?").join(", ")})`, + ).run(...values); + } + return true; +} + +function deduplicatePlan(plan: ProjectPlan): ProjectPlan { + const rows = new Map(); + for (const row of plan.rows) { + const key = `${row.table}:${canonicalJson(row.key)}`; + const existing = rows.get(key); + if (existing) { + const relationshipColumns = + row.table === "identity_devices" + ? ["identity_id", "status", "revision"] + : row.table === "project_recipients" + ? ["status", "policy_revision"] + : row.table === "policy_team_memberships" + ? ["role", "status", "revision"] + : ["status", "revision"]; + if (relationshipColumns.some((column) => existing.values[column] !== row.values[column])) { + throw new Error( + row.table === "identity_devices" ? "device_identity_conflict" : "intent_conflict", + ); + } + } + rows.set(key, existing ?? row); + } + const actors = new Map(); + for (const actor of plan.actors) { + const existing = actors.get(actor.actorId); + if (existing && existing.displayName !== actor.displayName) + throw new Error("identity_conflict"); + actors.set(actor.actorId, actor); + } + return { ...plan, rows: [...rows.values()], actors: [...actors.values()] }; +} + +function safeMigrationErrorCode(error: unknown): string { + const allowed = new Set([ + "reviewed_project_set_digest_mismatch", + "linked_identity_invalid", + "review_decision_input_invalid", + "review_recommendation_invalid", + "review_recipient_stale", + "review_decision_input_stale", + "review_decision_unsupported", + "review_preview_stale", + "identity_conflict", + "device_identity_conflict", + "intent_conflict", + ]); + const message = error instanceof Error ? error.message : ""; + if (allowed.has(message)) return message; + const code = + error && typeof error === "object" && "code" in error + ? String((error as { code?: unknown }).code ?? "") + : ""; + if (code === "SQLITE_BUSY") return "migration_busy"; + if (code.startsWith("SQLITE_CONSTRAINT")) return "intent_conflict"; + return "migration_failed"; +} + +export function migrateRecipientPolicyIntent( + db: Database, + context: RecipientPolicyReviewContext, + options: RecipientPolicyMigrationOptions = {}, +): RecipientPolicyMigrationResultV1 { + const dryRun = options.dryRun === true; + const now = (context.now ?? (() => new Date().toISOString()))(); + const projections = listLegacyRecipientPolicyProjections(db, context); + const reviewState = deriveRecipientPolicyReviewState(db, context, projections); + const resolutions = db + .prepare( + `SELECT review_item_id, source_fingerprint, decision, decision_input_json, preview_json + FROM recipient_policy_review_resolutions ORDER BY resolved_at, review_item_id`, + ) + .all() as StoredResolution[]; + const resolutionBySource = new Map( + resolutions.map((resolution) => [ + `${resolution.review_item_id}\u0000${resolution.source_fingerprint}`, + resolution, + ]), + ); + const currentItemsByProject = new Map(); + for (const item of reviewState.allReviewItems) { + const projectId = projectIdForReviewItem(item); + if (!projectId) continue; + const items = currentItemsByProject.get(projectId) ?? []; + items.push(item); + currentItemsByProject.set(projectId, items); + } + const results: RecipientPolicyMigrationProjectResultV1[] = []; + for (const projection of projections) { + const projectId = projection.project.canonicalIdentity; + const currentItems = currentItemsByProject.get(projectId) ?? []; + const matchingResolutions = currentItems.map((item) => + resolutionBySource.get(`${item.reviewItemId}\u0000${item.sourceFingerprint}`), + ); + if (matchingResolutions.some((resolution) => !resolution)) { + const hasStaleResolution = currentItems.some((item) => + resolutions.some( + (resolution) => + resolution.review_item_id === item.reviewItemId && + resolution.source_fingerprint !== item.sourceFingerprint, + ), + ); + results.push({ + canonicalProjectIdentity: projectId, + status: "skipped", + writeCount: 0, + idempotent: false, + errorCode: hasStaleResolution ? "review_resolution_stale" : "review_resolution_missing", + }); + continue; + } + try { + let plan: ProjectPlan = { rows: [], actors: [], hadApplicableEvidence: false }; + const preserveResolutions = matchingResolutions + .map((resolution, index) => ({ resolution, currentItem: currentItems[index] })) + .filter( + ( + entry, + ): entry is { + resolution: StoredResolution; + currentItem: RecipientPolicyActionableReviewItemV1; + } => + entry.resolution?.decision === "preserve_current_access" && entry.currentItem != null, + ); + for (const { resolution, currentItem } of preserveResolutions) { + if (!currentItem.options.some((option) => option.decision === resolution.decision)) { + throw new Error("review_decision_unsupported"); + } + const reviewError = addReviewDecision(db, plan, projection, currentItem, resolution, now); + if (reviewError !== "review_preserves_legacy_access") { + throw new Error(reviewError ?? "review_decision_unsupported"); + } + } + if (preserveResolutions.length > 0) { + results.push({ + canonicalProjectIdentity: projectId, + status: "skipped", + writeCount: 0, + idempotent: true, + errorCode: "review_preserves_legacy_access", + }); + continue; + } + const operationError = addAutomaticOperationEvidence( + db, + plan, + projection, + context.localActorId, + now, + ); + if (operationError) throw new Error(operationError); + for (const [index, resolution] of matchingResolutions.entries()) { + if (!resolution) continue; + const currentItem = currentItems[index]; + if (!currentItem?.options.some((option) => option.decision === resolution.decision)) { + throw new Error("review_decision_unsupported"); + } + const reviewError = addReviewDecision(db, plan, projection, currentItem, resolution, now); + if (reviewError) throw new Error(reviewError); + } + plan = deduplicatePlan(plan); + if (!plan.hadApplicableEvidence) { + results.push({ + canonicalProjectIdentity: projectId, + status: "skipped", + writeCount: 0, + idempotent: false, + errorCode: "migration_evidence_missing", + }); + continue; + } + let plannedWriteCount = 0; + const apply = (write: boolean) => { + for (const actor of plan.actors) { + if (validateOrWriteActor(db, actor, now, write)) plannedWriteCount += 1; + } + for (const row of plan.rows) { + if (validateOrWriteRow(db, row, write)) plannedWriteCount += 1; + } + }; + if (dryRun) apply(false); + else db.transaction(() => apply(true)).immediate(); + results.push({ + canonicalProjectIdentity: projectId, + status: plannedWriteCount === 0 ? "unchanged" : dryRun ? "would_migrate" : "migrated", + writeCount: dryRun ? 0 : plannedWriteCount, + idempotent: plannedWriteCount === 0, + errorCode: null, + }); + } catch (error) { + results.push({ + canonicalProjectIdentity: projectId, + status: "blocked", + writeCount: 0, + idempotent: false, + errorCode: safeMigrationErrorCode(error), + }); + } + } + return { version: RECIPIENT_POLICY_CONTRACT_VERSION, dryRun, results }; +} diff --git a/packages/core/src/recipient-policy-review.ts b/packages/core/src/recipient-policy-review.ts index 26640ff9..81c4c926 100644 --- a/packages/core/src/recipient-policy-review.ts +++ b/packages/core/src/recipient-policy-review.ts @@ -65,7 +65,7 @@ export interface RecipientPolicyReviewBulkResultV1 { results: RecipientPolicyReviewResolveResultV1[]; } -interface DerivedReviewState { +export interface RecipientPolicyDerivedReviewState { allReviewItems: RecipientPolicyActionableReviewItemV1[]; blockedItems: RecipientPolicyBlockedItemV1[]; } @@ -316,11 +316,11 @@ function blockedOwner(code: LegacyRecipientPolicyConditionCodeV1): { }; } -function deriveReviewState( +export function deriveRecipientPolicyReviewState( db: Database, context: RecipientPolicyReviewContext, projections = listLegacyRecipientPolicyProjections(db, context), -): DerivedReviewState { +): RecipientPolicyDerivedReviewState { const memoryCounts = memoryCountsByProject(db); const allReviewItems: RecipientPolicyActionableReviewItemV1[] = []; const blockedItems: RecipientPolicyBlockedItemV1[] = []; @@ -401,7 +401,7 @@ export function listRecipientPolicyReview( db: Database, context: RecipientPolicyReviewContext, ): RecipientPolicyReviewListV1 { - const state = deriveReviewState(db, context); + const state = deriveRecipientPolicyReviewState(db, context); return { version: RECIPIENT_POLICY_CONTRACT_VERSION, reviewItems: state.allReviewItems.filter((item) => !hasResolution(db, item)), @@ -532,7 +532,7 @@ function resolveInTransaction( return invalid(request, "request_invalid"); } const projections = listLegacyRecipientPolicyProjections(db, context); - const state = deriveReviewState(db, context, projections); + const state = deriveRecipientPolicyReviewState(db, context, projections); const item = state.allReviewItems.find( (candidate) => candidate.reviewItemId === request.reviewItemId, ); diff --git a/packages/core/src/schema.ts b/packages/core/src/schema.ts index 08ef686a..0edd431d 100644 --- a/packages/core/src/schema.ts +++ b/packages/core/src/schema.ts @@ -735,6 +735,104 @@ export type RecipientPolicyReviewResolution = typeof recipientPolicyReviewResolu export type NewRecipientPolicyReviewResolution = typeof recipientPolicyReviewResolutions.$inferInsert; +export const policyTeams = sqliteTable("policy_teams", { + team_id: text("team_id").primaryKey(), + display_name: text("display_name").notNull(), + status: text("status").notNull(), + provenance: text("provenance").notNull(), + revision: text("revision").notNull(), + migration_state: text("migration_state").notNull(), + source_fingerprint: text("source_fingerprint"), + idempotency_key: text("idempotency_key").notNull().unique(), + created_at: text("created_at").notNull(), + updated_at: text("updated_at").notNull(), +}); + +export type PolicyTeam = typeof policyTeams.$inferSelect; +export type NewPolicyTeam = typeof policyTeams.$inferInsert; + +export const policyTeamMemberships = sqliteTable( + "policy_team_memberships", + { + team_id: text("team_id").notNull(), + identity_id: text("identity_id").notNull(), + role: text("role").notNull(), + status: text("status").notNull(), + provenance: text("provenance").notNull(), + revision: text("revision").notNull(), + migration_state: text("migration_state").notNull(), + source_fingerprint: text("source_fingerprint"), + idempotency_key: text("idempotency_key").notNull().unique(), + created_at: text("created_at").notNull(), + updated_at: text("updated_at").notNull(), + }, + (table) => ({ + pk: primaryKey({ columns: [table.team_id, table.identity_id] }), + identityStatusIdx: index("idx_policy_team_memberships_identity_status").on( + table.identity_id, + table.status, + ), + }), +); + +export type PolicyTeamMembership = typeof policyTeamMemberships.$inferSelect; +export type NewPolicyTeamMembership = typeof policyTeamMemberships.$inferInsert; + +export const identityDevices = sqliteTable( + "identity_devices", + { + device_id: text("device_id").primaryKey(), + identity_id: text("identity_id").notNull(), + display_name: text("display_name").notNull(), + status: text("status").notNull(), + provenance: text("provenance").notNull(), + revision: text("revision").notNull(), + migration_state: text("migration_state").notNull(), + source_fingerprint: text("source_fingerprint"), + idempotency_key: text("idempotency_key").notNull().unique(), + created_at: text("created_at").notNull(), + updated_at: text("updated_at").notNull(), + }, + (table) => ({ + identityStatusIdx: index("idx_identity_devices_identity_status").on( + table.identity_id, + table.status, + ), + }), +); + +export type IdentityDevice = typeof identityDevices.$inferSelect; +export type NewIdentityDevice = typeof identityDevices.$inferInsert; + +export const projectRecipients = sqliteTable( + "project_recipients", + { + canonical_project_identity: text("canonical_project_identity").notNull(), + recipient_kind: text("recipient_kind").notNull(), + recipient_id: text("recipient_id").notNull(), + status: text("status").notNull(), + provenance: text("provenance").notNull(), + policy_revision: text("policy_revision").notNull(), + migration_state: text("migration_state").notNull(), + source_fingerprint: text("source_fingerprint"), + idempotency_key: text("idempotency_key").notNull().unique(), + created_at: text("created_at").notNull(), + updated_at: text("updated_at").notNull(), + }, + (table) => ({ + pk: primaryKey({ + columns: [table.canonical_project_identity, table.recipient_kind, table.recipient_id], + }), + projectStatusIdx: index("idx_project_recipients_project_status").on( + table.canonical_project_identity, + table.status, + ), + }), +); + +export type ProjectRecipient = typeof projectRecipients.$inferSelect; +export type NewProjectRecipient = typeof projectRecipients.$inferInsert; + export const schema = { sessions, replicationScopes, @@ -768,4 +866,8 @@ export const schema = { coordinatorGroupPreferences, actors, recipientPolicyReviewResolutions, + policyTeams, + policyTeamMemberships, + identityDevices, + projectRecipients, }; diff --git a/packages/core/src/test-schema.generated.ts b/packages/core/src/test-schema.generated.ts index 2adabd81..ebe06800 100644 --- a/packages/core/src/test-schema.generated.ts +++ b/packages/core/src/test-schema.generated.ts @@ -6,4 +6,4 @@ */ export const TEST_SCHEMA_BASE_DDL = - "CREATE TABLE IF NOT EXISTS `sessions` (\n\t`id` integer PRIMARY KEY NOT NULL,\n\t`started_at` text NOT NULL,\n\t`ended_at` text,\n\t`cwd` text,\n\t`project` text,\n\t`git_remote` text,\n\t`git_branch` text,\n\t`user` text,\n\t`tool_version` text,\n\t`metadata_json` text,\n\t`import_key` text\n);\n\nCREATE TABLE IF NOT EXISTS `replication_scopes` (\n\t`scope_id` text PRIMARY KEY NOT NULL,\n\t`label` text NOT NULL,\n\t`kind` text DEFAULT 'user' NOT NULL,\n\t`authority_type` text DEFAULT 'local' NOT NULL,\n\t`coordinator_id` text,\n\t`group_id` text,\n\t`manifest_issuer_device_id` text,\n\t`membership_epoch` integer DEFAULT 0 NOT NULL,\n\t`manifest_hash` text,\n\t`status` text DEFAULT 'active' NOT NULL,\n\t`created_at` text NOT NULL,\n\t`updated_at` text NOT NULL\n);\n\nCREATE INDEX IF NOT EXISTS `idx_replication_scopes_status` ON `replication_scopes` (`status`);\nCREATE INDEX IF NOT EXISTS `idx_replication_scopes_authority_group` ON `replication_scopes` (`coordinator_id`,`group_id`);\nCREATE TABLE IF NOT EXISTS `project_scope_mappings` (\n\t`id` integer PRIMARY KEY NOT NULL,\n\t`workspace_identity` text,\n\t`project_pattern` text NOT NULL,\n\t`scope_id` text NOT NULL,\n\t`priority` integer DEFAULT 0 NOT NULL,\n\t`source` text DEFAULT 'user' NOT NULL,\n\t`created_at` text NOT NULL,\n\t`updated_at` text NOT NULL\n);\n\nCREATE INDEX IF NOT EXISTS `idx_project_scope_mappings_workspace_priority` ON `project_scope_mappings` (`workspace_identity`,`priority`);\nCREATE INDEX IF NOT EXISTS `idx_project_scope_mappings_pattern_priority` ON `project_scope_mappings` (`project_pattern`,`priority`);\nCREATE INDEX IF NOT EXISTS `idx_project_scope_mappings_scope` ON `project_scope_mappings` (`scope_id`);\nCREATE TABLE IF NOT EXISTS `scope_memberships` (\n\t`scope_id` text NOT NULL,\n\t`device_id` text NOT NULL,\n\t`role` text DEFAULT 'member' NOT NULL,\n\t`status` text DEFAULT 'active' NOT NULL,\n\t`membership_epoch` integer DEFAULT 0 NOT NULL,\n\t`coordinator_id` text,\n\t`group_id` text,\n\t`manifest_issuer_device_id` text,\n\t`manifest_hash` text,\n\t`signed_manifest_json` text,\n\t`updated_at` text NOT NULL,\n\tPRIMARY KEY(`scope_id`, `device_id`)\n);\n\nCREATE INDEX IF NOT EXISTS `idx_scope_memberships_device_status` ON `scope_memberships` (`device_id`,`status`);\nCREATE INDEX IF NOT EXISTS `idx_scope_memberships_scope_status` ON `scope_memberships` (`scope_id`,`status`);\nCREATE INDEX IF NOT EXISTS `idx_scope_memberships_authority_group` ON `scope_memberships` (`coordinator_id`,`group_id`);\nCREATE TABLE IF NOT EXISTS `artifacts` (\n\t`id` integer PRIMARY KEY NOT NULL,\n\t`session_id` integer NOT NULL,\n\t`kind` text NOT NULL,\n\t`path` text,\n\t`content_text` text,\n\t`content_hash` text,\n\t`created_at` text NOT NULL,\n\t`metadata_json` text,\n\tFOREIGN KEY (`session_id`) REFERENCES `sessions`(`id`) ON UPDATE no action ON DELETE cascade\n);\n\nCREATE INDEX IF NOT EXISTS `idx_artifacts_session_kind` ON `artifacts` (`session_id`,`kind`);\nCREATE TABLE IF NOT EXISTS `memory_items` (\n\t`id` integer PRIMARY KEY NOT NULL,\n\t`session_id` integer NOT NULL,\n\t`kind` text NOT NULL,\n\t`title` text NOT NULL,\n\t`subtitle` text,\n\t`body_text` text NOT NULL,\n\t`confidence` real DEFAULT 0.5,\n\t`tags_text` text DEFAULT '',\n\t`active` integer DEFAULT 1,\n\t`created_at` text NOT NULL,\n\t`updated_at` text NOT NULL,\n\t`metadata_json` text,\n\t`actor_id` text,\n\t`actor_display_name` text,\n\t`visibility` text,\n\t`workspace_id` text,\n\t`workspace_kind` text,\n\t`origin_device_id` text,\n\t`origin_source` text,\n\t`trust_state` text,\n\t`facts` text,\n\t`narrative` text,\n\t`concepts` text,\n\t`files_read` text,\n\t`files_modified` text,\n\t`user_prompt_id` integer,\n\t`prompt_number` integer,\n\t`deleted_at` text,\n\t`rev` integer DEFAULT 0,\n\t`dedup_key` text,\n\t`import_key` text,\n\t`scope_id` text,\n\t`project` text,\n\tFOREIGN KEY (`session_id`) REFERENCES `sessions`(`id`) ON UPDATE no action ON DELETE cascade\n);\n\nCREATE INDEX IF NOT EXISTS `idx_memory_items_active_created` ON `memory_items` (`active`,`created_at`);\nCREATE INDEX IF NOT EXISTS `idx_memory_items_session` ON `memory_items` (`session_id`);\nCREATE INDEX IF NOT EXISTS `idx_memory_items_project` ON `memory_items` (`project`);\nCREATE INDEX IF NOT EXISTS `idx_memory_items_scope_visibility_created` ON `memory_items` (`scope_id`,`visibility`,`created_at`);\nCREATE INDEX IF NOT EXISTS `idx_memory_items_scope_backfill_pending` ON `memory_items` (`id`) WHERE scope_id IS NULL OR scope_id = '';\nCREATE INDEX IF NOT EXISTS `idx_memory_items_dedup_key_active_created` ON `memory_items` (`dedup_key`,`active`,`created_at`);\nCREATE UNIQUE INDEX IF NOT EXISTS `idx_memory_items_same_session_dedup_unique` ON `memory_items` (`session_id`,`kind`,`visibility`,`workspace_id`,`dedup_key`) WHERE active = 1 AND dedup_key IS NOT NULL;\nCREATE TABLE IF NOT EXISTS `memory_file_refs` (\n\t`memory_id` integer NOT NULL,\n\t`file_path` text NOT NULL,\n\t`relation` text NOT NULL,\n\tPRIMARY KEY(`memory_id`, `file_path`, `relation`),\n\tFOREIGN KEY (`memory_id`) REFERENCES `memory_items`(`id`) ON UPDATE no action ON DELETE cascade\n);\n\nCREATE INDEX IF NOT EXISTS `idx_memory_file_refs_path` ON `memory_file_refs` (`file_path`);\nCREATE TABLE IF NOT EXISTS `memory_concept_refs` (\n\t`memory_id` integer NOT NULL,\n\t`concept` text NOT NULL,\n\tPRIMARY KEY(`memory_id`, `concept`),\n\tFOREIGN KEY (`memory_id`) REFERENCES `memory_items`(`id`) ON UPDATE no action ON DELETE cascade\n);\n\nCREATE INDEX IF NOT EXISTS `idx_memory_concept_refs_concept` ON `memory_concept_refs` (`concept`);\nCREATE TABLE IF NOT EXISTS `usage_events` (\n\t`id` integer PRIMARY KEY NOT NULL,\n\t`session_id` integer,\n\t`event` text NOT NULL,\n\t`tokens_read` integer DEFAULT 0,\n\t`tokens_written` integer DEFAULT 0,\n\t`tokens_saved` integer DEFAULT 0,\n\t`created_at` text NOT NULL,\n\t`metadata_json` text,\n\tFOREIGN KEY (`session_id`) REFERENCES `sessions`(`id`) ON UPDATE no action ON DELETE set null\n);\n\nCREATE INDEX IF NOT EXISTS `idx_usage_events_event_created` ON `usage_events` (`event`,`created_at`);\nCREATE INDEX IF NOT EXISTS `idx_usage_events_session` ON `usage_events` (`session_id`);\nCREATE TABLE IF NOT EXISTS `raw_events` (\n\t`id` integer PRIMARY KEY NOT NULL,\n\t`source` text DEFAULT 'opencode' NOT NULL,\n\t`stream_id` text DEFAULT '' NOT NULL,\n\t`opencode_session_id` text NOT NULL,\n\t`event_id` text,\n\t`event_seq` integer NOT NULL,\n\t`event_type` text NOT NULL,\n\t`ts_wall_ms` integer,\n\t`ts_mono_ms` real,\n\t`payload_json` text NOT NULL,\n\t`created_at` text NOT NULL\n);\n\nCREATE UNIQUE INDEX IF NOT EXISTS `idx_raw_events_source_stream_seq` ON `raw_events` (`source`,`stream_id`,`event_seq`);\nCREATE UNIQUE INDEX IF NOT EXISTS `idx_raw_events_source_stream_event_id` ON `raw_events` (`source`,`stream_id`,`event_id`);\nCREATE INDEX IF NOT EXISTS `idx_raw_events_session_seq` ON `raw_events` (`opencode_session_id`,`event_seq`);\nCREATE INDEX IF NOT EXISTS `idx_raw_events_created` ON `raw_events` (`created_at`);\nCREATE TABLE IF NOT EXISTS `raw_event_sessions` (\n\t`source` text DEFAULT 'opencode' NOT NULL,\n\t`stream_id` text DEFAULT '' NOT NULL,\n\t`opencode_session_id` text NOT NULL,\n\t`cwd` text,\n\t`project` text,\n\t`started_at` text,\n\t`last_seen_ts_wall_ms` integer,\n\t`last_received_event_seq` integer DEFAULT -1 NOT NULL,\n\t`last_flushed_event_seq` integer DEFAULT -1 NOT NULL,\n\t`updated_at` text NOT NULL,\n\tPRIMARY KEY(`source`, `stream_id`)\n);\n\nCREATE TABLE IF NOT EXISTS `opencode_sessions` (\n\t`source` text DEFAULT 'opencode' NOT NULL,\n\t`stream_id` text DEFAULT '' NOT NULL,\n\t`opencode_session_id` text NOT NULL,\n\t`session_id` integer,\n\t`created_at` text NOT NULL,\n\tPRIMARY KEY(`source`, `stream_id`),\n\tFOREIGN KEY (`session_id`) REFERENCES `sessions`(`id`) ON UPDATE no action ON DELETE cascade\n);\n\nCREATE INDEX IF NOT EXISTS `idx_opencode_sessions_session` ON `opencode_sessions` (`session_id`);\nCREATE TABLE IF NOT EXISTS `raw_event_flush_batches` (\n\t`id` integer PRIMARY KEY NOT NULL,\n\t`source` text DEFAULT 'opencode' NOT NULL,\n\t`stream_id` text DEFAULT '' NOT NULL,\n\t`opencode_session_id` text NOT NULL,\n\t`start_event_seq` integer NOT NULL,\n\t`end_event_seq` integer NOT NULL,\n\t`extractor_version` text NOT NULL,\n\t`status` text NOT NULL,\n\t`error_message` text,\n\t`error_type` text,\n\t`observer_provider` text,\n\t`observer_model` text,\n\t`observer_runtime` text,\n\t`observer_auth_source` text,\n\t`observer_auth_type` text,\n\t`observer_error_code` text,\n\t`observer_error_message` text,\n\t`attempt_count` integer DEFAULT 0 NOT NULL,\n\t`created_at` text NOT NULL,\n\t`updated_at` text NOT NULL\n);\n\nCREATE UNIQUE INDEX IF NOT EXISTS `idx_flush_batches_source_stream_seq_ver` ON `raw_event_flush_batches` (`source`,`stream_id`,`start_event_seq`,`end_event_seq`,`extractor_version`);\nCREATE INDEX IF NOT EXISTS `idx_flush_batches_session_created` ON `raw_event_flush_batches` (`opencode_session_id`,`created_at`);\nCREATE INDEX IF NOT EXISTS `idx_flush_batches_status_updated` ON `raw_event_flush_batches` (`status`,`updated_at`);\nCREATE TABLE IF NOT EXISTS `user_prompts` (\n\t`id` integer PRIMARY KEY NOT NULL,\n\t`session_id` integer,\n\t`project` text,\n\t`prompt_text` text NOT NULL,\n\t`prompt_number` integer,\n\t`created_at` text NOT NULL,\n\t`created_at_epoch` integer NOT NULL,\n\t`metadata_json` text,\n\t`import_key` text,\n\tFOREIGN KEY (`session_id`) REFERENCES `sessions`(`id`) ON UPDATE no action ON DELETE cascade\n);\n\nCREATE INDEX IF NOT EXISTS `idx_user_prompts_session` ON `user_prompts` (`session_id`);\nCREATE INDEX IF NOT EXISTS `idx_user_prompts_project` ON `user_prompts` (`project`);\nCREATE INDEX IF NOT EXISTS `idx_user_prompts_epoch` ON `user_prompts` (`created_at_epoch`);\nCREATE TABLE IF NOT EXISTS `session_summaries` (\n\t`id` integer PRIMARY KEY NOT NULL,\n\t`session_id` integer,\n\t`project` text,\n\t`request` text,\n\t`investigated` text,\n\t`learned` text,\n\t`completed` text,\n\t`next_steps` text,\n\t`notes` text,\n\t`files_read` text,\n\t`files_edited` text,\n\t`prompt_number` integer,\n\t`created_at` text NOT NULL,\n\t`created_at_epoch` integer NOT NULL,\n\t`metadata_json` text,\n\t`import_key` text,\n\tFOREIGN KEY (`session_id`) REFERENCES `sessions`(`id`) ON UPDATE no action ON DELETE cascade\n);\n\nCREATE INDEX IF NOT EXISTS `idx_session_summaries_session` ON `session_summaries` (`session_id`);\nCREATE INDEX IF NOT EXISTS `idx_session_summaries_project` ON `session_summaries` (`project`);\nCREATE INDEX IF NOT EXISTS `idx_session_summaries_epoch` ON `session_summaries` (`created_at_epoch`);\nCREATE TABLE IF NOT EXISTS `replication_ops` (\n\t`op_id` text PRIMARY KEY NOT NULL,\n\t`entity_type` text NOT NULL,\n\t`entity_id` text NOT NULL,\n\t`op_type` text NOT NULL,\n\t`payload_json` text,\n\t`clock_rev` integer NOT NULL,\n\t`clock_updated_at` text NOT NULL,\n\t`clock_device_id` text NOT NULL,\n\t`device_id` text NOT NULL,\n\t`created_at` text NOT NULL,\n\t`scope_id` text\n);\n\nCREATE INDEX IF NOT EXISTS `idx_replication_ops_created` ON `replication_ops` (`created_at`,`op_id`);\nCREATE INDEX IF NOT EXISTS `idx_replication_ops_entity` ON `replication_ops` (`entity_type`,`entity_id`);\nCREATE INDEX IF NOT EXISTS `idx_replication_ops_scope_created` ON `replication_ops` (`scope_id`,`created_at`,`op_id`);\nCREATE TABLE IF NOT EXISTS `replication_cursors` (\n\t`peer_device_id` text PRIMARY KEY NOT NULL,\n\t`last_applied_cursor` text,\n\t`last_acked_cursor` text,\n\t`updated_at` text NOT NULL\n);\n\nCREATE TABLE IF NOT EXISTS `replication_cursors_v2` (\n\t`peer_device_id` text NOT NULL,\n\t`scope_id` text NOT NULL,\n\t`last_applied_cursor` text,\n\t`last_acked_cursor` text,\n\t`updated_at` text NOT NULL,\n\tPRIMARY KEY(`peer_device_id`, `scope_id`)\n);\n\nCREATE INDEX IF NOT EXISTS `idx_replication_cursors_v2_scope` ON `replication_cursors_v2` (`scope_id`);\nCREATE TABLE IF NOT EXISTS `sync_peers` (\n\t`peer_device_id` text PRIMARY KEY NOT NULL,\n\t`name` text,\n\t`pinned_fingerprint` text,\n\t`public_key` text,\n\t`addresses_json` text,\n\t`claimed_local_actor` integer DEFAULT 0 NOT NULL,\n\t`actor_id` text,\n\t`projects_include_json` text,\n\t`projects_exclude_json` text,\n\t`created_at` text NOT NULL,\n\t`last_seen_at` text,\n\t`last_sync_at` text,\n\t`last_error` text,\n\t`discovered_via_coordinator_id` text,\n\t`discovered_via_group_id` text,\n\t`pending_bootstrap_grant_id` text\n);\n\nCREATE TABLE IF NOT EXISTS `sync_nonces` (\n\t`nonce` text PRIMARY KEY NOT NULL,\n\t`device_id` text NOT NULL,\n\t`created_at` text NOT NULL\n);\n\nCREATE TABLE IF NOT EXISTS `sync_device` (\n\t`device_id` text PRIMARY KEY NOT NULL,\n\t`public_key` text NOT NULL,\n\t`fingerprint` text NOT NULL,\n\t`created_at` text NOT NULL\n);\n\nCREATE TABLE IF NOT EXISTS `sync_attempts` (\n\t`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,\n\t`peer_device_id` text NOT NULL,\n\t`started_at` text NOT NULL,\n\t`finished_at` text,\n\t`ok` integer NOT NULL,\n\t`ops_in` integer NOT NULL,\n\t`ops_out` integer NOT NULL,\n\t`error` text,\n\t`local_sync_capability` text,\n\t`peer_sync_capability` text,\n\t`negotiated_sync_capability` text\n);\n\nCREATE INDEX IF NOT EXISTS `idx_sync_attempts_peer_started` ON `sync_attempts` (`peer_device_id`,`started_at`);\nCREATE TABLE IF NOT EXISTS `sync_scope_rejections` (\n\t`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,\n\t`peer_device_id` text,\n\t`op_id` text NOT NULL,\n\t`entity_type` text NOT NULL,\n\t`entity_id` text NOT NULL,\n\t`scope_id` text,\n\t`reason` text NOT NULL,\n\t`created_at` text NOT NULL\n);\n\nCREATE INDEX IF NOT EXISTS `idx_sync_scope_rejections_peer_created` ON `sync_scope_rejections` (`peer_device_id`,`created_at`);\nCREATE INDEX IF NOT EXISTS `idx_sync_scope_rejections_scope_created` ON `sync_scope_rejections` (`scope_id`,`created_at`);\nCREATE TABLE IF NOT EXISTS `sync_daemon_state` (\n\t`id` integer PRIMARY KEY NOT NULL,\n\t`last_error` text,\n\t`last_traceback` text,\n\t`last_error_at` text,\n\t`last_ok_at` text,\n\t`phase` text\n);\n\nCREATE TABLE IF NOT EXISTS `sync_reset_state` (\n\t`id` integer PRIMARY KEY NOT NULL,\n\t`generation` integer NOT NULL,\n\t`snapshot_id` text NOT NULL,\n\t`baseline_cursor` text,\n\t`retained_floor_cursor` text,\n\t`updated_at` text NOT NULL\n);\n\nCREATE TABLE IF NOT EXISTS `sync_reset_state_v2` (\n\t`scope_id` text PRIMARY KEY NOT NULL,\n\t`generation` integer NOT NULL,\n\t`snapshot_id` text NOT NULL,\n\t`baseline_cursor` text,\n\t`retained_floor_cursor` text,\n\t`updated_at` text NOT NULL\n);\n\nCREATE TABLE IF NOT EXISTS `sync_retention_state_v2` (\n\t`scope_id` text PRIMARY KEY NOT NULL,\n\t`last_run_at` text,\n\t`last_duration_ms` integer,\n\t`last_deleted_ops` integer DEFAULT 0 NOT NULL,\n\t`last_estimated_bytes_before` integer,\n\t`last_estimated_bytes_after` integer,\n\t`retained_floor_cursor` text,\n\t`last_error` text,\n\t`last_error_at` text\n);\n\nCREATE TABLE IF NOT EXISTS `raw_event_ingest_samples` (\n\t`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,\n\t`created_at` text NOT NULL,\n\t`inserted_events` integer DEFAULT 0 NOT NULL,\n\t`skipped_invalid` integer DEFAULT 0 NOT NULL,\n\t`skipped_duplicate` integer DEFAULT 0 NOT NULL,\n\t`skipped_conflict` integer DEFAULT 0 NOT NULL\n);\n\nCREATE TABLE IF NOT EXISTS `raw_event_ingest_stats` (\n\t`id` integer PRIMARY KEY NOT NULL,\n\t`inserted_events` integer DEFAULT 0 NOT NULL,\n\t`skipped_events` integer DEFAULT 0 NOT NULL,\n\t`skipped_invalid` integer DEFAULT 0 NOT NULL,\n\t`skipped_duplicate` integer DEFAULT 0 NOT NULL,\n\t`skipped_conflict` integer DEFAULT 0 NOT NULL,\n\t`updated_at` text NOT NULL\n);\n\nCREATE TABLE IF NOT EXISTS `coordinator_group_preferences` (\n\t`coordinator_id` text NOT NULL,\n\t`group_id` text NOT NULL,\n\t`projects_include_json` text,\n\t`projects_exclude_json` text,\n\t`auto_seed_scope` integer DEFAULT 1 NOT NULL,\n\t`default_space_scope_id` text,\n\t`auto_grant_default_space_on_join` integer DEFAULT 0 NOT NULL,\n\t`updated_at` text NOT NULL,\n\tPRIMARY KEY(`coordinator_id`, `group_id`)\n);\n\nCREATE TABLE IF NOT EXISTS `actors` (\n\t`actor_id` text PRIMARY KEY NOT NULL,\n\t`display_name` text NOT NULL,\n\t`is_local` integer DEFAULT 0 NOT NULL,\n\t`status` text DEFAULT 'active' NOT NULL,\n\t`merged_into_actor_id` text,\n\t`created_at` text NOT NULL,\n\t`updated_at` text NOT NULL\n);\n\nCREATE INDEX IF NOT EXISTS `idx_actors_is_local` ON `actors` (`is_local`);\nCREATE INDEX IF NOT EXISTS `idx_actors_status` ON `actors` (`status`);\nCREATE TABLE IF NOT EXISTS `recipient_policy_review_resolutions` (\n\t`review_item_id` text NOT NULL,\n\t`source_fingerprint` text NOT NULL,\n\t`decision` text NOT NULL,\n\t`decision_input_json` text NOT NULL,\n\t`preview_json` text NOT NULL,\n\t`decided_by_identity_id` text NOT NULL,\n\t`decided_by_device_id` text NOT NULL,\n\t`resolved_at` text NOT NULL,\n\tPRIMARY KEY(`review_item_id`, `source_fingerprint`)\n);\n"; + "CREATE TABLE IF NOT EXISTS `sessions` (\n\t`id` integer PRIMARY KEY NOT NULL,\n\t`started_at` text NOT NULL,\n\t`ended_at` text,\n\t`cwd` text,\n\t`project` text,\n\t`git_remote` text,\n\t`git_branch` text,\n\t`user` text,\n\t`tool_version` text,\n\t`metadata_json` text,\n\t`import_key` text\n);\n\nCREATE TABLE IF NOT EXISTS `replication_scopes` (\n\t`scope_id` text PRIMARY KEY NOT NULL,\n\t`label` text NOT NULL,\n\t`kind` text DEFAULT 'user' NOT NULL,\n\t`authority_type` text DEFAULT 'local' NOT NULL,\n\t`coordinator_id` text,\n\t`group_id` text,\n\t`manifest_issuer_device_id` text,\n\t`membership_epoch` integer DEFAULT 0 NOT NULL,\n\t`manifest_hash` text,\n\t`status` text DEFAULT 'active' NOT NULL,\n\t`created_at` text NOT NULL,\n\t`updated_at` text NOT NULL\n);\n\nCREATE INDEX IF NOT EXISTS `idx_replication_scopes_status` ON `replication_scopes` (`status`);\nCREATE INDEX IF NOT EXISTS `idx_replication_scopes_authority_group` ON `replication_scopes` (`coordinator_id`,`group_id`);\nCREATE TABLE IF NOT EXISTS `project_scope_mappings` (\n\t`id` integer PRIMARY KEY NOT NULL,\n\t`workspace_identity` text,\n\t`project_pattern` text NOT NULL,\n\t`scope_id` text NOT NULL,\n\t`priority` integer DEFAULT 0 NOT NULL,\n\t`source` text DEFAULT 'user' NOT NULL,\n\t`created_at` text NOT NULL,\n\t`updated_at` text NOT NULL\n);\n\nCREATE INDEX IF NOT EXISTS `idx_project_scope_mappings_workspace_priority` ON `project_scope_mappings` (`workspace_identity`,`priority`);\nCREATE INDEX IF NOT EXISTS `idx_project_scope_mappings_pattern_priority` ON `project_scope_mappings` (`project_pattern`,`priority`);\nCREATE INDEX IF NOT EXISTS `idx_project_scope_mappings_scope` ON `project_scope_mappings` (`scope_id`);\nCREATE TABLE IF NOT EXISTS `scope_memberships` (\n\t`scope_id` text NOT NULL,\n\t`device_id` text NOT NULL,\n\t`role` text DEFAULT 'member' NOT NULL,\n\t`status` text DEFAULT 'active' NOT NULL,\n\t`membership_epoch` integer DEFAULT 0 NOT NULL,\n\t`coordinator_id` text,\n\t`group_id` text,\n\t`manifest_issuer_device_id` text,\n\t`manifest_hash` text,\n\t`signed_manifest_json` text,\n\t`updated_at` text NOT NULL,\n\tPRIMARY KEY(`scope_id`, `device_id`)\n);\n\nCREATE INDEX IF NOT EXISTS `idx_scope_memberships_device_status` ON `scope_memberships` (`device_id`,`status`);\nCREATE INDEX IF NOT EXISTS `idx_scope_memberships_scope_status` ON `scope_memberships` (`scope_id`,`status`);\nCREATE INDEX IF NOT EXISTS `idx_scope_memberships_authority_group` ON `scope_memberships` (`coordinator_id`,`group_id`);\nCREATE TABLE IF NOT EXISTS `artifacts` (\n\t`id` integer PRIMARY KEY NOT NULL,\n\t`session_id` integer NOT NULL,\n\t`kind` text NOT NULL,\n\t`path` text,\n\t`content_text` text,\n\t`content_hash` text,\n\t`created_at` text NOT NULL,\n\t`metadata_json` text,\n\tFOREIGN KEY (`session_id`) REFERENCES `sessions`(`id`) ON UPDATE no action ON DELETE cascade\n);\n\nCREATE INDEX IF NOT EXISTS `idx_artifacts_session_kind` ON `artifacts` (`session_id`,`kind`);\nCREATE TABLE IF NOT EXISTS `memory_items` (\n\t`id` integer PRIMARY KEY NOT NULL,\n\t`session_id` integer NOT NULL,\n\t`kind` text NOT NULL,\n\t`title` text NOT NULL,\n\t`subtitle` text,\n\t`body_text` text NOT NULL,\n\t`confidence` real DEFAULT 0.5,\n\t`tags_text` text DEFAULT '',\n\t`active` integer DEFAULT 1,\n\t`created_at` text NOT NULL,\n\t`updated_at` text NOT NULL,\n\t`metadata_json` text,\n\t`actor_id` text,\n\t`actor_display_name` text,\n\t`visibility` text,\n\t`workspace_id` text,\n\t`workspace_kind` text,\n\t`origin_device_id` text,\n\t`origin_source` text,\n\t`trust_state` text,\n\t`facts` text,\n\t`narrative` text,\n\t`concepts` text,\n\t`files_read` text,\n\t`files_modified` text,\n\t`user_prompt_id` integer,\n\t`prompt_number` integer,\n\t`deleted_at` text,\n\t`rev` integer DEFAULT 0,\n\t`dedup_key` text,\n\t`import_key` text,\n\t`scope_id` text,\n\t`project` text,\n\tFOREIGN KEY (`session_id`) REFERENCES `sessions`(`id`) ON UPDATE no action ON DELETE cascade\n);\n\nCREATE INDEX IF NOT EXISTS `idx_memory_items_active_created` ON `memory_items` (`active`,`created_at`);\nCREATE INDEX IF NOT EXISTS `idx_memory_items_session` ON `memory_items` (`session_id`);\nCREATE INDEX IF NOT EXISTS `idx_memory_items_project` ON `memory_items` (`project`);\nCREATE INDEX IF NOT EXISTS `idx_memory_items_scope_visibility_created` ON `memory_items` (`scope_id`,`visibility`,`created_at`);\nCREATE INDEX IF NOT EXISTS `idx_memory_items_scope_backfill_pending` ON `memory_items` (`id`) WHERE scope_id IS NULL OR scope_id = '';\nCREATE INDEX IF NOT EXISTS `idx_memory_items_dedup_key_active_created` ON `memory_items` (`dedup_key`,`active`,`created_at`);\nCREATE UNIQUE INDEX IF NOT EXISTS `idx_memory_items_same_session_dedup_unique` ON `memory_items` (`session_id`,`kind`,`visibility`,`workspace_id`,`dedup_key`) WHERE active = 1 AND dedup_key IS NOT NULL;\nCREATE TABLE IF NOT EXISTS `memory_file_refs` (\n\t`memory_id` integer NOT NULL,\n\t`file_path` text NOT NULL,\n\t`relation` text NOT NULL,\n\tPRIMARY KEY(`memory_id`, `file_path`, `relation`),\n\tFOREIGN KEY (`memory_id`) REFERENCES `memory_items`(`id`) ON UPDATE no action ON DELETE cascade\n);\n\nCREATE INDEX IF NOT EXISTS `idx_memory_file_refs_path` ON `memory_file_refs` (`file_path`);\nCREATE TABLE IF NOT EXISTS `memory_concept_refs` (\n\t`memory_id` integer NOT NULL,\n\t`concept` text NOT NULL,\n\tPRIMARY KEY(`memory_id`, `concept`),\n\tFOREIGN KEY (`memory_id`) REFERENCES `memory_items`(`id`) ON UPDATE no action ON DELETE cascade\n);\n\nCREATE INDEX IF NOT EXISTS `idx_memory_concept_refs_concept` ON `memory_concept_refs` (`concept`);\nCREATE TABLE IF NOT EXISTS `usage_events` (\n\t`id` integer PRIMARY KEY NOT NULL,\n\t`session_id` integer,\n\t`event` text NOT NULL,\n\t`tokens_read` integer DEFAULT 0,\n\t`tokens_written` integer DEFAULT 0,\n\t`tokens_saved` integer DEFAULT 0,\n\t`created_at` text NOT NULL,\n\t`metadata_json` text,\n\tFOREIGN KEY (`session_id`) REFERENCES `sessions`(`id`) ON UPDATE no action ON DELETE set null\n);\n\nCREATE INDEX IF NOT EXISTS `idx_usage_events_event_created` ON `usage_events` (`event`,`created_at`);\nCREATE INDEX IF NOT EXISTS `idx_usage_events_session` ON `usage_events` (`session_id`);\nCREATE TABLE IF NOT EXISTS `raw_events` (\n\t`id` integer PRIMARY KEY NOT NULL,\n\t`source` text DEFAULT 'opencode' NOT NULL,\n\t`stream_id` text DEFAULT '' NOT NULL,\n\t`opencode_session_id` text NOT NULL,\n\t`event_id` text,\n\t`event_seq` integer NOT NULL,\n\t`event_type` text NOT NULL,\n\t`ts_wall_ms` integer,\n\t`ts_mono_ms` real,\n\t`payload_json` text NOT NULL,\n\t`created_at` text NOT NULL\n);\n\nCREATE UNIQUE INDEX IF NOT EXISTS `idx_raw_events_source_stream_seq` ON `raw_events` (`source`,`stream_id`,`event_seq`);\nCREATE UNIQUE INDEX IF NOT EXISTS `idx_raw_events_source_stream_event_id` ON `raw_events` (`source`,`stream_id`,`event_id`);\nCREATE INDEX IF NOT EXISTS `idx_raw_events_session_seq` ON `raw_events` (`opencode_session_id`,`event_seq`);\nCREATE INDEX IF NOT EXISTS `idx_raw_events_created` ON `raw_events` (`created_at`);\nCREATE TABLE IF NOT EXISTS `raw_event_sessions` (\n\t`source` text DEFAULT 'opencode' NOT NULL,\n\t`stream_id` text DEFAULT '' NOT NULL,\n\t`opencode_session_id` text NOT NULL,\n\t`cwd` text,\n\t`project` text,\n\t`started_at` text,\n\t`last_seen_ts_wall_ms` integer,\n\t`last_received_event_seq` integer DEFAULT -1 NOT NULL,\n\t`last_flushed_event_seq` integer DEFAULT -1 NOT NULL,\n\t`updated_at` text NOT NULL,\n\tPRIMARY KEY(`source`, `stream_id`)\n);\n\nCREATE TABLE IF NOT EXISTS `opencode_sessions` (\n\t`source` text DEFAULT 'opencode' NOT NULL,\n\t`stream_id` text DEFAULT '' NOT NULL,\n\t`opencode_session_id` text NOT NULL,\n\t`session_id` integer,\n\t`created_at` text NOT NULL,\n\tPRIMARY KEY(`source`, `stream_id`),\n\tFOREIGN KEY (`session_id`) REFERENCES `sessions`(`id`) ON UPDATE no action ON DELETE cascade\n);\n\nCREATE INDEX IF NOT EXISTS `idx_opencode_sessions_session` ON `opencode_sessions` (`session_id`);\nCREATE TABLE IF NOT EXISTS `raw_event_flush_batches` (\n\t`id` integer PRIMARY KEY NOT NULL,\n\t`source` text DEFAULT 'opencode' NOT NULL,\n\t`stream_id` text DEFAULT '' NOT NULL,\n\t`opencode_session_id` text NOT NULL,\n\t`start_event_seq` integer NOT NULL,\n\t`end_event_seq` integer NOT NULL,\n\t`extractor_version` text NOT NULL,\n\t`status` text NOT NULL,\n\t`error_message` text,\n\t`error_type` text,\n\t`observer_provider` text,\n\t`observer_model` text,\n\t`observer_runtime` text,\n\t`observer_auth_source` text,\n\t`observer_auth_type` text,\n\t`observer_error_code` text,\n\t`observer_error_message` text,\n\t`attempt_count` integer DEFAULT 0 NOT NULL,\n\t`created_at` text NOT NULL,\n\t`updated_at` text NOT NULL\n);\n\nCREATE UNIQUE INDEX IF NOT EXISTS `idx_flush_batches_source_stream_seq_ver` ON `raw_event_flush_batches` (`source`,`stream_id`,`start_event_seq`,`end_event_seq`,`extractor_version`);\nCREATE INDEX IF NOT EXISTS `idx_flush_batches_session_created` ON `raw_event_flush_batches` (`opencode_session_id`,`created_at`);\nCREATE INDEX IF NOT EXISTS `idx_flush_batches_status_updated` ON `raw_event_flush_batches` (`status`,`updated_at`);\nCREATE TABLE IF NOT EXISTS `user_prompts` (\n\t`id` integer PRIMARY KEY NOT NULL,\n\t`session_id` integer,\n\t`project` text,\n\t`prompt_text` text NOT NULL,\n\t`prompt_number` integer,\n\t`created_at` text NOT NULL,\n\t`created_at_epoch` integer NOT NULL,\n\t`metadata_json` text,\n\t`import_key` text,\n\tFOREIGN KEY (`session_id`) REFERENCES `sessions`(`id`) ON UPDATE no action ON DELETE cascade\n);\n\nCREATE INDEX IF NOT EXISTS `idx_user_prompts_session` ON `user_prompts` (`session_id`);\nCREATE INDEX IF NOT EXISTS `idx_user_prompts_project` ON `user_prompts` (`project`);\nCREATE INDEX IF NOT EXISTS `idx_user_prompts_epoch` ON `user_prompts` (`created_at_epoch`);\nCREATE TABLE IF NOT EXISTS `session_summaries` (\n\t`id` integer PRIMARY KEY NOT NULL,\n\t`session_id` integer,\n\t`project` text,\n\t`request` text,\n\t`investigated` text,\n\t`learned` text,\n\t`completed` text,\n\t`next_steps` text,\n\t`notes` text,\n\t`files_read` text,\n\t`files_edited` text,\n\t`prompt_number` integer,\n\t`created_at` text NOT NULL,\n\t`created_at_epoch` integer NOT NULL,\n\t`metadata_json` text,\n\t`import_key` text,\n\tFOREIGN KEY (`session_id`) REFERENCES `sessions`(`id`) ON UPDATE no action ON DELETE cascade\n);\n\nCREATE INDEX IF NOT EXISTS `idx_session_summaries_session` ON `session_summaries` (`session_id`);\nCREATE INDEX IF NOT EXISTS `idx_session_summaries_project` ON `session_summaries` (`project`);\nCREATE INDEX IF NOT EXISTS `idx_session_summaries_epoch` ON `session_summaries` (`created_at_epoch`);\nCREATE TABLE IF NOT EXISTS `replication_ops` (\n\t`op_id` text PRIMARY KEY NOT NULL,\n\t`entity_type` text NOT NULL,\n\t`entity_id` text NOT NULL,\n\t`op_type` text NOT NULL,\n\t`payload_json` text,\n\t`clock_rev` integer NOT NULL,\n\t`clock_updated_at` text NOT NULL,\n\t`clock_device_id` text NOT NULL,\n\t`device_id` text NOT NULL,\n\t`created_at` text NOT NULL,\n\t`scope_id` text\n);\n\nCREATE INDEX IF NOT EXISTS `idx_replication_ops_created` ON `replication_ops` (`created_at`,`op_id`);\nCREATE INDEX IF NOT EXISTS `idx_replication_ops_entity` ON `replication_ops` (`entity_type`,`entity_id`);\nCREATE INDEX IF NOT EXISTS `idx_replication_ops_scope_created` ON `replication_ops` (`scope_id`,`created_at`,`op_id`);\nCREATE TABLE IF NOT EXISTS `replication_cursors` (\n\t`peer_device_id` text PRIMARY KEY NOT NULL,\n\t`last_applied_cursor` text,\n\t`last_acked_cursor` text,\n\t`updated_at` text NOT NULL\n);\n\nCREATE TABLE IF NOT EXISTS `replication_cursors_v2` (\n\t`peer_device_id` text NOT NULL,\n\t`scope_id` text NOT NULL,\n\t`last_applied_cursor` text,\n\t`last_acked_cursor` text,\n\t`updated_at` text NOT NULL,\n\tPRIMARY KEY(`peer_device_id`, `scope_id`)\n);\n\nCREATE INDEX IF NOT EXISTS `idx_replication_cursors_v2_scope` ON `replication_cursors_v2` (`scope_id`);\nCREATE TABLE IF NOT EXISTS `sync_peers` (\n\t`peer_device_id` text PRIMARY KEY NOT NULL,\n\t`name` text,\n\t`pinned_fingerprint` text,\n\t`public_key` text,\n\t`addresses_json` text,\n\t`claimed_local_actor` integer DEFAULT 0 NOT NULL,\n\t`actor_id` text,\n\t`projects_include_json` text,\n\t`projects_exclude_json` text,\n\t`created_at` text NOT NULL,\n\t`last_seen_at` text,\n\t`last_sync_at` text,\n\t`last_error` text,\n\t`discovered_via_coordinator_id` text,\n\t`discovered_via_group_id` text,\n\t`pending_bootstrap_grant_id` text\n);\n\nCREATE TABLE IF NOT EXISTS `sync_nonces` (\n\t`nonce` text PRIMARY KEY NOT NULL,\n\t`device_id` text NOT NULL,\n\t`created_at` text NOT NULL\n);\n\nCREATE TABLE IF NOT EXISTS `sync_device` (\n\t`device_id` text PRIMARY KEY NOT NULL,\n\t`public_key` text NOT NULL,\n\t`fingerprint` text NOT NULL,\n\t`created_at` text NOT NULL\n);\n\nCREATE TABLE IF NOT EXISTS `sync_attempts` (\n\t`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,\n\t`peer_device_id` text NOT NULL,\n\t`started_at` text NOT NULL,\n\t`finished_at` text,\n\t`ok` integer NOT NULL,\n\t`ops_in` integer NOT NULL,\n\t`ops_out` integer NOT NULL,\n\t`error` text,\n\t`local_sync_capability` text,\n\t`peer_sync_capability` text,\n\t`negotiated_sync_capability` text\n);\n\nCREATE INDEX IF NOT EXISTS `idx_sync_attempts_peer_started` ON `sync_attempts` (`peer_device_id`,`started_at`);\nCREATE TABLE IF NOT EXISTS `sync_scope_rejections` (\n\t`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,\n\t`peer_device_id` text,\n\t`op_id` text NOT NULL,\n\t`entity_type` text NOT NULL,\n\t`entity_id` text NOT NULL,\n\t`scope_id` text,\n\t`reason` text NOT NULL,\n\t`created_at` text NOT NULL\n);\n\nCREATE INDEX IF NOT EXISTS `idx_sync_scope_rejections_peer_created` ON `sync_scope_rejections` (`peer_device_id`,`created_at`);\nCREATE INDEX IF NOT EXISTS `idx_sync_scope_rejections_scope_created` ON `sync_scope_rejections` (`scope_id`,`created_at`);\nCREATE TABLE IF NOT EXISTS `sync_daemon_state` (\n\t`id` integer PRIMARY KEY NOT NULL,\n\t`last_error` text,\n\t`last_traceback` text,\n\t`last_error_at` text,\n\t`last_ok_at` text,\n\t`phase` text\n);\n\nCREATE TABLE IF NOT EXISTS `sync_reset_state` (\n\t`id` integer PRIMARY KEY NOT NULL,\n\t`generation` integer NOT NULL,\n\t`snapshot_id` text NOT NULL,\n\t`baseline_cursor` text,\n\t`retained_floor_cursor` text,\n\t`updated_at` text NOT NULL\n);\n\nCREATE TABLE IF NOT EXISTS `sync_reset_state_v2` (\n\t`scope_id` text PRIMARY KEY NOT NULL,\n\t`generation` integer NOT NULL,\n\t`snapshot_id` text NOT NULL,\n\t`baseline_cursor` text,\n\t`retained_floor_cursor` text,\n\t`updated_at` text NOT NULL\n);\n\nCREATE TABLE IF NOT EXISTS `sync_retention_state_v2` (\n\t`scope_id` text PRIMARY KEY NOT NULL,\n\t`last_run_at` text,\n\t`last_duration_ms` integer,\n\t`last_deleted_ops` integer DEFAULT 0 NOT NULL,\n\t`last_estimated_bytes_before` integer,\n\t`last_estimated_bytes_after` integer,\n\t`retained_floor_cursor` text,\n\t`last_error` text,\n\t`last_error_at` text\n);\n\nCREATE TABLE IF NOT EXISTS `raw_event_ingest_samples` (\n\t`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,\n\t`created_at` text NOT NULL,\n\t`inserted_events` integer DEFAULT 0 NOT NULL,\n\t`skipped_invalid` integer DEFAULT 0 NOT NULL,\n\t`skipped_duplicate` integer DEFAULT 0 NOT NULL,\n\t`skipped_conflict` integer DEFAULT 0 NOT NULL\n);\n\nCREATE TABLE IF NOT EXISTS `raw_event_ingest_stats` (\n\t`id` integer PRIMARY KEY NOT NULL,\n\t`inserted_events` integer DEFAULT 0 NOT NULL,\n\t`skipped_events` integer DEFAULT 0 NOT NULL,\n\t`skipped_invalid` integer DEFAULT 0 NOT NULL,\n\t`skipped_duplicate` integer DEFAULT 0 NOT NULL,\n\t`skipped_conflict` integer DEFAULT 0 NOT NULL,\n\t`updated_at` text NOT NULL\n);\n\nCREATE TABLE IF NOT EXISTS `coordinator_group_preferences` (\n\t`coordinator_id` text NOT NULL,\n\t`group_id` text NOT NULL,\n\t`projects_include_json` text,\n\t`projects_exclude_json` text,\n\t`auto_seed_scope` integer DEFAULT 1 NOT NULL,\n\t`default_space_scope_id` text,\n\t`auto_grant_default_space_on_join` integer DEFAULT 0 NOT NULL,\n\t`updated_at` text NOT NULL,\n\tPRIMARY KEY(`coordinator_id`, `group_id`)\n);\n\nCREATE TABLE IF NOT EXISTS `actors` (\n\t`actor_id` text PRIMARY KEY NOT NULL,\n\t`display_name` text NOT NULL,\n\t`is_local` integer DEFAULT 0 NOT NULL,\n\t`status` text DEFAULT 'active' NOT NULL,\n\t`merged_into_actor_id` text,\n\t`created_at` text NOT NULL,\n\t`updated_at` text NOT NULL\n);\n\nCREATE INDEX IF NOT EXISTS `idx_actors_is_local` ON `actors` (`is_local`);\nCREATE INDEX IF NOT EXISTS `idx_actors_status` ON `actors` (`status`);\nCREATE TABLE IF NOT EXISTS `recipient_policy_review_resolutions` (\n\t`review_item_id` text NOT NULL,\n\t`source_fingerprint` text NOT NULL,\n\t`decision` text NOT NULL,\n\t`decision_input_json` text NOT NULL,\n\t`preview_json` text NOT NULL,\n\t`decided_by_identity_id` text NOT NULL,\n\t`decided_by_device_id` text NOT NULL,\n\t`resolved_at` text NOT NULL,\n\tPRIMARY KEY(`review_item_id`, `source_fingerprint`)\n);\n\nCREATE TABLE IF NOT EXISTS `policy_teams` (\n\t`team_id` text PRIMARY KEY NOT NULL,\n\t`display_name` text NOT NULL,\n\t`status` text NOT NULL,\n\t`provenance` text NOT NULL,\n\t`revision` text NOT NULL,\n\t`migration_state` text NOT NULL,\n\t`source_fingerprint` text,\n\t`idempotency_key` text NOT NULL,\n\t`created_at` text NOT NULL,\n\t`updated_at` text NOT NULL\n);\n\nCREATE UNIQUE INDEX IF NOT EXISTS `policy_teams_idempotency_key_unique` ON `policy_teams` (`idempotency_key`);\nCREATE TABLE IF NOT EXISTS `policy_team_memberships` (\n\t`team_id` text NOT NULL,\n\t`identity_id` text NOT NULL,\n\t`role` text NOT NULL,\n\t`status` text NOT NULL,\n\t`provenance` text NOT NULL,\n\t`revision` text NOT NULL,\n\t`migration_state` text NOT NULL,\n\t`source_fingerprint` text,\n\t`idempotency_key` text NOT NULL,\n\t`created_at` text NOT NULL,\n\t`updated_at` text NOT NULL,\n\tPRIMARY KEY(`team_id`, `identity_id`)\n);\n\nCREATE UNIQUE INDEX IF NOT EXISTS `policy_team_memberships_idempotency_key_unique` ON `policy_team_memberships` (`idempotency_key`);\nCREATE INDEX IF NOT EXISTS `idx_policy_team_memberships_identity_status` ON `policy_team_memberships` (`identity_id`,`status`);\nCREATE TABLE IF NOT EXISTS `identity_devices` (\n\t`device_id` text PRIMARY KEY NOT NULL,\n\t`identity_id` text NOT NULL,\n\t`display_name` text NOT NULL,\n\t`status` text NOT NULL,\n\t`provenance` text NOT NULL,\n\t`revision` text NOT NULL,\n\t`migration_state` text NOT NULL,\n\t`source_fingerprint` text,\n\t`idempotency_key` text NOT NULL,\n\t`created_at` text NOT NULL,\n\t`updated_at` text NOT NULL\n);\n\nCREATE UNIQUE INDEX IF NOT EXISTS `identity_devices_idempotency_key_unique` ON `identity_devices` (`idempotency_key`);\nCREATE INDEX IF NOT EXISTS `idx_identity_devices_identity_status` ON `identity_devices` (`identity_id`,`status`);\nCREATE TABLE IF NOT EXISTS `project_recipients` (\n\t`canonical_project_identity` text NOT NULL,\n\t`recipient_kind` text NOT NULL,\n\t`recipient_id` text NOT NULL,\n\t`status` text NOT NULL,\n\t`provenance` text NOT NULL,\n\t`policy_revision` text NOT NULL,\n\t`migration_state` text NOT NULL,\n\t`source_fingerprint` text,\n\t`idempotency_key` text NOT NULL,\n\t`created_at` text NOT NULL,\n\t`updated_at` text NOT NULL,\n\tPRIMARY KEY(`canonical_project_identity`, `recipient_kind`, `recipient_id`)\n);\n\nCREATE UNIQUE INDEX IF NOT EXISTS `project_recipients_idempotency_key_unique` ON `project_recipients` (`idempotency_key`);\nCREATE INDEX IF NOT EXISTS `idx_project_recipients_project_status` ON `project_recipients` (`canonical_project_identity`,`status`);"; diff --git a/packages/viewer-server/src/index.test.ts b/packages/viewer-server/src/index.test.ts index b61fe1bd..9b42f8b2 100644 --- a/packages/viewer-server/src/index.test.ts +++ b/packages/viewer-server/src/index.test.ts @@ -8093,6 +8093,158 @@ describe("viewer-server", () => { } }); + it("serves safe recipient intent and strictly validates per-Project migration", async () => { + const { app, getStore, cleanup } = createTestApp(); + try { + await app.request("/api/stats"); + const store = getStore(); + if (!store) throw new Error("store not initialized"); + const now = "2026-07-21T12:00:00.000Z"; + const projectId = "https://git.example.invalid/acme/intent-route.git"; + const scopeId = "protected-route-scope"; + const recipientId = "identity-route-recipient"; + const deviceId = "device-route-recipient"; + const sessionId = insertTestSession(store.db); + store.db + .prepare("UPDATE sessions SET git_remote = ?, project = ? WHERE id = ?") + .run(projectId, "intent-route", sessionId); + insertTestMemory(store, { + sessionId, + kind: "discovery", + title: "intent route fixture", + scopeId, + }); + store.db + .prepare( + `INSERT INTO actors(actor_id, display_name, is_local, status, created_at, updated_at) + VALUES (?, 'Route recipient', 0, 'active', ?, ?)`, + ) + .run(recipientId, now, now); + store.db + .prepare( + `INSERT INTO replication_scopes( + scope_id, label, kind, authority_type, coordinator_id, group_id, + membership_epoch, status, created_at, updated_at + ) VALUES (?, 'Intent route', 'managed_project', 'coordinator', + 'private-coordinator', 'private-group', 1, 'active', ?, ?)`, + ) + .run(scopeId, now, now); + store.db + .prepare( + `INSERT INTO project_scope_mappings( + workspace_identity, project_pattern, scope_id, priority, source, created_at, updated_at + ) VALUES (?, ?, ?, 1000, 'test', ?, ?)`, + ) + .run(projectId, projectId, scopeId, now, now); + store.db + .prepare( + `INSERT INTO sync_peers( + peer_device_id, name, actor_id, public_key, pinned_fingerprint, addresses_json, created_at + ) VALUES (?, 'Route laptop', ?, 'private-key', 'private-transport-fingerprint', + '["private-address"]', ?)`, + ) + .run(deviceId, recipientId, now); + store.db + .prepare( + `INSERT INTO scope_memberships( + scope_id, device_id, status, membership_epoch, coordinator_id, group_id, updated_at + ) VALUES (?, ?, 'active', 1, 'private-coordinator', 'private-group', ?)`, + ) + .run(scopeId, deviceId, now); + const project = { + canonicalIdentity: projectId, + displayName: "intent-route", + identitySource: "git_remote", + existingMemoryCount: 1, + }; + const reviewedDigest = core.shareProjectSetDigest([project]); + store.db + .prepare( + `INSERT INTO share_operations( + operation_id, state, inviter_actor_id, inviter_device_ids_json, person_id, + person_kind, teammate_name, history_policy, reviewed_project_set_digest, + coordinator_group_id, invite_token_digest, invite_expires_at, + recipient_actor_id, recipient_device_id, acceptance_consumed_at, created_at, updated_at + ) VALUES ('route-operation', 'active', ?, '[]', ?, 'existing', 'Route recipient', + 'existing_and_future', ?, 'private-group', 'private-invite-digest', + '2099-01-01T00:00:00.000Z', ?, ?, ?, ?, ?)`, + ) + .run(store.actorId, recipientId, reviewedDigest, recipientId, deviceId, now, now, now); + store.db + .prepare( + `INSERT INTO share_operation_projects( + operation_id, canonical_project_identity, display_name, identity_source, + existing_memory_count, ordinal + ) VALUES ('route-operation', ?, 'intent-route', 'git_remote', 1, 0)`, + ) + .run(projectId); + + const invalid = await app.request("/api/sync/recipient-policy/v1/migrate", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ dryRun: true, actorId: "client-controlled" }), + }); + expect(invalid.status).toBe(400); + + const dryRun = await app.request("/api/sync/recipient-policy/v1/migrate", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ dryRun: true }), + }); + expect(dryRun.status).toBe(200); + expect(await dryRun.json()).toMatchObject({ dryRun: true }); + expect(store.db.prepare("SELECT COUNT(*) FROM project_recipients").pluck().get()).toBe(0); + + const migrate = await app.request("/api/sync/recipient-policy/v1/migrate", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({}), + }); + expect(migrate.status).toBe(200); + const intentResponse = await app.request("/api/sync/recipient-policy/v1/intent"); + const intentText = await intentResponse.text(); + const intent = JSON.parse(intentText) as Record; + + expect(intentResponse.status).toBe(200); + expect(intent).toMatchObject({ + version: 1, + projectRecipients: [ + expect.objectContaining({ + canonicalProjectIdentity: projectId, + recipientKind: "identity", + identityId: recipientId, + }), + ], + }); + for (const forbidden of [ + scopeId, + "private-group", + "private-coordinator", + "private-address", + "private-key", + "private-transport-fingerprint", + "private-invite-digest", + ]) { + expect(intentText).not.toContain(forbidden); + } + for (const forbiddenKey of [ + "scopeId", + "groupId", + "address", + "publicKey", + "fingerprint", + "epoch", + "cursor", + "filter", + "payload", + ]) { + expect(intentText).not.toContain(forbiddenKey); + } + } finally { + cleanup(); + } + }); + it("returns searchable project inventory for the Projects screen", async () => { const { app, getStore, cleanup } = createTestApp(); try { diff --git a/packages/viewer-server/src/routes/sync.ts b/packages/viewer-server/src/routes/sync.ts index 34864ec8..9c4fa2bf 100644 --- a/packages/viewer-server/src/routes/sync.ts +++ b/packages/viewer-server/src/routes/sync.ts @@ -90,12 +90,14 @@ import { listProjectScopeCandidates, listProjectScopeInventory, listProjectScopeSettingsMappings, + listRecipientPolicyIntent, listRecipientPolicyReview, listSharingDomainSettingsScopes, loadMemorySnapshotPageForPeer, loadReplicationOpsForPeer, lookupCoordinatorPeers, mergeAddresses, + migrateRecipientPolicyIntent, negotiateSyncCapability, normalizeAddress, normalizeSyncCapability, @@ -4097,6 +4099,33 @@ export function syncRoutes( ); }); + app.get("/api/sync/recipient-policy/v1/intent", (c) => { + const store = getStore(); + return c.json(listRecipientPolicyIntent(store.db)); + }); + + app.post("/api/sync/recipient-policy/v1/migrate", async (c) => { + const store = getStore(); + const value = await parseViewerJsonBody(c); + if (!value || typeof value !== "object" || Array.isArray(value)) { + return c.json({ error: "request_invalid" }, 400); + } + const body = value as Record; + if ( + Object.keys(body).some((key) => key !== "dryRun") || + (Object.hasOwn(body, "dryRun") && typeof body.dryRun !== "boolean") + ) { + return c.json({ error: "request_invalid" }, 400); + } + return c.json( + migrateRecipientPolicyIntent( + store.db, + { localActorId: store.actorId, localDeviceId: store.deviceId }, + { dryRun: body.dryRun === true }, + ), + ); + }); + const parseReviewRequest = (value: unknown): RecipientPolicyReviewResolveRequestV1 | null => { if (!value || typeof value !== "object" || Array.isArray(value)) return null; const body = value as Record;