diff --git a/apps/api/src/lib/agent-channel.ts b/apps/api/src/lib/agent-channel.ts index b91a9cfc..c046d185 100644 --- a/apps/api/src/lib/agent-channel.ts +++ b/apps/api/src/lib/agent-channel.ts @@ -47,7 +47,7 @@ export const AGENT_CHANNEL_AUTONOMOUS_REQUIRED_RUNTIME_CAPABILITIES = [ 'accepted_event_rehydration_v1', ] as const; export type AgentChannelAdapterMode = 'supervised_runtime' | 'autonomous_platform'; -export const DEFT_RELEASE_VERSION = process.env.DEFT_RELEASE_VERSION || '0.3.0-preview.138'; +export const DEFT_RELEASE_VERSION = process.env.DEFT_RELEASE_VERSION || '0.3.0-preview.139'; export const DEFT_BUILD_COMMIT = process.env.DEFT_BUILD_COMMIT || process.env.VCS_REF || 'unknown'; export const DEFT_SCHEMA_HEAD = '0.3.0-preview.131'; export const AGENT_CHANNEL_DEFAULT_LEASE_MS = 120_000; diff --git a/apps/api/src/lib/app-attachment-sync-reviewed.ts b/apps/api/src/lib/app-attachment-sync-reviewed.ts index c7c942b3..97bcef92 100644 --- a/apps/api/src/lib/app-attachment-sync-reviewed.ts +++ b/apps/api/src/lib/app-attachment-sync-reviewed.ts @@ -1,4 +1,4 @@ -import { digestResourceSyncDescriptorV2 } from '@deft/app-kit/core'; +import { digestAppAuthorityDescriptor } from './app-authority-descriptor.js'; import type { AppRunTransaction } from './app-run-repository.js'; import { attachmentStale, loadReviewedAttachmentApp } from './app-attachment-authority.js'; @@ -9,6 +9,6 @@ export async function loadReviewedAttachmentSyncDescriptor(tx: AppRunTransaction : reviewed.manifest.sync_descriptors.find(item => item.key === resourceKey); if (!descriptor) throw attachmentStale(); return { installation: reviewed.installation, version: reviewed.version, grant: reviewed.grant, manifest: reviewed.manifest, - descriptor, descriptor_digest: await digestResourceSyncDescriptorV2(descriptor), + descriptor, descriptor_digest: await digestAppAuthorityDescriptor(descriptor, { org_id: orgId, app_version_id: reviewed.version.id }), descriptors: reviewed.manifest.sync_descriptors }; } diff --git a/apps/api/src/lib/app-authority-descriptor.ts b/apps/api/src/lib/app-authority-descriptor.ts new file mode 100644 index 00000000..0b7dfc82 --- /dev/null +++ b/apps/api/src/lib/app-authority-descriptor.ts @@ -0,0 +1,21 @@ +import { digestResourceSyncDescriptorV2 } from '@deft/app-kit/core'; +import { createAsyncAppAuthorityContentCache, type AppAuthorityContentScope } from './app-authority-content-cache.js'; + +export const APP_AUTHORITY_DESCRIPTOR_CACHE_ENTRIES = 32; +export const APP_AUTHORITY_DESCRIPTOR_CACHE_KEY_BYTES = 128 * 1024; + +/** Pure Kit validation/digest reuse over complete freshly read descriptor JSON. + * IDs or advertised digests never substitute for content. Current DB, owner, + * admission, policy, epoch, configuration and deadline checks stay in callers. + * The Kit digester parses/canonicalizes/encodes synchronously before its first + * await, so it retains no caller JSON while WebCrypto completes. */ +export function createAppAuthorityDescriptorDigester(digester = digestResourceSyncDescriptorV2) { + const cache = createAsyncAppAuthorityContentCache>>({ + maxEntries: APP_AUTHORITY_DESCRIPTOR_CACHE_ENTRIES, maxBytes: 64 * 1024, + maxContentBytes: APP_AUTHORITY_DESCRIPTOR_CACHE_KEY_BYTES, + }); + return (raw: unknown, scope: AppAuthorityContentScope): ReturnType => + cache(scope, raw, () => digester(raw)); +} + +export const digestAppAuthorityDescriptor = createAppAuthorityDescriptorDigester(); diff --git a/apps/api/src/lib/app-experience-exposure-db.ts b/apps/api/src/lib/app-experience-exposure-db.ts index f2fc1f42..bc5a8079 100644 --- a/apps/api/src/lib/app-experience-exposure-db.ts +++ b/apps/api/src/lib/app-experience-exposure-db.ts @@ -1,3 +1,4 @@ +import type { CoreDatabaseTransaction } from './database-transaction.js'; import { drizzle } from 'drizzle-orm/node-postgres'; import pg from 'pg'; import * as schema from '@deft/db/schema'; @@ -15,8 +16,7 @@ export const APP_EXPERIENCE_EXPOSURE_DB_LIMITS = Object.freeze({ confirmation_operation_ms: 15_000, }); -type ExposureDatabase = ReturnType>; -export type ExperienceExposureTransaction = Parameters[0]>[0]; +export type ExperienceExposureTransaction = CoreDatabaseTransaction; /** Separate capacity and server-enforced SQL limits for Experience exposure. * Cancellation waits for the bounded active statement and ROLLBACK; no rejected diff --git a/apps/api/src/lib/app-owner-connection-authority.ts b/apps/api/src/lib/app-owner-connection-authority.ts index 8df88904..c1fcdbfa 100644 --- a/apps/api/src/lib/app-owner-connection-authority.ts +++ b/apps/api/src/lib/app-owner-connection-authority.ts @@ -9,7 +9,7 @@ import { connectionProfileDigest, getAppConnectionSessionRegistry, type AppConne import { isAppOwnerConnectionsEnabled } from './env.js'; import { AppError } from './app-errors.js'; import { runtimeActionDescriptors } from './app-runtime-review.js'; -import { digestResourceSyncDescriptorV2 } from '@deft/app-kit/core'; +import { digestAppAuthorityDescriptor } from './app-authority-descriptor.js'; import { parseAppAuthorityManifest } from './app-authority-manifest.js'; type LockedReviewedApp = Pick>, 'installation' | 'version' | 'grant'> @@ -118,7 +118,7 @@ export async function loadOwnerConnectionAdmission(tx: AppRunTransaction, orgId: const actions = runtimeActionDescriptors(reviewed.manifest).filter(item => policy.action_keys.includes(item.action_key)) .map(item => ({ action_key: item.action_key, contract_digest: item.contract_digest })) .sort((a,b) => a.action_key < b.action_key ? -1 : a.action_key > b.action_key ? 1 : 0); - if (!descriptor || snapshot.resource_descriptor_digest !== await digestResourceSyncDescriptorV2(descriptor) + if (!descriptor || snapshot.resource_descriptor_digest !== await digestAppAuthorityDescriptor(descriptor, { org_id: orgId, app_version_id: reviewed.version.id }) || digestAppGrantValue(snapshot.actions) !== digestAppGrantValue(actions)) throw ownerConnectionDenied(); // The registry stays transaction-local and never enters a serializable // admission/snapshot result. Publish it only after every current check passed. diff --git a/apps/api/src/lib/app-public-budgets.ts b/apps/api/src/lib/app-public-budgets.ts index 2227b101..079ba00f 100644 --- a/apps/api/src/lib/app-public-budgets.ts +++ b/apps/api/src/lib/app-public-budgets.ts @@ -1,10 +1,10 @@ +import type { CoreDatabaseTransaction } from './database-transaction.js'; import { sql, eq, and } from 'drizzle-orm'; import { PublicBudgetPolicySchema } from '@deft/app-kit/core'; import { APP_RUN_TERMINAL_STATES } from '@deft/shared'; import { appCanonicalClaims, appPublicEndpoints } from '@deft/db/schema'; -import type { db } from './db.js'; -type Transaction = Parameters[0]>[0]; +type Transaction = CoreDatabaseTransaction; type Endpoint = typeof appPublicEndpoints.$inferSelect; export const PUBLIC_APP_BUDGET_CEILINGS = Object.freeze({ max_pending: 25, max_confirmed_per_utc_day: 100 }); export class PublicBudgetExceededError extends Error {} diff --git a/apps/api/src/lib/app-resource-sync-management.ts b/apps/api/src/lib/app-resource-sync-management.ts index f6ccf70c..3608587a 100644 --- a/apps/api/src/lib/app-resource-sync-management.ts +++ b/apps/api/src/lib/app-resource-sync-management.ts @@ -1,3 +1,4 @@ +import type { CoreDatabaseTransaction } from './database-transaction.js'; import {appConsentDeadline} from '@deft/app-kit/core'; import { digestResourceSyncDescriptorV2 } from '@deft/app-kit/core'; import { assertAttachmentBrokerEnabled, attachmentFinalAuthorityIsCurrent } from './app-attachment-authority.js'; @@ -37,7 +38,7 @@ import { APP_RESOURCE_SYNC_HOST_POLICY, APP_RESOURCE_SYNC_SESSION_MS, APP_RESOUR assertResourceSyncConsentWindow, hashAppResourceSyncToken, type AppResourceSyncConsentRequest } from './app-resource-sync-policy.js'; -type Tx = Parameters[0]>[0]; +type Tx = CoreDatabaseTransaction; export type ResourceSyncManagementGuard = (tx: Tx) => Promise; async function managementTransaction(guard: ResourceSyncManagementGuard | undefined, diff --git a/apps/api/src/lib/app-resource-sync-status.ts b/apps/api/src/lib/app-resource-sync-status.ts index 1e492131..84755bb3 100644 --- a/apps/api/src/lib/app-resource-sync-status.ts +++ b/apps/api/src/lib/app-resource-sync-status.ts @@ -1,3 +1,4 @@ +import type { CoreDatabaseTransaction } from './database-transaction.js'; import { and, asc, desc, eq, gt, sql } from 'drizzle-orm'; import { z } from 'zod'; import { appResourceBindings, appRuntimeRegistrations, appSyncCheckpoints, @@ -9,7 +10,7 @@ import { assertResourceSyncManager } from './app-resource-sync-web-authority.js' import type { ResourceSyncManagementGuard } from './app-resource-sync-management.js'; import { finishAttachmentSyncMetadata, type AttachmentSyncReadMode } from './app-resource-sync-read-mode.js'; -type Tx = Parameters[0]>[0]; +type Tx = CoreDatabaseTransaction; const Id = z.string().uuid(); export const ResourceSyncListQuery = z.strictObject({ after: Id.optional(), limit: z.coerce.number().int().min(1).max(50).default(20), diff --git a/apps/api/src/lib/app-run-bounded-db.ts b/apps/api/src/lib/app-run-bounded-db.ts index c25a8289..a02d4b08 100644 --- a/apps/api/src/lib/app-run-bounded-db.ts +++ b/apps/api/src/lib/app-run-bounded-db.ts @@ -1,6 +1,5 @@ import { drizzle } from 'drizzle-orm/node-postgres'; import pg from 'pg'; -import * as schema from '@deft/db/schema'; import type { AppRunTransaction } from './app-run-repository.js'; export const APP_RUN_TRANSACTION_LIMITS = Object.freeze({ budget_ms: 10_000, @@ -59,7 +58,10 @@ export function createBoundedAppRunDatabase(connectionString: string, options: { return result; }; } }); - return await drizzle(guarded, { schema }).transaction(work); + // Core table queries carry their own columns and codecs. Building the + // unused relational schema here would re-extract every table and index + // for each fresh bounded transaction, including each heartbeat guard. + return await drizzle(guarded).transaction(work); } finally { clearTimeout(timer); signal.removeEventListener('abort', discard); if (!settled && !released) { try { await client.query('ROLLBACK'); } catch { broken = true; } } diff --git a/apps/api/src/lib/app-run-repository.ts b/apps/api/src/lib/app-run-repository.ts index 7a819056..25a965fe 100644 --- a/apps/api/src/lib/app-run-repository.ts +++ b/apps/api/src/lib/app-run-repository.ts @@ -15,8 +15,9 @@ import type { AppRunSubmission, } from '@deft/shared'; import { db } from './db.js'; +import type { CoreDatabaseTransaction } from './database-transaction.js'; -export type AppRunTransaction = Parameters[0]>[0]; +export type AppRunTransaction = CoreDatabaseTransaction; export const safeRunSelection = { id: appRuns.id, diff --git a/apps/api/src/lib/app-runtime-review.ts b/apps/api/src/lib/app-runtime-review.ts index 21470fa9..0e545291 100644 --- a/apps/api/src/lib/app-runtime-review.ts +++ b/apps/api/src/lib/app-runtime-review.ts @@ -1,3 +1,4 @@ +import type { CoreDatabaseTransaction } from './database-transaction.js'; import { randomUUID } from 'node:crypto'; import { and, desc, eq, inArray, sql } from 'drizzle-orm'; import { z } from 'zod'; @@ -23,7 +24,7 @@ export const RuntimeAppReviewRequestSchema = z.strictObject({ export const RuntimeAppActivateRequestSchema = RuntimeAppReviewRequestSchema.extend({ expected_review_digest: AppDigestSchema, accept_host_policy: z.literal(true), }); -type ReviewTransaction = Parameters[0]>[0]; +type ReviewTransaction = CoreDatabaseTransaction; export type RuntimeAppReviewOptions = Readonly<{ guard?: (tx: ReviewTransaction) => Promise; assertAdmission?: (manifest: RuntimeAppManifest | DeftAppManifestV5) => void; diff --git a/apps/api/src/lib/app-service.ts b/apps/api/src/lib/app-service.ts index 77c9cef8..e8c35783 100644 --- a/apps/api/src/lib/app-service.ts +++ b/apps/api/src/lib/app-service.ts @@ -1,3 +1,4 @@ +import type { CoreDatabaseTransaction } from './database-transaction.js'; import { randomUUID } from 'node:crypto'; import { and, asc, desc, eq, inArray, sql } from 'drizzle-orm'; import { @@ -216,7 +217,7 @@ export async function inspectAppPackageJson(value: string): Promise[0]>[0]) => Promise; + options: { guard?: (tx: CoreDatabaseTransaction) => Promise; attachmentStage?: boolean; attachmentComposition?: boolean } = {}, ): Promise { assertHumanManager(actor); diff --git a/apps/api/src/lib/app-upgrade-trust-continuity.ts b/apps/api/src/lib/app-upgrade-trust-continuity.ts index 938a0f9b..bdb23e2e 100644 --- a/apps/api/src/lib/app-upgrade-trust-continuity.ts +++ b/apps/api/src/lib/app-upgrade-trust-continuity.ts @@ -1,3 +1,4 @@ +import type { CoreDatabaseTransaction } from './database-transaction.js'; import { randomUUID } from 'node:crypto'; import { isDeepStrictEqual } from 'node:util'; import { and, eq, sql, inArray } from 'drizzle-orm'; @@ -25,7 +26,7 @@ import type { WebAuthorityGuard } from './app-resource-sync-web-authority.js'; import { REATTACH_PHASE_MS } from './app-connection-reattachment-deadline.js'; import { assertCurrentUpgradeRowPins } from './app-upgrade-row-pins.js'; -export type UpgradeTx = Parameters[0]>[0]; +export type UpgradeTx = CoreDatabaseTransaction; const denied = () => new AppError('Automatic upgrade continuity requires review', 'APP_REVIEW_REQUIRED', 409); export const UPGRADE_PROOF_ACTION = 'app.upgrade_continuity.authorized'; export const UPGRADE_ALIAS_ACTION = 'app.upgrade_continuity.registration'; diff --git a/apps/api/src/lib/app-upgrade-trust-policy.ts b/apps/api/src/lib/app-upgrade-trust-policy.ts index ac4ea1d8..0da7b2d6 100644 --- a/apps/api/src/lib/app-upgrade-trust-policy.ts +++ b/apps/api/src/lib/app-upgrade-trust-policy.ts @@ -1,3 +1,4 @@ +import type { CoreDatabaseTransaction } from './database-transaction.js'; import { and, eq, sql } from 'drizzle-orm'; import { z } from 'zod'; import { appInstallations, auditLog } from '@deft/db/schema'; @@ -8,7 +9,7 @@ import { assertCurrentModuleManagerWithExecutor } from './module-service.js'; import type { WebAuthorityGuard } from './app-resource-sync-web-authority.js'; import { diffUpgradeAuthority, type UpgradeAuthorityDiff } from './app-upgrade-manifest-diff.js'; -type Tx = Parameters[0]>[0]; +type Tx = CoreDatabaseTransaction; const TRUST_ACTION = 'app.upgrade_continuity_trust_changed'; const TrustSchema = z.strictObject({ trusted: z.boolean(), revision: z.number().int().nonnegative().max(2147483647) }); export const UpgradeTrustUpdateSchema = TrustSchema.safeExtend({ expected_revision: z.number().int().nonnegative().max(2147483646) }).omit({ revision: true }); diff --git a/apps/api/src/lib/database-transaction.ts b/apps/api/src/lib/database-transaction.ts new file mode 100644 index 00000000..3445398c --- /dev/null +++ b/apps/api/src/lib/database-transaction.ts @@ -0,0 +1,11 @@ +import type { NodePgDatabase } from 'drizzle-orm/node-postgres'; + +type Transaction = Parameters[0]>[0]; + +/** Table queries carry their own columns and codecs. Shared helpers need no + * relational schema, client handles or driver internals. Nested callbacks keep + * the same contract even when the caller uses a schema-less bounded client. */ +export type CoreDatabaseTransaction = Pick & { + transaction(work: (tx: CoreDatabaseTransaction) => Promise): Promise; +}; diff --git a/apps/api/src/lib/native-calendar.ts b/apps/api/src/lib/native-calendar.ts index e8cc7f35..c71e85f0 100644 --- a/apps/api/src/lib/native-calendar.ts +++ b/apps/api/src/lib/native-calendar.ts @@ -1,8 +1,9 @@ +import type { CoreDatabaseTransaction } from './database-transaction.js'; import { and, eq } from 'drizzle-orm'; import { events } from '@deft/db/schema'; import { db } from './db.js'; -type Transaction = Parameters[0]>[0]; +type Transaction = CoreDatabaseTransaction; type CalendarInput = { title: string; start: string; end: string; description?: string; location?: string; attendees?: { email: string; displayName?: string; name?: string }[]; diff --git a/apps/api/src/lib/native-create.ts b/apps/api/src/lib/native-create.ts index c4c838e4..25c01270 100644 --- a/apps/api/src/lib/native-create.ts +++ b/apps/api/src/lib/native-create.ts @@ -1,10 +1,11 @@ +import type { CoreDatabaseTransaction } from './database-transaction.js'; import { createHash } from 'node:crypto'; import { eq, sql } from 'drizzle-orm'; import { z } from 'zod'; import { nativeCreateRequests } from '@deft/db/schema'; import { db } from './db.js'; -type Transaction = Parameters[0]>[0]; +type Transaction = CoreDatabaseTransaction; const keySchema = z.string().min(1).max(128).regex(/^[A-Za-z0-9._:-]+$/); export class NativeCreateError extends Error { diff --git a/apps/api/src/lib/native-mentions.ts b/apps/api/src/lib/native-mentions.ts index 09e249d4..e8b71fa9 100644 --- a/apps/api/src/lib/native-mentions.ts +++ b/apps/api/src/lib/native-mentions.ts @@ -1,3 +1,4 @@ +import type { CoreDatabaseTransaction } from './database-transaction.js'; import { DEFTY_SYSTEM_EMPLOYEE_SLUG, DEFTY_SYSTEM_RUNTIME_KIND } from './defty-identity.js'; import { createHash } from 'node:crypto'; import { and, desc, eq, sql } from 'drizzle-orm'; @@ -101,7 +102,7 @@ export async function loadNativeSource( return visible.length && await employeeBoundary(ctx, row, executor) ? row : null; } -async function reconcileWithin(executor: Parameters[0]>[0], orgId: string, source: NativeMentionSource) { +async function reconcileWithin(executor: CoreDatabaseTransaction, orgId: string, source: NativeMentionSource) { const current = await loadNativeSource({ orgId }, source, executor, true); if (!current) { await executor.update(nativeReferenceStates).set({ current_refs: [], is_deleted: true, updated_at: new Date() }) @@ -194,7 +195,7 @@ export async function handleNativeMentionReconciliation(data: { orgId: string; s /** Called only at authenticated human Send/Post boundaries, in the content transaction. */ export async function enqueueNativeMentionPublication( - executor: Parameters[0]>[0], + executor: CoreDatabaseTransaction, ctx: NativeMentionContext, source: NativeMentionSource, content: string, ) { if (!nativeMentionsEnabled()) return; diff --git a/apps/api/test/app-attachment-authority-payload.test.ts b/apps/api/test/app-attachment-authority-payload.test.ts index 120ac629..0dc2c6ca 100644 --- a/apps/api/test/app-attachment-authority-payload.test.ts +++ b/apps/api/test/app-attachment-authority-payload.test.ts @@ -78,7 +78,7 @@ function fixture() { return returned; }).then(resolve, reject) }; return chain; } }; - return { org, installationId, rows, version, requested, grant, calls, + return { org, installationId, rows, version, requested, grant, calls, tx, run: () => loadReviewedAttachmentApp(tx as never, org, installationId) }; } @@ -148,3 +148,20 @@ test('resource snapshot serialization uses only the current version ID and packa const changed = buildResourceSyncAuthorizationSnapshot({ ...facts, version: guarded } as never); assert.notDeepEqual(changed, full, 'the reviewed package digest remains part of the authority pin'); }); + +test('warm descriptor digest reuse still reloads current locked App/grant bytes and rejects drift', async () => { + const { loadReviewedAttachmentSyncDescriptor } = await import('../src/lib/app-attachment-sync-reviewed.js'); + const f = fixture(), read = () => loadReviewedAttachmentSyncDescriptor(f.tx as never, f.org, f.installationId); + const first = await read(), before = f.calls.length, digest = first.descriptor_digest; + first.descriptor.attachments.max_attachment_bytes--; + const next = await read(); + assert.equal(next.descriptor_digest, digest, 'caller mutation cannot alter a retained digest or current descriptor'); + assert.equal(f.calls.length - before, 2, 'warm digest reuse still reads locked installation and exact version/grants'); + assert.deepEqual(f.calls.slice(-2).map(call => [call.table, call.share]), [['app_installations', true], ['app_versions', true]]); + f.version.manifest.sync_descriptors[0].attachments.max_attachment_bytes--; + await assert.rejects(read(), 'actual descriptor drift under unchanged advertised manifest/grant digests fails current reconstruction'); + const disabled = fixture(); + await loadReviewedAttachmentSyncDescriptor(disabled.tx as never, disabled.org, disabled.installationId); + disabled.rows.app_installations![0]!.state = 'disabled'; + await assert.rejects(loadReviewedAttachmentSyncDescriptor(disabled.tx as never, disabled.org, disabled.installationId)); +}); diff --git a/apps/api/test/app-authority-descriptor.test.ts b/apps/api/test/app-authority-descriptor.test.ts new file mode 100644 index 00000000..aea6c6e8 --- /dev/null +++ b/apps/api/test/app-authority-descriptor.test.ts @@ -0,0 +1,117 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { test, mock } from 'node:test'; +import { digestResourceSyncDescriptorV2 } from '@deft/app-kit/core'; +import { createAppAuthorityDescriptorDigester, APP_AUTHORITY_DESCRIPTOR_CACHE_ENTRIES, + APP_AUTHORITY_DESCRIPTOR_CACHE_KEY_BYTES } from '../src/lib/app-authority-descriptor.js'; + +const scope = { org_id: 'org-a', app_version_id: 'version-a' }; +const descriptor = () => JSON.parse(readFileSync(new URL('./fixtures/runtime-security-manifest.json', import.meta.url), 'utf8')).sync_descriptors[0]; + +test('complete current descriptor bytes reuse only the exact Kit digest in one org/version', async () => { + const digester = mock.fn(digestResourceSyncDescriptorV2), digest = createAppAuthorityDescriptorDigester(digester), raw = descriptor(); + const expected = await digestResourceSyncDescriptorV2(raw); + assert.equal(await digest(raw, scope), expected); + assert.equal(await digest(structuredClone(raw), scope), expected); + assert.equal(digester.mock.callCount(), 1); + await digest(raw, { ...scope, org_id: 'org-b' }); + await digest(raw, { ...scope, app_version_id: 'version-b' }); + assert.equal(digester.mock.callCount(), 3, 'identical content cannot cross organization/version scope'); +}); + +test('every changed descriptor subtree recomputes under unchanged descriptor identity', async () => { + const raw = descriptor(), digester = mock.fn(digestResourceSyncDescriptorV2), digest = createAppAuthorityDescriptorDigester(digester); + const initial = await digest(raw, scope); + const changes = [ + (value: typeof raw) => { value.record_schema.properties.body.maxLength--; }, + (value: typeof raw) => { value.record_schema.required = ['subject']; }, + (value: typeof raw) => { value.attachments.max_attachment_bytes--; }, + (value: typeof raw) => { value.attachments.allowed_media_types = ['text/plain']; }, + (value: typeof raw) => { value.attachments.retention_days++; }, + (value: typeof raw) => { value.runtime_requirement_key = 'other_provider'; }, + (value: typeof raw) => { value.resource_type = 'other_record'; }, + ]; + for (const change of changes) { + const changed = structuredClone(raw); change(changed); + assert.equal(changed.key, raw.key); + const expected = await digestResourceSyncDescriptorV2(changed); + assert.notEqual(expected, initial); + assert.equal(await digest(changed, scope), expected); + } + assert.equal(digester.mock.callCount(), changes.length + 1); + assert.equal(await digest(raw, scope), initial, 'mutating caller input cannot poison retained content'); +}); + +test('malformed content never reuses valid content and failures never seed a digest', async () => { + const raw = descriptor(), digester = mock.fn(digestResourceSyncDescriptorV2), digest = createAppAuthorityDescriptorDigester(digester); + await digest(raw, scope); + for (const changed of [{ ...raw, unexpected: true }, { ...raw, schema_version: 'wrong' }, + { ...raw, attachments: { ...raw.attachments, max_attachment_bytes: -1 } }, + { ...raw, record_schema: { ...raw.record_schema, properties: { subject: { type: 'invalid' } } } }]) { + const before = digester.mock.callCount(); + await assert.rejects(digest(changed, scope)); await assert.rejects(digest(changed, scope)); + assert.equal(digester.mock.callCount(), before + 2); + } +}); + +test('non-JSON, lossy and oversized evidence always runs the original digester without retention', async () => { + const expected = await digestResourceSyncDescriptorV2(descriptor()); + const digester = mock.fn(async (_raw: unknown): Promise => expected), digest = createAppAuthorityDescriptorDigester(digester); + const accessor = Object.defineProperty({}, 'value', { enumerable: true, get: () => 'do not inspect' }); + const symbol = { [Symbol('key')]: true }, prototype = Object.create({ inherited: true }); + const cyclic: { self?: unknown } = {}; cyclic.self = cyclic; + for (const raw of [{ value: undefined }, { value: NaN }, { value: -0 }, { value: new Date() }, + { value: () => {} }, { value: Array(1) }, accessor, symbol, prototype, cyclic, + { value: 'x'.repeat(APP_AUTHORITY_DESCRIPTOR_CACHE_KEY_BYTES + 1) }]) { + const before = digester.mock.callCount(); + assert.equal(await digest(raw, scope), expected); assert.equal(await digest(raw, scope), expected); + assert.equal(digester.mock.callCount(), before + 2); + } +}); + +test('async mutation cannot seed an earlier content fingerprint', async () => { + const raw = descriptor(), original = structuredClone(raw); + let release!: () => void, captured!: () => void, calls = 0; + const gate = new Promise(resolve => { release = resolve; }); + const started = new Promise(resolve => { captured = resolve; }); + const digest = createAppAuthorityDescriptorDigester(async value => { + calls++; const result = await digestResourceSyncDescriptorV2(structuredClone(value)); + if (calls === 1) { captured(); await gate; } + return result; + }); + const first = digest(raw, scope); await started; + raw.attachments.max_attachment_bytes--; release(); await first; + assert.equal(await digest(raw, scope), await digestResourceSyncDescriptorV2(raw)); + await digest(original, scope); + assert.equal(calls, 3, 'changed-in-flight evidence must not retain the first digest'); +}); + +test('real Kit captures bytes synchronously and an ABA caller mutation cannot poison the digest', async () => { + const raw = descriptor(), expected = await digestResourceSyncDescriptorV2(raw); + let returned = false, reads = 0; + const guarded = new Proxy(raw, { get(target, key, receiver) { + assert.equal(returned, false, 'Kit must not read caller JSON after its first await'); reads++; + return Reflect.get(target, key, receiver); + } }); + const pendingKit = digestResourceSyncDescriptorV2(guarded); + returned = true; assert.ok(reads > 0, 'real schema parsing starts before returning the promise'); + assert.equal(await pendingKit, expected); + const digester = mock.fn(digestResourceSyncDescriptorV2), digest = createAppAuthorityDescriptorDigester(digester); + const pending = digest(raw, scope); + raw.attachments.max_attachment_bytes--; raw.attachments.max_attachment_bytes++; + assert.equal(await pending, expected); + assert.equal(await digest(raw, scope), expected); assert.equal(digester.mock.callCount(), 1); + raw.attachments.max_attachment_bytes--; + assert.equal(await digest(raw, scope), await digestResourceSyncDescriptorV2(raw)); + assert.equal(digester.mock.callCount(), 2); +}); + +test('concurrent misses retain no promise and bounded eviction computes again', async () => { + const raw = descriptor(), digester = mock.fn(digestResourceSyncDescriptorV2), digest = createAppAuthorityDescriptorDigester(digester); + await Promise.all([digest(raw, scope), digest(structuredClone(raw), scope)]); + assert.equal(digester.mock.callCount(), 2); + await digest(raw, scope); assert.equal(digester.mock.callCount(), 2); + for (let index = 0; index < APP_AUTHORITY_DESCRIPTOR_CACHE_ENTRIES; index++) await digest({ ...raw, key: 'resource_' + index }, scope); + const before = digester.mock.callCount(); await digest(raw, scope); + assert.equal(digester.mock.callCount(), before + 1); +}); diff --git a/apps/api/test/app-run-bounded-db.test.ts b/apps/api/test/app-run-bounded-db.test.ts new file mode 100644 index 00000000..d0a7e0d5 --- /dev/null +++ b/apps/api/test/app-run-bounded-db.test.ts @@ -0,0 +1,183 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import pg from 'pg'; +import { eq, sql } from 'drizzle-orm'; +import { appRunAttempts } from '@deft/db/schema'; +import { APP_RUN_TRANSACTION_LIMITS, createBoundedAppRunDatabase } from '../src/lib/app-run-bounded-db.js'; +import type { AppRunTransaction } from '../src/lib/app-run-repository.js'; + +type Query = { text: string; values?: unknown[]; rowMode?: string }; +class Client { + queries: Query[] = []; + releases: boolean[] = []; + fail = false; + async query(value: string | Query, values?: unknown[]) { + const query = typeof value === 'string' ? { text: value, values } : { ...value, values }; + this.queries.push(query); + if (this.fail && /^select .*lease_expires_at/i.test(query.text)) throw Error('Statement failed'); + const rows = /^select .*lease_expires_at/i.test(query.text) ? [['2026-10-22 12:00:00.123']] : []; + return { rows, fields: [], rowCount: rows.length, command: 'SELECT' }; + } + release(discard = false) { this.releases.push(discard); } +} + +async function fixture(work: (database: ReturnType, clients: Client[], options: Record) => Promise) { + const original = pg.Pool; + const clients: Client[] = []; + let options: Record = {}; + class Pool { + constructor(value: Record) { options = value; } + on() { return this; } + async connect() { const client = new Client(); clients.push(client); return client; } + async end() {} + } + // Only the network transport is replaced; production Drizzle creates and + // executes every transaction, table query, codec and savepoint below. + (pg as unknown as { Pool: unknown }).Pool = Pool; + const database = createBoundedAppRunDatabase('postgres://unused/isolated', { max: 1, application_name: 'bounded-test' }); + try { await work(database, clients, options); } + finally { await database.close(); pg.Pool = original; } +} +const signal = () => new AbortController().signal; +const deadline = () => performance.now() + 5000; +const commands = (client: Client) => client.queries.map(query => query.text.toLowerCase()); + +test('three bounded transactions retain fresh clients, native Date codecs and heartbeat updates without relational metadata', async () => { + await fixture(async (database, clients, options) => { + const transactions = new Set(); + for (let index = 0; index < 3; index++) { + await database.transaction(async tx => { + transactions.add(tx); + // The unsupported API must not reconstruct the complete schema merely + // to populate relational query builders on every bounded transaction. + assert.deepEqual(Object.keys((tx as unknown as { query: object }).query), []); + const [row] = await tx.select({ lease: appRunAttempts.lease_expires_at }).from(appRunAttempts); + assert.ok(row!.lease instanceof Date); + assert.equal(row!.lease.toISOString(), '2026-10-22T12:00:00.123Z'); + await tx.update(appRunAttempts).set({ lease_expires_at: row!.lease, updated_at: row!.lease }) + .where(eq(appRunAttempts.id, '00000000-0000-0000-0000-000000000001')); + }, signal(), deadline()); + } + assert.equal(transactions.size, 3); assert.equal(clients.length, 3); + for (const client of clients) { + assert.equal(commands(client)[0], 'begin'); assert.equal(commands(client).at(-1), 'commit'); + assert.equal(commands(client).filter(command => command === 'begin').length, 1); + assert.deepEqual(client.releases, [false]); + const update = client.queries.find(query => /^update /i.test(query.text))!; + assert.equal(update.values![0], '2026-10-22T12:00:00.123Z'); + assert.equal(update.values![1], '2026-10-22T12:00:00.123Z'); + } + assert.equal(options.statement_timeout, APP_RUN_TRANSACTION_LIMITS.statement_ms); + assert.equal(options.lock_timeout, APP_RUN_TRANSACTION_LIMITS.lock_ms); + assert.equal(options.connectionTimeoutMillis, APP_RUN_TRANSACTION_LIMITS.acquisition_ms); + }); +}); + +test('core-only nested transactions retain savepoint rollback and parent commit', async () => { + await fixture(async (database, clients) => { + await database.transaction(async tx => { + await assert.rejects(tx.transaction(async nested => { await nested.execute(sql`SELECT 1`); throw Error('Nested failure'); }), /Nested failure/); + await tx.execute(sql`SELECT 2`); + }, signal(), deadline()); + assert.equal(clients.length, 1); + assert.deepEqual(commands(clients[0]!), ['begin', 'savepoint sp1', 'select 1', 'rollback to savepoint sp1', 'select 2', 'commit']); + assert.deepEqual(clients[0]!.releases, [false]); + }); +}); + +test('callback failure rolls back the current transaction and never commits', async () => { + await fixture(async (database, clients) => { + await assert.rejects(database.transaction(async tx => { await tx.execute(sql`SELECT 1`); throw Error('Work failed'); }, signal(), deadline()), /Work failed/); + assert.deepEqual(commands(clients[0]!), ['begin', 'select 1', 'rollback']); + assert.deepEqual(clients[0]!.releases, [false]); + }); +}); + +test('pre-aborted and expired work cannot acquire a client', async () => { + await fixture(async (database, clients) => { + const controller = new AbortController(); controller.abort(); + await assert.rejects(database.transaction(async () => { assert.fail('Aborted work'); }, controller.signal, deadline())); + await assert.rejects(database.transaction(async () => { assert.fail('Expired work'); }, signal(), performance.now() - 1), /deadline/); + assert.equal(clients.length, 0); + }); +}); + +test('abort after a statement discards the exact client once and blocks commit', async () => { + await fixture(async (database, clients) => { + const controller = new AbortController(); + await assert.rejects(database.transaction(async tx => { await tx.execute(sql`SELECT 1`); controller.abort(); }, controller.signal, deadline())); + assert.ok(!commands(clients[0]!).includes('commit')); + assert.deepEqual(clients[0]!.releases, [true]); + }); +}); + +test('a deadline reached during work discards the client and blocks the next statement', async context => { + let now = performance.now(); + const clock = context.mock.method(performance, 'now', () => now); + context.mock.timers.enable({ apis: ['setTimeout'] }); + try { + await fixture(async (database, clients) => { + await assert.rejects(database.transaction(async tx => { + await tx.execute(sql`SELECT 1`); + now += 10_001; + context.mock.timers.tick(10_001); + await tx.execute(sql`SELECT 2`); + }, signal(), now + 10_000), + (error: unknown) => error instanceof Error && error.cause instanceof Error && error.cause.message === 'App Run transaction deadline'); + assert.ok(!commands(clients[0]!).includes('select 2')); + assert.ok(!commands(clients[0]!).includes('commit')); + assert.deepEqual(clients[0]!.releases, [true]); + }); + } finally { context.mock.timers.reset(); clock.mock.restore(); } +}); + +test('overlapping transactions keep independent clients and abort budgets', async () => { + await fixture(async (database, clients) => { + const controller = new AbortController(); + let enter!: () => void, resume!: () => void; + const entered = new Promise(resolve => { enter = resolve; }); + const held = new Promise(resolve => { resume = resolve; }); + const first = database.transaction(async tx => { await tx.execute(sql`SELECT 1`); enter(); await held; }, controller.signal, deadline()); + await entered; + await database.transaction(async tx => { await tx.execute(sql`SELECT 2`); controller.abort(); }, signal(), deadline()); + const rejected = assert.rejects(first); resume(); await rejected; + assert.equal(clients.length, 2); + assert.ok(!commands(clients[0]!).includes('commit')); assert.deepEqual(clients[0]!.releases, [true]); + assert.equal(commands(clients[1]!).at(-1), 'commit'); assert.deepEqual(clients[1]!.releases, [false]); + }); +}); + +test('a failed statement discards its current client without committing', async () => { + await fixture(async (database, clients) => { + await assert.rejects(database.transaction(async tx => { + clients[0]!.fail = true; + await tx.select({ lease: appRunAttempts.lease_expires_at }).from(appRunAttempts); + }, signal(), deadline())); + assert.ok(!commands(clients[0]!).includes('commit')); assert.deepEqual(clients[0]!.releases, [true]); + }); +}); + +test('remaining transaction budget still bounds statement and lock timeouts', async () => { + await fixture(async (database, clients) => { + await database.transaction(async tx => { await tx.execute(sql`SELECT 1`); }, signal(), performance.now() + 1000); + const settings = clients[0]!.queries.filter(query => query.text.includes("set_config('statement_timeout'")); + assert.ok(settings.length > 0); + for (const setting of settings) { + assert.ok(Number(setting.values![0]) > 0 && Number(setting.values![0]) <= 1000); + assert.ok(Number(setting.values![1]) > 0 && Number(setting.values![1]) <= APP_RUN_TRANSACTION_LIMITS.lock_ms); + } + assert.equal(commands(clients[0]!).at(-1), 'commit'); + }); +}); + +// Include this test in a TypeScript check to verify both unsupported APIs; +// the core column inference and codecs are exercised by the runtime test too. +function coreContract(tx: AppRunTransaction) { + // @ts-expect-error Bounded Run transactions do not expose relational queries. + void tx.query; + return tx.transaction(async nested => { + // @ts-expect-error A nested transaction has the same core-only contract. + void nested.query; + }); +} +void coreContract; diff --git a/docs/decisions/2026-10-22-operation-authority-proofs.md b/docs/decisions/2026-10-22-operation-authority-proofs.md index fe658797..1e98dbc1 100644 --- a/docs/decisions/2026-10-22-operation-authority-proofs.md +++ b/docs/decisions/2026-10-22-operation-authority-proofs.md @@ -41,6 +41,16 @@ The final integrated staging cohort has 20 warm samples per path: browser p50/p9 New compose used the legacy optional-default opening path, which repeated account/metadata/recovery reads and timed out after 30 seconds on the demo. Reuse the existing compound snapshot wrapper for forms with an optional initial change. Each default source request retains its own current Host and Runtime authority; a final fresh destination target/consent proof follows the optional read before displaying snapshot content. Reject authoritative denials, account/pin drift and expired snapshots; absent optional defaults retain the existing unavailable notice. Existing saved rows skip defaults, and later browser recovery still reads current authority and the exact row. This uses existing declarations and capabilities and leaves Send review unchanged. +## Full resource callback CPU refinement + +The full resource callback is a different cost from the DATA callback: Host138 executes 126 warm statements and three bounded transactions, including its mandatory heartbeat. An exact warm V8 profile found three Drizzle relational-schema extractions inside those transactions, with 50.463 ms sampled union time. Core table queries already carry their columns/codecs and never use the relational query API. Omit the unused schema from bounded Drizzle construction and use a neutral core-only transaction contract for the shared consumers. Fresh clients, native Date decoding, existing queries, locks, lease updates, deadlines, cancellation and rollback remain unchanged. + +Reuse only the Kit-validated descriptor digest string by organization, current reviewed version and the complete freshly read descriptor bytes. The current DB/app/grant reads and every mutable authority check remain mandatory. The Kit digester snapshots strict parsed/canonical bytes before its first await; failed, unsupported, oversized or changed-in-flight evidence never seeds a stale digest. Scope/content drift, invalid input, concurrency/eviction, mutation isolation and current guard negatives have regression coverage. No registry credentials, current allowed result, client or transaction enter this cache. + +Each unprofiled staging cohort has 20 warm samples. RESOURCE p50/p95 changed 733.300/1011.329 ms to 629.274/953.796 ms, retaining the same 126-statement sequence; CPU p50/p95 was 393.682/596.859 ms versus 366.505/548.643 ms. DATA browser p95 was 377.160 ms versus 353.138 ms and delegated 343.637 ms versus 295.001 ms, retaining 33/37 statements. All callbacks allowed with zero durable writes/provider requests under clone-only rollback/integrity checks. Shared load differed, so these sequential cohorts do not isolate causal wall-time savings or prove a 150 ms floor. All targets remain required and missed; live acceptance must be repeated after release. + +Keep the existing separate heartbeat and final resource-authority transactions. An atomic lease refactor is not selected: original dynamic lock order, old-lease admission and rollback-on-every-denial equivalence are not proved. Part B remains deferred, and no fresh protected boundary is removed. + ## Part B: conditional, local preparation only Add the operation proof only if Part A misses latency targets, or if evidence demonstrates a clearly low-risk benefit. Record that decision and measurements before release. Part B is deferred while Part A is measured. diff --git a/integrations/hermes/integration-manifest.json b/integrations/hermes/integration-manifest.json index 43a71872..b84bdcc9 100644 --- a/integrations/hermes/integration-manifest.json +++ b/integrations/hermes/integration-manifest.json @@ -1,8 +1,8 @@ { "schema": "deft.hermes.integration.v2", "integration_version": "0.5.1", - "deft_release": "0.3.0-preview.138", - "deft_release_compatibility": "=0.3.0-preview.138", + "deft_release": "0.3.0-preview.139", + "deft_release_compatibility": "=0.3.0-preview.139", "agent_channel_protocols": [ "deft.agent_channel.v2" ], diff --git a/package.json b/package.json index 6c14ccdc..7cb52fee 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "deft", - "version": "0.3.0-preview.138", + "version": "0.3.0-preview.139", "private": true, "license": "AGPL-3.0-only", "repository": {