diff --git a/.gitignore b/.gitignore index 5fddc534..8ab57e3e 100644 --- a/.gitignore +++ b/.gitignore @@ -35,6 +35,10 @@ action_plane/typescript/node_modules/ action_plane/typescript/dist/ action_plane/typescript/*.tgz action_plane/typescript/*.tsbuildinfo +bot_delivery/typescript/node_modules/ +bot_delivery/typescript/dist/ +bot_delivery/typescript/*.tgz +bot_delivery/typescript/*.tsbuildinfo # --- OS / editor --- .DS_Store diff --git a/Makefile b/Makefile index dc029f15..3a6414cc 100644 --- a/Makefile +++ b/Makefile @@ -1,9 +1,10 @@ -.PHONY: install build lint typecheck test catalog security-gate smoke db-up db-down db-reset integration openapi-generate openapi-check openapi-breaking-check sdk-generate sdk-check sdk-build sdk-test sdk-pack action-typecheck action-build action-test check +.PHONY: install build lint typecheck test catalog security-gate smoke db-up db-down db-reset integration openapi-generate openapi-check openapi-breaking-check sdk-generate sdk-check sdk-build sdk-test sdk-pack action-typecheck action-build action-test bot-typecheck bot-build bot-test check install: uv sync --frozen npm --prefix sdk/typescript ci --ignore-scripts npm --prefix action_plane/typescript ci --ignore-scripts + npm --prefix bot_delivery/typescript ci --ignore-scripts build: uv build @@ -11,9 +12,10 @@ build: lint: uv run ruff check . -typecheck: +typecheck: sdk-build uv run mypy npm --prefix action_plane/typescript run typecheck + npm --prefix bot_delivery/typescript run typecheck test: uv run pytest -q tests/unit @@ -74,4 +76,14 @@ action-test: action-build npm --prefix action_plane/typescript run test:runtime npm --prefix action_plane/typescript run test:package -check: build lint typecheck openapi-check sdk-check sdk-build sdk-test sdk-pack action-build action-test test catalog smoke integration security-gate +bot-typecheck: + npm --prefix bot_delivery/typescript run typecheck + +bot-build: sdk-build + npm --prefix bot_delivery/typescript run build + +bot-test: bot-build + npm --prefix bot_delivery/typescript run test:runtime + npm --prefix bot_delivery/typescript run test:package + +check: build lint typecheck openapi-check sdk-check sdk-build sdk-test sdk-pack action-build action-test bot-build bot-test test catalog smoke integration security-gate diff --git a/bot_delivery/typescript/.node-version b/bot_delivery/typescript/.node-version new file mode 100644 index 00000000..1d9b7831 --- /dev/null +++ b/bot_delivery/typescript/.node-version @@ -0,0 +1 @@ +22.12.0 diff --git a/bot_delivery/typescript/README.md b/bot_delivery/typescript/README.md new file mode 100644 index 00000000..77d16501 --- /dev/null +++ b/bot_delivery/typescript/README.md @@ -0,0 +1,34 @@ +# ContextEngine BotDelivery model egress + +This private TypeScript module owns the only active model-generation boundary +for BotDelivery. It accepts a nominal `AuthorizedModelInput` built from exactly +one complete, current, audience-bound `ContextPackage` returned by the generated +resolve SDK, one opaque model `EgressGrant`, and the module-owned +`privateModelGatewayProfileV1()` policy. Callers cannot construct or modify the +registered profile. Before any provider bytes leave, the sealed +`createPrivateModelGenerationBoundary` factory creates and owns the PostgreSQL +client; no public constructor or structural query object can supply authority. +It redeems the grant through the dedicated non-owner, function-only PostgreSQL +egress authority with exact Organization, Package, audience, purpose, Policy Epoch, +provider, model, region, retention, sensitivity, issuer, consumer, and profile +bindings. + +Issue #70 activates only `DeterministicModelGatewayTwin`. The twin receives the +authorized Package blocks plus the declared question and instructions—never the +grant, trusted identity, denied details, audit data, or arbitrary extra text. A +successful result is bounded by the versioned profile, cites only Evidence from +that Package, and is recorded in digest-only restricted audit before release. +All binding, replay, provider, output, or audit failures return one generic +unavailable result. Real model providers, streaming, group delivery, and action +effects remain inactive; effects must use `ActionPlane.prepare` and +`ActionPlane.perform`. + +Repository verification builds the generated SDK before this package. Run the +`bot-typecheck`, `bot-build`, and `bot-test` Make targets for the standalone +contract, runtime, and installed-package checks. Real PostgreSQL and local API +evidence is exercised by the repository integration suite. + +All hashed JSON first passes the same I-JSON Unicode-scalar domain used by the +Python Package digest implementation. A shared fixture proves RFC 8785 Unicode, +UTF-16 property ordering, number serialization, and lone-surrogate rejection +across both runtimes. diff --git a/bot_delivery/typescript/THIRD_PARTY_NOTICES.md b/bot_delivery/typescript/THIRD_PARTY_NOTICES.md new file mode 100644 index 00000000..68b922bf --- /dev/null +++ b/bot_delivery/typescript/THIRD_PARTY_NOTICES.md @@ -0,0 +1,14 @@ +# Third-party notices + +Production dependencies are pinned by `package-lock.json`. + +- `canonicalize` 3.0.0 — Apache License 2.0. Used only to encode RFC 8785 JSON + documents before hashing. +- `pg` 8.22.0 and its pinned transitive dependencies — MIT or compatible + licenses. Used by the sealed boundary to reach the dedicated PostgreSQL + egress authority; callers cannot inject a database implementation. +- `@context-engine/resolve-sdk` 0.0.0-v0 — private peer package from this + repository, used only for the frozen public resolve wire types. + +Complete third-party license texts are included with installed packages and +their published distributions. diff --git a/bot_delivery/typescript/package-lock.json b/bot_delivery/typescript/package-lock.json new file mode 100644 index 00000000..7d849246 --- /dev/null +++ b/bot_delivery/typescript/package-lock.json @@ -0,0 +1,250 @@ +{ + "name": "@context-engine/bot-delivery", + "version": "0.0.0-m2-model-egress", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@context-engine/bot-delivery", + "version": "0.0.0-m2-model-egress", + "license": "UNLICENSED", + "dependencies": { + "canonicalize": "3.0.0", + "pg": "8.22.0" + }, + "devDependencies": { + "@context-engine/resolve-sdk": "file:../../sdk/typescript", + "@types/node": "22.10.2", + "@types/pg": "8.20.0", + "typescript": "5.9.3" + }, + "engines": { + "node": "22.12.0", + "npm": "10.9.0" + }, + "peerDependencies": { + "@context-engine/resolve-sdk": "0.0.0-v0" + } + }, + "../../sdk/typescript": { + "name": "@context-engine/resolve-sdk", + "version": "0.0.0-v0", + "dev": true, + "license": "UNLICENSED", + "devDependencies": { + "@hey-api/openapi-ts": "0.95.0", + "@types/node": "22.10.2", + "typescript": "5.9.3" + }, + "engines": { + "node": "22.12.0", + "npm": "10.9.0" + } + }, + "node_modules/@context-engine/resolve-sdk": { + "resolved": "../../sdk/typescript", + "link": true + }, + "node_modules/@types/node": { + "version": "22.10.2", + "resolved": "https://registry.npmmirror.com/@types/node/-/node-22.10.2.tgz", + "integrity": "sha512-Xxr6BBRCAOQixvonOye19wnzyDiUtTeqldOOmj3CkeblonbccA12PFwlufvRdrpjXxqnmUaeiU5EOA+7s5diUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.20.0" + } + }, + "node_modules/@types/pg": { + "version": "8.20.0", + "resolved": "https://registry.npmmirror.com/@types/pg/-/pg-8.20.0.tgz", + "integrity": "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "pg-protocol": "*", + "pg-types": "^2.2.0" + } + }, + "node_modules/canonicalize": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/canonicalize/-/canonicalize-3.0.0.tgz", + "integrity": "sha512-yYLfHyDMIXRyRqsKBRLX023riFLpXY2YOfdtqKXZRZy9qsfOJ9U+4F9YZL7MEzL5+ziN2x2nlBvY/Voi3EBljA==", + "license": "Apache-2.0", + "bin": { + "canonicalize": "bin/canonicalize.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/pg": { + "version": "8.22.0", + "resolved": "https://registry.npmmirror.com/pg/-/pg-8.22.0.tgz", + "integrity": "sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.14.0", + "pg-pool": "^3.14.0", + "pg-protocol": "^1.15.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.4.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.4.0", + "resolved": "https://registry.npmmirror.com/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz", + "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.14.0", + "resolved": "https://registry.npmmirror.com/pg-connection-string/-/pg-connection-string-2.14.0.tgz", + "integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.14.0", + "resolved": "https://registry.npmmirror.com/pg-pool/-/pg-pool-3.14.0.tgz", + "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.15.0", + "resolved": "https://registry.npmmirror.com/pg-protocol/-/pg-protocol-1.15.0.tgz", + "integrity": "sha512-cq9sECI5s0+uPUXjbz8ioyPJni6RzsRib0US67i5IoTZKw8fNeYlVE7u8F4dG7vEJJtc5wdD1K189lCCUwqWTQ==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmmirror.com/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmmirror.com/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmmirror.com/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmmirror.com/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.20.0", + "resolved": "https://registry.npmmirror.com/undici-types/-/undici-types-6.20.0.tgz", + "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==", + "dev": true, + "license": "MIT" + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmmirror.com/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + } + } +} diff --git a/bot_delivery/typescript/package.json b/bot_delivery/typescript/package.json new file mode 100644 index 00000000..5cc7d774 --- /dev/null +++ b/bot_delivery/typescript/package.json @@ -0,0 +1,46 @@ +{ + "name": "@context-engine/bot-delivery", + "version": "0.0.0-m2-model-egress", + "description": "Trusted Package-bound BotDelivery model egress module", + "type": "module", + "private": true, + "license": "UNLICENSED", + "engines": { + "node": "22.12.0", + "npm": "10.9.0" + }, + "packageManager": "npm@10.9.0", + "exports": { + ".": { + "types": "./dist/public.d.ts", + "import": "./dist/public.js" + } + }, + "types": "./dist/public.d.ts", + "files": [ + "dist", + "README.md", + "THIRD_PARTY_NOTICES.md" + ], + "scripts": { + "clean": "node scripts/clean.mjs", + "typecheck": "tsc --project tsconfig.json --noEmit", + "build": "npm run clean && tsc --project tsconfig.build.json", + "test:runtime": "node --test test/model-egress.test.mjs", + "test:package": "node scripts/test-package.mjs", + "test": "npm run typecheck && npm run build && npm run test:runtime && npm run test:package" + }, + "dependencies": { + "canonicalize": "3.0.0", + "pg": "8.22.0" + }, + "peerDependencies": { + "@context-engine/resolve-sdk": "0.0.0-v0" + }, + "devDependencies": { + "@context-engine/resolve-sdk": "file:../../sdk/typescript", + "@types/node": "22.10.2", + "@types/pg": "8.20.0", + "typescript": "5.9.3" + } +} diff --git a/bot_delivery/typescript/scripts/clean.mjs b/bot_delivery/typescript/scripts/clean.mjs new file mode 100644 index 00000000..b04f5f9f --- /dev/null +++ b/bot_delivery/typescript/scripts/clean.mjs @@ -0,0 +1,4 @@ +import { rmSync } from "node:fs"; +import { resolve } from "node:path"; + +rmSync(resolve(import.meta.dirname, "../dist"), { force: true, recursive: true }); diff --git a/bot_delivery/typescript/scripts/test-package.mjs b/bot_delivery/typescript/scripts/test-package.mjs new file mode 100644 index 00000000..14b23d0e --- /dev/null +++ b/bot_delivery/typescript/scripts/test-package.mjs @@ -0,0 +1,166 @@ +import { cpSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { builtinModules } from "node:module"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { spawnSync } from "node:child_process"; + +import ts from "typescript"; + +const packageRoot = resolve(import.meta.dirname, ".."); +const sdkRoot = resolve(packageRoot, "../../sdk/typescript"); +const temporaryRoot = mkdtempSync(join(tmpdir(), "context-engine-bot-delivery-package-")); +const builtins = new Set(builtinModules.flatMap((name) => [name, `node:${name}`])); + +function externalPackageName(specifier) { + if ( + specifier.startsWith(".") + || specifier.startsWith("/") + || specifier.startsWith("#") + || specifier.startsWith("node:") + || builtins.has(specifier) + ) { + return undefined; + } + const segments = specifier.split("/"); + return specifier.startsWith("@") ? segments.slice(0, 2).join("/") : segments[0]; +} + +function importedPackages(filename) { + const sourceFile = ts.createSourceFile( + filename, + readFileSync(filename, "utf8"), + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.JS, + ); + const imported = new Set(); + function visit(node) { + let moduleSpecifier; + if ( + (ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) + && node.moduleSpecifier !== undefined + && ts.isStringLiteralLike(node.moduleSpecifier) + ) { + moduleSpecifier = node.moduleSpecifier.text; + } else if ( + ts.isCallExpression(node) + && ( + node.expression.kind === ts.SyntaxKind.ImportKeyword + || (ts.isIdentifier(node.expression) && node.expression.text === "require") + ) + && node.arguments.length === 1 + && ts.isStringLiteralLike(node.arguments[0]) + ) { + moduleSpecifier = node.arguments[0].text; + } + if (moduleSpecifier !== undefined) { + const packageName = externalPackageName(moduleSpecifier); + if (packageName !== undefined) imported.add(packageName); + } + ts.forEachChild(node, visit); + } + visit(sourceFile); + return imported; +} + +function assertPackedRuntimeImportsAreDeclared(installedPackageRoot, packedFiles) { + const document = JSON.parse(readFileSync(join(installedPackageRoot, "package.json"), "utf8")); + const declared = new Set([ + ...Object.keys(document.dependencies ?? {}), + ...Object.keys(document.optionalDependencies ?? {}), + ...Object.keys(document.peerDependencies ?? {}), + ]); + const observed = new Set(); + const undeclared = new Set(); + for (const packedFile of packedFiles) { + if (!packedFile.path.endsWith(".js")) continue; + for (const packageName of importedPackages(resolve(installedPackageRoot, packedFile.path))) { + observed.add(packageName); + if (!declared.has(packageName)) undeclared.add(packageName); + } + } + if (undeclared.size > 0) { + throw new Error( + `packed BotDelivery has undeclared runtime imports: ${[...undeclared].sort().join(", ")}`, + ); + } + for (const required of ["canonicalize", "pg"]) { + if (!observed.has(required)) { + throw new Error(`packed runtime import audit did not observe ${required}`); + } + } +} + +function run(command, args, cwd) { + const result = spawnSync(command, args, { + cwd, + encoding: "utf8", + env: { + ...process.env, + npm_config_cache: join(temporaryRoot, "npm-cache"), + npm_config_offline: "true", + }, + timeout: 120_000, + }); + if (result.status !== 0) { + throw new Error( + `${command} ${args.join(" ")} failed:\n${result.error ?? ""}\n${result.stdout}\n${result.stderr}`, + ); + } + return result.stdout; +} + +function pack(packageDirectory) { + const output = run( + "npm", + ["pack", "--json", "--ignore-scripts", "--pack-destination", temporaryRoot], + packageDirectory, + ); + const report = JSON.parse(output); + const filename = report[0]?.filename; + if (typeof filename !== "string") { + throw new Error(`npm pack did not report an artifact for ${packageDirectory}`); + } + return { filename, report: report[0] }; +} + +try { + const botArtifact = pack(packageRoot); + const sdkArtifact = pack(sdkRoot); + const consumerRoot = join(temporaryRoot, "consumer"); + cpSync(resolve(packageRoot, "test/package-consumer"), consumerRoot, { recursive: true }); + const packageDocument = JSON.parse(readFileSync(join(consumerRoot, "package.json"), "utf8")); + const packageLock = JSON.parse(readFileSync(join(packageRoot, "package-lock.json"), "utf8")); + const localProductionDependencies = {}; + const localOptionalDependencies = {}; + for (const [dependencyPath, metadata] of Object.entries(packageLock.packages ?? {})) { + if (!dependencyPath.startsWith("node_modules/") || metadata.dev === true) continue; + const dependencyRoot = resolve(packageRoot, dependencyPath); + const dependencyDocument = JSON.parse(readFileSync(join(dependencyRoot, "package.json"), "utf8")); + const target = metadata.optional === true + ? localOptionalDependencies + : localProductionDependencies; + target[dependencyDocument.name] = `file:${dependencyRoot}`; + } + packageDocument.dependencies = { + "@context-engine/bot-delivery": `file:${join(temporaryRoot, botArtifact.filename)}`, + "@context-engine/resolve-sdk": `file:${join(temporaryRoot, sdkArtifact.filename)}`, + ...localProductionDependencies, + }; + packageDocument.optionalDependencies = localOptionalDependencies; + writeFileSync(join(consumerRoot, "package.json"), `${JSON.stringify(packageDocument, null, 2)}\n`); + run("npm", ["install", "--ignore-scripts"], consumerRoot); + assertPackedRuntimeImportsAreDeclared( + join(consumerRoot, "node_modules/@context-engine/bot-delivery"), + botArtifact.report?.files ?? [], + ); + run( + process.execPath, + [resolve(packageRoot, "node_modules/typescript/bin/tsc"), "--project", "tsconfig.json"], + consumerRoot, + ); + run(process.execPath, ["runtime.mjs"], consumerRoot); + process.stdout.write("packed BotDelivery consumer passed\n"); +} finally { + rmSync(temporaryRoot, { force: true, recursive: true }); +} diff --git a/bot_delivery/typescript/src/canonical-json.ts b/bot_delivery/typescript/src/canonical-json.ts new file mode 100644 index 00000000..d61f4a9e --- /dev/null +++ b/bot_delivery/typescript/src/canonical-json.ts @@ -0,0 +1,109 @@ +import { createHash } from "node:crypto"; + +import canonicalize from "canonicalize"; + +function requireUnicodeScalars(value: string): void { + for (let index = 0; index < value.length; index += 1) { + const codeUnit = value.charCodeAt(index); + if (codeUnit >= 0xd800 && codeUnit <= 0xdbff) { + const next = value.charCodeAt(index + 1); + if (!(next >= 0xdc00 && next <= 0xdfff)) { + throw new TypeError("canonical JSON strings must contain Unicode scalar values"); + } + index += 1; + } else if (codeUnit >= 0xdc00 && codeUnit <= 0xdfff) { + throw new TypeError("canonical JSON strings must contain Unicode scalar values"); + } + } +} + +function validateCanonicalJson(value: unknown, ancestors: Set): void { + if (value === null || typeof value === "boolean") return; + if (typeof value === "string") { + requireUnicodeScalars(value); + return; + } + if (typeof value === "number") { + if (!Number.isFinite(value)) { + throw new TypeError("canonical JSON numbers must be finite IEEE 754 values"); + } + return; + } + if (typeof value !== "object") { + throw new TypeError("canonical JSON accepts only exact JSON values"); + } + if (ancestors.has(value)) { + throw new TypeError("canonical JSON must not contain cyclic containers"); + } + ancestors.add(value); + try { + if (Array.isArray(value)) { + if (Object.getPrototypeOf(value) !== Array.prototype) { + throw new TypeError("canonical JSON accepts only exact arrays"); + } + const ownKeys = Reflect.ownKeys(value); + const expectedKeys = Array.from({ length: value.length }, (_, index) => String(index)); + if ( + ownKeys.length !== expectedKeys.length + 1 + || ownKeys[ownKeys.length - 1] !== "length" + || expectedKeys.some((key, index) => ownKeys[index] !== key) + ) { + throw new TypeError("canonical JSON arrays must be dense and unadorned"); + } + for (const item of value) validateCanonicalJson(item, ancestors); + return; + } + const prototype = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null) { + throw new TypeError("canonical JSON accepts only exact objects"); + } + const ownKeys = Reflect.ownKeys(value); + if (ownKeys.some((key) => typeof key !== "string")) { + throw new TypeError("canonical JSON objects require exact string keys"); + } + for (const key of ownKeys as string[]) { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (descriptor?.enumerable !== true || !("value" in descriptor)) { + throw new TypeError("canonical JSON objects require enumerable data properties"); + } + requireUnicodeScalars(key); + validateCanonicalJson(descriptor.value, ancestors); + } + } finally { + ancestors.delete(value); + } +} + +export function canonicalJson(value: unknown): Buffer { + validateCanonicalJson(value, new Set()); + const encoded = canonicalize(value); + if (encoded === undefined) { + throw new TypeError("value is outside the RFC 8785 JSON domain"); + } + return Buffer.from(encoded, "utf8"); +} + +function rejectEmbeddedPackageDigest(value: unknown): void { + if (Array.isArray(value)) { + for (const item of value) rejectEmbeddedPackageDigest(item); + return; + } + if (typeof value !== "object" || value === null) return; + for (const key of Object.keys(value)) { + if (key === "packageDigest") { + throw new TypeError("Package digest document must not contain packageDigest"); + } + rejectEmbeddedPackageDigest((value as Readonly>)[key]); + } +} + +/** Digest one Package document whose wire-level digest field has been excluded. */ +export function contextPackageDocumentDigest(value: unknown): string { + rejectEmbeddedPackageDigest(value); + return createHash("sha256").update(canonicalJson(value)).digest("hex"); +} + +/** Internal conformance seam; package exports do not expose this module. */ +export function canonicalJsonDigest(value: unknown): string { + return createHash("sha256").update(canonicalJson(value)).digest("hex"); +} diff --git a/bot_delivery/typescript/src/index.ts b/bot_delivery/typescript/src/index.ts new file mode 100644 index 00000000..0e341d70 --- /dev/null +++ b/bot_delivery/typescript/src/index.ts @@ -0,0 +1,1162 @@ +import { createHash, timingSafeEqual } from "node:crypto"; + +import type { + ContextPackageWire, + ModelEgressGrantWire, +} from "@context-engine/resolve-sdk"; +import pg from "pg"; + +import { canonicalJson, contextPackageDocumentDigest } from "./canonical-json.js"; + +const { Pool } = pg; + +const SHA256_HEX = /^[0-9a-f]{64}$/; +const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; +const PACKAGE_REF_PATTERN = /^pkg_[0-9a-f]{32}$/; +const EVIDENCE_REF_PATTERN = /^ev_[0-9a-f]{64}$/; +const BLOCK_REF_PATTERN = /^block_[0-9a-f]{64}$/; +const MODEL_GRANT_PATTERN = /^egrm_[0-9a-f]{64}$/; +const UTC_TIMESTAMP_PATTERN = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.(\d{1,6}))?Z$/; +const MODEL_INPUT_DOMAIN = Buffer.from("context-engine.authorized-model-input.v1\0"); +const ANSWER_PAYLOAD_DOMAIN = Buffer.from("context-engine.answer-payload.v1\0"); +const QUESTION_DIGEST_DOMAIN = Buffer.from("context-engine.model-question.v1\0"); +const EGRESS_GRANT_DIGEST_PROFILE = "egress-grant-locator-sha256-v1"; +const MODEL_AUDIT_PROFILE = "model-generation-audit-v1"; + +function requireExactKeys( + name: string, + value: unknown, + expected: readonly string[], +): Readonly> { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new TypeError(`${name} must be one closed object`); + } + const prototype = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null) { + throw new TypeError(`${name} must be one closed object`); + } + const ownKeys = Reflect.ownKeys(value); + if (ownKeys.some((key) => typeof key !== "string")) { + throw new TypeError(`${name} has an invalid field set`); + } + const observed = (ownKeys as string[]).sort(); + const required = [...expected].sort(); + if (observed.length !== required.length || observed.some((key, index) => key !== required[index])) { + throw new TypeError(`${name} has an invalid field set`); + } + for (const key of observed) { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (descriptor?.enumerable !== true || !("value" in descriptor)) { + throw new TypeError(`${name} must use enumerable data fields`); + } + } + return value as Readonly>; +} + +function requireRef(name: string, value: unknown, maximum = 256): string { + if ( + typeof value !== "string" + || value.length === 0 + || value.length > maximum + || value.trim() !== value + || /\s/u.test(value) + ) { + throw new TypeError(`${name} must be a bounded opaque reference`); + } + return value; +} + +function requireSha256(name: string, value: unknown): string { + if (typeof value !== "string" || !SHA256_HEX.test(value)) { + throw new TypeError(`${name} must be lowercase SHA-256`); + } + return value; +} + +function requirePattern(name: string, value: unknown, pattern: RegExp): string { + if (typeof value !== "string" || !pattern.test(value)) { + throw new TypeError(`${name} is invalid`); + } + return value; +} + +function requireUuid(name: string, value: unknown): string { + if (typeof value !== "string" || !UUID_PATTERN.test(value)) { + throw new TypeError(`${name} must be a canonical UUID`); + } + return value; +} + +function requirePositiveInteger(name: string, value: unknown): number { + if (!Number.isSafeInteger(value) || (value as number) < 1) { + throw new TypeError(`${name} must be a positive safe integer`); + } + return value as number; +} + +function requireNonnegativeInteger(name: string, value: unknown): number { + if (!Number.isSafeInteger(value) || (value as number) < 0) { + throw new TypeError(`${name} must be a nonnegative safe integer`); + } + return value as number; +} + +function requireCanonicalTimestamp(name: string, value: unknown): bigint { + if (typeof value !== "string") { + throw new TypeError(`${name} must be a canonical timestamp`); + } + const match = UTC_TIMESTAMP_PATTERN.exec(value); + if (match === null) { + throw new TypeError(`${name} must be a canonical timestamp`); + } + const parsed = new Date(value); + const [, year, month, day, hour, minute, second, fraction = ""] = match; + const millisecond = Number(fraction.padEnd(3, "0").slice(0, 3)); + if ( + Number.isNaN(parsed.getTime()) + || parsed.getUTCFullYear() !== Number(year) + || parsed.getUTCMonth() + 1 !== Number(month) + || parsed.getUTCDate() !== Number(day) + || parsed.getUTCHours() !== Number(hour) + || parsed.getUTCMinutes() !== Number(minute) + || parsed.getUTCSeconds() !== Number(second) + || parsed.getUTCMilliseconds() !== millisecond + ) { + throw new TypeError(`${name} must be a canonical timestamp`); + } + const subMillisecondMicroseconds = BigInt(fraction.padEnd(6, "0").slice(3)); + return BigInt(parsed.getTime()) * 1_000n + subMillisecondMicroseconds; +} + +function requireDate(name: string, value: unknown): Date { + if (!(value instanceof Date) || Number.isNaN(value.getTime())) { + throw new TypeError(`${name} must be a valid Date`); + } + return value; +} + +function safeEqual(left: string, right: string): boolean { + return Buffer.byteLength(left) === Buffer.byteLength(right) + && timingSafeEqual(Buffer.from(left), Buffer.from(right)); +} + +const PROFILE_KEYS = [ + "auditRetentionSeconds", + "consumerRef", + "issuerRef", + "maximumCostMicrounits", + "maximumElapsedMs", + "maximumInputBytes", + "maximumInstructionBytes", + "maximumOutputBytes", + "maximumProviderCalls", + "maximumQuestionBytes", + "modelRef", + "profileRef", + "providerRef", + "regionRef", + "retentionPolicyRef", + "sensitivityPolicyRef", +] as const; + +const PRIVATE_MODEL_GATEWAY_PROFILE_V1 = Object.freeze({ + auditRetentionSeconds: 2_592_000, + consumerRef: "model-gateway-integration", + issuerRef: "context-runtime-integration", + maximumCostMicrounits: 10_000, + maximumElapsedMs: 2_000, + maximumInputBytes: 32_768, + maximumInstructionBytes: 512, + maximumOutputBytes: 2_048, + maximumProviderCalls: 1, + maximumQuestionBytes: 1_024, + modelRef: "deterministic-model-spy", + profileRef: "file-model-egress-integration-v1", + providerRef: "deterministic-provider-spy", + regionRef: "local-test-region", + retentionPolicyRef: "no-provider-retention-v1", + sensitivityPolicyRef: "authorized-package-only-v1", +}); + +interface PrivateModelGatewayProfileOptions { + readonly auditRetentionSeconds: number; + readonly consumerRef: string; + readonly issuerRef: string; + readonly maximumCostMicrounits: number; + readonly maximumElapsedMs: number; + readonly maximumInputBytes: number; + readonly maximumInstructionBytes: number; + readonly maximumOutputBytes: number; + readonly maximumProviderCalls: number; + readonly maximumQuestionBytes: number; + readonly modelRef: string; + readonly profileRef: string; + readonly providerRef: string; + readonly regionRef: string; + readonly retentionPolicyRef: string; + readonly sensitivityPolicyRef: string; +} + +const registeredProfiles = new WeakSet(); +const profileConstruction = Object.freeze({}); +let mintPrivateModelGatewayProfile: ( + options: PrivateModelGatewayProfileOptions, +) => PrivateModelGatewayProfile; + +export class PrivateModelGatewayProfile { + readonly auditRetentionSeconds: number; + readonly consumerRef: string; + readonly issuerRef: string; + readonly maximumCostMicrounits: number; + readonly maximumElapsedMs: number; + readonly maximumInputBytes: number; + readonly maximumInstructionBytes: number; + readonly maximumOutputBytes: number; + readonly maximumProviderCalls: number; + readonly maximumQuestionBytes: number; + readonly modelRef: string; + readonly profileRef: string; + readonly providerRef: string; + readonly regionRef: string; + readonly retentionPolicyRef: string; + readonly sensitivityPolicyRef: string; + + private constructor(options: PrivateModelGatewayProfileOptions, authority?: object) { + if (authority !== profileConstruction) { + throw new TypeError("private model profiles can only be constructed by BotDelivery"); + } + requireExactKeys("model gateway profile", options, PROFILE_KEYS); + this.auditRetentionSeconds = requirePositiveInteger( + "model audit retention seconds", + options.auditRetentionSeconds, + ); + this.consumerRef = requireRef("model consumer", options.consumerRef); + this.issuerRef = requireRef("model issuer", options.issuerRef); + this.maximumCostMicrounits = requirePositiveInteger( + "model maximum cost", + options.maximumCostMicrounits, + ); + this.maximumElapsedMs = requirePositiveInteger( + "model maximum elapsed time", + options.maximumElapsedMs, + ); + this.maximumInputBytes = requirePositiveInteger( + "model maximum input bytes", + options.maximumInputBytes, + ); + this.maximumInstructionBytes = requirePositiveInteger( + "model maximum instruction bytes", + options.maximumInstructionBytes, + ); + this.maximumOutputBytes = requirePositiveInteger( + "model maximum output bytes", + options.maximumOutputBytes, + ); + this.maximumProviderCalls = requirePositiveInteger( + "model maximum provider calls", + options.maximumProviderCalls, + ); + if (this.maximumProviderCalls !== 1) { + throw new TypeError("private model profile permits exactly one provider call"); + } + this.maximumQuestionBytes = requirePositiveInteger( + "model maximum question bytes", + options.maximumQuestionBytes, + ); + this.modelRef = requireRef("model", options.modelRef); + this.profileRef = requireRef("model profile", options.profileRef); + this.providerRef = requireRef("model provider", options.providerRef); + this.regionRef = requireRef("model region", options.regionRef); + this.retentionPolicyRef = requireRef( + "model retention policy", + options.retentionPolicyRef, + ); + this.sensitivityPolicyRef = requireRef( + "model sensitivity policy", + options.sensitivityPolicyRef, + ); + registeredProfiles.add(this); + Object.freeze(this); + } + + static { + mintPrivateModelGatewayProfile = ( + options: PrivateModelGatewayProfileOptions, + ): PrivateModelGatewayProfile => new PrivateModelGatewayProfile( + options, + profileConstruction, + ); + } +} + +export function privateModelGatewayProfileV1(): PrivateModelGatewayProfile { + return mintPrivateModelGatewayProfile(PRIVATE_MODEL_GATEWAY_PROFILE_V1); +} + +function sameProfile( + left: PrivateModelGatewayProfile, + right: PrivateModelGatewayProfile, +): boolean { + return PROFILE_KEYS.every((key) => left[key] === right[key]); +} + +const PACKAGE_KEYS = [ + "asOf", + "audienceDigest", + "blocks", + "budgetUsage", + "continuation", + "coverage", + "decisionRef", + "evidence", + "expiresAt", + "gaps", + "packageDigest", + "packageId", + "packageSchemaRef", + "policyEpoch", + "policySnapshotRef", + "purpose", + "releaseManifestRef", + "retentionPolicyRef", + "runRef", + "tokenizerRef", + "ttlSeconds", +] as const; +const BLOCK_KEYS = ["blockId", "evidenceRefs", "text"] as const; +const EVIDENCE_KEYS = [ + "authorizationAsOf", + "citationOpenRef", + "decisionRef", + "evidenceRef", + "fragmentRef", + "policyEpoch", + "policySnapshotRef", + "projectedFields", + "purpose", + "resourceRef", + "revisionRef", + "runRef", + "sourceAclEvidence", + "sourceRef", +] as const; + +interface ValidatedEvidence { + readonly citationOpenRef: string | null; + readonly evidenceRef: string; +} + +interface ValidatedPackage { + readonly canonicalPayload: Buffer; + readonly evidence: readonly ValidatedEvidence[]; + readonly packageSnapshot: ContextPackageWire; +} + +function requirePackage( + value: unknown, + now: Date, + profile: PrivateModelGatewayProfile, +): ValidatedPackage { + const packageRecord = requireExactKeys("ContextPackage", value, PACKAGE_KEYS); + const packageDigest = requireSha256("Package digest", packageRecord.packageDigest); + requirePattern("Package reference", packageRecord.packageId, PACKAGE_REF_PATTERN); + const audienceDigest = requireSha256("Package audience", packageRecord.audienceDigest); + const policyEpoch = requirePositiveInteger("Package Policy Epoch", packageRecord.policyEpoch); + const purpose = requireRef("Package purpose", packageRecord.purpose); + const decisionRef = requireRef("Package decision", packageRecord.decisionRef); + const runRef = requireRef("Package run", packageRecord.runRef); + const policySnapshotRef = requireRef("Package policy snapshot", packageRecord.policySnapshotRef); + requireRef("Package release manifest", packageRecord.releaseManifestRef); + requireRef("Package retention policy", packageRecord.retentionPolicyRef); + requireRef("Package tokenizer", packageRecord.tokenizerRef); + requireRef("Package schema", packageRecord.packageSchemaRef); + const ttlSeconds = requirePositiveInteger("Package TTL", packageRecord.ttlSeconds); + const asOf = requireCanonicalTimestamp("Package asOf", packageRecord.asOf); + const expiresAt = requireCanonicalTimestamp("Package expiry", packageRecord.expiresAt); + const current = requireDate("model input clock", now); + const currentMicroseconds = BigInt(current.getTime()) * 1_000n; + if ( + currentMicroseconds < asOf + || currentMicroseconds >= expiresAt + || expiresAt - asOf !== BigInt(ttlSeconds) * 1_000_000n + ) { + throw new TypeError("model input requires one current Package"); + } + if (packageRecord.continuation !== null) { + throw new TypeError("active private generation Package cannot contain continuation"); + } + if (!Array.isArray(packageRecord.gaps) || packageRecord.gaps.length !== 0) { + throw new TypeError("private generation Package gaps must be empty"); + } + const coverage = requireExactKeys("Package coverage", packageRecord.coverage, ["status"]); + if (coverage.status !== "sufficient") { + throw new TypeError("model input requires sufficient Package coverage"); + } + const budget = requireExactKeys( + "Package budget usage", + packageRecord.budgetUsage, + ["costMicrounits", "elapsedMs", "providerCalls", "tokens"], + ); + for (const key of ["costMicrounits", "elapsedMs", "providerCalls", "tokens"] as const) { + requireNonnegativeInteger(`Package ${key}`, budget[key]); + } + if (!Array.isArray(packageRecord.evidence) || packageRecord.evidence.length === 0) { + throw new TypeError("model input requires Package Evidence"); + } + const evidenceRefs = new Set(); + const evidence: ValidatedEvidence[] = packageRecord.evidence.map((item, index) => { + const record = requireExactKeys(`Package Evidence ${index}`, item, EVIDENCE_KEYS); + const evidenceRef = requirePattern("Evidence reference", record.evidenceRef, EVIDENCE_REF_PATTERN); + if (evidenceRefs.has(evidenceRef)) { + throw new TypeError("Package Evidence refs must be unique"); + } + evidenceRefs.add(evidenceRef); + requireRef("Evidence source", record.sourceRef); + requireRef("Evidence resource", record.resourceRef); + requireRef("Evidence revision", record.revisionRef); + requireRef("Evidence fragment", record.fragmentRef); + if (!Array.isArray(record.projectedFields) || record.projectedFields.length === 0) { + throw new TypeError("Evidence projected fields must be a non-empty array"); + } + for (const field of record.projectedFields) requireRef("Evidence projected field", field); + if ( + record.runRef !== runRef + || record.purpose !== purpose + || record.decisionRef !== decisionRef + || record.policySnapshotRef !== policySnapshotRef + || record.policyEpoch !== policyEpoch + ) { + throw new TypeError("Package Evidence lineage must match the Package"); + } + requireCanonicalTimestamp("Evidence authorizationAsOf", record.authorizationAsOf); + const sourceAcl = record.sourceAclEvidence; + if (typeof sourceAcl !== "object" || sourceAcl === null || Array.isArray(sourceAcl)) { + throw new TypeError("Evidence source ACL must be one closed object"); + } + const sourceAclRecord = sourceAcl as Readonly>; + if (sourceAclRecord.kind === "mirrored") { + requireExactKeys("mirrored source ACL", sourceAcl, [ + "aclAsOf", "freshnessProfileRef", "kind", "projectionRef", + ]); + requireCanonicalTimestamp("mirrored source ACL asOf", sourceAclRecord.aclAsOf); + requireRef("mirrored source ACL freshness profile", sourceAclRecord.freshnessProfileRef); + requireRef("mirrored source ACL projection", sourceAclRecord.projectionRef); + } else if (sourceAclRecord.kind === "live") { + requireExactKeys("live source ACL", sourceAcl, [ + "checkedAt", "kind", "sourceDecisionRef", "verificationProtocolRef", + ]); + requireCanonicalTimestamp("live source ACL checkedAt", sourceAclRecord.checkedAt); + requireRef("live source ACL source decision", sourceAclRecord.sourceDecisionRef); + requireRef("live source ACL verification protocol", sourceAclRecord.verificationProtocolRef); + } else if (sourceAclRecord.kind === "weak") { + requireExactKeys("weak source ACL", sourceAcl, [ + "boundedMembershipEvidenceRef", + "checkedAt", + "declarationRef", + "expiresAt", + "historySemanticsRef", + "kind", + "membershipCompleteness", + "sensitivityPolicyRef", + "snapshotAsOf", + ]); + const weakCheckedAt = requireCanonicalTimestamp( + "weak source ACL checkedAt", + sourceAclRecord.checkedAt, + ); + const weakSnapshotAsOf = requireCanonicalTimestamp( + "weak source ACL snapshotAsOf", + sourceAclRecord.snapshotAsOf, + ); + const weakExpiresAt = requireCanonicalTimestamp( + "weak source ACL expiresAt", + sourceAclRecord.expiresAt, + ); + if ( + weakSnapshotAsOf > weakCheckedAt + || weakCheckedAt > asOf + || currentMicroseconds >= weakExpiresAt + || expiresAt > weakExpiresAt + ) { + throw new TypeError("weak source ACL proof must cover the current Package"); + } + requireRef("weak source ACL declaration", sourceAclRecord.declarationRef); + requireRef( + "weak source ACL bounded membership Evidence", + sourceAclRecord.boundedMembershipEvidenceRef, + ); + if (sourceAclRecord.membershipCompleteness !== "complete") { + throw new TypeError("weak source ACL membership must be complete"); + } + requireRef("weak source ACL sensitivity policy", sourceAclRecord.sensitivityPolicyRef); + requireRef("weak source ACL history semantics", sourceAclRecord.historySemanticsRef); + } else { + throw new TypeError("Evidence source ACL kind is outside the closed union"); + } + const citationOpenRef = record.citationOpenRef; + if ( + citationOpenRef !== null + && (typeof citationOpenRef !== "string" || !/^cor_[0-9a-f]{64}$/.test(citationOpenRef)) + ) { + throw new TypeError("Evidence CitationOpenRef is invalid"); + } + return Object.freeze({ citationOpenRef, evidenceRef }); + }); + if (!Array.isArray(packageRecord.blocks) || packageRecord.blocks.length === 0) { + throw new TypeError("model input requires Package blocks"); + } + const usedEvidence = new Set(); + const blockRefs = new Set(); + for (const [index, item] of packageRecord.blocks.entries()) { + const block = requireExactKeys(`Package block ${index}`, item, BLOCK_KEYS); + const blockRef = requirePattern("Package block reference", block.blockId, BLOCK_REF_PATTERN); + if (blockRefs.has(blockRef)) { + throw new TypeError("Package block refs must be unique"); + } + blockRefs.add(blockRef); + if (typeof block.text !== "string" || block.text.length === 0) { + throw new TypeError("Package block text must be non-empty"); + } + if ( + !Array.isArray(block.evidenceRefs) + || block.evidenceRefs.length !== 1 + || !evidenceRefs.has(block.evidenceRefs[0] as string) + ) { + throw new TypeError("Package block must cite one carried Evidence"); + } + const blockEvidenceRef = block.evidenceRefs[0] as string; + if (block.blockId !== `block_${blockEvidenceRef.slice("ev_".length)}`) { + throw new TypeError("Package block reference must derive from its Evidence"); + } + usedEvidence.add(blockEvidenceRef); + } + if (usedEvidence.size !== evidenceRefs.size) { + throw new TypeError("Package blocks and Evidence must form an exact closure"); + } + const digestDocument = { ...packageRecord }; + delete digestDocument.packageDigest; + const computedDigest = contextPackageDocumentDigest(digestDocument); + if (!safeEqual(packageDigest, computedDigest)) { + throw new TypeError("Package digest does not match its complete document"); + } + const canonicalPayload = canonicalJson(packageRecord); + if (canonicalPayload.byteLength > profile.maximumInputBytes) { + throw new TypeError("Package exceeds the model input profile"); + } + requireSha256("Package audience", audienceDigest); + return { + canonicalPayload, + evidence: Object.freeze(evidence), + packageSnapshot: JSON.parse(canonicalPayload.toString("utf8")) as ContextPackageWire, + }; +} + +function requireModelGrant(value: unknown): ModelEgressGrantWire { + const record = requireExactKeys("model EgressGrant", value, ["kind", "value"]); + if (record.kind !== "model" || typeof record.value !== "string" || !MODEL_GRANT_PATTERN.test(record.value)) { + throw new TypeError("model input requires one opaque model EgressGrant"); + } + return value as ModelEgressGrantWire; +} + +interface QuestionEnvelope { + readonly instructions: string; + readonly question: string; +} + +function requireEnvelope( + value: unknown, + profile: PrivateModelGatewayProfile, +): QuestionEnvelope { + const record = requireExactKeys("question envelope", value, ["instructions", "question"]); + if ( + typeof record.instructions !== "string" + || record.instructions.trim().length === 0 + || Buffer.byteLength(record.instructions) > profile.maximumInstructionBytes + || typeof record.question !== "string" + || record.question.trim().length === 0 + || Buffer.byteLength(record.question) > profile.maximumQuestionBytes + ) { + throw new TypeError("question envelope exceeds its versioned profile"); + } + return Object.freeze({ + instructions: record.instructions, + question: record.question, + }); +} + +interface AuthorizedModelInputState { + readonly evidence: readonly ValidatedEvidence[]; + readonly grantDigest: Buffer; + readonly package: ContextPackageWire; + readonly payloadDigest: string; + readonly profile: PrivateModelGatewayProfile; + readonly providerRequest: ModelProviderRequest; + readonly questionDigest: string; +} + +const authorizedInputs = new WeakMap(); +const authorizedConstruction = Object.freeze({}); +let mintAuthorizedModelInput: (state: AuthorizedModelInputState) => AuthorizedModelInput; + +export class AuthorizedModelInput { + private constructor(authority?: object) { + if (authority !== authorizedConstruction) { + throw new TypeError("AuthorizedModelInput can only be constructed by BotDelivery"); + } + Object.freeze(this); + } + + static { + mintAuthorizedModelInput = (state: AuthorizedModelInputState): AuthorizedModelInput => { + const input = new AuthorizedModelInput(authorizedConstruction); + authorizedInputs.set(input, state); + return input; + }; + } + + toJSON(): Readonly> { + return Object.freeze({}); + } + +} + +export interface PrepareAuthorizedModelInputOptions { + readonly envelope: QuestionEnvelope; + readonly grant: ModelEgressGrantWire; + readonly now: Date; + readonly package: ContextPackageWire; + readonly profile: PrivateModelGatewayProfile; +} + +export function prepareAuthorizedModelInput( + options: PrepareAuthorizedModelInputOptions, +): AuthorizedModelInput { + const record = requireExactKeys( + "authorized model input request", + options, + ["envelope", "grant", "now", "package", "profile"], + ); + if (!(record.profile instanceof PrivateModelGatewayProfile)) { + throw new TypeError("model input requires PrivateModelGatewayProfile"); + } + const profile = record.profile; + if (!registeredProfiles.has(profile)) { + throw new TypeError("model input requires one registered versioned profile"); + } + const now = requireDate("model input time", record.now); + const grantValue = requireModelGrant(record.grant); + const validated = requirePackage(record.package, now, profile); + const envelope = requireEnvelope(record.envelope, profile); + const payloadDigest = createHash("sha256") + .update(MODEL_INPUT_DOMAIN) + .update(validated.canonicalPayload) + .digest("hex"); + const questionDigest = createHash("sha256") + .update(QUESTION_DIGEST_DOMAIN) + .update(canonicalJson(envelope)) + .digest("hex"); + const providerRequest = Object.freeze({ + context: Object.freeze(validated.packageSnapshot.blocks.map((block) => Object.freeze({ + evidenceRefs: Object.freeze([...block.evidenceRefs]), + text: block.text, + }))), + instructions: envelope.instructions, + question: envelope.question, + }); + if (canonicalJson(providerRequest).byteLength > profile.maximumInputBytes) { + throw new TypeError("complete model request exceeds its versioned profile"); + } + return mintAuthorizedModelInput(Object.freeze({ + evidence: validated.evidence, + grantDigest: createHash("sha256").update(grantValue.value).digest(), + package: validated.packageSnapshot, + payloadDigest, + profile, + providerRequest, + questionDigest, + })); +} + +export interface ModelProviderRequest { + readonly context: readonly { + readonly evidenceRefs: readonly string[]; + readonly text: string; + }[]; + readonly instructions: string; + readonly question: string; +} + +interface ModelProviderOutput { + readonly citations: readonly string[]; + readonly costMicrounits: number; + readonly elapsedMs: number; + readonly text: string; +} + +interface DeterministicModelGatewayTwinOptions extends ModelProviderOutput { + readonly profile: PrivateModelGatewayProfile; +} + +const trustedGatewayTwins = new WeakSet(); +const gatewayProfiles = new WeakMap(); +interface DeterministicGatewayState { + callCount: number; + outboundBytes: number; + readonly output: ModelProviderOutput; + readonly requests: ModelProviderRequest[]; +} +const gatewayStates = new WeakMap(); + +export class DeterministicModelGatewayTwin { + constructor(options: DeterministicModelGatewayTwinOptions) { + const record = requireExactKeys( + "deterministic model gateway", + options, + ["citations", "costMicrounits", "elapsedMs", "profile", "text"], + ); + if (!(record.profile instanceof PrivateModelGatewayProfile)) { + throw new TypeError("deterministic gateway requires PrivateModelGatewayProfile"); + } + if (!Array.isArray(record.citations) || record.citations.some((item) => typeof item !== "string")) { + throw new TypeError("deterministic gateway citations must be an array of refs"); + } + if (typeof record.text !== "string") { + throw new TypeError("deterministic gateway text must be a string"); + } + const output = Object.freeze({ + citations: Object.freeze([...record.citations] as string[]), + costMicrounits: requireNonnegativeInteger("provider cost", record.costMicrounits), + elapsedMs: requireNonnegativeInteger("provider elapsed time", record.elapsedMs), + text: record.text, + }); + trustedGatewayTwins.add(this); + gatewayProfiles.set(this, record.profile); + gatewayStates.set(this, { callCount: 0, outboundBytes: 0, output, requests: [] }); + Object.freeze(this); + } + + get callCount(): number { + return gatewayStates.get(this)?.callCount ?? 0; + } + + get outboundBytes(): number { + return gatewayStates.get(this)?.outboundBytes ?? 0; + } + + get requests(): readonly ModelProviderRequest[] { + return Object.freeze([...(gatewayStates.get(this)?.requests ?? [])]); + } +} + +async function invokeDeterministicGateway( + gateway: DeterministicModelGatewayTwin, + request: ModelProviderRequest, +): Promise { + const state = gatewayStates.get(gateway); + if (state === undefined || !trustedGatewayTwins.has(gateway)) { + throw new TypeError("deterministic ModelGateway twin is not trusted"); + } + const bytes = canonicalJson(request); + state.callCount += 1; + state.outboundBytes += bytes.byteLength; + state.requests.push(request); + return state.output; +} + +export interface AnswerCitation { + readonly citationOpenRef: string; + readonly evidenceRef: string; +} + +export interface BoundedAnswerArtifact { + readonly answerPayloadDigest: string; + readonly citations: readonly AnswerCitation[]; + readonly text: string; +} + +export interface ModelUsage { + readonly costMicrounits: number; + readonly elapsedMs: number; + readonly outputBytes: number; + readonly providerCalls: 1; +} + +export interface GeneratedAnswer { + readonly answer: BoundedAnswerArtifact; + readonly kind: "generated"; + readonly usage: ModelUsage; +} + +export interface GenerationNotAvailable { + readonly kind: "generation_not_available"; +} + +export type ModelGenerationOutcome = GeneratedAnswer | GenerationNotAvailable; + +export function answerPayloadDigest(input: { + readonly citations: readonly AnswerCitation[]; + readonly text: string; +}): string { + const record = requireExactKeys("answer payload", input, ["citations", "text"]); + if ( + typeof record.text !== "string" + || record.text.trim().length === 0 + || !Array.isArray(record.citations) + ) { + throw new TypeError("answer payload is invalid"); + } + const seenEvidence = new Set(); + const seenCitationOpenRefs = new Set(); + for (const [index, value] of record.citations.entries()) { + const citation = requireExactKeys(`answer citation ${index}`, value, [ + "citationOpenRef", + "evidenceRef", + ]); + const evidenceRef = requirePattern( + "answer Evidence reference", + citation.evidenceRef, + EVIDENCE_REF_PATTERN, + ); + const citationOpenRef = requirePattern( + "answer CitationOpenRef", + citation.citationOpenRef, + /^cor_[0-9a-f]{64}$/, + ); + if (seenEvidence.has(evidenceRef) || seenCitationOpenRefs.has(citationOpenRef)) { + throw new TypeError("answer citations must be unique"); + } + seenEvidence.add(evidenceRef); + seenCitationOpenRefs.add(citationOpenRef); + } + return createHash("sha256") + .update(ANSWER_PAYLOAD_DOMAIN) + .update(canonicalJson(record)) + .digest("hex"); +} + +interface DatabaseQueryResult { + readonly rows: readonly Readonly>[]; +} + +interface ModelEgressQueryAuthority { + query(config: { + readonly text: string; + readonly values: readonly unknown[]; + }): Promise; +} + +const REDEEM_SQL = ` +SELECT context_egress_redeem_grant( + $1::uuid, $2::bytea, $3::text, $4::text, $5::bytea, $6::bytea, + $7::text, $8::bytea, $9::bigint, $10::text, $11::text, $12::text, + $13::text, $14::text, $15::text, $16::text, $17::text, $18::text, + $19::text +) AS accepted`; + +const RECORD_OUTCOME_SQL = ` +SELECT context_egress_record_model_outcome( + $1::uuid, $2::bytea, $3::bytea, $4::bytea, $5::bytea, $6::bytea, + $7::text, $8::bigint, $9::bigint, $10::bigint, $11::bigint, + $12::text, $13::bigint, $14::text +) AS recorded`; + +interface InternalModelGenerationBoundaryOptions { + readonly close: () => Promise; + readonly clock?: () => Date; + readonly database: ModelEgressQueryAuthority; + readonly gateway: DeterministicModelGatewayTwin; + readonly organizationId: string; + readonly profile: PrivateModelGatewayProfile; +} + +export interface CreatePrivateModelGenerationBoundaryOptions { + readonly databaseUrl: string; + readonly gateway: DeterministicModelGatewayTwin; + readonly organizationId: string; + readonly profile: PrivateModelGatewayProfile; +} + +const boundaryConstruction = Object.freeze({}); +let mintModelGenerationBoundary: ( + options: InternalModelGenerationBoundaryOptions, +) => ModelGenerationBoundary; + +export class ModelGenerationBoundary { + readonly #clock: () => Date; + readonly #closeDatabase: () => Promise; + readonly #database: ModelEgressQueryAuthority; + readonly #gateway: DeterministicModelGatewayTwin; + readonly #organizationId: string; + readonly #profile: PrivateModelGatewayProfile; + #closed = false; + + private constructor(options: InternalModelGenerationBoundaryOptions, authority?: object) { + if (authority !== boundaryConstruction) { + throw new TypeError("model generation boundaries can only be constructed by BotDelivery"); + } + const record = requireExactKeys( + "model generation boundary", + options, + options.clock === undefined + ? ["close", "database", "gateway", "organizationId", "profile"] + : ["clock", "close", "database", "gateway", "organizationId", "profile"], + ); + if (!(record.profile instanceof PrivateModelGatewayProfile)) { + throw new TypeError("model generation boundary requires a profile"); + } + if (typeof (record.database as ModelEgressQueryAuthority | undefined)?.query !== "function") { + throw new TypeError("model generation boundary requires a database authority"); + } + if (typeof record.close !== "function") { + throw new TypeError("model generation boundary requires database lifecycle authority"); + } + if ( + !(record.gateway instanceof DeterministicModelGatewayTwin) + || Object.getPrototypeOf(record.gateway) !== DeterministicModelGatewayTwin.prototype + ) { + throw new TypeError("Issue #70 permits only the exact deterministic ModelGateway twin"); + } + this.#clock = (record.clock as (() => Date) | undefined) ?? (() => new Date()); + this.#closeDatabase = record.close as () => Promise; + this.#database = record.database as ModelEgressQueryAuthority; + this.#gateway = record.gateway; + this.#organizationId = requireUuid("model Organization", record.organizationId); + this.#profile = record.profile; + Object.freeze(this); + } + + static { + mintModelGenerationBoundary = ( + options: InternalModelGenerationBoundaryOptions, + ): ModelGenerationBoundary => new ModelGenerationBoundary( + options, + boundaryConstruction, + ); + } + + async close(): Promise { + if (this.#closed) return; + this.#closed = true; + await this.#closeDatabase(); + } + + async #recordOutcome( + state: AuthorizedModelInputState, + category: "generated" | "output_rejected" | "provider_unavailable", + usage: Omit & { readonly providerCalls: number }, + answerDigest: string | null, + ): Promise { + try { + const row = (await this.#database.query({ + text: RECORD_OUTCOME_SQL, + values: [ + this.#organizationId, + state.grantDigest, + Buffer.from(state.package.packageDigest, "hex"), + Buffer.from(state.payloadDigest, "hex"), + Buffer.from(state.questionDigest, "hex"), + answerDigest === null ? null : Buffer.from(answerDigest, "hex"), + category, + usage.providerCalls, + usage.costMicrounits, + usage.elapsedMs, + usage.outputBytes, + this.#profile.profileRef, + this.#profile.auditRetentionSeconds, + MODEL_AUDIT_PROFILE, + ], + })).rows[0]; + return row?.recorded === true; + } catch { + return false; + } + } + + async generate( + input: AuthorizedModelInput, + grant: ModelEgressGrantWire, + ): Promise { + if (!(input instanceof AuthorizedModelInput) || !authorizedInputs.has(input)) { + throw new TypeError("model generation requires AuthorizedModelInput"); + } + let grantValue: ModelEgressGrantWire; + try { + grantValue = requireModelGrant(grant); + } catch { + return { kind: "generation_not_available" }; + } + const state = authorizedInputs.get(input) as AuthorizedModelInputState; + const gatewayProfile = gatewayProfiles.get(this.#gateway); + if ( + this.#closed + || + !trustedGatewayTwins.has(this.#gateway) + || gatewayProfile === undefined + || !registeredProfiles.has(this.#profile) + || !registeredProfiles.has(gatewayProfile) + || !sameProfile(gatewayProfile, this.#profile) + || !sameProfile(state.profile, this.#profile) + || !safeEqual( + state.grantDigest.toString("hex"), + createHash("sha256").update(grantValue.value).digest("hex"), + ) + || BigInt(requireDate("model generation clock", this.#clock()).getTime()) * 1_000n + >= requireCanonicalTimestamp("Package expiry", state.package.expiresAt) + ) { + return { kind: "generation_not_available" }; + } + try { + const redemption = (await this.#database.query({ + text: REDEEM_SQL, + values: [ + this.#organizationId, + state.grantDigest, + EGRESS_GRANT_DIGEST_PROFILE, + "model", + Buffer.from(state.package.packageDigest, "hex"), + Buffer.from(state.payloadDigest, "hex"), + state.package.purpose, + Buffer.from(state.package.audienceDigest, "hex"), + state.package.policyEpoch, + this.#profile.retentionPolicyRef, + this.#profile.sensitivityPolicyRef, + this.#profile.issuerRef, + this.#profile.consumerRef, + this.#profile.providerRef, + this.#profile.modelRef, + null, + null, + this.#profile.regionRef, + this.#profile.profileRef, + ], + })).rows[0]; + if (redemption?.accepted !== true) { + return { kind: "generation_not_available" }; + } + } catch { + return { kind: "generation_not_available" }; + } + + let output: ModelProviderOutput; + try { + output = await invokeDeterministicGateway(this.#gateway, state.providerRequest); + } catch { + await this.#recordOutcome( + state, + "provider_unavailable", + { costMicrounits: 0, elapsedMs: 0, outputBytes: 0, providerCalls: 1 }, + null, + ); + return { kind: "generation_not_available" }; + } + const outputBytes = Buffer.byteLength(output.text); + const usage = { + costMicrounits: output.costMicrounits, + elapsedMs: output.elapsedMs, + outputBytes, + providerCalls: 1 as const, + }; + const evidenceByRef = new Map(state.evidence.map((item) => [item.evidenceRef, item])); + const seen = new Set(); + const citations: AnswerCitation[] = []; + let valid = ( + output.text.trim().length > 0 + && outputBytes <= this.#profile.maximumOutputBytes + && output.costMicrounits <= this.#profile.maximumCostMicrounits + && output.elapsedMs <= this.#profile.maximumElapsedMs + && output.citations.length <= state.evidence.length + ); + for (const evidenceRef of output.citations) { + const evidence = evidenceByRef.get(evidenceRef); + if (seen.has(evidenceRef) || evidence?.citationOpenRef === null || evidence === undefined) { + valid = false; + break; + } + seen.add(evidenceRef); + citations.push(Object.freeze({ + citationOpenRef: evidence.citationOpenRef, + evidenceRef, + })); + } + if (!valid) { + await this.#recordOutcome(state, "output_rejected", usage, null); + return { kind: "generation_not_available" }; + } + let answer: BoundedAnswerArtifact; + try { + answer = Object.freeze({ + answerPayloadDigest: answerPayloadDigest({ citations, text: output.text }), + citations: Object.freeze(citations), + text: output.text, + }); + } catch { + await this.#recordOutcome(state, "output_rejected", usage, null); + return { kind: "generation_not_available" }; + } + if (!(await this.#recordOutcome(state, "generated", usage, answer.answerPayloadDigest))) { + return { kind: "generation_not_available" }; + } + return Object.freeze({ answer, kind: "generated", usage }); + } +} + +function requirePostgresUrl(value: unknown): string { + if ( + typeof value !== "string" + || value.length === 0 + || value.length > 4_096 + || value.trim() !== value + ) { + throw new TypeError("model egress database URL must be a bounded PostgreSQL URL"); + } + let parsed: URL; + try { + parsed = new URL(value); + } catch { + throw new TypeError("model egress database URL must be a bounded PostgreSQL URL"); + } + if ( + !["postgres:", "postgresql:"].includes(parsed.protocol) + || parsed.hostname.length === 0 + || parsed.pathname.length <= 1 + ) { + throw new TypeError("model egress database URL must be a bounded PostgreSQL URL"); + } + return value; +} + +export function createPrivateModelGenerationBoundary( + options: CreatePrivateModelGenerationBoundaryOptions, +): ModelGenerationBoundary { + const record = requireExactKeys( + "private model generation boundary factory", + options, + ["databaseUrl", "gateway", "organizationId", "profile"], + ); + const pool = new Pool({ + connectionString: requirePostgresUrl(record.databaseUrl), + max: 2, + }); + const database: ModelEgressQueryAuthority = { + async query(config): Promise { + const result = await pool.query({ + text: config.text, + values: [...config.values], + }); + return { rows: result.rows as readonly Readonly>[] }; + }, + }; + try { + return mintModelGenerationBoundary({ + close: async () => { await pool.end(); }, + database, + gateway: record.gateway as DeterministicModelGatewayTwin, + organizationId: record.organizationId as string, + profile: record.profile as PrivateModelGatewayProfile, + }); + } catch (error) { + void pool.end(); + throw error; + } +} diff --git a/bot_delivery/typescript/src/public.ts b/bot_delivery/typescript/src/public.ts new file mode 100644 index 00000000..ec825b27 --- /dev/null +++ b/bot_delivery/typescript/src/public.ts @@ -0,0 +1,22 @@ +export { + AuthorizedModelInput, + DeterministicModelGatewayTwin, + ModelGenerationBoundary, + PrivateModelGatewayProfile, + answerPayloadDigest, + createPrivateModelGenerationBoundary, + privateModelGatewayProfileV1, + prepareAuthorizedModelInput, +} from "./index.js"; + +export type { + AnswerCitation, + BoundedAnswerArtifact, + CreatePrivateModelGenerationBoundaryOptions, + GeneratedAnswer, + GenerationNotAvailable, + ModelGenerationOutcome, + ModelProviderRequest, + ModelUsage, + PrepareAuthorizedModelInputOptions, +} from "./index.js"; diff --git a/bot_delivery/typescript/test/model-egress.test.mjs b/bot_delivery/typescript/test/model-egress.test.mjs new file mode 100644 index 00000000..672c5893 --- /dev/null +++ b/bot_delivery/typescript/test/model-egress.test.mjs @@ -0,0 +1,794 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { readFileSync } from "node:fs"; +import { inspect } from "node:util"; +import { test } from "node:test"; + +import canonicalize from "canonicalize"; +import pg from "pg"; + +import { + AuthorizedModelInput, + DeterministicModelGatewayTwin, + ModelGenerationBoundary, + PrivateModelGatewayProfile, + answerPayloadDigest, + createPrivateModelGenerationBoundary, + privateModelGatewayProfileV1, + prepareAuthorizedModelInput, +} from "../dist/index.js"; +import { + canonicalJsonDigest, + contextPackageDocumentDigest, +} from "../dist/canonical-json.js"; + +const packageDigest = (document) => createHash("sha256") + .update(canonicalize(document)) + .digest("hex"); + +const exactProfileOptions = { + auditRetentionSeconds: 2_592_000, + consumerRef: "model-gateway-integration", + issuerRef: "context-runtime-integration", + maximumCostMicrounits: 10_000, + maximumElapsedMs: 2_000, + maximumInputBytes: 32_768, + maximumInstructionBytes: 512, + maximumOutputBytes: 2_048, + maximumProviderCalls: 1, + maximumQuestionBytes: 1_024, + modelRef: "deterministic-model-spy", + profileRef: "file-model-egress-integration-v1", + providerRef: "deterministic-provider-spy", + regionRef: "local-test-region", + retentionPolicyRef: "no-provider-retention-v1", + sensitivityPolicyRef: "authorized-package-only-v1", +}; + +const organizationId = "81e18bca-86a1-478a-937d-7675c6fe69b0"; +const grant = { kind: "model", value: `egrm_${"1".repeat(64)}` }; +const now = new Date(Math.floor(Date.now() / 1_000) * 1_000); +const packageAsOf = new Date(now.getTime() - 1_000).toISOString().replace(".000Z", "Z"); +const packageExpiresAt = new Date(now.getTime() + 299_000).toISOString().replace(".000Z", "Z"); +const fakeDatabases = new Map(); +let fakeDatabaseSequence = 0; + +const { Pool } = pg; +Pool.prototype.query = async function query(config) { + const databaseName = new URL(this.options.connectionString).pathname.slice(1); + const database = fakeDatabases.get(databaseName); + if (database === undefined) throw new Error("unregistered test database"); + return database.query(config); +}; + +function sourceAclEvidence(index, kind) { + if (kind === "live") { + return { + checkedAt: packageAsOf, + kind, + sourceDecisionRef: `source-decision:${index}`, + verificationProtocolRef: "same-operation-projection-v1", + }; + } + if (kind === "weak") { + return { + boundedMembershipEvidenceRef: `bounded-membership:${index}`, + checkedAt: packageAsOf, + declarationRef: `weak-source-declaration:${index}`, + expiresAt: packageExpiresAt, + historySemanticsRef: "current-members-only-v1", + kind, + membershipCompleteness: "complete", + sensitivityPolicyRef: "weak-source-sensitivity-v1", + snapshotAsOf: packageAsOf, + }; + } + return { + aclAsOf: packageAsOf, + freshnessProfileRef: "file-source-access-current-transaction-v1", + kind: "mirrored", + projectionRef: `source-acl:${index}`, + }; +} + +function evidence(index, aclKind = "mirrored") { + const suffix = String(index).repeat(64); + return { + authorizationAsOf: packageAsOf, + citationOpenRef: `cor_${String(index).repeat(64)}`, + decisionRef: `dec_${"d".repeat(32)}`, + evidenceRef: `ev_${suffix}`, + fragmentRef: `fragment:paragraph:${index}`, + policyEpoch: 7, + policySnapshotRef: "policy-snapshot-v7", + projectedFields: ["body"], + purpose: "context.answer", + resourceRef: "resource:handbook", + revisionRef: "revision:handbook:v1", + runRef: "run:private-answer", + sourceAclEvidence: sourceAclEvidence(index, aclKind), + sourceRef: "source:handbook", + }; +} + +function contextPackage(aclKind = "mirrored") { + const first = evidence(1, aclKind); + const second = evidence(2, aclKind); + const document = { + asOf: packageAsOf, + audienceDigest: "a".repeat(64), + blocks: [ + { + blockId: `block_${"1".repeat(64)}`, + evidenceRefs: [first.evidenceRef], + text: "Authorized handbook fact one.", + }, + { + blockId: `block_${"2".repeat(64)}`, + evidenceRefs: [second.evidenceRef], + text: "Authorized handbook fact two.", + }, + ], + budgetUsage: { + costMicrounits: 0, + elapsedMs: 0, + providerCalls: 0, + tokens: 58, + }, + continuation: null, + coverage: { status: "sufficient" }, + decisionRef: `dec_${"d".repeat(32)}`, + evidence: [first, second], + expiresAt: packageExpiresAt, + gaps: [], + packageId: `pkg_${"c".repeat(32)}`, + packageSchemaRef: "context-package-openapi-v0", + policyEpoch: 7, + policySnapshotRef: "policy-snapshot-v7", + purpose: "context.answer", + releaseManifestRef: "release:private-answer", + retentionPolicyRef: "package-digest-only-retention-v1", + runRef: "run:private-answer", + tokenizerRef: "utf8-byte-budget-v1", + ttlSeconds: 300, + }; + return Object.freeze({ ...document, packageDigest: packageDigest(document) }); +} + +function exactRedemptionDatabase(expectedPackage, expectedGrant = grant) { + const expectedGrantDigest = createHash("sha256").update(expectedGrant.value).digest(); + const calls = []; + return { + calls, + async query(config) { + calls.push(config); + if (config.text.includes("context_egress_redeem_grant")) { + assert.equal(config.values[0], organizationId); + assert.deepEqual(config.values[1], expectedGrantDigest); + assert.equal(config.values[3], "model"); + assert.deepEqual(config.values[4], Buffer.from(expectedPackage.packageDigest, "hex")); + assert.equal(config.values[6], expectedPackage.purpose); + assert.deepEqual(config.values[7], Buffer.from(expectedPackage.audienceDigest, "hex")); + assert.equal(config.values[8], expectedPackage.policyEpoch); + assert.equal(config.values[9], exactProfileOptions.retentionPolicyRef); + assert.equal(config.values[10], exactProfileOptions.sensitivityPolicyRef); + assert.equal(config.values[11], exactProfileOptions.issuerRef); + assert.equal(config.values[12], exactProfileOptions.consumerRef); + assert.equal(config.values[13], exactProfileOptions.providerRef); + assert.equal(config.values[14], exactProfileOptions.modelRef); + assert.equal(config.values[17], exactProfileOptions.regionRef); + assert.equal(config.values[18], exactProfileOptions.profileRef); + return { rows: [{ accepted: true }] }; + } + if (config.text.includes("context_egress_record_model_outcome")) { + return { rows: [{ recorded: true }] }; + } + throw new Error("unexpected model egress query"); + }, + }; +} + +function configuredBoundary({ + database, + gateway, + organization = organizationId, + profile = privateModelGatewayProfileV1(), +}) { + fakeDatabaseSequence += 1; + const databaseName = `context_engine_model_egress_test_${fakeDatabaseSequence}`; + fakeDatabases.set(databaseName, database); + return createPrivateModelGenerationBoundary({ + databaseUrl: `postgresql://context_engine_egress:unused@127.0.0.1/${databaseName}`, + gateway, + organizationId: organization, + profile, + }); +} + +test("TypeScript and Python share RFC 8785/I-JSON vectors", () => { + const fixture = JSON.parse(readFileSync( + new URL("../../../tests/fixtures/canonical-json-cross-language-v1.json", import.meta.url), + "utf8", + )); + assert.equal(fixture.profile, "rfc8785-ijson-cross-language-v1"); + for (const vector of fixture.valid) { + assert.equal(contextPackageDocumentDigest(vector.document), vector.sha256, vector.name); + } + for (const vector of fixture.invalidPackageDocuments) { + assert.throws( + () => contextPackageDocumentDigest(vector.document), + /must not contain packageDigest/, + vector.name, + ); + } + for (const vector of fixture.invalidJsonDocuments) { + assert.throws( + () => canonicalJsonDigest(JSON.parse(vector.json)), + /Unicode scalar values/, + vector.name, + ); + } + for (const value of [Number.NaN, Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY]) { + assert.throws( + () => canonicalJsonDigest({ value }), + /finite IEEE 754 values/, + ); + } +}); + +test("AuthorizedModelInput is nominal, redacted, and bound to one complete current Package", () => { + const packageValue = contextPackage(); + const profile = privateModelGatewayProfileV1(); + const input = prepareAuthorizedModelInput({ + envelope: { + instructions: "Answer only from the supplied context.", + question: "What are the two facts?", + }, + grant, + now, + package: packageValue, + profile, + }); + + assert.equal(input instanceof AuthorizedModelInput, true); + assert.equal("create" in AuthorizedModelInput, false); + assert.equal(JSON.stringify(input).includes(grant.value), false); + assert.equal(inspect(input).includes(grant.value), false); + assert.throws(() => new AuthorizedModelInput(), /only be constructed/); + assert.throws( + () => prepareAuthorizedModelInput({ envelope: {}, grant, now, package: "arbitrary text", profile }), + /ContextPackage/, + ); + assert.throws( + () => prepareAuthorizedModelInput({ + envelope: { instructions: "Use this", question: "Question" }, + grant, + now, + package: [packageValue, contextPackage()], + profile, + }), + /ContextPackage/, + ); + assert.throws( + () => prepareAuthorizedModelInput({ + envelope: { instructions: "Use this", question: "Question" }, + grant, + now, + package: { + fragmentRef: "fragment:denied", + resourceRef: "resource:denied", + snippet: "denied secret", + }, + profile, + }), + /ContextPackage/, + ); + assert.throws( + () => prepareAuthorizedModelInput({ + envelope: { instructions: "Use this", question: "Question", extraContext: "denied secret" }, + grant, + now, + package: packageValue, + profile, + }), + /question envelope/, + ); + const symbolEnvelope = { instructions: "Use this", question: "Question" }; + symbolEnvelope[Symbol("extraContext")] = "denied secret"; + assert.throws( + () => prepareAuthorizedModelInput({ + envelope: symbolEnvelope, + grant, + now, + package: packageValue, + profile, + }), + /invalid field set/, + ); + for (const envelope of [ + { instructions: "Use this\ud800", question: "Question" }, + { instructions: "Use this", question: "Question\udfff" }, + ]) { + assert.throws( + () => prepareAuthorizedModelInput({ envelope, grant, now, package: packageValue, profile }), + /Unicode scalar values/, + ); + } + const { packageDigest: ignoredSurrogateDigest, ...surrogatePackage } = packageValue; + assert.equal(typeof ignoredSurrogateDigest, "string"); + surrogatePackage.blocks = surrogatePackage.blocks.map((block, index) => ( + index === 0 ? { ...block, text: "invalid\ud800package" } : block + )); + assert.throws( + () => prepareAuthorizedModelInput({ + envelope: { instructions: "Use this", question: "Question" }, + grant, + now, + package: { ...surrogatePackage, packageDigest: packageDigest(surrogatePackage) }, + profile, + }), + /Unicode scalar values/, + ); + const malformedPackage = { + ...packageValue, + packageId: `pkg_${"z".repeat(32)}`, + }; + const { packageDigest: ignored, ...malformedDocument } = malformedPackage; + assert.equal(typeof ignored, "string"); + assert.throws( + () => prepareAuthorizedModelInput({ + envelope: { instructions: "Use this", question: "Question" }, + grant, + now, + package: { ...malformedPackage, packageDigest: packageDigest(malformedDocument) }, + profile, + }), + /Package reference/, + ); + const { packageDigest: ignoredDigest, ...microsecondDocument } = packageValue; + assert.equal(typeof ignoredDigest, "string"); + microsecondDocument.asOf = now.toISOString().replace(".000Z", ".000500Z"); + microsecondDocument.expiresAt = new Date(now.getTime() + 300_000) + .toISOString().replace(".000Z", ".000500Z"); + assert.throws( + () => prepareAuthorizedModelInput({ + envelope: { instructions: "Use this", question: "Question" }, + grant, + now, + package: { + ...microsecondDocument, + packageDigest: packageDigest(microsecondDocument), + }, + profile, + }), + /current Package/, + ); + assert.throws( + () => prepareAuthorizedModelInput({ + envelope: { instructions: "Use this", question: "Question" }, + grant, + now, + package: packageValue, + profile: new PrivateModelGatewayProfile(exactProfileOptions), + }), + /only be constructed by BotDelivery/, + ); +}); + +test("all public SourceAclEvidence variants can become authorized model input", () => { + const profile = privateModelGatewayProfileV1(); + for (const kind of ["live", "mirrored", "weak"]) { + const input = prepareAuthorizedModelInput({ + envelope: { instructions: "Use this.", question: "Question?" }, + grant, + now, + package: contextPackage(kind), + profile, + }); + assert.equal(input instanceof AuthorizedModelInput, true, kind); + } +}); + +test("malformed public SourceAclEvidence variants fail closed", () => { + const profile = privateModelGatewayProfileV1(); + const base = { + envelope: { instructions: "Use this.", question: "Question?" }, + grant, + now, + profile, + }; + for (const [kind, mutation] of [ + ["live", { verificationProtocolRef: "" }], + ["mirrored", { aclAsOf: "not-a-timestamp" }], + ["weak", { membershipCompleteness: "partial" }], + ["weak", { sourceRef: "source:unexpected" }], + ["weak", { expiresAt: new Date(now.getTime() - 1_000).toISOString().replace(".000Z", "Z") }], + ["weak", { expiresAt: new Date(now.getTime() + 60_000).toISOString().replace(".000Z", "Z") }], + ["weak", { snapshotAsOf: new Date(now.getTime() + 1_000).toISOString().replace(".000Z", "Z") }], + ]) { + const packageValue = contextPackage(kind); + const { packageDigest: ignored, ...document } = packageValue; + assert.equal(typeof ignored, "string"); + document.evidence = document.evidence.map((item, index) => ( + index === 0 + ? { ...item, sourceAclEvidence: { ...item.sourceAclEvidence, ...mutation } } + : item + )); + assert.throws( + () => prepareAuthorizedModelInput({ + ...base, + package: { ...document, packageDigest: packageDigest(document) }, + }), + /source ACL/, + `${kind}: ${JSON.stringify(mutation)}`, + ); + } +}); + +test("only the exact deterministic ModelGateway twin can own provider bytes", () => { + const packageValue = contextPackage(); + const profile = privateModelGatewayProfileV1(); + class InheritedGateway extends DeterministicModelGatewayTwin {} + const inherited = new InheritedGateway({ + citations: [packageValue.evidence[0].evidenceRef], + costMicrounits: 1, + elapsedMs: 1, + profile, + text: "Subclass must not run.", + }); + assert.equal("generate" in inherited, false); + assert.throws( + () => { inherited.callCount = 99; }, + /getter|read only|Cannot set/u, + ); + assert.throws( + () => configuredBoundary({ + database: exactRedemptionDatabase(packageValue), + gateway: inherited, + profile, + }), + /exact deterministic ModelGateway twin/, + ); + assert.equal(inherited.outboundBytes, 0); + assert.throws( + () => new ModelGenerationBoundary({}), + /only be constructed by BotDelivery/, + ); +}); + +test("valid private Package and grant invoke the gateway once and return bounded citation subset", async () => { + const packageValue = contextPackage(); + const profile = privateModelGatewayProfileV1(); + const input = prepareAuthorizedModelInput({ + envelope: { + instructions: "Answer only from the supplied context.", + question: "What are the two facts?", + }, + grant, + now, + package: packageValue, + profile, + }); + const database = exactRedemptionDatabase(packageValue); + const gateway = new DeterministicModelGatewayTwin({ + citations: [packageValue.evidence[1].evidenceRef], + costMicrounits: 17, + elapsedMs: 9, + profile, + text: "The second authorized fact.", + }); + + const outcome = await configuredBoundary({ database, gateway, profile }).generate(input, grant); + + assert.equal(outcome.kind, "generated"); + assert.equal(gateway.callCount, 1); + assert.ok(gateway.outboundBytes > 0); + assert.deepEqual(outcome.answer.citations, [{ + citationOpenRef: packageValue.evidence[1].citationOpenRef, + evidenceRef: packageValue.evidence[1].evidenceRef, + }]); + assert.equal(outcome.answer.text, "The second authorized fact."); + assert.equal(outcome.usage.providerCalls, 1); + assert.equal(outcome.usage.costMicrounits, 17); + assert.equal(outcome.usage.elapsedMs, 9); + assert.equal(outcome.answer.answerPayloadDigest, answerPayloadDigest({ + citations: outcome.answer.citations, + text: outcome.answer.text, + })); + assert.equal("destinationRef" in outcome.answer, false); + assert.equal("operation" in outcome.answer, false); + assert.equal(database.calls.length, 2); + const auditValues = database.calls[1].values; + assert.equal(auditValues.includes(grant.value), false); + assert.equal(auditValues.includes(outcome.answer.text), false); + assert.throws( + () => answerPayloadDigest({ + citations: [{ + citationOpenRef: packageValue.evidence[1].citationOpenRef, + evidenceRef: packageValue.evidence[1].evidenceRef, + operation: "send", + }], + text: outcome.answer.text, + }), + /answer citation/, + ); +}); + +test("AuthorizedModelInput snapshots its Package before caller mutation", async () => { + const authorizedPackage = contextPackage(); + const mutablePackage = structuredClone(authorizedPackage); + const profile = privateModelGatewayProfileV1(); + const input = prepareAuthorizedModelInput({ + envelope: { instructions: "Use context.", question: "Question?" }, + grant, + now, + package: mutablePackage, + profile, + }); + const gateway = new DeterministicModelGatewayTwin({ + citations: [authorizedPackage.evidence[0].evidenceRef], + costMicrounits: 1, + elapsedMs: 1, + profile, + text: "Authorized answer.", + }); + + mutablePackage.expiresAt = "not-a-timestamp"; + mutablePackage.packageDigest = "0".repeat(64); + mutablePackage.purpose = "context.tampered"; + mutablePackage.audienceDigest = "0".repeat(64); + mutablePackage.policyEpoch = 999; + mutablePackage.blocks[0].text = "post-prepare denied secret"; + mutablePackage.evidence[0].citationOpenRef = `cor_${"9".repeat(64)}`; + + const outcome = await configuredBoundary({ + database: exactRedemptionDatabase(authorizedPackage), + gateway, + profile, + }).generate(input, grant); + + assert.equal(outcome.kind, "generated"); + assert.equal(gateway.callCount, 1); + assert.equal(JSON.stringify(gateway.requests).includes("post-prepare denied secret"), false); + assert.deepEqual(outcome.answer.citations, [{ + citationOpenRef: authorizedPackage.evidence[0].citationOpenRef, + evidenceRef: authorizedPackage.evidence[0].evidenceRef, + }]); +}); + +test("provider request contains only declared question, instructions, and authorized Package blocks", async () => { + const packageValue = contextPackage(); + const profile = privateModelGatewayProfileV1(); + const input = prepareAuthorizedModelInput({ + envelope: { instructions: "Use cited context.", question: "Summarize." }, + grant, + now, + package: packageValue, + profile, + }); + const gateway = new DeterministicModelGatewayTwin({ + citations: [packageValue.evidence[0].evidenceRef], + costMicrounits: 1, + elapsedMs: 1, + profile, + text: "Authorized answer.", + }); + + const outcome = await configuredBoundary({ + database: exactRedemptionDatabase(packageValue), + gateway, + profile, + }).generate(input, grant); + + assert.equal(outcome.kind, "generated"); + const request = gateway.requests[0]; + const serialized = JSON.stringify(request); + assert.deepEqual(Object.keys(request).sort(), ["context", "instructions", "question"]); + assert.match(serialized, /Authorized handbook fact one/); + assert.match(serialized, /Authorized handbook fact two/); + assert.match(serialized, /Summarize/); + for (const forbidden of [ + grant.value, + organizationId, + packageValue.audienceDigest, + packageValue.decisionRef, + "denied secret", + "candidate", + ]) { + assert.equal(serialized.includes(forbidden), false); + } +}); + +test("tampered or stale Package, wrong grant kind, and replay emit zero provider bytes", async () => { + const packageValue = contextPackage(); + const profile = privateModelGatewayProfileV1(); + const base = { + envelope: { instructions: "Use context.", question: "Question?" }, + grant, + now, + profile, + }; + const tampered = { + ...packageValue, + blocks: [...packageValue.blocks, { + blockId: `block_${packageValue.evidence[0].evidenceRef.slice("ev_".length)}`, + evidenceRefs: [packageValue.evidence[0].evidenceRef], + text: "denied secret", + }], + }; + assert.throws( + () => prepareAuthorizedModelInput({ ...base, package: tampered }), + /Package/, + ); + assert.throws( + () => prepareAuthorizedModelInput({ ...base, now: new Date(packageValue.expiresAt), package: packageValue }), + /current Package/, + ); + assert.throws( + () => prepareAuthorizedModelInput({ + ...base, + grant: { kind: "channel", value: `egrc_${"2".repeat(64)}` }, + package: packageValue, + }), + /model EgressGrant/, + ); + + const input = prepareAuthorizedModelInput({ ...base, package: packageValue }); + let consumed = false; + const database = { + async query(config) { + if (config.text.includes("context_egress_redeem_grant")) { + if (consumed) return { rows: [{ accepted: false }] }; + consumed = true; + return { rows: [{ accepted: true }] }; + } + return { rows: [{ recorded: true }] }; + }, + }; + const firstGateway = new DeterministicModelGatewayTwin({ + citations: [], costMicrounits: 1, elapsedMs: 1, profile, text: "First.", + }); + const first = await configuredBoundary({ database, gateway: firstGateway, profile }) + .generate(input, grant); + assert.equal(first.kind, "generated"); + const replayGateway = new DeterministicModelGatewayTwin({ + citations: [], costMicrounits: 1, elapsedMs: 1, profile, text: "Replay.", + }); + const replay = await configuredBoundary({ database, gateway: replayGateway, profile }) + .generate(input, grant); + assert.deepEqual(replay, { kind: "generation_not_available" }); + assert.equal(replayGateway.callCount, 0); + assert.equal(replayGateway.outboundBytes, 0); +}); + +test("complete grant/input mutation matrix fails generically before gateway bytes", async () => { + const packageValue = contextPackage(); + const baseProfile = privateModelGatewayProfileV1(); + const withPackageMutation = (updates, evidenceUpdates = {}) => { + const { packageDigest: ignored, ...document } = packageValue; + assert.equal(typeof ignored, "string"); + const mutatedDocument = { + ...document, + ...updates, + evidence: packageValue.evidence.map((item) => ({ ...item, ...evidenceUpdates })), + }; + return Object.freeze({ + ...mutatedDocument, + packageDigest: packageDigest(mutatedDocument), + }); + }; + const otherPackage = withPackageMutation({ packageId: `pkg_${"b".repeat(32)}` }); + const otherAudience = withPackageMutation({ audienceDigest: "b".repeat(64) }); + const otherPurpose = withPackageMutation( + { purpose: "context.summarize" }, + { purpose: "context.summarize" }, + ); + const otherEpoch = withPackageMutation( + { policyEpoch: 8 }, + { policyEpoch: 8 }, + ); + const mutations = [ + ["organization", { organizationId: "91e18bca-86a1-478a-937d-7675c6fe69b0" }], + ["package", { inputPackage: otherPackage }], + ["audience", { inputPackage: otherAudience }], + ["purpose", { inputPackage: otherPurpose }], + ["epoch", { inputPackage: otherEpoch }], + ["provider", {}], + ["model", {}], + ["region", {}], + ["retention", {}], + ["sensitivity", {}], + ["grant expiry", {}], + ]; + + for (const [name, mutation] of mutations) { + const profile = privateModelGatewayProfileV1(); + const gatewayProfile = privateModelGatewayProfileV1(); + const gateway = new DeterministicModelGatewayTwin({ + citations: [], costMicrounits: 1, elapsedMs: 1, profile: gatewayProfile, text: "No.", + }); + const database = { + calls: 0, + async query(config) { + this.calls += 1; + if (config.text.includes("context_egress_redeem_grant")) { + const expected = { + model: name === "model" ? "other-model" : exactProfileOptions.modelRef, + provider: name === "provider" ? "other-provider" : exactProfileOptions.providerRef, + region: name === "region" ? "other-region" : exactProfileOptions.regionRef, + retention: name === "retention" + ? "other-retention" + : exactProfileOptions.retentionPolicyRef, + sensitivity: name === "sensitivity" + ? "other-sensitivity" + : exactProfileOptions.sensitivityPolicyRef, + }; + const exactBinding = ( + config.values[0] === organizationId + && config.values[3] === "model" + && config.values[4].equals(Buffer.from(packageValue.packageDigest, "hex")) + && config.values[6] === packageValue.purpose + && config.values[7].equals(Buffer.from(packageValue.audienceDigest, "hex")) + && config.values[8] === packageValue.policyEpoch + && config.values[9] === expected.retention + && config.values[10] === expected.sensitivity + && config.values[13] === expected.provider + && config.values[14] === expected.model + && config.values[17] === expected.region + && config.values[18] === exactProfileOptions.profileRef + ); + return { + rows: [{ accepted: exactBinding && name !== "grant expiry" }], + }; + } + throw new Error("denial cannot write model outcome audit"); + }, + }; + const boundary = configuredBoundary({ + database, + gateway, + organization: mutation.organizationId ?? organizationId, + profile, + }); + const input = prepareAuthorizedModelInput({ + envelope: { instructions: "Use context.", question: "Question?" }, + grant, + now, + package: mutation.inputPackage ?? packageValue, + profile: baseProfile, + }); + const outcome = await boundary.generate(input, grant); + assert.deepEqual(outcome, { kind: "generation_not_available" }, name); + assert.equal(gateway.callCount, 0, name); + assert.equal(gateway.outboundBytes, 0, name); + } +}); + +test("invalid citations and profile output/cost/time limits return generic failure with restricted audit", async () => { + const packageValue = contextPackage(); + const profile = privateModelGatewayProfileV1(); + const input = prepareAuthorizedModelInput({ + envelope: { instructions: "Use context.", question: "Question?" }, + grant, + now, + package: packageValue, + profile, + }); + const outputs = [ + { citations: ["ev_denied"], costMicrounits: 1, elapsedMs: 1, text: "Denied cite." }, + { citations: [], costMicrounits: exactProfileOptions.maximumCostMicrounits + 1, elapsedMs: 1, text: "Cost." }, + { citations: [], costMicrounits: 1, elapsedMs: exactProfileOptions.maximumElapsedMs + 1, text: "Time." }, + { citations: [], costMicrounits: 1, elapsedMs: 1, text: "x".repeat(exactProfileOptions.maximumOutputBytes + 1) }, + { citations: [], costMicrounits: 1, elapsedMs: 1, text: "invalid\ud800output" }, + ]; + + for (const output of outputs) { + const database = exactRedemptionDatabase(packageValue); + const gateway = new DeterministicModelGatewayTwin({ ...output, profile }); + const result = await configuredBoundary({ database, gateway, profile }).generate(input, grant); + assert.deepEqual(result, { kind: "generation_not_available" }); + assert.equal(gateway.callCount, 1); + assert.equal(database.calls.length, 2); + assert.equal(database.calls[1].values.includes(output.text), false); + } +}); diff --git a/bot_delivery/typescript/test/package-consumer/contract.ts b/bot_delivery/typescript/test/package-consumer/contract.ts new file mode 100644 index 00000000..50141919 --- /dev/null +++ b/bot_delivery/typescript/test/package-consumer/contract.ts @@ -0,0 +1,48 @@ +import type { + ContextPackageWire, + ModelEgressGrantWire, +} from "@context-engine/resolve-sdk"; +import { + AuthorizedModelInput, + DeterministicModelGatewayTwin, + ModelGenerationBoundary, + PrivateModelGatewayProfile, + createPrivateModelGenerationBoundary, + privateModelGatewayProfileV1, + prepareAuthorizedModelInput, + type PrepareAuthorizedModelInputOptions, +} from "@context-engine/bot-delivery"; + +const profile = privateModelGatewayProfileV1(); + +declare const packageValue: ContextPackageWire; +declare const grant: ModelEgressGrantWire; +const options: PrepareAuthorizedModelInputOptions = { + envelope: { instructions: "Use context.", question: "Question?" }, + grant, + now: new Date(), + package: packageValue, + profile, +}; +const input: AuthorizedModelInput = prepareAuthorizedModelInput(options); +void input; + +// @ts-expect-error package consumers cannot construct nominal authorized input +new AuthorizedModelInput(); +// @ts-expect-error package consumers cannot construct server-owned profiles +new PrivateModelGatewayProfile({}); +// @ts-expect-error package consumers cannot construct a boundary or inject database authority +new ModelGenerationBoundary({}); +// @ts-expect-error packages are the only content input; extra context is closed +prepareAuthorizedModelInput({ ...options, arbitraryText: "denied" }); +declare const gateway: DeterministicModelGatewayTwin; +createPrivateModelGenerationBoundary({ + // @ts-expect-error the public factory accepts a connection URL, never a structural query object + database: { query: async () => ({ rows: [{ accepted: true }] }) }, + databaseUrl: "postgresql://unused.invalid/context_engine", + gateway, + organizationId: "81e18bca-86a1-478a-937d-7675c6fe69b0", + profile, +}); +// @ts-expect-error package consumers cannot import an internal implementation subpath +await import("@context-engine/bot-delivery/internal.js"); diff --git a/bot_delivery/typescript/test/package-consumer/package.json b/bot_delivery/typescript/test/package-consumer/package.json new file mode 100644 index 00000000..ddff11e9 --- /dev/null +++ b/bot_delivery/typescript/test/package-consumer/package.json @@ -0,0 +1,6 @@ +{ + "name": "context-engine-bot-delivery-package-consumer", + "version": "0.0.0", + "private": true, + "type": "module" +} diff --git a/bot_delivery/typescript/test/package-consumer/runtime.mjs b/bot_delivery/typescript/test/package-consumer/runtime.mjs new file mode 100644 index 00000000..da9ff4f4 --- /dev/null +++ b/bot_delivery/typescript/test/package-consumer/runtime.mjs @@ -0,0 +1,134 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; + +import { + AuthorizedModelInput, + DeterministicModelGatewayTwin, + ModelGenerationBoundary, + PrivateModelGatewayProfile, + createPrivateModelGenerationBoundary, + privateModelGatewayProfileV1, + prepareAuthorizedModelInput, +} from "@context-engine/bot-delivery"; +import canonicalize from "canonicalize"; + +const now = new Date("2026-07-24T08:00:00.000Z"); +const organizationId = "81e18bca-86a1-478a-937d-7675c6fe69b0"; +const evidenceRef = `ev_${"1".repeat(64)}`; +const citationOpenRef = `cor_${"2".repeat(64)}`; +const grant = { kind: "model", value: `egrm_${"3".repeat(64)}` }; +const profile = privateModelGatewayProfileV1(); +assert.throws( + () => new PrivateModelGatewayProfile({}), + /only be constructed by BotDelivery/, +); +const evidence = { + authorizationAsOf: "2026-07-24T08:00:00Z", + citationOpenRef, + decisionRef: "decision:package-consumer", + evidenceRef, + fragmentRef: "fragment:package-consumer", + policyEpoch: 1, + policySnapshotRef: "policy-snapshot-v1", + projectedFields: ["body"], + purpose: "context.answer", + resourceRef: "resource:package-consumer", + revisionRef: "revision:package-consumer", + runRef: "run:package-consumer", + sourceAclEvidence: { + aclAsOf: "2026-07-24T08:00:00Z", + freshnessProfileRef: "file-source-access-current-transaction-v1", + kind: "mirrored", + projectionRef: "source-acl:package-consumer", + }, + sourceRef: "source:package-consumer", +}; +const document = { + asOf: "2026-07-24T08:00:00Z", + audienceDigest: "a".repeat(64), + blocks: [{ + blockId: `block_${"1".repeat(64)}`, + evidenceRefs: [evidenceRef], + text: "Authorized installed-package context.", + }], + budgetUsage: { costMicrounits: 0, elapsedMs: 0, providerCalls: 0, tokens: 12 }, + continuation: null, + coverage: { status: "sufficient" }, + decisionRef: "decision:package-consumer", + evidence: [evidence], + expiresAt: "2026-07-24T08:05:00Z", + gaps: [], + packageId: `pkg_${"c".repeat(32)}`, + packageSchemaRef: "context-package-openapi-v0", + policyEpoch: 1, + policySnapshotRef: "policy-snapshot-v1", + purpose: "context.answer", + releaseManifestRef: "release:package-consumer", + retentionPolicyRef: "package-digest-only-retention-v1", + runRef: "run:package-consumer", + tokenizerRef: "utf8-byte-budget-v1", + ttlSeconds: 300, +}; +const packageValue = { + ...document, + packageDigest: createHash("sha256").update(canonicalize(document)).digest("hex"), +}; +const input = prepareAuthorizedModelInput({ + envelope: { instructions: "Use context only.", question: "What is available?" }, + grant, + now, + package: packageValue, + profile, +}); +assert.equal(input instanceof AuthorizedModelInput, true); +const gateway = new DeterministicModelGatewayTwin({ + citations: [evidenceRef], + costMicrounits: 2, + elapsedMs: 3, + profile, + text: "Authorized installed-package answer.", +}); +assert.throws( + () => new ModelGenerationBoundary({}), + /only be constructed by BotDelivery/, +); +assert.throws( + () => createPrivateModelGenerationBoundary({ + database: { async query() { return { rows: [{ accepted: true }] }; } }, + databaseUrl: "postgresql://context_engine_egress:unused@127.0.0.1/context_engine", + gateway, + organizationId, + profile, + }), + /invalid field set/, +); +const boundary = createPrivateModelGenerationBoundary({ + databaseUrl: "postgresql://context_engine_egress:unused@127.0.0.1/context_engine", + gateway, + organizationId, + profile, +}); +await boundary.close(); +assert.equal(gateway.callCount, 0); +assert.equal(input instanceof AuthorizedModelInput, true); +await assert.rejects( + import("@context-engine/bot-delivery/internal.js"), + (error) => error?.code === "ERR_PACKAGE_PATH_NOT_EXPORTED", +); +await assert.rejects( + import("@context-engine/bot-delivery/index.js"), + (error) => error?.code === "ERR_PACKAGE_PATH_NOT_EXPORTED", +); +const publicUrl = import.meta.resolve("@context-engine/bot-delivery"); +const fileUrlSibling = await import(new URL("./index.js", publicUrl)); +assert.equal("createModelGenerationBoundaryForTest" in fileUrlSibling, false); +assert.throws( + () => new fileUrlSibling.ModelGenerationBoundary({ + close: async () => undefined, + database: { async query() { return { rows: [{ accepted: true }] }; } }, + gateway, + organizationId, + profile, + }), + /only be constructed by BotDelivery/, +); diff --git a/bot_delivery/typescript/test/package-consumer/tsconfig.json b/bot_delivery/typescript/test/package-consumer/tsconfig.json new file mode 100644 index 00000000..54214563 --- /dev/null +++ b/bot_delivery/typescript/test/package-consumer/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "exactOptionalPropertyTypes": true, + "lib": ["ES2023"], + "module": "NodeNext", + "moduleResolution": "NodeNext", + "noEmit": true, + "noUncheckedIndexedAccess": true, + "skipLibCheck": false, + "strict": true, + "target": "ES2023", + "verbatimModuleSyntax": true + }, + "include": ["contract.ts"] +} diff --git a/bot_delivery/typescript/tsconfig.build.json b/bot_delivery/typescript/tsconfig.build.json new file mode 100644 index 00000000..10462fa7 --- /dev/null +++ b/bot_delivery/typescript/tsconfig.build.json @@ -0,0 +1,11 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "declaration": true, + "declarationMap": true, + "noEmit": false, + "outDir": "dist", + "rootDir": "src", + "sourceMap": true + } +} diff --git a/bot_delivery/typescript/tsconfig.json b/bot_delivery/typescript/tsconfig.json new file mode 100644 index 00000000..52870e42 --- /dev/null +++ b/bot_delivery/typescript/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "exactOptionalPropertyTypes": true, + "forceConsistentCasingInFileNames": true, + "module": "NodeNext", + "moduleResolution": "NodeNext", + "noEmit": true, + "noFallthroughCasesInSwitch": true, + "noImplicitOverride": true, + "noUncheckedIndexedAccess": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "skipLibCheck": false, + "strict": true, + "target": "ES2023", + "types": ["node"], + "verbatimModuleSyntax": true + }, + "include": ["src/**/*.ts"] +} diff --git a/docs/decisions/0052-gate-model-generation-by-package.md b/docs/decisions/0052-gate-model-generation-by-package.md new file mode 100644 index 00000000..bf8131ad --- /dev/null +++ b/docs/decisions/0052-gate-model-generation-by-package.md @@ -0,0 +1,123 @@ +--- +name: adr-0052-gate-model-generation-by-package +version: "1.0.0" +description: > + Permit private BotDelivery generation only from one current audience-bound + ContextPackage and an exactly matching one-shot model EgressGrant. +--- + +# 0052. Gate model generation by one authorized Package + +- Status: accepted +- Date: 2026-07-24 +- Refines: ADR-0002, ADR-0006, ADR-0011, ADR-0012, ADR-0017, ADR-0031, ADR-0045, ADR-0046, ADR-0048, ADR-0049, ADR-0050 + +## Context + +The engine deliberately delivers context rather than answers. BotDelivery may +generate an answer only after the engine has returned one complete, +audience-bound `ContextPackage`; allowing arbitrary prompt context, individual +projections, candidates, multiple Packages, or a Package without final egress +authority would create a second disclosure path outside the sealed Runtime. + +The earlier EgressGrant decision establishes exact one-shot authority, but a +production-shaped BotDelivery boundary must also constrain the provider request, +model output, usage, audit, and package distribution. This first carrier must be +testable without implying that a real model provider, streaming, group delivery, +or action effect is active. + +## Decision + +The trusted Bot application contains a private TypeScript BotDelivery module. +It imports only the generated SDK contract, never engine internals. Its +`prepareAuthorizedModelInput` factory accepts exactly one complete current +`ContextPackage`, one opaque model `EgressGrant`, a closed question envelope, +one versioned private model profile, and the trusted local time. It validates +the exact frozen Package shape and digest, sufficient coverage, one-to-one +Block/Evidence closure, Evidence lineage, audience, purpose, Policy Epoch, +expiry, and input limits, then creates a nominal, redacted, non-serializing +`AuthorizedModelInput` backed by a canonical independent Package snapshot. +Callers cannot construct or recover its private state, and mutating the input +wire object after preparation cannot change its authorization or provider bytes. +Live, Mirrored, and Weak SourceAclEvidence are validated against the exact +public OpenAPI union. Weak evidence additionally requires an ordered +snapshot/check/package timeline, a proof not expired at preparation time, and +a proof expiry no earlier than Package expiry; malformed or shorter-lived +proofs cannot cross the model boundary. + +The boundary has no public constructor or database injection interface. The +sealed `createPrivateModelGenerationBoundary` factory creates and owns its +concrete PostgreSQL pool; a package consumer can supply neither a structural +`query` object nor an accepted/recorded result. The resulting boundary accepts +only that nominal value, the same grant, and an exact deterministic +`ModelGateway` twin owned by this module. Immediately before provider bytes it calls +the existing function-only grant redemption with exact Organization, Package, +payload, audience, purpose, Policy Epoch, provider, model, region, retention, +sensitivity, issuer, consumer, lifetime, hop, and profile bindings. Mismatch, +staleness, expiry, wrong hop, database failure, or replay returns one generic +`generation_not_available` outcome with zero provider bytes. + +The provider request contains only the authorized Package Block text and +Evidence refs plus the declared question and instructions. It contains no grant, +trusted identity, audience digest, decision audit, denied detail, arbitrary +extra context, or transport credential. The deterministic twin is not a public +provider interface and cannot be subclassed or directly invoked to obtain +outbound behavior. + +Every digest-bearing TypeScript document is constrained to exact JSON values, +finite IEEE 754 numbers, dense arrays, enumerable data properties, and Unicode +scalar strings and keys before RFC 8785 encoding. One shared Python/TypeScript +fixture fixes Unicode, UTF-16 property order, numeric edge, and lone-surrogate +rejection behavior so the two digest authorities cannot silently diverge. The +wire-level `packageDigest` is excluded before hashing; any nested digest field +is rejected in both runtimes rather than silently excluded. + +The module registers one exact versioned profile for the activated deterministic +carrier and exposes only its zero-argument factory; callers cannot construct a +profile with the same reference but different limits or bindings. That profile bounds input, question, instruction, +provider-call count, cost, elapsed time, and output bytes. A generated answer is released only when +its citations are unique members of that Package's Evidence and have matching +opaque `CitationOpenRef` values. The answer exposes only text, citations, a +canonical answer-payload digest, and bounded usage. It cannot contain an effect +intent, operation, destination, audience, ticket, or ActionPlane bypass; any +later effect must independently canonicalize and bind the answer during +`ActionPlane.prepare`. + +After grant consumption, PostgreSQL records exactly one restricted outcome row +before a successful answer is released. FORCE RLS storage contains only grant, +Package, input, question, and optional answer digests; category; bounded usage; +profile lineage; database time; and the fixed retention deadline. It contains +no bearer, Package content, question, answer text, identity, or denied detail. +Only the dedicated egress definer can record it, and only the restricted +security operator can delete one Organization's expired rows. + +Issue #70 activates only the deterministic private ModelGateway carrier. Real +provider network calls, streaming, group/public audience generation, complete +BotDelivery orchestration, and external effects remain `NOT_ACTIVE`. + +## Rationale + +Making `ContextPackage` the sole content input preserves the engine's one online +deliverable and keeps provider egress downstream of final authorization. Exact +database redemption makes the grant—not JavaScript object possession—the +disclosure authority. A closed provider request and digest-only outcome audit +make the active carrier measurable while minimizing content and secret spread. + +## Consequences + +- Installed generated-SDK output can feed installed BotDelivery without a + handwritten duplicate wire schema. +- Every denied binding and replay is observable as zero provider bytes and the + same generic outcome. +- Output citations remain openable references to Evidence from exactly one + Package; the model cannot invent source authority. +- Audit failure suppresses a generated answer after provider work rather than + releasing an unaudited result. +- This boundary produces no external effect authority; ActionPlane remains the + only path for mutations. + +## Revisit trigger + +Revisit before enabling a real provider, streaming or cancellation, multiple +Package composition, group/public generation, another retention/profile +version, or any provider output that must cross into ActionPlane. diff --git a/docs/decisions/README.md b/docs/decisions/README.md index ec11f24e..1aa06764 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -50,6 +50,7 @@ kernel, capability separation, and publication visibility model. | Public OpenAPI v0 | [0047 — Freeze OpenAPI v0 through one Runtime path](0047-freeze-openapi-v0-through-one-runtime-path.md) | One public `/v0/resolve` schema and a hidden provisional v1 bridge share the same sealed Runtime; Package release lineage is read-only from the Learning-published active manifest | Two authorization compositions, caller-authored release facts, Runtime publication/fallback, or in-place mutation of historical snapshots | | Generated TypeScript SDK | [0048 — Generate the TypeScript SDK behind a closed facade](0048-generate-typescript-sdk-behind-a-closed-facade.md) | OpenAPI v0 generates the semantic contract and internal fetch client; a package export map exposes only a metadata-safe facade and the contract checksum | Handwritten wire schemas, raw generated transport exports, arbitrary caller headers, floating generator/runtime versions, or claiming inactive capability redemption | | Citation open | [0051 — Reauthorize opaque citation opens](0051-reauthorize-opaque-citation-opens.md) | Digest-only multi-use locators recover only content-free target lineage; every open uses a current UserActor and the sealed Kernel to produce a replacement Package | Bearer authority, prior-decision reuse, source URLs, locator consumption on denial, or content before exact reauthorization | +| Private model egress | [0052 — Gate model generation by one authorized Package](0052-gate-model-generation-by-package.md) | One nominal SDK-Package input and exact one-shot model grant gate the deterministic private ModelGateway, bounded answer, Package-subset citations, and digest-only audit | Arbitrary prompt context, multiple Packages, direct Gateway invocation, invented citations, effect intents, or unaudited answer release | Each baseline ADR is `accepted` and contains Context, Decision, Rationale, Consequences, and Revisit trigger sections. A revisit trigger permits review; it diff --git a/engine/persistence/schema_security_manifest.yaml b/engine/persistence/schema_security_manifest.yaml index d16d7f99..9b25a763 100644 --- a/engine/persistence/schema_security_manifest.yaml +++ b/engine/persistence/schema_security_manifest.yaml @@ -1,5 +1,5 @@ { - "manifestVersion": "23.0.0", + "manifestVersion": "24.0.0", "controlOperations": [ { "name": "register_file_source", @@ -2922,6 +2922,177 @@ "OBS-005" ] }, + { + "name": "model_egress_audit", + "classification": "tenant_owned", + "nonOwnerEvidence": { + "evidenceId": "PG-MODEL-EGRESS-070", + "selector": { + "table": "model_egress_audit" + } + }, + "purpose": "Restricted digest-only generation outcome and usage lineage after one consumed model grant", + "organizationColumn": "organization_id", + "organizationInclusiveKeys": [ + { + "name": "pk_model_egress_audit", + "kind": "primary_key", + "columns": [ + "organization_id", + "audit_id" + ] + }, + { + "name": "uq_model_egress_audit_exact_grant", + "kind": "unique", + "columns": [ + "organization_id", + "grant_digest" + ] + } + ], + "foreignKeys": [ + { + "name": "fk_model_egress_audit_exact_grant", + "columns": [ + "organization_id", + "grant_digest" + ], + "references": { + "table": "egress_grant", + "columns": [ + "organization_id", + "grant_digest" + ] + }, + "onDelete": "RESTRICT" + } + ], + "checkConstraints": [ + { + "name": "ck_model_egress_audit_sha256_digests", + "expression": "grant, Package, model payload, question, and optional answer payload lineage are 32-byte SHA-256 digests" + }, + { + "name": "ck_model_egress_audit_outcome_category", + "expression": "outcome is generated, output_rejected, or provider_unavailable" + }, + { + "name": "ck_model_egress_audit_usage", + "expression": "exactly one provider call and nonnegative cost, elapsed time, and output bytes" + }, + { + "name": "ck_model_egress_audit_answer_digest", + "expression": "only a generated outcome carries an answer payload digest" + }, + { + "name": "ck_model_egress_audit_profiles", + "expression": "gateway profile is nonblank and audit profile is model-generation-audit-v1" + }, + { + "name": "ck_model_egress_audit_retention", + "expression": "retain_until is exactly 30 days after database-owned recorded_at" + } + ], + "rowLevelSecurity": { + "enabled": true, + "forced": true, + "policies": [ + { + "name": "model_egress_audit_migrator_administration", + "command": "ALL", + "roles": [ + "context_engine_migrator" + ], + "using": "true", + "withCheck": "true" + }, + { + "name": "model_egress_audit_definer_select", + "command": "SELECT", + "roles": [ + "context_engine_egress_grant_definer" + ], + "using": "true" + }, + { + "name": "model_egress_audit_definer_insert", + "command": "INSERT", + "roles": [ + "context_engine_egress_grant_definer" + ], + "withCheck": "true" + }, + { + "name": "model_egress_audit_definer_delete", + "command": "DELETE", + "roles": [ + "context_engine_egress_grant_definer" + ], + "using": "true" + } + ] + }, + "functionOnlyMutation": { + "databaseFunctions": [ + "context_egress_record_model_outcome", + "context_security_delete_expired_model_egress_audit" + ], + "definerRole": "context_engine_egress_grant_definer", + "directTableMutationAllowed": false + }, + "retention": { + "class": "restricted_digest_audit_30_days_v1", + "databaseOwnedTime": true, + "bearerStored": false, + "rawPackageStored": false, + "rawQuestionStored": false, + "rawAnswerStored": false, + "deniedContentStored": false, + "cleanup": "context_security_delete_expired_model_egress_audit deletes only one exact Organization's rows after database-clock retain_until" + }, + "permittedOperations": { + "context_engine_runtime": [], + "context_engine_egress": [ + "EXECUTE context_egress_record_model_outcome" + ], + "context_engine_egress_grant_definer": [ + "SELECT", + "INSERT", + "DELETE" + ], + "context_engine_control": [], + "context_engine_worker": [], + "context_engine_learning": [], + "context_engine_security_operator": [ + "EXECUTE context_security_delete_expired_model_egress_audit" + ] + }, + "partitions": [], + "securityInvariantIds": [ + "TENANT-OWNERSHIP-001", + "TENANT-FK-002", + "RLS-FAIL-CLOSED-003", + "INDEX-NOT-AUTHORITY-005", + "EGRESS-011", + "NON-ENUMERATION-009", + "TRACE-REDACTION-012", + "ACTION-SEPARATION-014" + ], + "negativeTestIds": [ + "EGR-001", + "EGR-004", + "DB-001", + "DB-002", + "DB-004", + "DB-008", + "DB-009", + "DB-010", + "OBS-004", + "OBS-005", + "PG-MODEL-EGRESS-070" + ] + }, { "name": "action_delivery_attempt", "classification": "tenant_owned", diff --git a/eval/catalogs/m0-security-evidence.yaml b/eval/catalogs/m0-security-evidence.yaml index ac4060ae..5e5ae3b9 100644 --- a/eval/catalogs/m0-security-evidence.yaml +++ b/eval/catalogs/m0-security-evidence.yaml @@ -90,6 +90,9 @@ {"id": "PROP-EGRESS-011", "layer": "property", "selector": "tests/unit/test_egress_grant.py::test_each_egress_binding_mutation_and_cross_kind_emits_zero_bytes_effects"}, {"id": "PG-EGRESS-011", "layer": "postgres", "selector": "tests/integration/test_egress_grant.py::test_digest_only_grant_is_atomic_one_shot_and_audited"}, {"id": "RUNTIME-EGRESS-011", "layer": "runtime", "selector": "tests/integration/test_z_egress_grant_file.py::test_file_http_package_redeems_exact_model_grant_before_gateway_bytes"}, + {"id": "TS-MODEL-EGRESS-070", "layer": "property", "selector": "tests/unit/test_bot_delivery_model_egress_contract.py::test_typescript_model_egress_is_closed_pinned_and_zero_byte_on_denial"}, + {"id": "PG-MODEL-EGRESS-070", "layer": "postgres", "selector": "tests/integration/test_z_egress_grant_file.py::test_packed_typescript_sdk_resolves_authorized_file_package_over_live_http"}, + {"id": "SDK-MODEL-EGRESS-070", "layer": "runtime", "selector": "tests/integration/test_z_egress_grant_file.py::test_packed_typescript_sdk_resolves_authorized_file_package_over_live_http"}, {"id": "PROP-TRACE-REDACTION-012", "layer": "property", "selector": "tests/unit/test_context_run.py::test_records_retain_digests_not_raw_query_package_or_denial_details"}, {"id": "PG-TRACE-REDACTION-012", "layer": "postgres", "selector": "tests/integration/test_context_run_schema.py::test_runtime_cross_organization_insert_attempts_are_bidirectionally_zero_effect"}, {"id": "RUNTIME-TRACE-REDACTION-012", "layer": "runtime", "selector": "tests/unit/test_runtime_authorized_evidence.py::test_empty_decision_audit_is_generic_and_retains_no_denied_detail"}, @@ -128,7 +131,7 @@ {"invariantRef": "TRANSPORT-UNTRUSTED-008", "evidenceRefs": {"property": ["PROP-TRANSPORT-UNTRUSTED-008", "PROP-DELIVERY-EVIDENCE-063", "SDK-CONTRACT-064"], "postgres": ["PG-TRANSPORT-UNTRUSTED-008", "PG-DELIVERY-EVIDENCE-063"], "runtime": ["RUNTIME-TRANSPORT-UNTRUSTED-008", "HTTP-DELIVERY-EVIDENCE-063", "FILE-DELIVERY-EVIDENCE-063", "SDK-LIVE-FILE-064"]}}, {"invariantRef": "NON-ENUMERATION-009", "evidenceRefs": {"property": ["PROP-NON-ENUMERATION-009"], "postgres": ["PG-NON-ENUMERATION-009"], "runtime": ["RUNTIME-NON-ENUMERATION-009"]}}, {"invariantRef": "CITATION-AUTH-010", "evidenceRefs": {"property": ["PROP-CITATION-AUTH-010"], "postgres": ["PG-CITATION-AUTH-010"], "runtime": ["RUNTIME-CITATION-AUTH-010"]}}, - {"invariantRef": "EGRESS-011", "evidenceRefs": {"property": ["PROP-EGRESS-011"], "postgres": ["PG-EGRESS-011", "PG-ACTION-PERFORM-068"], "runtime": ["RUNTIME-EGRESS-011"]}}, + {"invariantRef": "EGRESS-011", "evidenceRefs": {"property": ["PROP-EGRESS-011", "TS-MODEL-EGRESS-070"], "postgres": ["PG-EGRESS-011", "PG-ACTION-PERFORM-068", "PG-MODEL-EGRESS-070"], "runtime": ["RUNTIME-EGRESS-011", "SDK-MODEL-EGRESS-070"]}}, {"invariantRef": "TRACE-REDACTION-012", "evidenceRefs": {"property": ["PROP-TRACE-REDACTION-012"], "postgres": ["PG-TRACE-REDACTION-012"], "runtime": ["RUNTIME-TRACE-REDACTION-012"]}}, {"invariantRef": "ACTION-SEPARATION-014", "evidenceRefs": {"property": ["PROP-ACTION-SEPARATION-014"], "postgres": ["PG-ACTION-SEPARATION-014", "PG-ACTION-PREPARE-067", "PG-ACTION-PERFORM-068"], "runtime": ["RUNTIME-ACTION-SEPARATION-014"]}}, {"invariantRef": "CROSS-ORG-LEARN-015", "evidenceRefs": {"property": ["PROP-CROSS-ORG-LEARN-015"], "postgres": ["PG-CROSS-ORG-LEARN-015"], "runtime": ["RUNTIME-CROSS-ORG-LEARN-015"]}}, diff --git a/eval/catalogs/security-catalog.schema.json b/eval/catalogs/security-catalog.schema.json index b84e5728..6b3d0ff2 100644 --- a/eval/catalogs/security-catalog.schema.json +++ b/eval/catalogs/security-catalog.schema.json @@ -95,8 +95,8 @@ }, "activations": { "type": "array", - "minItems": 13, - "maxItems": 13, + "minItems": 14, + "maxItems": 14, "uniqueItems": true, "prefixItems": [ { @@ -672,6 +672,51 @@ "raw source URL locator" ] } + }, + { + "const": { + "issueRef": "#70", + "invariantRef": "EGRESS-011", + "carrier": "private Package-bound deterministic TypeScript ModelGateway", + "status": "active_fail_closed", + "policyEpochScope": "organization-v0", + "controlBoundary": "generated SDK ContextPackage plus opaque model EgressGrant -> nominal AuthorizedModelInput -> exact non-owner PostgreSQL redemption -> deterministic ModelGateway twin -> bounded answer and digest-only audit", + "testEvidence": [ + { + "id": "TS-MODEL-EGRESS-070", + "surface": "tests/unit/test_bot_delivery_model_egress_contract.py::test_typescript_model_egress_is_closed_pinned_and_zero_byte_on_denial", + "oracle": "The private installed TypeScript package exposes only its closed root, accepts one complete current SDK Package and one model grant through nominal AuthorizedModelInput, sends only authorized blocks and declared question/instructions to the exact deterministic twin, and proves every binding mutation, replay, stale input, forged input, invalid citation, and cost/time/output limit emits zero unauthorized provider bytes or one generic unavailable result." + }, + { + "id": "SDK-MODEL-EGRESS-070", + "surface": "tests/integration/test_z_egress_grant_file.py::test_packed_typescript_sdk_resolves_authorized_file_package_over_live_http", + "oracle": "An installed generated SDK obtains one authorized File Package and model grant from a real local HTTP API, then the installed BotDelivery package invokes the deterministic twin exactly once and returns a bounded answer whose citation is a subset of that Package; grant replay emits zero additional bytes." + }, + { + "id": "PG-MODEL-EGRESS-070", + "surface": "tests/integration/test_z_egress_grant_file.py::test_packed_typescript_sdk_resolves_authorized_file_package_over_live_http", + "oracle": "The dedicated non-owner egress login redeems the exact Organization, Package, payload, audience, purpose, epoch, provider, model, region, retention, sensitivity, issuer, consumer, and profile bindings. FORCE-RLS audit stores only digests, bounded usage, outcome category, and lineage, and exact-Organization operator cleanup observes the fixed database-clock retention window." + } + ], + "deferredEvidence": [ + "production provider ModelGateway conformance", + "streaming and cancellation semantics", + "group-public and asker-private answer orchestration" + ], + "futureCarriers": [ + "production provider ModelGateway", + "streaming generation", + "group-public answer generation", + "complete BotDelivery orchestration" + ], + "notActive": [ + "real model or provider network call", + "streaming tokens", + "group AudienceSnapshot", + "model-authored effect intent or ActionTicket", + "external channel effect" + ] + } } ], "items": false @@ -952,7 +997,10 @@ "PG-ACTION-PREPARE-067", "PG-ACTION-PERFORM-068", "PG-CITATION-AUTH-010", - "RUNTIME-CITATION-AUTH-010" + "RUNTIME-CITATION-AUTH-010", + "TS-MODEL-EGRESS-070", + "SDK-MODEL-EGRESS-070", + "PG-MODEL-EGRESS-070" ] }, "surface": { @@ -993,7 +1041,8 @@ "#64", "#67", "#68", - "#69" + "#69", + "#70" ] }, "invariantRef": { @@ -1023,6 +1072,7 @@ "ActionPlane.prepare private create-placeholder, finalize-reply, or follow-up ticket", "ActionPlane.perform private deterministic Sender-twin effect and receipt reconciliation", "private/direct File CitationOpenRef issuance and OpenCitation", + "private Package-bound deterministic TypeScript ModelGateway", "ContextRuntime.resolve(Continue | profile-disabled OpenCitation | server-owned unavailable Acquire plan)" ] }, @@ -1049,7 +1099,8 @@ "immutable OpenAPI v0 -> pinned generator -> generated semantic types and internal fetch client -> closed package exports and metadata-safe facade -> authenticated HTTP ingress -> sealed ContextRuntime.resolve", "trusted co-resident intent -> closed TypeScript ActionPlane.prepare -> least-privilege PostgreSQL prepare function -> operation-specific audience-bound ActionTicket", "operation-specific ActionTicket plus exact canonical payload -> least-privilege PostgreSQL begin/complete/reconcile state machine -> deterministic private Sender twin", - "digest-only locator -> current UserActor transaction -> CandidateRef -> AuthorizationKernel -> AuthorizedProjection -> replacement ContextPackage" + "digest-only locator -> current UserActor transaction -> CandidateRef -> AuthorizationKernel -> AuthorizedProjection -> replacement ContextPackage", + "generated SDK ContextPackage plus opaque model EgressGrant -> nominal AuthorizedModelInput -> exact non-owner PostgreSQL redemption -> deterministic ModelGateway twin -> bounded answer and digest-only audit" ] }, "testEvidence": { diff --git a/eval/catalogs/security-invariants.yaml b/eval/catalogs/security-invariants.yaml index 16c50154..109b12b1 100644 --- a/eval/catalogs/security-invariants.yaml +++ b/eval/catalogs/security-invariants.yaml @@ -16,7 +16,8 @@ "#64", "#67", "#68", - "#69" + "#69", + "#70" ], "documentRefs": [ "README.md", @@ -37,9 +38,10 @@ "docs/decisions/0048-generate-typescript-sdk-behind-a-closed-facade.md", "docs/decisions/0049-prepare-one-exact-private-effect.md", "docs/decisions/0050-perform-one-exact-private-effect.md", - "docs/decisions/0051-reauthorize-opaque-citation-opens.md" + "docs/decisions/0051-reauthorize-opaque-citation-opens.md", + "docs/decisions/0052-gate-model-generation-by-package.md" ], - "reconciliation": "Issue #2 fixes the product and testing decisions, issue #5 requires exactly fifteen release invariants and twelve canonical acceptance fixtures, and ADR-0019 resolves the later nineteen-label prose expansion without weakening any safeguard. Issue #15 activates only Organization-level next-request resolve(Acquire) revocation evidence under REVOCATION-006: at that activation, PG-REVOCATION-006, RUN-006, and CACHE-002 are active while BLOB-002 and Continue, citation, Policy-Epoch-bound WorkerLease, production ContextAccessTicket/ActionTicket, audit, outbox, cleanup, finer-epoch, UI, and external-admin carriers remain future or NOT_ACTIVE; later issue records are authoritative for subsequently activated carriers. Issue #16 activates only the M0 refusal gate for unavailable Continue, profile-disabled OpenCitation, and server-owned unavailable Acquire plans: at that activation its real continuation, profile-enabled citation, federated/source-native, and File carriers remain future, while its Runtime and HTTP refusal surfaces prove generic outcomes before content I/O; Issue #69 later activates the private/direct File profile-enabled citation carrier. Issue #17 activates only the signed one-shot persistent no-op durable-job WorkerLease subcarrier under WORKER-LEASE-007. It binds one exact worker audience but no end-user delivery audience or Policy Epoch, and proves only LEASE-SIGNING-017, PG-WORKER-LEASE-NOOP-017, and WORKER-LEASE-REPLAY-007; Source, Resource, Revision, Policy Epoch, end-user delivery audience, idempotency, generation, business mutation, outbox, File publication, and the full ACCEPT-008 matrix remain deferred or NOT_ACTIVE. Issue #18 activates only distinct signed synthetic ContextAccessTicket Provider-read and ActionTicket no-op channel-action subcarriers under ACTION-SEPARATION-014, with current Organization-v0 Policy Epoch validation. TICKET-AUDIENCE-018 and PG-TICKET-EPOCH-018 do not activate production ContextProvider integration, ContextRuntime ticket integration, BotDelivery, full M2 ActionPlane.prepare/perform, a real Sender or external effect, payload/destination/approval/idempotency binding, durable one-shot/replay/reconciliation, or full ACCEPT-012 PASS; those remain future or NOT_ACTIVE. Issue #19 activates only the current Acquire authorized-only ContextRun and restricted delivered-empty DecisionAudit subcarrier under TRACE-REDACTION-012. DIGEST-019, RUN-LINEAGE-019, AUTHORIZED-RUN-019, and PG-TRACE-REDACTION-012 prove deterministic Package and Organization-bound query digests, retained-UserActor-transaction persistence, decisionRef resolution, redaction, and short-lived exact-Organization operator ticket reads with no application-role table access; the supported reader commits deletion before returning, while a direct caller rollback is not claimed as durable exactly-once redemption. Raw query retention, full ContextPackage body retention, unauthenticated transport failures as ContextRuns, cross-Organization analytics, and general observability redaction remain NOT_ACTIVE. Issue #48 activates only the current ACCEPT-002 authenticated HTTP Acquire Membership field-projection carrier under SCOPE-INTERSECTION-004, INDEX-NOT-AUTHORITY-005, and TRACE-REDACTION-012. PROP-FIELD-PROJECTION-048, PG-FIELD-PROJECTION-048, and HTTP-ACCEPT-002-048 bind one current Membership/version field ceiling to same-transaction FORCE-RLS reduction, the sealed AuthorizationKernel, AuthorizedProjection and Evidence integrity, and authorized-only ContextRun/audit persistence. General permission DSLs, caller-authored projection lists, CandidateRef or index field authority, production Provider/source-native ACL negotiation, Supply publication, File/Base field ACL, typed fields, Continue, and Issue #20 runner substitution remain future or NOT_ACTIVE; Issue #69 later activates private/direct File OpenCitation through the same field-projection gates. Issue #63 activates only the digest-only private authenticated HTTP Acquire DeliveryEvidenceRef carrier under TRANSPORT-UNTRUSTED-008. PROP-DELIVERY-EVIDENCE-063, PG-DELIVERY-EVIDENCE-063, HTTP-DELIVERY-EVIDENCE-063, and FILE-DELIVERY-EVIDENCE-063 prove exact service/request/Organization/asker/Membership-version/destination/consumer/purpose/audience/epoch/lifetime binding, stable identical retry identity, role isolation, expiry cleanup, pre-content generic rejection, and one File-backed sealed Runtime delivery. Group AudienceSnapshot, group/public DeliveryEvidenceRef, production ModelGateway, ActionPlane, and the BotDelivery application remain future or NOT_ACTIVE; Issues #64, #66, and #69 later activate the frozen OpenAPI, generated TypeScript SDK, and private/direct OpenCitation delivery-evidence carriers. Issue #65 activates only one opaque digest-only model or channel EgressGrant after final Package policy, exact atomic PostgreSQL redemption and restricted audit, nominal BotDelivery inputs, and deterministic network-free ModelGateway or Sender-preflight spies under EGRESS-011. PROP-EGRESS-011, PG-EGRESS-011, and RUNTIME-EGRESS-011 prove exact Package/Organization/purpose/audience/epoch/hop/profile/lifetime binding and zero additional bytes on replay. Real model/provider calls, a real Sender or channel write, ActionTicket effects, group AudienceSnapshot revalidation, and the BotDelivery application process remain future or NOT_ACTIVE; Issue #64 later activates the generated SDK consumer. Issue #66 activates the frozen public POST /v0/resolve OpenAPI carrier under TRANSPORT-UNTRUSTED-008. OPENAPI-CONTRACT-066, OPENAPI-BREAKING-066, HTTP-V0-066, and PG-RUNTIME-RELEASE-066 prove one public closed operation, deterministic immutable snapshot and breaking-change refusal, a hidden v1 bridge through the same handler and sealed Runtime path, and exact read-only observation of the active Learning-promoted release with fail-closed missing-release behavior before content work. A production BotDelivery caller, Continue redemption, MCP, group AudienceSnapshot, and external effects remain future or NOT_ACTIVE; Issues #64 and #69 later activate the generated TypeScript SDK and private/direct OpenCitation redemption through this frozen operation. Issue #64 activates only the packaged generated TypeScript POST /v0/resolve client under TRANSPORT-UNTRUSTED-008. SDK-CONTRACT-064 and SDK-LIVE-FILE-064 prove deterministic pinned generation, strict closed types, a narrow export map and metadata-only facade, installable tarball consumption, and one real PostgreSQL/File-backed Acquire through CandidateRef, AuthorizationKernel, AuthorizedProjection, ContextPackage, and opaque model egress grant. Issue #69 later extends SDK-LIVE-FILE-064 with a successful private/direct File OpenCitation through a second request-bound DeliveryEvidenceRef; generated Continue remains generic unavailable. External package publication, production BotDelivery, MCP, group AudienceSnapshot, real Continue redemption, and external effects remain future or NOT_ACTIVE. Issue #67 activates only private ActionPlane.prepare for create-placeholder, finalize-reply, and private-follow-up operation-specific tickets under ACTION-SEPARATION-014. PG-ACTION-PREPARE-067 proves exact current delivery, Organization, destination, audience, source, payload, approval, epoch, lifetime, and idempotency binding under a dedicated non-owner PostgreSQL role with digest-only FORCE-RLS persistence and zero effects. Issue #68 activates private ActionPlane.perform only through a deterministic Sender twin. PG-ACTION-PERFORM-068 proves one pre-Sender current-authority validation, one provider-attempt identity, immutable applied receipt replay, zero-effect ticket/payload mutation and stale-audience refusal, same-label cross-Organization isolation, and monotonic applied/rejected reconciliation including crash interleavings. Real provider or channel network effects, group AudienceSnapshot, compensation/delete, production BotDelivery orchestration, and the full ACCEPT-012 pass remain future or NOT_ACTIVE. The canonical set is IDs 001 through 012, 014, 015, and 019: CACHE-SCOPE-013 remains a preregistered conditional extension; AUDIENCE-016 is absorbed by SCOPE-INTERSECTION-004 and EGRESS-011; ACL-PROOF-017 is absorbed by INDEX-NOT-AUTHORITY-005 and REVOCATION-006; DELIVERY-EVIDENCE-018 is absorbed by TRANSPORT-UNTRUSTED-008. ACCEPT-001 through ACCEPT-012 follow ADR-0019's category order. Protected-asset references A-01 through A-08 refer, in order, to the eight bullets in the threat model's Protected assets section. Every expectedEvidence value below is a stable planned case identifier, not a claim that the case ran or passed; only an exact activation record upgrades named evidence, while fixture carrier status and the explicit M0 oracle preserve every other accepted-versus-active distinction. Issue #69 activates private/direct File CitationOpenRef issuance and OpenCitation under CITATION-AUTH-010: digest-only multi-use locators reveal only prior Package/Evidence and Fragment location lineage, every open obtains a current UserActor and trusted delivery context then traverses CandidateRef, AuthorizationKernel, AuthorizedProjection, a replacement ContextPackage, EgressGrant, ContextRun, and restricted DecisionAudit. PG-CITATION-AUTH-010, RUNTIME-CITATION-AUTH-010, and SDK-LIVE-FILE-064 prove A/B/A reauthorization, non-consumption on denial, database-clock expiry, cross-kind and cross-Organization opacity, and the generated SDK carrier. Group/public AudienceSnapshot, non-File providers, raw source URL locators, and Continue remain future or NOT_ACTIVE." + "reconciliation": "Issue #2 fixes the product and testing decisions, issue #5 requires exactly fifteen release invariants and twelve canonical acceptance fixtures, and ADR-0019 resolves the later nineteen-label prose expansion without weakening any safeguard. Issue #15 activates only Organization-level next-request resolve(Acquire) revocation evidence under REVOCATION-006: at that activation, PG-REVOCATION-006, RUN-006, and CACHE-002 are active while BLOB-002 and Continue, citation, Policy-Epoch-bound WorkerLease, production ContextAccessTicket/ActionTicket, audit, outbox, cleanup, finer-epoch, UI, and external-admin carriers remain future or NOT_ACTIVE; later issue records are authoritative for subsequently activated carriers. Issue #16 activates only the M0 refusal gate for unavailable Continue, profile-disabled OpenCitation, and server-owned unavailable Acquire plans: at that activation its real continuation, profile-enabled citation, federated/source-native, and File carriers remain future, while its Runtime and HTTP refusal surfaces prove generic outcomes before content I/O; Issue #69 later activates the private/direct File profile-enabled citation carrier. Issue #17 activates only the signed one-shot persistent no-op durable-job WorkerLease subcarrier under WORKER-LEASE-007. It binds one exact worker audience but no end-user delivery audience or Policy Epoch, and proves only LEASE-SIGNING-017, PG-WORKER-LEASE-NOOP-017, and WORKER-LEASE-REPLAY-007; Source, Resource, Revision, Policy Epoch, end-user delivery audience, idempotency, generation, business mutation, outbox, File publication, and the full ACCEPT-008 matrix remain deferred or NOT_ACTIVE. Issue #18 activates only distinct signed synthetic ContextAccessTicket Provider-read and ActionTicket no-op channel-action subcarriers under ACTION-SEPARATION-014, with current Organization-v0 Policy Epoch validation. TICKET-AUDIENCE-018 and PG-TICKET-EPOCH-018 do not activate production ContextProvider integration, ContextRuntime ticket integration, BotDelivery, full M2 ActionPlane.prepare/perform, a real Sender or external effect, payload/destination/approval/idempotency binding, durable one-shot/replay/reconciliation, or full ACCEPT-012 PASS; those remain future or NOT_ACTIVE. Issue #19 activates only the current Acquire authorized-only ContextRun and restricted delivered-empty DecisionAudit subcarrier under TRACE-REDACTION-012. DIGEST-019, RUN-LINEAGE-019, AUTHORIZED-RUN-019, and PG-TRACE-REDACTION-012 prove deterministic Package and Organization-bound query digests, retained-UserActor-transaction persistence, decisionRef resolution, redaction, and short-lived exact-Organization operator ticket reads with no application-role table access; the supported reader commits deletion before returning, while a direct caller rollback is not claimed as durable exactly-once redemption. Raw query retention, full ContextPackage body retention, unauthenticated transport failures as ContextRuns, cross-Organization analytics, and general observability redaction remain NOT_ACTIVE. Issue #48 activates only the current ACCEPT-002 authenticated HTTP Acquire Membership field-projection carrier under SCOPE-INTERSECTION-004, INDEX-NOT-AUTHORITY-005, and TRACE-REDACTION-012. PROP-FIELD-PROJECTION-048, PG-FIELD-PROJECTION-048, and HTTP-ACCEPT-002-048 bind one current Membership/version field ceiling to same-transaction FORCE-RLS reduction, the sealed AuthorizationKernel, AuthorizedProjection and Evidence integrity, and authorized-only ContextRun/audit persistence. General permission DSLs, caller-authored projection lists, CandidateRef or index field authority, production Provider/source-native ACL negotiation, Supply publication, File/Base field ACL, typed fields, Continue, and Issue #20 runner substitution remain future or NOT_ACTIVE; Issue #69 later activates private/direct File OpenCitation through the same field-projection gates. Issue #63 activates only the digest-only private authenticated HTTP Acquire DeliveryEvidenceRef carrier under TRANSPORT-UNTRUSTED-008. PROP-DELIVERY-EVIDENCE-063, PG-DELIVERY-EVIDENCE-063, HTTP-DELIVERY-EVIDENCE-063, and FILE-DELIVERY-EVIDENCE-063 prove exact service/request/Organization/asker/Membership-version/destination/consumer/purpose/audience/epoch/lifetime binding, stable identical retry identity, role isolation, expiry cleanup, pre-content generic rejection, and one File-backed sealed Runtime delivery. Group AudienceSnapshot, group/public DeliveryEvidenceRef, production ModelGateway, ActionPlane, and the BotDelivery application remain future or NOT_ACTIVE; Issues #64, #66, #69, and #70 later activate the frozen OpenAPI, generated TypeScript SDK, private/direct OpenCitation, and deterministic private model-egress carriers. Issue #65 activates only one opaque digest-only model or channel EgressGrant after final Package policy, exact atomic PostgreSQL redemption and restricted audit, nominal BotDelivery inputs, and deterministic network-free ModelGateway or Sender-preflight spies under EGRESS-011. PROP-EGRESS-011, PG-EGRESS-011, and RUNTIME-EGRESS-011 prove exact Package/Organization/purpose/audience/epoch/hop/profile/lifetime binding and zero additional bytes on replay. Real model/provider calls, a real Sender or channel write, ActionTicket effects, group AudienceSnapshot revalidation, and the BotDelivery application process remain future or NOT_ACTIVE; Issue #64 later activates the generated SDK consumer and Issue #70 later activates the deterministic private TypeScript ModelGateway. Issue #66 activates the frozen public POST /v0/resolve OpenAPI carrier under TRANSPORT-UNTRUSTED-008. OPENAPI-CONTRACT-066, OPENAPI-BREAKING-066, HTTP-V0-066, and PG-RUNTIME-RELEASE-066 prove one public closed operation, deterministic immutable snapshot and breaking-change refusal, a hidden v1 bridge through the same handler and sealed Runtime path, and exact read-only observation of the active Learning-promoted release with fail-closed missing-release behavior before content work. A production BotDelivery caller, Continue redemption, MCP, group AudienceSnapshot, and external effects remain future or NOT_ACTIVE; Issues #64 and #69 later activate the generated TypeScript SDK and private/direct OpenCitation redemption through this frozen operation. Issue #64 activates only the packaged generated TypeScript POST /v0/resolve client under TRANSPORT-UNTRUSTED-008. SDK-CONTRACT-064 and SDK-LIVE-FILE-064 prove deterministic pinned generation, strict closed types, a narrow export map and metadata-only facade, installable tarball consumption, and one real PostgreSQL/File-backed Acquire through CandidateRef, AuthorizationKernel, AuthorizedProjection, ContextPackage, and opaque model egress grant. Issue #69 later extends SDK-LIVE-FILE-064 with a successful private/direct File OpenCitation through a second request-bound DeliveryEvidenceRef; Issue #70 extends the installed SDK fixture into deterministic Package-bound model generation; generated Continue remains generic unavailable. External package publication, production provider access, MCP, group AudienceSnapshot, real Continue redemption, and external effects remain future or NOT_ACTIVE. Issue #67 activates only private ActionPlane.prepare for create-placeholder, finalize-reply, and private-follow-up operation-specific tickets under ACTION-SEPARATION-014. PG-ACTION-PREPARE-067 proves exact current delivery, Organization, destination, audience, source, payload, approval, epoch, lifetime, and idempotency binding under a dedicated non-owner PostgreSQL role with digest-only FORCE-RLS persistence and zero effects. Issue #68 activates private ActionPlane.perform only through a deterministic Sender twin. PG-ACTION-PERFORM-068 proves one pre-Sender current-authority validation, one provider-attempt identity, immutable applied receipt replay, zero-effect ticket/payload mutation and stale-audience refusal, same-label cross-Organization isolation, and monotonic applied/rejected reconciliation including crash interleavings. Real provider or channel network effects, group AudienceSnapshot, compensation/delete, production BotDelivery orchestration, and the full ACCEPT-012 pass remain future or NOT_ACTIVE. The canonical set is IDs 001 through 012, 014, 015, and 019: CACHE-SCOPE-013 remains a preregistered conditional extension; AUDIENCE-016 is absorbed by SCOPE-INTERSECTION-004 and EGRESS-011; ACL-PROOF-017 is absorbed by INDEX-NOT-AUTHORITY-005 and REVOCATION-006; DELIVERY-EVIDENCE-018 is absorbed by TRANSPORT-UNTRUSTED-008. ACCEPT-001 through ACCEPT-012 follow ADR-0019's category order. Protected-asset references A-01 through A-08 refer, in order, to the eight bullets in the threat model's Protected assets section. Every expectedEvidence value below is a stable planned case identifier, not a claim that the case ran or passed; only an exact activation record upgrades named evidence, while fixture carrier status and the explicit M0 oracle preserve every other accepted-versus-active distinction. Issue #69 activates private/direct File CitationOpenRef issuance and OpenCitation under CITATION-AUTH-010: digest-only multi-use locators reveal only prior Package/Evidence and Fragment location lineage, every open obtains a current UserActor and trusted delivery context then traverses CandidateRef, AuthorizationKernel, AuthorizedProjection, a replacement ContextPackage, EgressGrant, ContextRun, and restricted DecisionAudit. PG-CITATION-AUTH-010, RUNTIME-CITATION-AUTH-010, and SDK-LIVE-FILE-064 prove A/B/A reauthorization, non-consumption on denial, database-clock expiry, cross-kind and cross-Organization opacity, and the generated SDK carrier. Group/public AudienceSnapshot, non-File providers, raw source URL locators, and Continue remain future or NOT_ACTIVE. Issue #70 activates only the private deterministic TypeScript ModelGateway under EGRESS-011. TS-MODEL-EGRESS-070, SDK-MODEL-EGRESS-070, and PG-MODEL-EGRESS-070 prove one current Package, exact grant redemption, closed provider input, bounded Package-subset citations, replay zero bytes, and digest-only retained audit. Real providers, streaming, group AudienceSnapshot, model-authored ActionPlane authority, and external effects remain future or NOT_ACTIVE." }, "hardOracles": [ { @@ -606,6 +608,49 @@ "Continue", "raw source URL locator" ] + }, + { + "issueRef": "#70", + "invariantRef": "EGRESS-011", + "carrier": "private Package-bound deterministic TypeScript ModelGateway", + "status": "active_fail_closed", + "policyEpochScope": "organization-v0", + "controlBoundary": "generated SDK ContextPackage plus opaque model EgressGrant -> nominal AuthorizedModelInput -> exact non-owner PostgreSQL redemption -> deterministic ModelGateway twin -> bounded answer and digest-only audit", + "testEvidence": [ + { + "id": "TS-MODEL-EGRESS-070", + "surface": "tests/unit/test_bot_delivery_model_egress_contract.py::test_typescript_model_egress_is_closed_pinned_and_zero_byte_on_denial", + "oracle": "The private installed TypeScript package exposes only its closed root, accepts one complete current SDK Package and one model grant through nominal AuthorizedModelInput, sends only authorized blocks and declared question/instructions to the exact deterministic twin, and proves every binding mutation, replay, stale input, forged input, invalid citation, and cost/time/output limit emits zero unauthorized provider bytes or one generic unavailable result." + }, + { + "id": "SDK-MODEL-EGRESS-070", + "surface": "tests/integration/test_z_egress_grant_file.py::test_packed_typescript_sdk_resolves_authorized_file_package_over_live_http", + "oracle": "An installed generated SDK obtains one authorized File Package and model grant from a real local HTTP API, then the installed BotDelivery package invokes the deterministic twin exactly once and returns a bounded answer whose citation is a subset of that Package; grant replay emits zero additional bytes." + }, + { + "id": "PG-MODEL-EGRESS-070", + "surface": "tests/integration/test_z_egress_grant_file.py::test_packed_typescript_sdk_resolves_authorized_file_package_over_live_http", + "oracle": "The dedicated non-owner egress login redeems the exact Organization, Package, payload, audience, purpose, epoch, provider, model, region, retention, sensitivity, issuer, consumer, and profile bindings. FORCE-RLS audit stores only digests, bounded usage, outcome category, and lineage, and exact-Organization operator cleanup observes the fixed database-clock retention window." + } + ], + "deferredEvidence": [ + "production provider ModelGateway conformance", + "streaming and cancellation semantics", + "group-public and asker-private answer orchestration" + ], + "futureCarriers": [ + "production provider ModelGateway", + "streaming generation", + "group-public answer generation", + "complete BotDelivery orchestration" + ], + "notActive": [ + "real model or provider network call", + "streaming tokens", + "group AudienceSnapshot", + "model-authored effect intent or ActionTicket", + "external channel effect" + ] } ], "invariants": [ @@ -1172,11 +1217,13 @@ "evidenceStatus": "accepted", "expectedEvidence": { "property": [ - "PROP-EGRESS-011" + "PROP-EGRESS-011", + "TS-MODEL-EGRESS-070" ], "postgres": [ "PG-EGRESS-011", - "PG-ACTION-PERFORM-068" + "PG-ACTION-PERFORM-068", + "PG-MODEL-EGRESS-070" ], "runtimeOrDelivery": [ "EGR-001", @@ -1185,7 +1232,8 @@ "EGR-005", "EGR-006", "RUN-014", - "RUN-015" + "RUN-015", + "SDK-MODEL-EGRESS-070" ] }, "authorityRefs": [ diff --git a/migrations/versions/20260724_0025_model_egress_audit.py b/migrations/versions/20260724_0025_model_egress_audit.py new file mode 100644 index 00000000..15620d90 --- /dev/null +++ b/migrations/versions/20260724_0025_model_egress_audit.py @@ -0,0 +1,246 @@ +"""Persist restricted model-generation outcome audit. + +Revision ID: 20260724_0025 +Revises: 20260724_0024 +Create Date: 2026-07-24 +""" + +# ruff: noqa: E501 + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +revision: str = "20260724_0025" +down_revision: str | None = "20260724_0024" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + +_TABLE = "model_egress_audit" +_MIGRATOR = "context_engine_migrator" +_EGRESS = "context_engine_egress" +_OPERATOR = "context_engine_security_operator" +_DEFINER = "context_engine_egress_grant_definer" +_RECORD = "context_egress_record_model_outcome" +_CLEANUP = "context_security_delete_expired_model_egress_audit" +_RECORD_SIGNATURE = "(uuid, bytea, bytea, bytea, bytea, bytea, text, bigint, bigint, bigint, bigint, text, bigint, text)" +_CLEANUP_SIGNATURE = "(uuid)" + + +def upgrade() -> None: + """Create function-only outcome recording and operator cleanup.""" + + op.create_table( + _TABLE, + sa.Column("organization_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column( + "audit_id", + postgresql.UUID(as_uuid=True), + server_default=sa.text("gen_random_uuid()"), + nullable=False, + ), + sa.Column("grant_digest", postgresql.BYTEA(), nullable=False), + sa.Column("package_digest", postgresql.BYTEA(), nullable=False), + sa.Column("payload_digest", postgresql.BYTEA(), nullable=False), + sa.Column("question_digest", postgresql.BYTEA(), nullable=False), + sa.Column("answer_payload_digest", postgresql.BYTEA(), nullable=True), + sa.Column("outcome_category", sa.Text(), nullable=False), + sa.Column("provider_calls", sa.BigInteger(), nullable=False), + sa.Column("cost_microunits", sa.BigInteger(), nullable=False), + sa.Column("elapsed_ms", sa.BigInteger(), nullable=False), + sa.Column("output_bytes", sa.BigInteger(), nullable=False), + sa.Column("profile_ref", sa.Text(), nullable=False), + sa.Column("audit_profile_ref", sa.Text(), nullable=False), + sa.Column("recorded_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("retain_until", sa.DateTime(timezone=True), nullable=False), + sa.PrimaryKeyConstraint( + "organization_id", "audit_id", name="pk_model_egress_audit" + ), + sa.UniqueConstraint( + "organization_id", + "grant_digest", + name="uq_model_egress_audit_exact_grant", + ), + sa.ForeignKeyConstraint( + ["organization_id", "grant_digest"], + ["egress_grant.organization_id", "egress_grant.grant_digest"], + name="fk_model_egress_audit_exact_grant", + ondelete="RESTRICT", + ), + sa.CheckConstraint( + "octet_length(grant_digest) = 32 AND octet_length(package_digest) = 32 AND octet_length(payload_digest) = 32 AND octet_length(question_digest) = 32 AND (answer_payload_digest IS NULL OR octet_length(answer_payload_digest) = 32)", + name="ck_model_egress_audit_sha256_digests", + ), + sa.CheckConstraint( + "outcome_category IN ('generated', 'output_rejected', 'provider_unavailable')", + name="ck_model_egress_audit_outcome_category", + ), + sa.CheckConstraint( + "provider_calls = 1 AND cost_microunits >= 0 AND elapsed_ms >= 0 AND output_bytes >= 0", + name="ck_model_egress_audit_usage", + ), + sa.CheckConstraint( + "((outcome_category = 'generated' AND answer_payload_digest IS NOT NULL) OR (outcome_category <> 'generated' AND answer_payload_digest IS NULL))", + name="ck_model_egress_audit_answer_digest", + ), + sa.CheckConstraint( + "btrim(profile_ref) <> '' AND audit_profile_ref = 'model-generation-audit-v1'", + name="ck_model_egress_audit_profiles", + ), + sa.CheckConstraint( + "retain_until = recorded_at + interval '30 days'", + name="ck_model_egress_audit_retention", + ), + ) + for role in ("PUBLIC", _EGRESS, _OPERATOR, _DEFINER): + op.execute(f"REVOKE ALL ON TABLE {_TABLE} FROM {role}") + op.execute(f"ALTER TABLE {_TABLE} ENABLE ROW LEVEL SECURITY") + op.execute(f"ALTER TABLE {_TABLE} FORCE ROW LEVEL SECURITY") + op.execute( + f"CREATE POLICY model_egress_audit_migrator_administration ON {_TABLE} FOR ALL TO {_MIGRATOR} USING (true) WITH CHECK (true)" + ) + op.execute( + f"CREATE POLICY model_egress_audit_definer_select ON {_TABLE} FOR SELECT TO {_DEFINER} USING (true)" + ) + op.execute( + f"CREATE POLICY model_egress_audit_definer_insert ON {_TABLE} FOR INSERT TO {_DEFINER} WITH CHECK (true)" + ) + op.execute( + f"CREATE POLICY model_egress_audit_definer_delete ON {_TABLE} FOR DELETE TO {_DEFINER} USING (true)" + ) + op.execute(f"GRANT SELECT, INSERT, DELETE ON TABLE {_TABLE} TO {_DEFINER}") + + op.execute( + f""" + CREATE FUNCTION public.{_RECORD}( + requested_organization_id uuid, requested_grant_digest bytea, + requested_package_digest bytea, requested_payload_digest bytea, + requested_question_digest bytea, requested_answer_payload_digest bytea, + requested_outcome_category text, requested_provider_calls bigint, + requested_cost_microunits bigint, requested_elapsed_ms bigint, + requested_output_bytes bigint, requested_profile_ref text, + requested_retention_seconds bigint, requested_audit_profile_ref text + ) RETURNS boolean + LANGUAGE plpgsql SECURITY DEFINER + SET search_path = pg_catalog, pg_temp + SET row_security = on + AS $function$ + DECLARE authority_now timestamptz := pg_catalog.clock_timestamp(); + BEGIN + IF SESSION_USER <> '{_EGRESS}' + OR requested_organization_id IS NULL + OR requested_grant_digest IS NULL + OR requested_package_digest IS NULL + OR requested_payload_digest IS NULL + OR requested_question_digest IS NULL + OR octet_length(requested_grant_digest) <> 32 + OR octet_length(requested_package_digest) <> 32 + OR octet_length(requested_payload_digest) <> 32 + OR octet_length(requested_question_digest) <> 32 + OR (requested_answer_payload_digest IS NOT NULL AND octet_length(requested_answer_payload_digest) <> 32) + OR requested_profile_ref IS NULL + OR btrim(requested_profile_ref) = '' + OR requested_outcome_category NOT IN ('generated', 'output_rejected', 'provider_unavailable') + OR (requested_outcome_category = 'generated') IS DISTINCT FROM (requested_answer_payload_digest IS NOT NULL) + OR requested_provider_calls <> 1 + OR requested_cost_microunits < 0 + OR requested_elapsed_ms < 0 + OR requested_output_bytes < 0 + OR requested_retention_seconds <> 2592000 + OR requested_audit_profile_ref <> 'model-generation-audit-v1' + OR NOT EXISTS ( + SELECT 1 FROM public.egress_grant AS grant_record + WHERE grant_record.organization_id = requested_organization_id + AND grant_record.grant_digest = requested_grant_digest + AND grant_record.hop_kind = 'model' + AND grant_record.package_digest = requested_package_digest + AND grant_record.payload_digest = requested_payload_digest + AND grant_record.profile_ref = requested_profile_ref + AND grant_record.consumed_at IS NOT NULL + ) + THEN RETURN false; END IF; + INSERT INTO public.{_TABLE} ( + organization_id, grant_digest, package_digest, payload_digest, + question_digest, answer_payload_digest, outcome_category, + provider_calls, cost_microunits, elapsed_ms, output_bytes, + profile_ref, audit_profile_ref, recorded_at, retain_until + ) VALUES ( + requested_organization_id, requested_grant_digest, + requested_package_digest, requested_payload_digest, + requested_question_digest, requested_answer_payload_digest, + requested_outcome_category, requested_provider_calls, + requested_cost_microunits, requested_elapsed_ms, + requested_output_bytes, requested_profile_ref, + requested_audit_profile_ref, authority_now, + authority_now + interval '30 days' + ) ON CONFLICT DO NOTHING; + RETURN FOUND; + END; + $function$ + """ + ) + op.execute( + f""" + CREATE FUNCTION public.{_CLEANUP}( + requested_organization_id uuid + ) RETURNS bigint + LANGUAGE plpgsql SECURITY DEFINER + SET search_path = pg_catalog, pg_temp + SET row_security = on + AS $function$ + DECLARE deleted_count bigint; + BEGIN + IF SESSION_USER <> '{_OPERATOR}' + OR requested_organization_id IS NULL + THEN RETURN 0; END IF; + DELETE FROM public.{_TABLE} AS audit + WHERE audit.organization_id = requested_organization_id + AND audit.retain_until <= pg_catalog.clock_timestamp(); + GET DIAGNOSTICS deleted_count = ROW_COUNT; + RETURN deleted_count; + END; + $function$ + """ + ) + for function_name, signature in ( + (_RECORD, _RECORD_SIGNATURE), + (_CLEANUP, _CLEANUP_SIGNATURE), + ): + op.execute( + f"REVOKE ALL ON FUNCTION public.{function_name}{signature} FROM PUBLIC" + ) + op.execute(f"GRANT CREATE ON SCHEMA public TO {_DEFINER}") + op.execute( + f"ALTER FUNCTION public.{function_name}{signature} OWNER TO {_DEFINER}" + ) + op.execute(f"REVOKE CREATE ON SCHEMA public FROM {_DEFINER}") + op.execute(f"SET LOCAL ROLE {_DEFINER}") + op.execute( + f"GRANT EXECUTE ON FUNCTION public.{_RECORD}{_RECORD_SIGNATURE} TO {_EGRESS}" + ) + op.execute( + f"GRANT EXECUTE ON FUNCTION public.{_CLEANUP}{_CLEANUP_SIGNATURE} TO {_OPERATOR}" + ) + op.execute("RESET ROLE") + + +def downgrade() -> None: + """Refuse to erase retained model-generation audit.""" + + op.execute( + f""" + DO $block$ BEGIN + IF EXISTS (SELECT 1 FROM public.{_TABLE}) + THEN RAISE EXCEPTION USING ERRCODE = '55000', + MESSAGE = 'cannot downgrade with model egress audit rows'; + END IF; + END; $block$ + """ + ) + op.execute(f"SET LOCAL ROLE {_DEFINER}") + op.execute(f"DROP FUNCTION public.{_CLEANUP}{_CLEANUP_SIGNATURE}") + op.execute(f"DROP FUNCTION public.{_RECORD}{_RECORD_SIGNATURE}") + op.execute("RESET ROLE") + op.drop_table(_TABLE) diff --git a/scripts/security_gate/rls.py b/scripts/security_gate/rls.py index ee3fda53..b7195cdf 100644 --- a/scripts/security_gate/rls.py +++ b/scripts/security_gate/rls.py @@ -28,6 +28,7 @@ "delivery_evidence": "PG-DELIVERY-EVIDENCE-063", "egress_grant": "PG-EGRESS-011", "egress_audit": "PG-EGRESS-011", + "model_egress_audit": "PG-MODEL-EGRESS-070", "membership": "PG-SCOPE-INTERSECTION-004", "organization_record": "PG-TENANT-FK-002", "context_resource": "PG-INDEX-NOT-AUTHORITY-005", diff --git a/scripts/validate_security_catalog.py b/scripts/validate_security_catalog.py index 5b1afa3f..b56b0ae1 100644 --- a/scripts/validate_security_catalog.py +++ b/scripts/validate_security_catalog.py @@ -400,7 +400,12 @@ "TRACE-REDACTION-012": ("PG-FIELD-PROJECTION-048",), } REQUIRED_POSTGRES_EVIDENCE["TRANSPORT-UNTRUSTED-008"] = ("PG-DELIVERY-EVIDENCE-063",) -REQUIRED_POSTGRES_EVIDENCE["EGRESS-011"] = ("PG-ACTION-PERFORM-068",) +REQUIRED_PROPERTY_EVIDENCE["EGRESS-011"] = ("TS-MODEL-EGRESS-070",) +REQUIRED_POSTGRES_EVIDENCE["EGRESS-011"] = ( + "PG-ACTION-PERFORM-068", + "PG-MODEL-EGRESS-070", +) +REQUIRED_RUNTIME_EVIDENCE["EGRESS-011"] += ("SDK-MODEL-EGRESS-070",) REQUIRED_POSTGRES_EVIDENCE["ACTION-SEPARATION-014"] = ( "PG-ACTION-PREPARE-067", "PG-ACTION-PERFORM-068", @@ -1471,6 +1476,84 @@ ], } +CANONICAL_MODEL_EGRESS_ACTIVATION: dict[str, object] = { + "issueRef": "#70", + "invariantRef": "EGRESS-011", + "carrier": "private Package-bound deterministic TypeScript ModelGateway", + "status": "active_fail_closed", + "policyEpochScope": "organization-v0", + "controlBoundary": ( + "generated SDK ContextPackage plus opaque model EgressGrant -> nominal " + "AuthorizedModelInput -> exact non-owner PostgreSQL redemption -> " + "deterministic ModelGateway twin -> bounded answer and digest-only audit" + ), + "testEvidence": [ + { + "id": "TS-MODEL-EGRESS-070", + "surface": ( + "tests/unit/test_bot_delivery_model_egress_contract.py::" + "test_typescript_model_egress_is_closed_pinned_and_zero_byte_on_denial" + ), + "oracle": ( + "The private installed TypeScript package exposes only its closed " + "root, accepts one complete current SDK Package and one model grant " + "through nominal AuthorizedModelInput, sends only authorized blocks " + "and declared question/instructions to the exact deterministic twin, " + "and proves every binding mutation, replay, stale input, forged input, " + "invalid citation, and cost/time/output limit emits zero unauthorized " + "provider bytes or one generic unavailable result." + ), + }, + { + "id": "SDK-MODEL-EGRESS-070", + "surface": ( + "tests/integration/test_z_egress_grant_file.py::" + "test_packed_typescript_sdk_resolves_authorized_file_package_over_live_http" + ), + "oracle": ( + "An installed generated SDK obtains one authorized File Package and " + "model grant from a real local HTTP API, then the installed " + "BotDelivery package invokes the deterministic twin exactly once and " + "returns a bounded answer whose citation is a subset of that Package; " + "grant replay emits zero additional bytes." + ), + }, + { + "id": "PG-MODEL-EGRESS-070", + "surface": ( + "tests/integration/test_z_egress_grant_file.py::" + "test_packed_typescript_sdk_resolves_authorized_file_package_over_live_http" + ), + "oracle": ( + "The dedicated non-owner egress login redeems the exact Organization, " + "Package, payload, audience, purpose, epoch, provider, model, region, " + "retention, sensitivity, issuer, consumer, and profile bindings. " + "FORCE-RLS audit stores only digests, bounded usage, outcome category, " + "and lineage, and exact-Organization operator cleanup observes the " + "fixed database-clock retention window." + ), + }, + ], + "deferredEvidence": [ + "production provider ModelGateway conformance", + "streaming and cancellation semantics", + "group-public and asker-private answer orchestration", + ], + "futureCarriers": [ + "production provider ModelGateway", + "streaming generation", + "group-public answer generation", + "complete BotDelivery orchestration", + ], + "notActive": [ + "real model or provider network call", + "streaming tokens", + "group AudienceSnapshot", + "model-authored effect intent or ActionTicket", + "external channel effect", + ], +} + CANONICAL_ACTIVATIONS: list[dict[str, object]] = [ CANONICAL_REVOCATION_ACTIVATION, CANONICAL_UNAVAILABLE_CAPABILITY_ACTIVATION, @@ -1485,6 +1568,7 @@ CANONICAL_ACTION_PREPARE_ACTIVATION, CANONICAL_ACTION_PERFORM_ACTIVATION, CANONICAL_CITATION_OPEN_ACTIVATION, + CANONICAL_MODEL_EGRESS_ACTIVATION, ] CANONICAL_ACTIVATION_ISSUE_LIST = ", ".join( f"Issue {activation['issueRef']}" for activation in CANONICAL_ACTIVATIONS diff --git a/sdk/typescript/test/live-consumer.mjs b/sdk/typescript/test/live-consumer.mjs index 344d01c6..58728e81 100644 --- a/sdk/typescript/test/live-consumer.mjs +++ b/sdk/typescript/test/live-consumer.mjs @@ -1,4 +1,10 @@ import { ContextEngineResolveClient } from "@context-engine/resolve-sdk"; +import { + DeterministicModelGatewayTwin, + createPrivateModelGenerationBoundary, + privateModelGatewayProfileV1, + prepareAuthorizedModelInput, +} from "@context-engine/bot-delivery"; const requiredEnvironment = [ "CONTEXT_ENGINE_SDK_BASE_URL", @@ -7,6 +13,8 @@ const requiredEnvironment = [ "CONTEXT_ENGINE_SDK_REQUEST_ID", "CONTEXT_ENGINE_SDK_TEST_AUTHENTICATION", "CONTEXT_ENGINE_SDK_TEST_DIRECT_AUTHENTICATION", + "CONTEXT_ENGINE_MODEL_EGRESS_DATABASE_URL", + "CONTEXT_ENGINE_MODEL_EGRESS_ORGANIZATION_ID", ]; for (const name of requiredEnvironment) { if (!process.env[name]) { @@ -34,6 +42,38 @@ const acquire = await client.resolve({ need: { query: "ContextEngine delivers context." }, }, }); +const modelProfile = privateModelGatewayProfileV1(); +const modelInput = prepareAuthorizedModelInput({ + envelope: { + instructions: "Answer only from the supplied Package.", + question: "What does ContextEngine deliver?", + }, + grant: acquire.egressGrant, + now: new Date(), + package: acquire.package, + profile: modelProfile, +}); +const gateway = new DeterministicModelGatewayTwin({ + citations: [acquire.package.evidence[0].evidenceRef], + costMicrounits: 7, + elapsedMs: 5, + profile: modelProfile, + text: "ContextEngine delivers authorized Package context.", +}); +const modelBoundary = createPrivateModelGenerationBoundary({ + databaseUrl: process.env.CONTEXT_ENGINE_MODEL_EGRESS_DATABASE_URL, + gateway, + organizationId: process.env.CONTEXT_ENGINE_MODEL_EGRESS_ORGANIZATION_ID, + profile: modelProfile, +}); +let generation; +let generationReplay; +try { + generation = await modelBoundary.generate(modelInput, acquire.egressGrant); + generationReplay = await modelBoundary.generate(modelInput, acquire.egressGrant); +} finally { + await modelBoundary.close(); +} const continuation = await directClient.resolve({ request: { continuationToken: "continuation_sdk_live_inactive", @@ -50,4 +90,15 @@ const citation = await client.resolve({ requestId: `${process.env.CONTEXT_ENGINE_SDK_REQUEST_ID}-citation`, }); -process.stdout.write(`${JSON.stringify({ acquire, citation, continuation })}\n`); +process.stdout.write(`${JSON.stringify({ + acquire, + citation, + continuation, + gateway: { + callCount: gateway.callCount, + outboundBytes: gateway.outboundBytes, + requests: gateway.requests, + }, + generation, + generationReplay, +})}\n`); diff --git a/tests/catalog/test_validate_m0_security_evidence.py b/tests/catalog/test_validate_m0_security_evidence.py index 0e5d17bd..1ed49bc5 100644 --- a/tests/catalog/test_validate_m0_security_evidence.py +++ b/tests/catalog/test_validate_m0_security_evidence.py @@ -142,15 +142,29 @@ def test_m0_registry_uses_activated_egress_and_honest_learning_evidence() -> Non "tests/integration/test_z_egress_grant_file.py::" "test_file_http_package_redeems_exact_model_grant_before_gateway_bytes" ) + assert evidence["TS-MODEL-EGRESS-070"] == ( + "tests/unit/test_bot_delivery_model_egress_contract.py::" + "test_typescript_model_egress_is_closed_pinned_and_zero_byte_on_denial" + ) + assert evidence["PG-MODEL-EGRESS-070"] == ( + "tests/integration/test_z_egress_grant_file.py::" + "test_packed_typescript_sdk_resolves_authorized_file_package_over_live_http" + ) + assert evidence["SDK-MODEL-EGRESS-070"] == evidence["PG-MODEL-EGRESS-070"] egress_mapping = next( mapping for mapping in registry["invariantMappings"] if mapping["invariantRef"] == "EGRESS-011" ) - assert egress_mapping["evidenceRefs"]["postgres"] == [ - "PG-EGRESS-011", - "PG-ACTION-PERFORM-068", - ] + assert egress_mapping["evidenceRefs"] == { + "property": ["PROP-EGRESS-011", "TS-MODEL-EGRESS-070"], + "postgres": [ + "PG-EGRESS-011", + "PG-ACTION-PERFORM-068", + "PG-MODEL-EGRESS-070", + ], + "runtime": ["RUNTIME-EGRESS-011", "SDK-MODEL-EGRESS-070"], + } assert evidence["PROP-CROSS-ORG-LEARN-015"] == ( "tests/unit/test_m0_learning_isolation.py::" "test_m0_learning_artifact_contract_has_no_cross_organization_carrier" diff --git a/tests/catalog/test_validate_security_catalog.py b/tests/catalog/test_validate_security_catalog.py index 5684da4e..165f1b68 100644 --- a/tests/catalog/test_validate_security_catalog.py +++ b/tests/catalog/test_validate_security_catalog.py @@ -31,6 +31,7 @@ CANONICAL_FAIL_CLOSED_OUTCOMES, CANONICAL_FIELD_PROJECTION_ACTIVATION, CANONICAL_INVARIANT_IDS, + CANONICAL_MODEL_EGRESS_ACTIVATION, CANONICAL_OPENAPI_V0_ACTIVATION, CANONICAL_PRIVATE_DELIVERY_EVIDENCE_ACTIVATION, CANONICAL_REVOCATION_ACTIVATION, @@ -573,6 +574,7 @@ def make_catalog() -> dict[str, object]: copy.deepcopy(CANONICAL_ACTION_PREPARE_ACTIVATION), copy.deepcopy(CANONICAL_ACTION_PERFORM_ACTIVATION), copy.deepcopy(CANONICAL_CITATION_OPEN_ACTIVATION), + copy.deepcopy(CANONICAL_MODEL_EGRESS_ACTIVATION), ], "invariants": invariants, "fixtures": fixtures, @@ -668,6 +670,7 @@ def make_schema() -> dict[str, object]: {"const": copy.deepcopy(CANONICAL_ACTION_PREPARE_ACTIVATION)}, {"const": copy.deepcopy(CANONICAL_ACTION_PERFORM_ACTIVATION)}, {"const": copy.deepcopy(CANONICAL_CITATION_OPEN_ACTIVATION)}, + {"const": copy.deepcopy(CANONICAL_MODEL_EGRESS_ACTIVATION)}, ], "items": False, }, @@ -1359,7 +1362,7 @@ def test_issue_68_action_perform_activation_stops_before_real_sender(self) -> No self.assertIn("PG-ACTION-PERFORM-068", egress_postgres_evidence) self.assertEqual( REQUIRED_POSTGRES_EVIDENCE["EGRESS-011"], - ("PG-ACTION-PERFORM-068",), + ("PG-ACTION-PERFORM-068", "PG-MODEL-EGRESS-070"), ) future_carriers = activation["futureCarriers"] not_active = activation["notActive"] @@ -1377,6 +1380,32 @@ def test_issue_68_action_perform_activation_stops_before_real_sender(self) -> No "future/NOT_ACTIVE boundaries", ) + def test_issue_70_model_egress_activation_stops_before_real_provider(self) -> None: + catalog = make_catalog() + activation = object_list_at(catalog, "activations")[13] + + self.assertEqual(activation, CANONICAL_MODEL_EGRESS_ACTIVATION) + self.assertEqual(activation["invariantRef"], "EGRESS-011") + self.assertEqual( + [item["id"] for item in object_list_at(activation, "testEvidence")], + [ + "TS-MODEL-EGRESS-070", + "SDK-MODEL-EGRESS-070", + "PG-MODEL-EGRESS-070", + ], + ) + future_carriers = activation["futureCarriers"] + not_active = activation["notActive"] + assert isinstance(future_carriers, list) + assert isinstance(not_active, list) + self.assertIn("production provider ModelGateway", future_carriers) + self.assertIn("real model or provider network call", not_active) + self.assertIn("model-authored effect intent or ActionTicket", not_active) + self.assertEqual( + REQUIRED_RUNTIME_EVIDENCE["EGRESS-011"], + ("EGR-003", "EGR-005", "EGR-006", "RUN-014", "SDK-MODEL-EGRESS-070"), + ) + def test_schema_independently_freezes_full_accept_008_as_future(self) -> None: catalog = make_catalog() schema = load_document(DEFAULT_SCHEMA_PATH) @@ -1799,7 +1828,7 @@ def test_tracked_catalog_freezes_issue_19_authority_and_bounded_scope( self.assertEqual(catalog["catalogVersion"], "1.3.0") self.assertEqual( - issue_refs[-13:], + issue_refs[-14:], [ "#15", "#16", @@ -1814,6 +1843,7 @@ def test_tracked_catalog_freezes_issue_19_authority_and_bounded_scope( "#67", "#68", "#69", + "#70", ], ) self.assertIn( @@ -1828,6 +1858,10 @@ def test_tracked_catalog_freezes_issue_19_authority_and_bounded_scope( "docs/decisions/0051-reauthorize-opaque-citation-opens.md", document_refs, ) + self.assertIn( + "docs/decisions/0052-gate-model-generation-by-package.md", + document_refs, + ) for boundary in ( "Issue #19 activates only the current Acquire authorized-only ContextRun", "DIGEST-019", diff --git a/tests/fixtures/canonical-json-cross-language-v1.json b/tests/fixtures/canonical-json-cross-language-v1.json new file mode 100644 index 00000000..1fc8aa95 --- /dev/null +++ b/tests/fixtures/canonical-json-cross-language-v1.json @@ -0,0 +1,53 @@ +{ + "profile": "rfc8785-ijson-cross-language-v1", + "valid": [ + { + "name": "canonical-unicode", + "document": { + "usage": { "tokens": 7 }, + "purpose": "support", + "enabled": true, + "coverage": { "status": "sufficient", "reason": null }, + "blocks": [{ "ordinal": 1, "body": "你好, 🌍" }] + }, + "sha256": "630ba3a578634388e9d107f318a9ba7e2f7c2b9313f8c1bd9034e9325797aa43" + }, + { + "name": "utf16-property-order", + "document": { + "€": "Euro Sign", + "\r": "Carriage Return", + "דּ": "Hebrew Letter Dalet With Dagesh", + "1": "One", + "😀": "Emoji: Grinning Face", + "€": "Control", + "ö": "Latin Small Letter O With Diaeresis" + }, + "sha256": "5e321556d22018a9656991a9e94f77ec175fa193e52a2429d312f8419ec8b08c" + }, + { + "name": "number-serialization", + "document": { + "numbers": [333333333.33333329, 1e30, 4.50, 2e-3, 1e-27, -0.0] + }, + "sha256": "5c34ad2f0b62822dda6a8fbcb5ad901f69fba1c84571ee09063f63266f06fc58" + }, + { + "name": "exact-large-binary64-integers", + "document": { + "numbers": [9007199254740992, 295147905179352825856] + }, + "sha256": "ce19560fd6d09c1841c89fe99d741d7176b969eeb8d405a75ff75888d2958aaa" + } + ], + "invalidJsonDocuments": [ + { "name": "lone-high-surrogate", "json": "{\"value\":\"\\ud800\"}" }, + { "name": "lone-low-surrogate-key", "json": "{\"key\\udfff\":\"value\"}" } + ], + "invalidPackageDocuments": [ + { + "name": "nested-package-digest", + "document": { "nested": { "packageDigest": "0000000000000000000000000000000000000000000000000000000000000000" } } + } + ] +} diff --git a/tests/integration/test_file_import_tracer.py b/tests/integration/test_file_import_tracer.py index bc44b904..8fd6e3ed 100644 --- a/tests/integration/test_file_import_tracer.py +++ b/tests/integration/test_file_import_tracer.py @@ -1582,7 +1582,7 @@ def _assert_structural_file_import_returns_coherent_authorized_units_over_http( connection.execute( text("SELECT version_num FROM alembic_version") ).scalar_one() - == "20260724_0024" + == "20260724_0025" ) diff --git a/tests/integration/test_m0_security_gate_rls.py b/tests/integration/test_m0_security_gate_rls.py index 75f2a4cf..5abaf4d0 100644 --- a/tests/integration/test_m0_security_gate_rls.py +++ b/tests/integration/test_m0_security_gate_rls.py @@ -30,7 +30,7 @@ def _manifest() -> dict[str, object]: def test_all_manifest_tenant_tables_pass_live_non_owner_rls_audit( guarded_runtime_engine: Engine, ) -> None: - """PG-RLS-ALL-TENANT-TABLES: the live denominator is exactly 48/48.""" + """PG-RLS-ALL-TENANT-TABLES: the live denominator is exactly 49/49.""" with guarded_runtime_engine.connect() as connection: report = audit_live_rls( @@ -41,13 +41,13 @@ def test_all_manifest_tenant_tables_pass_live_non_owner_rls_audit( assert report["passed"] is True assert report["denominator"] == { - "allTables": 51, - "tenantOwned": 48, + "allTables": 52, + "tenantOwned": 49, "global": 3, } assert report["coverage"] == { - "numerator": 48, - "denominator": 48, + "numerator": 49, + "denominator": 49, "percent": 100.0, } assert report["failures"] == [] @@ -86,9 +86,9 @@ def test_no_force_row_level_security_mutation_fails_and_rolls_back( assert mutated["passed"] is False assert mutated["coverage"] == { - "numerator": 47, - "denominator": 48, - "percent": 97.92, + "numerator": 48, + "denominator": 49, + "percent": 97.96, } tenant_tables = cast(list[dict[str, Any]], mutated["tenantTables"]) organization_record = next( @@ -111,8 +111,8 @@ def test_no_force_row_level_security_mutation_fails_and_rolls_back( ) assert restored["passed"] is True assert restored["coverage"] == { - "numerator": 48, - "denominator": 48, + "numerator": 49, + "denominator": 49, "percent": 100.0, } diff --git a/tests/integration/test_migrations.py b/tests/integration/test_migrations.py index 621be17f..a6e87078 100644 --- a/tests/integration/test_migrations.py +++ b/tests/integration/test_migrations.py @@ -52,7 +52,7 @@ pytestmark = pytest.mark.integration ROOT = Path(__file__).parents[2] -_HEAD_REVISION = "20260724_0024" +_HEAD_REVISION = "20260724_0025" HEAD_TABLES = [ "action_delivery_attempt", "action_perform_audit", @@ -91,6 +91,7 @@ "file_source_publish_watermark", "membership", "membership_resource_field_right", + "model_egress_audit", "organization", "organization_policy_epoch", "organization_record", @@ -907,6 +908,31 @@ def test_citation_open_revision_refuses_downgrade_with_retained_lineage( ) +def test_model_egress_revision_downgrades_only_while_audit_is_empty( + migration_configuration: DatabaseConfiguration, +) -> None: + """Issue #70 schema is reversible only before retained audit exists.""" + + alembic_configuration = Config(ROOT / "alembic.ini") + engine = create_database_engine(migration_configuration) + try: + with engine.begin() as connection: + connection.execute(text("DELETE FROM model_egress_audit")) + finally: + engine.dispose() + try: + command.downgrade(alembic_configuration, "20260724_0024") + assert _revision_rows(migration_configuration) == ["20260724_0024"] + assert "model_egress_audit" not in _application_tables( + migration_configuration + ) + finally: + command.upgrade(alembic_configuration, "head") + + assert _revision_rows(migration_configuration) == [_HEAD_REVISION] + assert "model_egress_audit" in _application_tables(migration_configuration) + + def test_file_source_offboarding_refuses_downgrade_with_committed_intent( tmp_path: Path, migration_configuration: DatabaseConfiguration, diff --git a/tests/integration/test_z_egress_grant_file.py b/tests/integration/test_z_egress_grant_file.py index 645577ff..d00000d8 100644 --- a/tests/integration/test_z_egress_grant_file.py +++ b/tests/integration/test_z_egress_grant_file.py @@ -244,6 +244,44 @@ def _pack_and_install_sdk(consumer_root: Path) -> None: ) report = json.loads(pack.stdout) artifact_name = report[0]["filename"] + for script in ("typecheck", "build", "test:runtime"): + _run_sdk_process( + ["npm", "--prefix", "bot_delivery/typescript", "run", script], + cwd=ROOT, + ) + bot_pack = _run_sdk_process( + [ + "npm", + "pack", + "--json", + "--ignore-scripts", + "--pack-destination", + str(artifact_root), + ], + cwd=ROOT / "bot_delivery/typescript", + ) + bot_report = json.loads(bot_pack.stdout) + bot_artifact_name = bot_report[0]["filename"] + bot_lock = json.loads( + (ROOT / "bot_delivery/typescript/package-lock.json").read_text( + encoding="utf-8" + ) + ) + local_production_dependencies: dict[str, str] = {} + local_optional_dependencies: dict[str, str] = {} + for dependency_path, metadata in bot_lock["packages"].items(): + if not dependency_path.startswith("node_modules/") or metadata.get("dev"): + continue + dependency_root = ROOT / "bot_delivery/typescript" / dependency_path + dependency_document = json.loads( + (dependency_root / "package.json").read_text(encoding="utf-8") + ) + target = ( + local_optional_dependencies + if metadata.get("optional") + else local_production_dependencies + ) + target[dependency_document["name"]] = f"file:{dependency_root}" (consumer_root / "package.json").write_text( json.dumps( { @@ -251,10 +289,15 @@ def _pack_and_install_sdk(consumer_root: Path) -> None: "private": True, "type": "module", "dependencies": { + "@context-engine/bot-delivery": ( + f"file:{artifact_root / bot_artifact_name}" + ), "@context-engine/resolve-sdk": ( f"file:{artifact_root / artifact_name}" - ) + ), + **local_production_dependencies, }, + "optionalDependencies": local_optional_dependencies, } ), encoding="utf-8", @@ -274,6 +317,8 @@ def _run_installed_live_consumer( base_url: str, delivery_evidence_ref: str, citation_delivery_evidence_ref: str, + egress_database_url: str, + organization_id: UUID, ) -> dict[str, object]: result = _run_sdk_process( ["node", "live-consumer.mjs"], @@ -288,6 +333,8 @@ def _run_installed_live_consumer( "CONTEXT_ENGINE_SDK_REQUEST_ID": "file-egress-sdk-http", "CONTEXT_ENGINE_SDK_TEST_AUTHENTICATION": "runtime-secret", "CONTEXT_ENGINE_SDK_TEST_DIRECT_AUTHENTICATION": ("runtime-direct-secret"), + "CONTEXT_ENGINE_MODEL_EGRESS_DATABASE_URL": egress_database_url, + "CONTEXT_ENGINE_MODEL_EGRESS_ORGANIZATION_ID": str(organization_id), }, ) document = json.loads(result.stdout) @@ -369,6 +416,7 @@ def _published_file_scenario( "citation_open_locator", "decision_audit", "context_run", + "model_egress_audit", "egress_audit", "egress_grant", "delivery_evidence", @@ -786,15 +834,25 @@ def test_file_http_citation_reauthorizes_unavailable_target( @pytest.mark.security_evidence(id="SDK-LIVE-FILE-064", layer="runtime") +@pytest.mark.security_evidence(id="SDK-MODEL-EGRESS-070", layer="runtime") +@pytest.mark.security_evidence(id="PG-MODEL-EGRESS-070", layer="postgres") def test_packed_typescript_sdk_resolves_authorized_file_package_over_live_http( _published_file_scenario: tuple[_FileImportScenario, PublishedFileImport, Engine], tmp_path: Path, + action_configuration: DatabaseConfiguration, + control_configuration: DatabaseConfiguration, identity_configuration: DatabaseConfiguration, + egress_configuration: DatabaseConfiguration, + learning_configuration: DatabaseConfiguration, + operator_configuration: DatabaseConfiguration, + runtime_configuration: DatabaseConfiguration, + worker_configuration: DatabaseConfiguration, guarded_runtime_engine: Engine, query_digest_keyring: QueryDigestKeyring, ) -> None: scenario, published, migration_engine = _published_file_scenario identity_engine = create_database_engine(identity_configuration) + operator_engine = create_database_engine(operator_configuration) server: Server | None = None server_thread: Thread | None = None try: @@ -810,6 +868,63 @@ def test_packed_typescript_sdk_resolves_authorized_file_package_over_live_http( "membership_id": scenario.membership_id, }, ).scalar_one() + application_roles = ( + action_configuration.expected_role, + control_configuration.expected_role, + identity_configuration.expected_role, + egress_configuration.expected_role, + learning_configuration.expected_role, + operator_configuration.expected_role, + runtime_configuration.expected_role, + worker_configuration.expected_role, + ) + privileges = { + role: tuple( + connection.execute( + text( + "SELECT " + "has_table_privilege(:role, 'model_egress_audit', " + "'SELECT'), " + "has_table_privilege(:role, 'model_egress_audit', " + "'INSERT'), " + "has_table_privilege(:role, 'model_egress_audit', " + "'UPDATE'), " + "has_table_privilege(:role, 'model_egress_audit', " + "'DELETE'), " + "has_function_privilege(:role, " + "'context_egress_record_model_outcome(uuid,bytea," + "bytea,bytea,bytea,bytea,text,bigint,bigint,bigint," + "bigint,text,bigint,text)', 'EXECUTE'), " + "has_function_privilege(:role, " + "'context_security_delete_expired_model_egress_audit(" + "uuid)', 'EXECUTE')" + ), + {"role": role}, + ).one() + ) + for role in application_roles + } + assert privileges[egress_configuration.expected_role] == ( + False, + False, + False, + False, + True, + False, + ) + assert privileges[operator_configuration.expected_role] == ( + False, + False, + False, + False, + False, + True, + ) + for role in set(application_roles) - { + egress_configuration.expected_role, + operator_configuration.expected_role, + }: + assert privileges[role] == (False,) * 6 consumer_root = tmp_path / "installed-sdk-consumer" consumer_root.mkdir() @@ -921,6 +1036,10 @@ def test_packed_typescript_sdk_resolves_authorized_file_package_over_live_http( base_url=f"http://127.0.0.1:{port}", delivery_evidence_ref=evidence_ref.evidence_ref, citation_delivery_evidence_ref=(citation_evidence_ref.evidence_ref), + egress_database_url=egress_configuration.url.set( + drivername="postgresql" + ).render_as_string(hide_password=False), + organization_id=scenario.organization_id, ) acquire = result["acquire"] @@ -943,6 +1062,35 @@ def test_packed_typescript_sdk_resolves_authorized_file_package_over_live_http( assert isinstance(grant["value"], str) assert grant["value"] assert evidence_ref.evidence_ref not in json.dumps(result) + generation = result["generation"] + assert isinstance(generation, dict) + assert generation["kind"] == "generated" + assert generation["answer"]["text"] == ( + "ContextEngine delivers authorized Package context." + ) + assert generation["answer"]["citations"] == [ + { + "citationOpenRef": evidence[0]["citationOpenRef"], + "evidenceRef": evidence[0]["evidenceRef"], + } + ] + assert result["generationReplay"] == {"kind": "generation_not_available"} + gateway = result["gateway"] + assert isinstance(gateway, dict) + assert gateway["callCount"] == 1 + assert gateway["outboundBytes"] > 0 + assert gateway["requests"] == [ + { + "context": [ + { + "evidenceRefs": [evidence[0]["evidenceRef"]], + "text": "ContextEngine delivers context.", + } + ], + "instructions": "Answer only from the supplied Package.", + "question": "What does ContextEngine deliver?", + } + ] assert result["continuation"] == { "kind": "request_not_available", "retryable": False, @@ -986,6 +1134,63 @@ def test_packed_typescript_sdk_resolves_authorized_file_package_over_live_http( ) assert len(observed) == 2 assert observed[0].package.decision_ref == package["decisionRef"] + with migration_engine.begin() as connection: + audit = connection.execute( + text( + "SELECT grant_digest, package_digest, payload_digest, " + "question_digest, answer_payload_digest, outcome_category, " + "provider_calls, cost_microunits, elapsed_ms, output_bytes, " + "profile_ref, audit_profile_ref, recorded_at, retain_until " + "FROM model_egress_audit WHERE organization_id = :org" + ), + {"org": scenario.organization_id}, + ).one() + assert audit.outcome_category == "generated" + assert audit.provider_calls == 1 + assert audit.cost_microunits == 7 + assert audit.elapsed_ms == 5 + assert audit.output_bytes == len( + b"ContextEngine delivers authorized Package context." + ) + assert audit.profile_ref == _file_model_profile().profile_ref + assert audit.audit_profile_ref == "model-generation-audit-v1" + assert audit.retain_until - audit.recorded_at == timedelta(days=30) + assert all( + len(bytes(digest)) == 32 + for digest in ( + audit.grant_digest, + audit.package_digest, + audit.payload_digest, + audit.question_digest, + audit.answer_payload_digest, + ) + ) + serialized_audit = repr(audit) + assert grant["value"] not in serialized_audit + assert generation["answer"]["text"] not in serialized_audit + assert blocks[0]["text"] not in serialized_audit + connection.execute( + text( + "UPDATE model_egress_audit SET " + "recorded_at = recorded_at - interval '31 days', " + "retain_until = retain_until - interval '31 days' " + "WHERE organization_id = :org" + ), + {"org": scenario.organization_id}, + ) + with operator_engine.begin() as connection: + assert connection.execute( + text( + "SELECT context_security_delete_expired_model_egress_audit(:org)" + ), + {"org": uuid4()}, + ).scalar_one() == 0 + assert connection.execute( + text( + "SELECT context_security_delete_expired_model_egress_audit(:org)" + ), + {"org": scenario.organization_id}, + ).scalar_one() == 1 finally: if server is not None: server.should_exit = True @@ -993,3 +1198,4 @@ def test_packed_typescript_sdk_resolves_authorized_file_package_over_live_http( server_thread.join(timeout=10) assert not server_thread.is_alive() identity_engine.dispose() + operator_engine.dispose() diff --git a/tests/integration/test_zz_file_content_noop.py b/tests/integration/test_zz_file_content_noop.py index 21fd5b06..ca63dc41 100644 --- a/tests/integration/test_zz_file_content_noop.py +++ b/tests/integration/test_zz_file_content_noop.py @@ -278,7 +278,7 @@ def test_repeated_canonically_identical_file_import_is_an_auditable_noop( connection.execute( text("SELECT version_num FROM alembic_version") ).scalar_one() - == "20260724_0024" + == "20260724_0025" ) diff --git a/tests/unit/test_bot_delivery_model_egress_contract.py b/tests/unit/test_bot_delivery_model_egress_contract.py new file mode 100644 index 00000000..9bc393ad --- /dev/null +++ b/tests/unit/test_bot_delivery_model_egress_contract.py @@ -0,0 +1,62 @@ +from __future__ import annotations + +import json +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).parents[2] +BOT_DELIVERY_ROOT = ROOT / "bot_delivery/typescript" + + +@pytest.mark.security_evidence(id="TS-MODEL-EGRESS-070", layer="property") +def test_typescript_model_egress_is_closed_pinned_and_zero_byte_on_denial() -> None: + package = json.loads( + (BOT_DELIVERY_ROOT / "package.json").read_text(encoding="utf-8") + ) + lock = json.loads( + (BOT_DELIVERY_ROOT / "package-lock.json").read_text(encoding="utf-8") + ) + + assert package["private"] is True + assert package["exports"] == { + ".": {"types": "./dist/public.d.ts", "import": "./dist/public.js"} + } + assert package["engines"] == {"node": "22.12.0", "npm": "10.9.0"} + assert package["packageManager"] == "npm@10.9.0" + assert package["dependencies"] == {"canonicalize": "3.0.0", "pg": "8.22.0"} + assert package["peerDependencies"] == { + "@context-engine/resolve-sdk": "0.0.0-v0" + } + assert lock["packages"][""]["dependencies"] == package["dependencies"] + assert lock["packages"][""]["peerDependencies"] == package["peerDependencies"] + assert (BOT_DELIVERY_ROOT / ".node-version").read_text( + encoding="ascii" + ).strip() == package["engines"]["node"] + public_source = (BOT_DELIVERY_ROOT / "src/public.ts").read_text(encoding="utf-8") + assert "ModelEgressDatabase" not in public_source + assert "createModelGenerationBoundaryForTest" not in public_source + live_integration = ( + ROOT / "tests/integration/test_z_egress_grant_file.py" + ).read_text(encoding="utf-8") + assert "local_production_dependencies" in live_integration + assert '"optionalDependencies": local_optional_dependencies' in live_integration + + for cwd, command in ( + (ROOT / "sdk/typescript", ["npm", "run", "build"]), + (BOT_DELIVERY_ROOT, ["npm", "run", "build"]), + (BOT_DELIVERY_ROOT, ["npm", "run", "test:runtime"]), + ): + completed = subprocess.run( + command, + cwd=cwd, + check=False, + capture_output=True, + text=True, + timeout=30, + ) + assert completed.returncode == 0, completed.stdout + completed.stderr + assert "tests 11" in completed.stdout + assert "pass 11" in completed.stdout + assert "fail 0" in completed.stdout diff --git a/tests/unit/test_database_harness_contract.py b/tests/unit/test_database_harness_contract.py index 89ab82ed..27020167 100644 --- a/tests/unit/test_database_harness_contract.py +++ b/tests/unit/test_database_harness_contract.py @@ -226,8 +226,8 @@ def test_ci_runs_the_same_make_database_contract_as_local() -> None: assert "make db-down" in workflow assert ( "check: build lint typecheck openapi-check sdk-check sdk-build sdk-test " - "sdk-pack action-build action-test test catalog smoke integration " - "security-gate" in makefile + "sdk-pack action-build action-test bot-build bot-test test catalog smoke " + "integration security-gate" in makefile ) assert "./scripts/database_harness.sh integration" in makefile assert "--baseline-ref $(OPENAPI_BASELINE_REF)" in makefile @@ -244,8 +244,8 @@ def test_ci_runs_and_retains_the_single_m0_security_gate_contract() -> None: ) assert ( "check: build lint typecheck openapi-check sdk-check sdk-build sdk-test " - "sdk-pack action-build action-test test catalog smoke integration " - "security-gate" + "sdk-pack action-build action-test bot-build bot-test test catalog smoke " + "integration security-gate" in makefile.splitlines() ) assert "actions/upload-artifact@v4" in workflow diff --git a/tests/unit/test_m0_rls_inventory.py b/tests/unit/test_m0_rls_inventory.py index 27300dcf..bd271f8b 100644 --- a/tests/unit/test_m0_rls_inventory.py +++ b/tests/unit/test_m0_rls_inventory.py @@ -33,6 +33,7 @@ "delivery_evidence", "egress_audit", "egress_grant", + "model_egress_audit", "decision_audit", "exact_phrase_candidate", "file_acquisition", @@ -145,7 +146,7 @@ def test_manifest_declares_exact_live_table_denominator_and_rls_evidence() -> No assert global_tables == GLOBAL_TABLES assert tenant_tables == TENANT_TABLES - assert len(tables) == 51 + assert len(tables) == 52 for name in sorted(GLOBAL_TABLES): rationale = tables[name]["classificationRationale"] @@ -168,8 +169,8 @@ def test_rls_auditor_requires_every_live_control_and_non_owner_evidence() -> Non assert report["passed"] is True assert report["coverage"] == { - "numerator": 48, - "denominator": 48, + "numerator": 49, + "denominator": 49, "percent": 100.0, } inventory = cast(dict[str, object], report["inventory"]) @@ -194,7 +195,7 @@ def test_rls_auditor_does_not_count_force_rls_or_evidence_gaps() -> None: assert report["passed"] is False assert report["coverage"] == { "numerator": 0, - "denominator": 48, + "denominator": 49, "percent": 0.0, } tenant_reports = cast(list[dict[str, Any]], report["tenantTables"]) diff --git a/tests/unit/test_package_digest.py b/tests/unit/test_package_digest.py index 33d8e716..d08f15bc 100644 --- a/tests/unit/test_package_digest.py +++ b/tests/unit/test_package_digest.py @@ -1,6 +1,8 @@ +import json import pickle from collections.abc import Mapping from dataclasses import fields +from pathlib import Path from types import MappingProxyType from typing import cast from uuid import UUID @@ -12,6 +14,7 @@ QUERY_DIGEST_PROFILE, QueryDigest, QueryDigestKeyring, + canonicalize_context_package, context_package_digest, query_digest, verify_context_package_digest, @@ -19,6 +22,26 @@ ORGANIZATION_ID = UUID("12345678-1234-5678-90ab-cdef12345678") QUERY_KEY = bytes(range(32)) +CANONICAL_JSON_FIXTURE = ( + Path(__file__).parents[1] / "fixtures/canonical-json-cross-language-v1.json" +) + + +def test_python_and_typescript_share_rfc8785_ijson_vectors() -> None: + fixture = json.loads(CANONICAL_JSON_FIXTURE.read_text(encoding="utf-8")) + + assert fixture["profile"] == "rfc8785-ijson-cross-language-v1" + for vector in fixture["valid"]: + assert context_package_digest(vector["document"]) == vector["sha256"], vector[ + "name" + ] + for vector in fixture["invalidJsonDocuments"]: + document = json.loads(vector["json"]) + with pytest.raises(ValueError, match="Unicode scalar values"): + canonicalize_context_package(document) + for vector in fixture["invalidPackageDocuments"]: + with pytest.raises(ValueError, match="packageDigest"): + canonicalize_context_package(vector["document"]) def test_context_package_digest_has_a_fixed_canonical_unicode_vector() -> None: diff --git a/tests/unit/test_schema_security_manifest.py b/tests/unit/test_schema_security_manifest.py index 401d28ea..71d98604 100644 --- a/tests/unit/test_schema_security_manifest.py +++ b/tests/unit/test_schema_security_manifest.py @@ -32,7 +32,7 @@ def test_manifest_classifies_the_exact_current_release_schema() -> None: document = manifest() tables = table_entries(document) - assert document["manifestVersion"] == "23.0.0" + assert document["manifestVersion"] == "24.0.0" assert set(tables) == { "active_release_manifest", "action_delivery_attempt", @@ -55,6 +55,7 @@ def test_manifest_classifies_the_exact_current_release_schema() -> None: "delivery_evidence", "egress_audit", "egress_grant", + "model_egress_audit", "exact_phrase_candidate", "file_acquisition", "file_acquisition_result", @@ -140,6 +141,23 @@ def test_manifest_classifies_the_exact_current_release_schema() -> None: assert tables["action_perform_audit"]["classification"] == "tenant_owned" assert tables["egress_grant"]["classification"] == "tenant_owned" assert tables["egress_audit"]["classification"] == "tenant_owned" + assert tables["model_egress_audit"]["classification"] == "tenant_owned" + model_audit = tables["model_egress_audit"] + assert model_audit["functionOnlyMutation"] == { + "databaseFunctions": [ + "context_egress_record_model_outcome", + "context_security_delete_expired_model_egress_audit", + ], + "definerRole": "context_engine_egress_grant_definer", + "directTableMutationAllowed": False, + } + assert model_audit["retention"]["bearerStored"] is False + assert model_audit["retention"]["rawPackageStored"] is False + assert model_audit["retention"]["rawQuestionStored"] is False + assert model_audit["retention"]["rawAnswerStored"] is False + assert model_audit["permittedOperations"]["context_engine_egress"] == [ + "EXECUTE context_egress_record_model_outcome" + ] assert tables["service_principal"]["classification"] == "tenant_owned" assert tables["worker_noop_job"]["classification"] == "tenant_owned" assert tables["context_source"]["classification"] == "tenant_owned"