From f495273a2d47d51634e4748ae600ad251ce7a543 Mon Sep 17 00:00:00 2001 From: Jason Doyle Date: Thu, 3 Sep 2026 23:28:49 -0700 Subject: [PATCH 1/7] Harden stable runtime --- .env.example | 5 + .github/release.yml | 19 + .github/workflows/ci.yml | 6 + .github/workflows/release.yml | 52 +- CHANGELOG.md | 22 + README.md | 10 +- SECURITY.md | 15 + SUPPORT.md | 9 +- benchmarks/sre/run.ts | 4 + deploy/CONTRACT.md | 6 +- deploy/README.md | 4 + deploy/aws/main.tf | 25 +- deploy/azure/main.bicep | 42 + deploy/gcp/README.md | 3 + deploy/gcp/main.tf | 1 + .../helm/agentic-data-kernel/README.md | 4 +- .../templates/configmap.yaml | 7 + .../agentic-data-kernel/templates/pvc.yaml | 5 +- .../templates/worker-deployment.yaml | 20 + .../helm/agentic-data-kernel/values.yaml | 4 + docker-compose.yml | 58 +- docs/API.md | 19 +- docs/INTEGRATIONS.md | 6 +- docs/PRODUCTION.md | 47 +- docs/RELEASING.md | 67 +- docs/RUNBOOKS.md | 79 + docs/STABILITY.md | 76 + docs/TRADEOFFS.md | 2 +- docs/UPGRADING.md | 57 + examples/assert-weight.json | 2 +- examples/integrations/local-library.ts | 2 +- examples/integrations/mcp-client.ts | 2 +- examples/integrations/production-http.ts | 2 +- examples/integrations/production-retail.ts | 2 +- examples/production-payment.json | 2 +- examples/put-product.json | 2 +- examples/resolve-weight.json | 2 +- package.json | 7 +- scripts/backup-common.ps1 | 158 ++ scripts/backup.ps1 | 22 +- scripts/generate-secrets.ps1 | 2 + scripts/restore.ps1 | 47 +- scripts/test-backup-manifest.ps1 | 63 + scripts/test-backup-restore.ps1 | 48 + scripts/test-package.mjs | 12 + scripts/validate-deployments.ps1 | 1 + scripts/validate-version.mjs | 14 + src/example.ts | 3 +- src/index.ts | 7 + src/ir.ts | 38 +- src/kernel.ts | 75 +- src/layers.ts | 4 +- src/mcp.ts | 10 +- src/production/artifact-reconciliation.ts | 44 +- src/production/artifacts.ts | 19 +- src/production/auth.ts | 56 +- src/production/bootstrap.ts | 45 + src/production/catalog.ts | 9 +- src/production/cli.ts | 222 ++- src/production/config.ts | 50 +- src/production/database.ts | 2 + src/production/effects.ts | 113 +- src/production/embeddings.ts | 1 + src/production/http.ts | 32 +- src/production/index.ts | 13 +- src/production/kernel.ts | 245 ++- src/production/load.ts | 4 +- src/production/logger.ts | 3 +- src/production/mcp.ts | 38 +- src/production/metrics.ts | 95 +- src/production/migrations.ts | 42 +- src/production/search.ts | 4 + src/production/sre-scenario.ts | 3 +- src/production/worker-monitor.ts | 99 ++ src/test/agency.test.ts | 90 + src/test/http.test.ts | 2 +- src/test/ir.test.ts | 39 +- src/test/mcp.test.ts | 2 +- src/test/production.test.ts | 1465 ++++++++++++++++- src/test/sre-scenario.test.ts | 4 + src/types.ts | 10 +- src/version.ts | 11 + 82 files changed, 3587 insertions(+), 376 deletions(-) create mode 100644 .github/release.yml create mode 100644 docs/RUNBOOKS.md create mode 100644 docs/STABILITY.md create mode 100644 docs/UPGRADING.md create mode 100644 scripts/backup-common.ps1 create mode 100644 scripts/test-backup-manifest.ps1 create mode 100644 scripts/test-backup-restore.ps1 create mode 100644 scripts/validate-version.mjs create mode 100644 src/production/worker-monitor.ts create mode 100644 src/version.ts diff --git a/.env.example b/.env.example index c00139e..f804051 100644 --- a/.env.example +++ b/.env.example @@ -18,6 +18,7 @@ ARTIFACT_CURRENT_KEY_ID=v1 ARTIFACT_KEYRING={"v1":"replace-with-a-base64-encoded-32-byte-key"} ARTIFACT_DIR=.data/production-artifacts ARTIFACT_DIR_HOST=./.data/production-artifacts +BACKUP_MANIFEST_KEY=replace-with-a-base64-encoded-32-byte-key # Any OpenAI-compatible endpoint returning the configured vector dimensions. EMBEDDING_BASE_URL=https://api.openai.com/v1 @@ -42,3 +43,7 @@ BIND_ADDRESS=127.0.0.1 HTTPS_PORT=8443 SERVER_NAME=localhost LOG_LEVEL=info +TRUSTED_PROXY_HOPS=1 +WORKER_MONITOR_HOST=127.0.0.1 +WORKER_MONITOR_PORT=4319 +SHUTDOWN_TIMEOUT_MS=10000 diff --git a/.github/release.yml b/.github/release.yml new file mode 100644 index 0000000..f41c78b --- /dev/null +++ b/.github/release.yml @@ -0,0 +1,19 @@ +changelog: + categories: + - title: Breaking changes + labels: + - breaking + - title: Features + labels: + - enhancement + - feature + - title: Fixes + labels: + - bug + - security + - title: Documentation + labels: + - documentation + - title: Other changes + labels: + - "*" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e0d1d58..7976a3e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,6 +34,8 @@ jobs: - run: npm run build - run: npm test - run: npm run test:package + - if: matrix.node == 24 + run: npm run test:backup-manifest container: name: Production container @@ -77,12 +79,16 @@ jobs: - name: Apply migrations env: DATABASE_URL: postgresql://postgres:ci-administrator-password@127.0.0.1:54329/agentic_data + DATABASE_SSL: disable run: node dist/production/cli.js migrate - run: npm test - name: Run SRE comparison and verify published evidence env: BENCHMARK_REPETITIONS: 3 run: npm run benchmark:sre:verify + - name: Run backup and restore drill + shell: pwsh + run: ./scripts/test-backup-restore.ps1 - name: Show container logs if: failure() run: docker compose logs postgres diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 90cd87b..91dfae6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -16,6 +16,11 @@ jobs: package: name: Validate release package runs-on: ubuntu-latest + env: + POSTGRES_PASSWORD: release-administrator-password + APP_DATABASE_PASSWORD: release-application-password + PRODUCTION_TEST_DATABASE_URL: postgresql://agentic_app:release-application-password@127.0.0.1:54329/agentic_data + PRODUCTION_TEST_MIGRATION_DATABASE_URL: postgresql://postgres:release-administrator-password@127.0.0.1:54329/agentic_data permissions: contents: read steps: @@ -26,6 +31,8 @@ jobs: with: node-version: 24 cache: npm + - uses: docker/setup-qemu-action@v4 + - uses: docker/setup-buildx-action@v4 - name: Use supported npm run: npm install --global npm@12.0.2 - run: npm ci @@ -34,19 +41,55 @@ jobs: git fetch origin main:refs/remotes/origin/main git merge-base --is-ancestor "$GITHUB_SHA" origin/main node scripts/validate-release-tag.mjs "$GITHUB_REF_NAME" + - run: docker compose up -d postgres + - run: docker compose run --rm bootstrap + - run: npm run build + - name: Apply migrations + env: + DATABASE_URL: postgresql://postgres:release-administrator-password@127.0.0.1:54329/agentic_data + DATABASE_SSL: disable + run: node dist/production/cli.js migrate - run: npm run release:check + - run: npm run deployment:check + - name: Verify SRE evidence + env: + BENCHMARK_REPETITIONS: 3 + run: npm run benchmark:sre:verify + - name: Run backup and restore drill + shell: pwsh + run: ./scripts/test-backup-restore.ps1 + - name: Smoke supported container architectures + run: | + docker buildx build \ + --platform linux/amd64 \ + --load \ + --tag agentic-data-kernel:release-amd64 \ + . + docker run --rm agentic-data-kernel:release-amd64 \ + node dist/production/cli.js --help + docker buildx build \ + --platform linux/arm64 \ + --load \ + --tag agentic-data-kernel:release-arm64 \ + . + docker run --rm agentic-data-kernel:release-arm64 \ + node dist/production/cli.js --help - name: Create npm artifact run: | mkdir release-artifacts npm pack --json --pack-destination release-artifacts + npm sbom --sbom-format spdx > release-artifacts/npm-sbom.spdx.json cd release-artifacts - sha256sum *.tgz > SHA256SUMS + sha256sum *.tgz *.json > SHA256SUMS - uses: actions/upload-artifact@v7 with: name: npm-package path: release-artifacts if-no-files-found: error retention-days: 14 + - name: Stop containers + if: always() + run: docker compose down -v npm: name: Publish npm package @@ -69,8 +112,6 @@ jobs: name: npm-package path: release-artifacts - name: Publish package - env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} run: | VERSION=$(node -p "require('./package.json').version") if npm view "agentic-data-kernel@$VERSION" version --json >/dev/null 2>&1; then @@ -114,6 +155,8 @@ jobs: flavor: latest=auto tags: | type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=semver,pattern={{major}} type=raw,value=next,enable=${{ contains(github.ref_name, '-') }} labels: | org.opencontainers.image.title=Agentic Data Kernel @@ -130,6 +173,8 @@ jobs: labels: ${{ steps.metadata.outputs.labels }} cache-from: type=gha cache-to: type=gha,mode=max + sbom: true + provenance: mode=max - name: Attest image provenance uses: actions/attest@v4 with: @@ -158,6 +203,7 @@ jobs: RELEASE_ARGS=( release create "$GITHUB_REF_NAME" release-artifacts/*.tgz + release-artifacts/*.json release-artifacts/SHA256SUMS --repo "$GITHUB_REPOSITORY" --verify-tag diff --git a/CHANGELOG.md b/CHANGELOG.md index 63d9fe4..ce327b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,28 @@ ## Unreleased +## 1.0.0 + +- Declared Agent Intent 1.0 and the documented TypeScript, HTTP, MCP, CLI, + migration, and deployment surfaces stable. Protocol 0.1 remains accepted + throughout 1.x. +- Added per-operation credential, tenant, purpose, and scope revalidation, + explicit tenant predicates, strict runtime-role verification, explicit + database TLS mode, redirect-free embeddings, and bounded query timeouts. +- Hardened effect execution with tenant-fair leasing, authorization fences, + abort propagation, expired-dispatch reconciliation, worker health endpoints, + and crash-after-provider-apply recovery coverage. +- Added signed coordinated backups, exact migration-manifest verification, + artifact integrity reconciliation, filesystem durability barriers, and + documented restore-only rollback after migrations begin. +- Added cumulative Prometheus histograms, worker liveness metrics, bounded + timer processing, effect pagination, and graceful process shutdown. +- Added validated Helm, Azure Container Apps, AWS ECS Fargate, and GKE + deployment paths with retained storage and separate runtime and + administrative secrets. +- Added stable compatibility, upgrade, rollback, runbook, package, SBOM, + provenance, benchmark, and release gates. + ## 0.3.0-alpha.5 - Added validated Helm, Azure Bicep, AWS OpenTofu, and GCP OpenTofu deployment diff --git a/README.md b/README.md index cf727c2..5a4ce22 100644 --- a/README.md +++ b/README.md @@ -214,7 +214,7 @@ embedded CLI accepts it from a file: ```json { - "protocolVersion": "0.1", + "protocolVersion": "1.0", "requestId": "observe-1001", "idempotencyKey": "observe-1001", "principal": { @@ -365,9 +365,10 @@ lineage verification PostgreSQL + pgvector + RLS ``` -## Current boundaries +## Stable support and boundaries -- The release is alpha. +- Version 1.0.0 supports the bounded production profile documented in + [Stability and Compatibility](docs/STABILITY.md). - Production targets one PostgreSQL primary. - The default rate limiter is process-local. - One embedding model, version, and dimension is active per deployment. @@ -392,6 +393,9 @@ See [Benefits and Tradeoffs](docs/TRADEOFFS.md) for a fuller fit assessment. - [Use cases](docs/USE_CASES.md) - [Integration guide](docs/INTEGRATIONS.md) - [Production profile](docs/PRODUCTION.md) +- [Stability and compatibility](docs/STABILITY.md) +- [Upgrade and rollback](docs/UPGRADING.md) +- [Production runbooks](docs/RUNBOOKS.md) - [Threat model](docs/THREAT_MODEL.md) - [Release process](docs/RELEASING.md) - [Security policy](SECURITY.md) diff --git a/SECURITY.md b/SECURITY.md index ec04dcf..f1e8f4c 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,5 +1,12 @@ # Security +## Supported versions + +| Version | Security support | +| --- | --- | +| 1.x | Supported | +| 0.x | Unsupported after 1.0.0 | + ## Supported profiles The PostgreSQL production profile is the only profile intended for deployment @@ -21,10 +28,14 @@ issue. ## Production security requirements - Run the application with the non-superuser `agentic_app` database role. +- Run `bootstrap-role` before migrations and require runtime role verification + at startup. - Reserve the PostgreSQL superuser connection for migrations, backup, and restore. - Terminate TLS at a trusted reverse proxy or service mesh. The included Compose profile uses Caddy and does not publish the Node.js listener. +- Set `TRUSTED_PROXY_HOPS` to the exact trusted forwarding chain and prevent + direct access around it. - Store API keys, the authentication pepper, artifact keys, database passwords, and embedding-provider credentials in a secret manager. - Keep embedding model, version, and dimensions aligned with the database @@ -39,6 +50,10 @@ issue. - Restrict `/metrics` and health endpoints at the network layer when operational metadata is considered sensitive. - Back up the PostgreSQL database and encrypted artifact directory together. +- Sign backup manifests with `BACKUP_MANIFEST_KEY` stored outside the backup + location. +- Reject backups whose signed migration versions and checksums do not exactly + match the restoring runtime. - Test restore procedures before relying on a backup. ## Security properties diff --git a/SUPPORT.md b/SUPPORT.md index ce9134c..e59b7a8 100644 --- a/SUPPORT.md +++ b/SUPPORT.md @@ -31,5 +31,10 @@ Do not open a public issue for a vulnerability. Follow ## Support level -This project is maintained on a best-effort basis. The PostgreSQL profile is an -alpha release and does not include a commercial support commitment. +The latest 1.x release receives best-effort security and critical correctness +fixes. Stable support covers the bounded profile in +[Stability and Compatibility](docs/STABILITY.md). + +Older 0.x alpha releases are unsupported after 1.0.0. This project does not +include a commercial support commitment, uptime SLA, hosted control plane, or +managed cloud account. diff --git a/benchmarks/sre/run.ts b/benchmarks/sre/run.ts index f593562..ce8ace9 100644 --- a/benchmarks/sre/run.ts +++ b/benchmarks/sre/run.ts @@ -568,6 +568,10 @@ function testConfig( logLevel: "silent", maxBodyBytes: 1_000_000, rateLimitPerMinute: 1_000, + trustedProxyHops: 0, + shutdownTimeoutMs: 10_000, + workerMonitorHost: "127.0.0.1", + workerMonitorPort: 4319, }; } diff --git a/deploy/CONTRACT.md b/deploy/CONTRACT.md index e23ae8a..c3c8b94 100644 --- a/deploy/CONTRACT.md +++ b/deploy/CONTRACT.md @@ -108,6 +108,8 @@ durability semantics. - Expose only the API. - Terminate TLS at the managed ingress or load balancer. +- Set `TRUSTED_PROXY_HOPS` to the exact number of trusted forwarding hops and + prevent direct access to the Node.js listener. - Keep PostgreSQL and artifact storage private. - Set `HOST=0.0.0.0` and `PORT=4318` for the API container. - Apply platform-specific egress controls for PostgreSQL, DNS, the embedding @@ -119,9 +121,11 @@ durability semantics. - Liveness: `GET /health/live` - Readiness: `GET /health/ready` +- Worker liveness, readiness, and metrics: port 4319 on the private network - Run bootstrap and migrations before increasing API or worker replicas. - Use immutable image tags or digests. - Restart workloads after rotating environment-injected secrets. The API rate limiter is process-local. Horizontal replicas multiply the -effective aggregate request allowance. +effective aggregate request allowance, so multi-replica deployments require a +shared ingress or gateway limiter. diff --git a/deploy/README.md b/deploy/README.md index ce99e02..4d67244 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -15,6 +15,10 @@ Read [CONTRACT.md](CONTRACT.md) before using any template. Every template requires an explicit immutable application image version or digest. No runnable default points at an older release. +When ingress is enabled, configure the exact trusted proxy hop count. The Helm +chart rejects ingress with `config.trustedProxyHops=0`; the cloud templates set +the value for their documented single-ingress topology. + The templates intentionally consume existing cloud networks, PostgreSQL servers, secret stores, and persistent storage. Landing zones and credentials vary substantially between organizations, and placing generated database or diff --git a/deploy/aws/main.tf b/deploy/aws/main.tf index 89bcb93..456f381 100644 --- a/deploy/aws/main.tf +++ b/deploy/aws/main.tf @@ -13,7 +13,11 @@ locals { { name = "EFFECT_ALLOWED_HOSTS", value = var.effect_allowed_hosts }, { name = "HOST", value = "0.0.0.0" }, { name = "PORT", value = "4318" }, - { name = "LOG_LEVEL", value = "info" } + { name = "LOG_LEVEL", value = "info" }, + { name = "TRUSTED_PROXY_HOPS", value = "1" }, + { name = "WORKER_MONITOR_HOST", value = "0.0.0.0" }, + { name = "WORKER_MONITOR_PORT", value = "4319" }, + { name = "SHUTDOWN_TIMEOUT_MS", value = "10000" } ] runtime_secrets = [ { name = "DATABASE_URL", valueFrom = var.secret_arns.database_url }, @@ -179,6 +183,25 @@ resource "aws_ecs_task_definition" "worker" { readOnly = false } ] + portMappings = [ + { + name = "monitor" + containerPort = 4319 + hostPort = 4319 + protocol = "tcp" + appProtocol = "http" + } + ] + healthCheck = { + command = [ + "CMD-SHELL", + "node -e \"fetch('http://127.0.0.1:4319/health/ready').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))\"" + ] + interval = 30 + timeout = 5 + retries = 3 + startPeriod = 30 + } logConfiguration = { logDriver = "awslogs" options = { diff --git a/deploy/azure/main.bicep b/deploy/azure/main.bicep index f53dd2e..275aa85 100644 --- a/deploy/azure/main.bicep +++ b/deploy/azure/main.bicep @@ -176,6 +176,22 @@ var runtimeEnvironment = concat([ name: 'LOG_LEVEL' value: 'info' } + { + name: 'TRUSTED_PROXY_HOPS' + value: '1' + } + { + name: 'WORKER_MONITOR_HOST' + value: '0.0.0.0' + } + { + name: 'WORKER_MONITOR_PORT' + value: '4319' + } + { + name: 'SHUTDOWN_TIMEOUT_MS' + value: '10000' + } ], databaseCaEnvironment) resource api 'Microsoft.App/containerApps@2025-07-01' = { @@ -294,6 +310,32 @@ resource worker 'Microsoft.App/containerApps@2025-07-01' = { cpu: json('0.5') memory: '1Gi' } + probes: [ + { + type: 'Liveness' + httpGet: { + path: '/health/live' + port: 4319 + scheme: 'HTTP' + } + initialDelaySeconds: 20 + periodSeconds: 20 + timeoutSeconds: 3 + failureThreshold: 3 + } + { + type: 'Readiness' + httpGet: { + path: '/health/ready' + port: 4319 + scheme: 'HTTP' + } + initialDelaySeconds: 5 + periodSeconds: 10 + timeoutSeconds: 3 + failureThreshold: 6 + } + ] volumeMounts: [ { volumeName: 'artifacts' diff --git a/deploy/gcp/README.md b/deploy/gcp/README.md index e0a73e7..d510e65 100644 --- a/deploy/gcp/README.md +++ b/deploy/gcp/README.md @@ -25,6 +25,9 @@ loopback hop while the proxy authenticates and encrypts the Cloud SQL connection. The API Service is annotated for a standalone GKE NEG so GCE ingress has a container-native backend even when automatic NEG injection is unavailable. +The module trusts two `X-Forwarded-For` hops for the documented GCE load +balancer topology so the pre-authentication limiter keys the originating +client rather than the load balancer. Create the namespace, both Secrets, and Filestore-backed PVC before applying this module. The required Secret keys are listed in the diff --git a/deploy/gcp/main.tf b/deploy/gcp/main.tf index a0aa818..4bcc907 100644 --- a/deploy/gcp/main.tf +++ b/deploy/gcp/main.tf @@ -58,6 +58,7 @@ locals { embeddingVersion = var.embedding_version embeddingDimensions = tostring(var.embedding_dimensions) effectAllowedHosts = var.effect_allowed_hosts + trustedProxyHops = "2" } databaseProxy = { enabled = true diff --git a/deploy/kubernetes/helm/agentic-data-kernel/README.md b/deploy/kubernetes/helm/agentic-data-kernel/README.md index fb13fba..df7e2ed 100644 --- a/deploy/kubernetes/helm/agentic-data-kernel/README.md +++ b/deploy/kubernetes/helm/agentic-data-kernel/README.md @@ -68,7 +68,9 @@ When enabling ingress, configure at least one `ingress.tls` entry and disable plain HTTP through the selected ingress controller. Set `ingress.tlsOnlyAnnotation` and `ingress.tlsOnlyValue` to its redirect or HTTP-disable annotation, for example -`nginx.ingress.kubernetes.io/ssl-redirect=true`. +`nginx.ingress.kubernetes.io/ssl-redirect=true`. Also set +`config.trustedProxyHops` to the exact number of trusted forwarding hops. The +chart rejects ingress with a zero hop count. Create the namespace and both Secrets before Helm runs. Runtime pods cannot read the administrative Secret. The bootstrap and migration hooks diff --git a/deploy/kubernetes/helm/agentic-data-kernel/templates/configmap.yaml b/deploy/kubernetes/helm/agentic-data-kernel/templates/configmap.yaml index 8c4357f..9837741 100644 --- a/deploy/kubernetes/helm/agentic-data-kernel/templates/configmap.yaml +++ b/deploy/kubernetes/helm/agentic-data-kernel/templates/configmap.yaml @@ -1,3 +1,6 @@ +{{- if and .Values.ingress.enabled (lt (int .Values.config.trustedProxyHops) 1) }} +{{- fail "config.trustedProxyHops must be at least 1 when ingress.enabled is true" }} +{{- end }} apiVersion: v1 kind: ConfigMap metadata: @@ -25,5 +28,9 @@ data: LOG_LEVEL: {{ .Values.config.logLevel | quote }} MAX_BODY_BYTES: {{ .Values.config.maxBodyBytes | quote }} RATE_LIMIT_PER_MINUTE: {{ .Values.config.rateLimitPerMinute | quote }} + TRUSTED_PROXY_HOPS: {{ .Values.config.trustedProxyHops | quote }} + WORKER_MONITOR_HOST: {{ .Values.config.workerMonitorHost | quote }} + WORKER_MONITOR_PORT: {{ .Values.config.workerMonitorPort | quote }} + SHUTDOWN_TIMEOUT_MS: {{ .Values.config.shutdownTimeoutMs | quote }} HOST: "0.0.0.0" PORT: "4318" diff --git a/deploy/kubernetes/helm/agentic-data-kernel/templates/pvc.yaml b/deploy/kubernetes/helm/agentic-data-kernel/templates/pvc.yaml index 2f331a9..6c91206 100644 --- a/deploy/kubernetes/helm/agentic-data-kernel/templates/pvc.yaml +++ b/deploy/kubernetes/helm/agentic-data-kernel/templates/pvc.yaml @@ -5,10 +5,11 @@ metadata: name: {{ include "agentic-data-kernel.pvcName" . }} labels: {{- include "agentic-data-kernel.labels" . | nindent 4 }} - {{- with .Values.persistence.annotations }} annotations: + helm.sh/resource-policy: keep + {{- with .Values.persistence.annotations }} {{- toYaml . | nindent 4 }} - {{- end }} + {{- end }} spec: accessModes: {{- toYaml .Values.persistence.accessModes | nindent 4 }} diff --git a/deploy/kubernetes/helm/agentic-data-kernel/templates/worker-deployment.yaml b/deploy/kubernetes/helm/agentic-data-kernel/templates/worker-deployment.yaml index 94a7026..1956175 100644 --- a/deploy/kubernetes/helm/agentic-data-kernel/templates/worker-deployment.yaml +++ b/deploy/kubernetes/helm/agentic-data-kernel/templates/worker-deployment.yaml @@ -98,6 +98,10 @@ spec: - node - dist/production/cli.js - worker + ports: + - name: monitor + containerPort: {{ .Values.config.workerMonitorPort }} + protocol: TCP envFrom: - configMapRef: name: {{ include "agentic-data-kernel.fullname" . }} @@ -130,6 +134,22 @@ spec: optional: true resources: {{- toYaml .Values.worker.resources | nindent 12 }} + readinessProbe: + httpGet: + path: /health/ready + port: monitor + initialDelaySeconds: 5 + periodSeconds: 10 + timeoutSeconds: 3 + failureThreshold: 6 + livenessProbe: + httpGet: + path: /health/live + port: monitor + initialDelaySeconds: 20 + periodSeconds: 20 + timeoutSeconds: 3 + failureThreshold: 3 securityContext: allowPrivilegeEscalation: false readOnlyRootFilesystem: true diff --git a/deploy/kubernetes/helm/agentic-data-kernel/values.yaml b/deploy/kubernetes/helm/agentic-data-kernel/values.yaml index 1dbca67..35fbdb0 100644 --- a/deploy/kubernetes/helm/agentic-data-kernel/values.yaml +++ b/deploy/kubernetes/helm/agentic-data-kernel/values.yaml @@ -39,6 +39,10 @@ config: logLevel: info maxBodyBytes: "1000000" rateLimitPerMinute: "600" + trustedProxyHops: "0" + workerMonitorHost: 0.0.0.0 + workerMonitorPort: "4319" + shutdownTimeoutMs: "10000" api: replicaCount: 1 diff --git a/docker-compose.yml b/docker-compose.yml index 61a8dde..7fceee5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -19,45 +19,21 @@ services: retries: 30 bootstrap: - image: pgvector/pgvector:0.8.6-pg18-bookworm + image: ${AGENTIC_DATA_IMAGE:-agentic-data-kernel:local} + build: + context: . depends_on: postgres: condition: service_healthy environment: - POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required} + MIGRATION_DATABASE_URL: postgresql://postgres:${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required}@postgres:5432/agentic_data APP_DATABASE_PASSWORD: ${APP_DATABASE_PASSWORD:?APP_DATABASE_PASSWORD is required} - entrypoint: - - /bin/sh - - -ec + DATABASE_SSL: ${DATABASE_SSL:-disable} + DATABASE_CA_CERT_BASE64: ${DATABASE_CA_CERT_BASE64:-} command: - - | - export PGPASSWORD="$$POSTGRES_PASSWORD" - if ! psql \ - --host postgres \ - --username postgres \ - --dbname agentic_data \ - --tuples-only \ - --no-align \ - --command "SELECT 1 FROM pg_roles WHERE rolname = 'agentic_app'" | - grep -q 1; then - printf '%s\n' \ - "CREATE ROLE agentic_app LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT NOBYPASSRLS PASSWORD :'app_password';" | - psql \ - --host postgres \ - --username postgres \ - --dbname agentic_data \ - --set=ON_ERROR_STOP=1 \ - --set=app_password="$$APP_DATABASE_PASSWORD" - else - printf '%s\n' \ - "ALTER ROLE agentic_app PASSWORD :'app_password';" | - psql \ - --host postgres \ - --username postgres \ - --dbname agentic_data \ - --set=ON_ERROR_STOP=1 \ - --set=app_password="$$APP_DATABASE_PASSWORD" - fi + - node + - dist/production/cli.js + - bootstrap-role migrate: profiles: @@ -122,6 +98,8 @@ services: HOST: 0.0.0.0 PORT: 4318 LOG_LEVEL: ${LOG_LEVEL:-info} + TRUSTED_PROXY_HOPS: ${TRUSTED_PROXY_HOPS:-1} + SHUTDOWN_TIMEOUT_MS: ${SHUTDOWN_TIMEOUT_MS:-10000} expose: - "4318" volumes: @@ -169,12 +147,26 @@ services: HNSW_MAX_SCAN_TUPLES: ${HNSW_MAX_SCAN_TUPLES:-20000} EFFECT_ALLOWED_HOSTS: ${EFFECT_ALLOWED_HOSTS:-} LOG_LEVEL: ${LOG_LEVEL:-info} + WORKER_MONITOR_HOST: 0.0.0.0 + WORKER_MONITOR_PORT: 4319 + SHUTDOWN_TIMEOUT_MS: ${SHUTDOWN_TIMEOUT_MS:-10000} + expose: + - "4319" volumes: - ${ARTIFACT_DIR_HOST:-./.data/production-artifacts}:/var/lib/agentic-data/artifacts command: - node - dist/production/cli.js - worker + healthcheck: + test: + - CMD + - node + - -e + - fetch('http://127.0.0.1:4319/health/ready').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1)) + interval: 5s + timeout: 3s + retries: 30 proxy: profiles: diff --git a/docs/API.md b/docs/API.md index cc8ed9d..82b42ca 100644 --- a/docs/API.md +++ b/docs/API.md @@ -17,7 +17,7 @@ ```json { - "protocolVersion": "0.1", + "protocolVersion": "1.0", "requestId": "unique-request-id", "idempotencyKey": "stable-retry-key", "principal": { @@ -34,6 +34,9 @@ operation across retries within a tenant and principal. Reusing a key with different operation content in that scope is rejected. Another principal has a separate idempotency namespace. +On replay, the outer response contains the current call's `requestId`; the +durable receipt retains the original request ID. + In the PostgreSQL profile, the supplied principal must exactly match the authenticated API key. @@ -86,6 +89,11 @@ profile accepts terminal effect state only from the effect worker. `record_effect_outcome` is the development-profile equivalent for generic effects. It is rejected by the production API and MCP surface. +`list_effects` accepts optional `afterEffectId` and `limit` fields. `limit` +must be from 1 through 100. Supplying a cursor without a limit uses 100. +Omitting both fields preserves the unbounded behavior used by protocol 0.1 +clients. New callers should always paginate. + ## Generic workflows `create_workflow` stores a caller-defined workflow type, initial state, JSON @@ -321,6 +329,9 @@ Internal errors return a generic message and request ID. ## Versioning -The current envelope version is `0.1`. New required fields or changed operation -semantics require a new protocol version. Additive optional fields may remain -within the current version when existing behavior is preserved. +The stable envelope version is `1.0`. Version `0.1` remains accepted throughout +the 1.x release line for existing alpha clients. New required fields or changed +operation semantics require a new protocol version. Additive optional fields +may remain within the current version when existing behavior is preserved. + +See [Stability and Compatibility](STABILITY.md). diff --git a/docs/INTEGRATIONS.md b/docs/INTEGRATIONS.md index 6b8169f..15fd681 100644 --- a/docs/INTEGRATIONS.md +++ b/docs/INTEGRATIONS.md @@ -5,10 +5,10 @@ embedding providers, effect receivers, and the retail workflow. ## Install -Use the prerelease distribution tag for evaluation: +Install the stable package: ```powershell -npm install agentic-data-kernel@next +npm install agentic-data-kernel ``` Pin an exact version in applications and production deployments. @@ -54,7 +54,7 @@ const store = new SqliteStore(".data/app.db"); const kernel = new AgenticKernel(store); const result = executeIntent(kernel, { - protocolVersion: "0.1", + protocolVersion: "1.0", requestId: "product-1", idempotencyKey: "product-1", principal: { diff --git a/docs/PRODUCTION.md b/docs/PRODUCTION.md index e280303..1dc0ab3 100644 --- a/docs/PRODUCTION.md +++ b/docs/PRODUCTION.md @@ -63,7 +63,9 @@ Generate local secrets: Copy `.env.example` to `.env`, replace every placeholder, and configure an OpenAI-compatible embeddings endpoint. -For managed PostgreSQL, set `DATABASE_SSL=require`. If the provider CA is not +`DATABASE_SSL` is required. Set it to `require` for managed PostgreSQL and to +`disable` only for local development or an authenticated loopback database +proxy. If the provider CA is not already in the container's trust store, set `DATABASE_CA_CERT_BASE64` to the base64 encoding of its PEM CA bundle. Do not add SSL query parameters such as `sslmode` to `DATABASE_URL` or @@ -194,8 +196,9 @@ Then run: npm run prod:mcp ``` -The MCP process authenticates once and does not accept caller-supplied tenant or -principal identities. +The MCP process authenticates at startup, binds every tool to that identity, +and revalidates revocation, expiry, tenant status, scope, and purpose for every +operation. It does not accept caller-supplied tenant or principal identities. ## Encrypted artifacts @@ -217,7 +220,7 @@ Artifact files use: - tenant-derived keys using HKDF-SHA256; - authenticated metadata binding tenant, artifact ID, media type, and content hash; -- atomic temporary-file creation and rename; +- atomic hard-link publication from a synced temporary file; - immutable content-address verification. Artifact metadata writes are serialized with a database advisory lock. A failed @@ -321,6 +324,16 @@ They are retried with the same idempotency key up to `EFFECT_MAX_ATTEMPTS`. A receiver must return a stable `providerReference` for success. +The worker rotates across active tenants between leases. Multiple replicas can +run concurrently because leases use `SKIP LOCKED`, but PostgreSQL connection +capacity must include every replica. On shutdown, outbound requests are +aborted. Ambiguous results remain durable and are reconciled with the original +effect ID and provider idempotency key. + +The worker exposes private liveness, readiness, and metrics endpoints on +`WORKER_MONITOR_HOST` and `WORKER_MONITOR_PORT`, defaulting to +`127.0.0.1:4319`. + Public clients cannot submit payment outcomes. ### Generic effects @@ -345,6 +358,27 @@ historical effects for duplicate keys within the same tenant and provider origin. Resolve any reported collision before retrying the migration; the migration rolls back without changing the schema. +### Timers + +`process_timers` is intentionally tenant-scoped and is not an autonomous global +scheduler. Invoke it periodically through an authenticated Agent Intent client +whose key has `workflows:run` for that tenant. Each call locks and processes at +most 100 due timers with `SKIP LOCKED`; continue until the returned array is +empty. Run only one logical scheduler per tenant unless duplicate invocations +are acceptable. + +### Rate limiting and proxies + +`RATE_LIMIT_PER_MINUTE` is enforced per API process and API key. A separate +pre-authentication limiter uses the resolved client address. Set +`TRUSTED_PROXY_HOPS` to the exact number of trusted proxies that append +`X-Forwarded-For`, and keep the Node.js listener unreachable except through +those proxies. Leave it at `0` for direct connections. + +Multiple API replicas multiply the built-in allowance. Production +multi-replica deployments need a shared limiter at the ingress, gateway, or +edge. + ## Backup and restore Create a checksum-manifested backup: @@ -362,7 +396,10 @@ Restore after stopping application and worker processes: -ConfirmRestore ``` -The database and encrypted artifacts are one recovery unit. Backup and restore +The database and encrypted artifacts are one recovery unit. Backup manifests +are authenticated with `BACKUP_MANIFEST_KEY`, include the exact database +migration versions and checksums, and must be copied with their signature. +Backup and restore set a database maintenance flag that every supported writer checks while holding a transaction lock. The scripts also refuse running Compose app or worker containers. diff --git a/docs/RELEASING.md b/docs/RELEASING.md index b44825e..b0f49fb 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -8,8 +8,9 @@ Releases publish the same source revision through three channels: The release workflow builds multi-architecture container images for `linux/amd64` and `linux/arm64`. npm packages published from the workflow carry -registry provenance, and container images receive a GitHub artifact -attestation. +registry provenance. Container images include an SBOM and maximal provenance +and receive a GitHub artifact attestation. The GitHub release contains the npm +tarball, an SPDX npm SBOM, and SHA-256 checksums. Release workflows are serialized. A tag is rejected when a newer tag already exists in the same stable or prerelease channel, which prevents an older @@ -27,31 +28,29 @@ Prerelease versions receive: - a GitHub prerelease. Stable versions receive the npm and container `latest` tags. +They also receive container tags for the full version, major/minor version, and +major version. Consumers should pin exact versions in production even when a moving tag is available. -## First npm publication +## npm trusted publishing -npm requires the package to exist before its trusted publisher can be -configured. Bootstrap the first release with a short-lived granular npm token: +The repository uses npm trusted publishing through GitHub OIDC. The release +workflow must not receive `NODE_AUTH_TOKEN` or a long-lived npm token. -1. Create a granular token that can publish public packages and satisfies the - account's two-factor authentication policy. -2. Store it as the `NPM_TOKEN` GitHub Actions repository secret. -3. Push the first version tag and wait for the Release workflow to finish. -4. In the npm settings for `agentic-data-kernel`, configure a GitHub Actions - trusted publisher with: +Verify the npm package settings before a stable release: + +1. Configure a GitHub Actions trusted publisher with: - owner: `Jason-Doyle`; - repository: `agentic-data-kernel`; - workflow filename: `release.yml`; - environment: none. -5. Delete the `NPM_TOKEN` repository secret. -6. Require two-factor authentication and disallow token-based publishing in the +2. Confirm no `NPM_TOKEN` repository secret is present. +3. Require two-factor authentication and disallow token-based publishing in the npm package settings. -Later releases use GitHub OIDC and do not require an npm token. The workflow -uses npm 12 because trusted publishing requires a recent npm CLI. +The workflow uses npm 12 because trusted publishing requires a recent npm CLI. ## First container publication @@ -79,12 +78,19 @@ workflow. - `CHANGELOG.md`; - the release version in `README.md`; - `AGENTIC_DATA_IMAGE` in `.env.example`. + - `src/version.ts`; + - Helm `appVersion` and deployment example image tags. 3. Run: ```powershell npm run release:check + npm run deployment:check + npm run benchmark:sre:verify + .\scripts\test-backup-restore.ps1 docker compose --env-file .env.example config --quiet docker build --tag agentic-data-kernel:release-check . + docker run --rm agentic-data-kernel:release-check ` + node dist/production/cli.js --help ``` 4. Merge the release pull request to `main`. @@ -99,17 +105,38 @@ workflow. ``` The tag starts `.github/workflows/release.yml`. The GitHub Release is created -only after npm and container publication succeed. +only after all tag-SHA gates, npm publication, and container publication +succeed. Do not create a beta or release-candidate tag for the 1.0 graduation. ## Verification -For a prerelease: +For a stable release: ```powershell -npm view agentic-data-kernel@next version +npm view agentic-data-kernel@latest version +npm view agentic-data-kernel@1.0.0 dist.attestations docker pull ghcr.io/jason-doyle/agentic-data-kernel: +docker pull ghcr.io/jason-doyle/agentic-data-kernel:1.0 +docker pull ghcr.io/jason-doyle/agentic-data-kernel:1 +docker pull ghcr.io/jason-doyle/agentic-data-kernel:latest gh release view v --repo Jason-Doyle/agentic-data-kernel ``` -Confirm the npm provenance statement, container attestation, release checksums, -and exact source revision before announcing the release. +Confirm: + +- npm `latest` resolves to the exact version and its provenance references the + release workflow and tag commit; +- all four container tags resolve to the same multi-architecture digest; +- the image supports `linux/amd64` and `linux/arm64`; +- the container SBOM and GitHub attestation are present; +- the release tarball and SPDX SBOM match `SHA256SUMS`; +- the GitHub release targets the exact signed or annotated tag. + +After `1.0.0` is verified, deprecate every npm version below 1.0.0: + +```powershell +npm deprecate "agentic-data-kernel@<1.0.0" ` + "Unsupported prerelease. Upgrade to agentic-data-kernel@^1.0.0." +``` + +Confirm the deprecation notice is visible on an older published version. diff --git a/docs/RUNBOOKS.md b/docs/RUNBOOKS.md new file mode 100644 index 0000000..8b524f5 --- /dev/null +++ b/docs/RUNBOOKS.md @@ -0,0 +1,79 @@ +# Production Runbooks + +## API is not ready + +1. Check `/health/live`, then `/health/ready`. +2. Verify the process uses the restricted `agentic_app` role. +3. Run `migration-status` with the administrative connection. +4. Confirm the configured embedding model, version, and dimensions match the + database. +5. Check PostgreSQL connection limits and statement timeout logs. + +## Effect backlog or unknown outcome + +1. Keep the worker running with the same artifact keys and effect allowlist. +2. Inspect effect status and attempts through `list_effects` with pagination. +3. Do not manually redeliver an expired `dispatching` effect. The worker + reconciles it after lease expiry. +4. Confirm the provider honors the original idempotency key and status URL. +5. Investigate queue age before increasing worker replicas. + +## Migration failure + +1. Keep API and worker replicas at zero. +2. Preserve migration logs and the signed pre-upgrade backup. +3. Correct configuration or provider-extension availability. +4. Rerun the idempotent migration command. +5. If an applied migration is incompatible, restore the backup. Never edit a + released migration checksum. + +## Artifact integrity failure + +1. Stop writes. +2. Run `reconcile-artifacts` with the administrative connection. +3. Treat missing, corrupt, or undecryptable referenced files as data-loss + incidents. +4. Restore the coordinated signed backup or restore the missing key version. +5. Do not remove an old key ID while retained artifacts reference it. + +## Pool exhaustion + +1. Multiply `DATABASE_POOL_SIZE` by total API and worker replicas. +2. Compare the result with PostgreSQL connection capacity and reserved + administrative connections. +3. Reduce per-process pools before increasing the database limit. +4. Check slow statements and provider calls before adding replicas. + +## Timer backlog + +1. Invoke `process_timers` with a tenant-scoped key that has `workflows:run`. +2. Repeat while the operation returns changed machines. Each call processes at + most 100 due timers. +3. Check scheduler credentials, purpose, and cadence before adding concurrent + invokers. +4. Keep separate scheduler state per tenant. There is no global timer sweep. + +## Rate limiting + +1. Confirm `TRUSTED_PROXY_HOPS` matches the exact ingress chain. +2. Ensure the Node.js listener cannot be reached around that trusted chain. +3. Remember that the built-in limiter is process-local. +4. Use a shared edge or gateway limiter before adding API replicas. + +## Backup and restore + +1. Keep `BACKUP_MANIFEST_KEY` in a separate secret store. +2. Stop all writers. +3. Run the backup script and copy the signed backup off-host. +4. Keep `manifest.json`, `manifest.hmac`, the database dump, and any artifact + archive together. +5. Run `scripts/test-backup-restore.ps1` regularly in a disposable + environment. +6. Record restore duration and verify API readiness after every drill. + +## Graceful shutdown + +Send `SIGTERM` once and allow `SHUTDOWN_TIMEOUT_MS` for draining. The HTTP +server stops accepting connections, closes idle connections, and forcibly +closes remaining sockets at the deadline. The worker aborts active outbound +requests and leaves ambiguous effects for reconciliation. diff --git a/docs/STABILITY.md b/docs/STABILITY.md new file mode 100644 index 0000000..adeede8 --- /dev/null +++ b/docs/STABILITY.md @@ -0,0 +1,76 @@ +# Stability and Compatibility + +Version 1.0.0 defines the stable public contract for Agentic Data Kernel. + +## Supported runtime scope + +The stable PostgreSQL profile supports: + +- Node.js 22.19 or newer in the Node 22 and Node 24 LTS lines; +- Linux `amd64` and `arm64` containers; +- PostgreSQL 18 with pgvector 0.8 or newer and pgcrypto; +- one PostgreSQL primary; +- one or more API replicas behind a trusted TLS gateway and shared rate + limiter; +- one or more effect workers using database leases; +- a shared POSIX filesystem supporting hard links and fsync; +- offline, forward-only database migrations; +- the documented Kubernetes, Azure, AWS, and GCP workload templates. + +The embedded SQLite profile is a stable local-development interface. It is not +supported as a network production service. + +## Public API + +The stable API consists of: + +- documented exports from `agentic-data-kernel`; +- documented exports from `agentic-data-kernel/production`; +- Agent Intent protocol 1.0 operations and results; +- the production HTTP routes and MCP tools; +- CLI commands and documented environment variables; +- PostgreSQL migration filenames, order, and checksums; +- documented deployment-template inputs. + +Files or symbols not exported by the package entry points are internal. + +Breaking changes to the stable API require a new major version. Additive +operations, optional fields, and backwards-compatible deployment inputs may +ship in a minor version. Fixes that preserve the contract ship in a patch +version. + +## Agent Intent compatibility + +Protocol `1.0` is the stable default. Protocol `0.1` remains accepted +throughout the 1.x release line for existing alpha clients. + +Legacy `list_effects` calls that omit pagination continue to return the full +matching set. New clients should use `afterEffectId` and `limit`. + +An idempotency key is scoped by tenant, principal, and protocol request +content. Exact retries return the original durable result and receipt while +the outer response uses the current call's `requestId`. + +## Database compatibility + +Released migration files are immutable. Runtime startup requires the exact +known migration set and checksums and rejects missing, changed, or newer +schemas. + +Migrations are forward-only and may require downtime. A database cannot be +downgraded by deploying an older container. Rollback requires restoring the +coordinated, signed database and artifact backup created before migration. + +The supported stable upgrade origin is `0.3.0-alpha.5`. + +## Reference deployment boundaries + +Cloud modules deploy application workloads into existing landing zones. Cloud +accounts, private networks, managed PostgreSQL policy, secret stores, TLS +certificates, storage classes, DNS, backups, and provider quotas remain +operator responsibilities. + +Tenant timer invocation and a shared edge rate limiter for multi-replica API +deployments also remain operator responsibilities. + +No commercial SLA or managed-service commitment is included. diff --git a/docs/TRADEOFFS.md b/docs/TRADEOFFS.md index 8195c29..7efd12f 100644 --- a/docs/TRADEOFFS.md +++ b/docs/TRADEOFFS.md @@ -139,7 +139,7 @@ application database. ### Current implementation boundaries -- The release is alpha. +- Stable support is bounded to the profile in `docs/STABILITY.md`. - The included deployment uses one PostgreSQL primary. - The default rate limiter is process-local. - One embedding space is active per deployment. diff --git a/docs/UPGRADING.md b/docs/UPGRADING.md new file mode 100644 index 0000000..40b4fb7 --- /dev/null +++ b/docs/UPGRADING.md @@ -0,0 +1,57 @@ +# Upgrade and Rollback + +## Upgrade from 0.3.0-alpha.5 to 1.0.0 + +1. Generate and store a 32-byte backup manifest key outside the backup + location: + + ```powershell + $env:BACKUP_MANIFEST_KEY = [Convert]::ToBase64String( + [Security.Cryptography.RandomNumberGenerator]::GetBytes(32) + ) + ``` + +2. Stop every API and worker replica. +3. Create a coordinated signed backup: + + ```powershell + .\scripts\backup.ps1 + ``` + +4. Deploy the `1.0.0` image with workloads disabled. +5. Run `bootstrap-role`. +6. Run `migrate` and require exit code zero. +7. Run administrative artifact reconciliation: + + ```powershell + node dist\production\cli.js reconcile-artifacts + ``` + +8. Start the worker and API. +9. Verify readiness, metrics, one authenticated read, one write/replay, and an + effect reconciliation in a non-production tenant. + +The deployment templates document the equivalent gate-off, migrate, and +gate-on sequence. + +## Agent Intent clients + +Change new clients to `"protocolVersion": "1.0"`. Existing 0.1 clients remain +compatible throughout 1.x. + +## Rollback + +Application-only rollback is safe only before a migration job begins. + +After migration starts, do not deploy an older container against the upgraded +database. Stop all workloads and restore the signed pre-upgrade backup: + +```powershell +.\scripts\restore.ps1 ` + -BackupDirectory ` + -ConfirmRestore +``` + +The restore script verifies the external manifest signature, archive +checksums, normalized filenames, PostgreSQL restore status, and the exact +migration versions and checksums supported by the restoring runtime. diff --git a/examples/assert-weight.json b/examples/assert-weight.json index a76a810..cd6aeba 100644 --- a/examples/assert-weight.json +++ b/examples/assert-weight.json @@ -1,5 +1,5 @@ { - "protocolVersion": "0.1", + "protocolVersion": "1.0", "requestId": "example-assert-weight", "idempotencyKey": "example-assert-weight", "principal": { diff --git a/examples/integrations/local-library.ts b/examples/integrations/local-library.ts index 3b3e038..69a816f 100644 --- a/examples/integrations/local-library.ts +++ b/examples/integrations/local-library.ts @@ -29,7 +29,7 @@ function execute( operation: AgentOperation, ): IntentExecutionResult { return executeIntent(kernel, { - protocolVersion: "0.1", + protocolVersion: "1.0", requestId: `library-${idempotencyKey}`, idempotencyKey, principal, diff --git a/examples/integrations/mcp-client.ts b/examples/integrations/mcp-client.ts index d41457f..ff544d3 100644 --- a/examples/integrations/mcp-client.ts +++ b/examples/integrations/mcp-client.ts @@ -78,7 +78,7 @@ async function executeIntent( name: "execute_intent", arguments: { envelope: { - protocolVersion: "0.1", + protocolVersion: "1.0", requestId: `mcp-${idempotencyKey}`, idempotencyKey, principal: { diff --git a/examples/integrations/production-http.ts b/examples/integrations/production-http.ts index 8db0f1b..8bb7c0c 100644 --- a/examples/integrations/production-http.ts +++ b/examples/integrations/production-http.ts @@ -10,7 +10,7 @@ const config = { const catalog = await request("GET", "/v1/catalog"); const execution = await request("POST", "/v1/execute", { - protocolVersion: "0.1", + protocolVersion: "1.0", requestId: randomUUID(), idempotencyKey: `http-example-${Date.now()}`, principal: { diff --git a/examples/integrations/production-retail.ts b/examples/integrations/production-retail.ts index c8a9f45..4f14e6c 100644 --- a/examples/integrations/production-retail.ts +++ b/examples/integrations/production-retail.ts @@ -83,7 +83,7 @@ async function execute( "x-agent-purpose": config.purpose, }, body: JSON.stringify({ - protocolVersion: "0.1", + protocolVersion: "1.0", requestId: randomUUID(), ...(idempotencyKey ? { idempotencyKey } : {}), principal: { diff --git a/examples/production-payment.json b/examples/production-payment.json index 1d5609e..0ac5777 100644 --- a/examples/production-payment.json +++ b/examples/production-payment.json @@ -1,5 +1,5 @@ { - "protocolVersion": "0.1", + "protocolVersion": "1.0", "requestId": "payment-order-1001", "idempotencyKey": "payment-order-1001", "principal": { diff --git a/examples/put-product.json b/examples/put-product.json index 27034ae..84a3e65 100644 --- a/examples/put-product.json +++ b/examples/put-product.json @@ -1,5 +1,5 @@ { - "protocolVersion": "0.1", + "protocolVersion": "1.0", "requestId": "example-put-product", "idempotencyKey": "example-put-product", "principal": { diff --git a/examples/resolve-weight.json b/examples/resolve-weight.json index c924ebb..4f1e129 100644 --- a/examples/resolve-weight.json +++ b/examples/resolve-weight.json @@ -1,5 +1,5 @@ { - "protocolVersion": "0.1", + "protocolVersion": "1.0", "requestId": "example-resolve-weight", "principal": { "tenantId": "example-retail", diff --git a/package.json b/package.json index 7cda51c..dd96ea8 100644 --- a/package.json +++ b/package.json @@ -61,6 +61,7 @@ "migrations", "docs", "scripts/backup.ps1", + "scripts/backup-common.ps1", "scripts/generate-secrets.ps1", "scripts/restore.ps1", "scripts/validate-deployments.ps1", @@ -69,7 +70,6 @@ "deploy", "docker", "docker-compose.yml", - "Dockerfile", "SECURITY.md", "SUPPORT.md", "CODE_OF_CONDUCT.md", @@ -82,10 +82,11 @@ "clean": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\"", "build": "npm run clean && tsc -p tsconfig.json && tsc -p tsconfig.examples.build.json", "prepack": "npm run build", - "check": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.benchmarks.json --noEmit", + "check": "node scripts/validate-version.mjs && tsc -p tsconfig.json --noEmit && tsc -p tsconfig.benchmarks.json --noEmit", "test": "npm run build && node --no-warnings --test \"dist/test/*.test.js\"", "test:package": "node scripts/test-package.mjs", - "release:check": "npm run check && npm test && npm run test:package", + "test:backup-manifest": "pwsh -NoProfile -File scripts/test-backup-manifest.ps1", + "release:check": "npm run check && npm test && npm run test:package && npm run test:backup-manifest", "deployment:check": "pwsh -NoProfile -File scripts/validate-deployments.ps1", "example": "node --no-warnings dist/cli.js example --db .data/example.db", "example:all": "npm run example && npm run example:library && npm run example:mcp", diff --git a/scripts/backup-common.ps1 b/scripts/backup-common.ps1 new file mode 100644 index 0000000..684b763 --- /dev/null +++ b/scripts/backup-common.ps1 @@ -0,0 +1,158 @@ +function Get-BackupManifestKey { + param([string]$EncodedKey) + + if (-not $EncodedKey) { + throw "BACKUP_MANIFEST_KEY is required." + } + try { + $key = [Convert]::FromBase64String($EncodedKey) + } catch { + throw "BACKUP_MANIFEST_KEY must be valid base64." + } + if ($key.Length -ne 32) { + throw "BACKUP_MANIFEST_KEY must decode to exactly 32 bytes." + } + return $key +} + +function Write-SignedBackupManifest { + param( + [object]$Manifest, + [string]$Directory, + [byte[]]$Key + ) + + $manifestPath = Join-Path $Directory "manifest.json" + $signaturePath = Join-Path $Directory "manifest.hmac" + $json = $Manifest | ConvertTo-Json -Depth 8 -Compress + $encoding = [System.Text.UTF8Encoding]::new($false) + [System.IO.File]::WriteAllText($manifestPath, $json, $encoding) + $hmac = [System.Security.Cryptography.HMACSHA256]::new($Key) + try { + $signature = $hmac.ComputeHash( + [System.IO.File]::ReadAllBytes($manifestPath) + ) + } finally { + $hmac.Dispose() + } + [System.IO.File]::WriteAllText( + $signaturePath, + [Convert]::ToHexString($signature).ToLowerInvariant(), + $encoding + ) +} + +function Read-VerifiedBackupManifest { + param( + [string]$Directory, + [byte[]]$Key + ) + + $manifestPath = Join-Path $Directory "manifest.json" + $signaturePath = Join-Path $Directory "manifest.hmac" + if ( + -not (Test-Path -LiteralPath $manifestPath) -or + -not (Test-Path -LiteralPath $signaturePath) + ) { + throw "Signed manifest files are required." + } + $signatureText = ( + Get-Content -LiteralPath $signaturePath -Raw + ).Trim() + if ($signatureText -notmatch "^[a-f0-9]{64}$") { + throw "Backup manifest signature is invalid." + } + $hmac = [System.Security.Cryptography.HMACSHA256]::new($Key) + try { + $actual = $hmac.ComputeHash( + [System.IO.File]::ReadAllBytes($manifestPath) + ) + } finally { + $hmac.Dispose() + } + $expected = [Convert]::FromHexString($signatureText) + if ( + -not [System.Security.Cryptography.CryptographicOperations]::FixedTimeEquals( + $actual, + $expected + ) + ) { + throw "Backup manifest signature does not match." + } + return Get-Content -LiteralPath $manifestPath -Raw | ConvertFrom-Json +} + +function Assert-BackupLeafName { + param([string]$Name) + + if ( + -not $Name -or + $Name -match '[/\\:]' -or + [System.IO.Path]::GetFileName($Name) -ne $Name -or + $Name -in @(".", "..") + ) { + throw "Backup manifest contains an invalid file name." + } +} + +function Get-LocalMigrationManifest { + param([string]$Directory) + + return @( + Get-ChildItem -LiteralPath $Directory -Filter "*.sql" | + Where-Object { $_.Name -match '^\d+_.+\.sql$' } | + Sort-Object Name | + ForEach-Object { + [ordered]@{ + version = ($_.BaseName -split "_", 2)[0] + checksum = ( + Get-FileHash -LiteralPath $_.FullName -Algorithm SHA256 + ).Hash.ToLowerInvariant() + } + } + ) +} + +function Get-DatabaseMigrationManifest { + param( + [string]$Container, + [string]$DatabaseUser, + [string]$DatabaseName + ) + + $output = docker exec $Container psql ` + --username $DatabaseUser ` + --dbname $DatabaseName ` + --tuples-only ` + --no-align ` + --set=ON_ERROR_STOP=1 ` + --command "SELECT COALESCE(json_agg(json_build_object('version', version, 'checksum', checksum) ORDER BY version), '[]'::json)::text FROM agentic.schema_migrations" + if ($LASTEXITCODE -ne 0) { + throw "Could not read the database migration manifest." + } + return @((($output -join "").Trim() | ConvertFrom-Json)) +} + +function Assert-ExactMigrationManifest { + param( + [object[]]$Actual, + [object[]]$Expected + ) + + $actualLines = @( + $Actual | + ForEach-Object { "$($_.version)|$($_.checksum)" } | + Sort-Object + ) + $expectedLines = @( + $Expected | + ForEach-Object { "$($_.version)|$($_.checksum)" } | + Sort-Object + ) + if ( + $actualLines.Count -ne $expectedLines.Count -or + (Compare-Object $actualLines $expectedLines) + ) { + throw "Backup migration manifest does not match this runtime." + } +} diff --git a/scripts/backup.ps1 b/scripts/backup.ps1 index 5cd0b7a..1ab25ab 100644 --- a/scripts/backup.ps1 +++ b/scripts/backup.ps1 @@ -2,10 +2,13 @@ param( [string]$Destination = ".backups", [string]$ArtifactDirectory = ".data\production-artifacts", [string]$DatabaseUser = "postgres", - [string]$DatabaseName = "agentic_data" + [string]$DatabaseName = "agentic_data", + [string]$ManifestKey = $env:BACKUP_MANIFEST_KEY ) $ErrorActionPreference = "Stop" +. (Join-Path $PSScriptRoot "backup-common.ps1") +$manifestKeyBytes = Get-BackupManifestKey $ManifestKey $writers = docker compose ps -q app worker if ($writers) { throw "Stop the app and worker services before creating a coordinated backup." @@ -48,6 +51,15 @@ try { if ($LASTEXITCODE -ne 0) { throw "docker cp failed." } + $migrations = Get-DatabaseMigrationManifest ` + -Container $container ` + -DatabaseUser $DatabaseUser ` + -DatabaseName $DatabaseName + Assert-ExactMigrationManifest ` + -Actual $migrations ` + -Expected (Get-LocalMigrationManifest ( + Join-Path $PSScriptRoot "..\migrations\postgres" + )) $artifactArchive = $null if ( @@ -62,11 +74,13 @@ try { } $manifest = [ordered]@{ + schemaVersion = 1 createdAt = (Get-Date).ToUniversalTime().ToString("o") database = @{ file = $databaseFile sha256 = (Get-FileHash -LiteralPath $localDatabaseFile -Algorithm SHA256).Hash.ToLowerInvariant() } + migrations = $migrations artifacts = if ($artifactArchive) { @{ file = Split-Path $artifactArchive -Leaf @@ -77,8 +91,10 @@ try { } } - $manifest | ConvertTo-Json -Depth 5 | - Set-Content -LiteralPath (Join-Path $backupDirectory "manifest.json") -Encoding utf8 + Write-SignedBackupManifest ` + -Manifest $manifest ` + -Directory $backupDirectory ` + -Key $manifestKeyBytes Write-Output $backupDirectory } finally { diff --git a/scripts/generate-secrets.ps1 b/scripts/generate-secrets.ps1 index 86ad583..01b8df5 100644 --- a/scripts/generate-secrets.ps1 +++ b/scripts/generate-secrets.ps1 @@ -1,8 +1,10 @@ $pepper = [Convert]::ToBase64String([System.Security.Cryptography.RandomNumberGenerator]::GetBytes(48)) $artifactKey = [Convert]::ToBase64String([System.Security.Cryptography.RandomNumberGenerator]::GetBytes(32)) +$backupManifestKey = [Convert]::ToBase64String([System.Security.Cryptography.RandomNumberGenerator]::GetBytes(32)) [PSCustomObject]@{ AUTH_PEPPER = $pepper ARTIFACT_CURRENT_KEY_ID = "v1" ARTIFACT_KEYRING = "{`"v1`":`"$artifactKey`"}" + BACKUP_MANIFEST_KEY = $backupManifestKey } | Format-List diff --git a/scripts/restore.ps1 b/scripts/restore.ps1 index ebf4c31..8bf1cdc 100644 --- a/scripts/restore.ps1 +++ b/scripts/restore.ps1 @@ -4,10 +4,12 @@ param( [string]$ArtifactDirectory = ".data\production-artifacts", [string]$DatabaseUser = "postgres", [string]$DatabaseName = "agentic_data", + [string]$ManifestKey = $env:BACKUP_MANIFEST_KEY, [switch]$ConfirmRestore ) $ErrorActionPreference = "Stop" +. (Join-Path $PSScriptRoot "backup-common.ps1") if (-not $ConfirmRestore) { throw "Pass -ConfirmRestore to acknowledge that database objects will be replaced." } @@ -17,8 +19,22 @@ if ($writers) { throw "Stop the app and worker services before restoring a coordinated backup." } -$manifestPath = Join-Path $BackupDirectory "manifest.json" -$manifest = Get-Content -LiteralPath $manifestPath -Raw | ConvertFrom-Json +$manifestKeyBytes = Get-BackupManifestKey $ManifestKey +$manifest = Read-VerifiedBackupManifest ` + -Directory $BackupDirectory ` + -Key $manifestKeyBytes +if ($manifest.schemaVersion -ne 1) { + throw "Unsupported backup manifest version." +} +if (-not $manifest.migrations) { + throw "Backup manifest does not contain migration metadata." +} +Assert-ExactMigrationManifest ` + -Actual @($manifest.migrations) ` + -Expected (Get-LocalMigrationManifest ( + Join-Path $PSScriptRoot "..\migrations\postgres" + )) +Assert-BackupLeafName $manifest.database.file $databasePath = Join-Path $BackupDirectory $manifest.database.file $databaseHash = (Get-FileHash -LiteralPath $databasePath -Algorithm SHA256).Hash.ToLowerInvariant() if ($databaseHash -ne $manifest.database.sha256) { @@ -38,6 +54,7 @@ foreach ($path in @($artifactStage, $artifactRollback)) { New-Item -ItemType Directory -Path $artifactStage -Force | Out-Null if ($manifest.artifacts) { + Assert-BackupLeafName $manifest.artifacts.file $artifactArchive = Join-Path $BackupDirectory $manifest.artifacts.file $artifactHash = (Get-FileHash -LiteralPath $artifactArchive -Algorithm SHA256).Hash.ToLowerInvariant() if ($artifactHash -ne $manifest.artifacts.sha256) { @@ -69,6 +86,7 @@ if ($acquired -notcontains "acquired") { $containerFile = "/tmp/$($manifest.database.file)" $artifactSwapped = $false +$databaseRestored = $false try { New-Item -ItemType Directory -Path $artifactParent -Force | Out-Null if (Test-Path -LiteralPath $artifactTarget) { @@ -94,12 +112,27 @@ try { if ($LASTEXITCODE -ne 0) { throw "pg_restore failed." } + $databaseRestored = $true + Assert-ExactMigrationManifest ` + -Actual (Get-DatabaseMigrationManifest ` + -Container $container ` + -DatabaseUser $DatabaseUser ` + -DatabaseName $DatabaseName) ` + -Expected @($manifest.migrations) } catch { - if ($artifactSwapped -and (Test-Path -LiteralPath $artifactTarget)) { - Remove-Item -LiteralPath $artifactTarget -Recurse -Force - } - if (Test-Path -LiteralPath $artifactRollback) { - Move-Item -LiteralPath $artifactRollback -Destination $artifactTarget + if (-not $databaseRestored) { + if ($artifactSwapped -and (Test-Path -LiteralPath $artifactTarget)) { + Remove-Item -LiteralPath $artifactTarget -Recurse -Force + } + if (Test-Path -LiteralPath $artifactRollback) { + Move-Item -LiteralPath $artifactRollback -Destination $artifactTarget + } + } elseif (Test-Path -LiteralPath $artifactRollback) { + Write-Warning ( + "Database restore committed before verification failed. " + + "Restored artifacts remain active; prior artifacts are retained at " + + $artifactRollback + ) } throw } finally { diff --git a/scripts/test-backup-manifest.ps1 b/scripts/test-backup-manifest.ps1 new file mode 100644 index 0000000..994aac4 --- /dev/null +++ b/scripts/test-backup-manifest.ps1 @@ -0,0 +1,63 @@ +$ErrorActionPreference = "Stop" +. (Join-Path $PSScriptRoot "backup-common.ps1") + +$directory = Join-Path ( + [System.IO.Path]::GetTempPath() +) "agentic-backup-manifest-$PID" +$key = [System.Security.Cryptography.RandomNumberGenerator]::GetBytes(32) +try { + New-Item -ItemType Directory -Path $directory -Force | Out-Null + $manifest = [ordered]@{ + schemaVersion = 1 + database = @{ + file = "database.dump" + sha256 = "a" * 64 + } + artifacts = $null + } + Write-SignedBackupManifest ` + -Manifest $manifest ` + -Directory $directory ` + -Key $key + $verified = Read-VerifiedBackupManifest ` + -Directory $directory ` + -Key $key + if ($verified.database.file -ne "database.dump") { + throw "Verified manifest content changed." + } + Assert-BackupLeafName $verified.database.file + try { + Assert-BackupLeafName "..\outside.dump" + throw "Unsafe manifest path was accepted." + } catch { + if ($_.Exception.Message -eq "Unsafe manifest path was accepted.") { + throw + } + } + try { + Assert-BackupLeafName "../outside.dump" + throw "Unsafe manifest path was accepted." + } catch { + if ($_.Exception.Message -eq "Unsafe manifest path was accepted.") { + throw + } + } + Add-Content ` + -LiteralPath (Join-Path $directory "manifest.json") ` + -Value " " + try { + Read-VerifiedBackupManifest ` + -Directory $directory ` + -Key $key | Out-Null + throw "Tampered manifest was accepted." + } catch { + if ($_.Exception.Message -eq "Tampered manifest was accepted.") { + throw + } + } + Write-Output "Backup manifest signing validated." +} finally { + if (Test-Path -LiteralPath $directory) { + Remove-Item -LiteralPath $directory -Recurse -Force + } +} diff --git a/scripts/test-backup-restore.ps1 b/scripts/test-backup-restore.ps1 new file mode 100644 index 0000000..2c5b1da --- /dev/null +++ b/scripts/test-backup-restore.ps1 @@ -0,0 +1,48 @@ +$ErrorActionPreference = "Stop" + +$destination = Join-Path $PSScriptRoot "..\.data\backup-drill" +$artifacts = Join-Path $PSScriptRoot "..\.data\backup-drill-artifacts" +$key = [Convert]::ToBase64String( + [System.Security.Cryptography.RandomNumberGenerator]::GetBytes(32) +) +try { + $backupDirectory = & (Join-Path $PSScriptRoot "backup.ps1") ` + -Destination $destination ` + -ArtifactDirectory $artifacts ` + -ManifestKey $key + if (-not $backupDirectory) { + throw "Backup drill did not return a backup directory." + } + $manifestPath = Join-Path $backupDirectory "manifest.json" + $originalManifest = [System.IO.File]::ReadAllBytes($manifestPath) + Add-Content -LiteralPath $manifestPath -Value " " + try { + & (Join-Path $PSScriptRoot "restore.ps1") ` + -BackupDirectory $backupDirectory ` + -ArtifactDirectory $artifacts ` + -ManifestKey $key ` + -ConfirmRestore + throw "Restore accepted a tampered manifest." + } catch { + if ($_.Exception.Message -eq "Restore accepted a tampered manifest.") { + throw + } + } + [System.IO.File]::WriteAllBytes( + $manifestPath, + $originalManifest + ) + & (Join-Path $PSScriptRoot "restore.ps1") ` + -BackupDirectory $backupDirectory ` + -ArtifactDirectory $artifacts ` + -ManifestKey $key ` + -ConfirmRestore + Write-Output "Backup and restore drill completed." +} finally { + foreach ($path in @($destination, $artifacts)) { + if (Test-Path -LiteralPath $path) { + Remove-Item -LiteralPath $path -Recurse -Force + } + } +} + diff --git a/scripts/test-package.mjs b/scripts/test-package.mjs index 755caa9..3b67220 100644 --- a/scripts/test-package.mjs +++ b/scripts/test-package.mjs @@ -51,6 +51,7 @@ try { "deploy/gcp/main.tf", "deploy/kubernetes/helm/agentic-data-kernel/Chart.yaml", "scripts/validate-deployments.ps1", + "scripts/backup-common.ps1", "README.md", "LICENSE", ]) { @@ -60,6 +61,7 @@ try { } for (const forbiddenPath of [ ".env", + "Dockerfile", "scripts/test-package.mjs", "src/index.ts", ]) { @@ -112,6 +114,7 @@ try { AgenticKernel, KNOWLEDGE_OPERATION_NAMES, KnowledgeLayer, + PACKAGE_VERSION, SqliteStore, formatTraceExplanation, } from "agentic-data-kernel"; @@ -144,6 +147,9 @@ try { ) { throw new Error("Layered API exports are unavailable"); } + if (PACKAGE_VERSION !== ${JSON.stringify(installedManifest.version)}) { + throw new Error("Published runtime version does not match package metadata"); + } if ( !existsSync(join(postgresMigrationDirectory, "001_core.sql")) || !existsSync(join(postgresMigrationDirectory, "002_embedding_space.sql")) || @@ -166,8 +172,10 @@ try { typeSmokeModule, `import { AgenticKernel, + type AgentIntentVersion, type KnowledgeOperationName, KnowledgeLayer, + PACKAGE_VERSION, SqliteStore, formatTraceExplanation, } from "agentic-data-kernel"; @@ -182,6 +190,8 @@ const store = new SqliteStore(":memory:"); const kernel: AgenticKernel = new AgenticKernel(store); const knowledgeLayer: KnowledgeLayer = kernel.knowledge; const knowledgeOperation: KnowledgeOperationName = "assert"; +const protocolVersion: AgentIntentVersion = "1.0"; +const packageVersion: string = PACKAGE_VERSION; const formatter: typeof formatTraceExplanation = formatTraceExplanation; const databaseType: typeof ProductionDatabase = ProductionDatabase; const bootstrapType: typeof bootstrapRuntimeRole = bootstrapRuntimeRole; @@ -194,6 +204,8 @@ const embeddingSpace: EmbeddingSpace = { void kernel; void knowledgeLayer; void knowledgeOperation; +void protocolVersion; +void packageVersion; void formatter; void databaseType; void bootstrapType; diff --git a/scripts/validate-deployments.ps1 b/scripts/validate-deployments.ps1 index 9b9ae8e..2086ecd 100644 --- a/scripts/validate-deployments.ps1 +++ b/scripts/validate-deployments.ps1 @@ -43,6 +43,7 @@ $templateArguments = @( "--set", "ingress.tls[0].hosts[0]=agentic-data.example.com", "--set-string", "ingress.tlsOnlyAnnotation=nginx.ingress.kubernetes.io/ssl-redirect", "--set-string", "ingress.tlsOnlyValue=true", + "--set-string", "config.trustedProxyHops=1", "--set", "databaseProxy.enabled=true", "--set", "databaseProxy.image=database-proxy:validation", "--set-string", "databaseProxy.args[0]=--private-ip", diff --git a/scripts/validate-version.mjs b/scripts/validate-version.mjs new file mode 100644 index 0000000..32fe4d2 --- /dev/null +++ b/scripts/validate-version.mjs @@ -0,0 +1,14 @@ +import { readFileSync } from "node:fs"; + +const packageManifest = JSON.parse( + readFileSync("package.json", "utf8"), +); +const source = readFileSync("src/version.ts", "utf8"); +const match = /PACKAGE_VERSION = "([^"]+)"/.exec(source); +if (match?.[1] !== packageManifest.version) { + throw new Error( + `src/version.ts ${match?.[1] ?? "missing"} does not match package.json ${packageManifest.version}`, + ); +} +console.log(`Validated source version ${packageManifest.version}`); + diff --git a/src/example.ts b/src/example.ts index c72d1b9..032d42d 100644 --- a/src/example.ts +++ b/src/example.ts @@ -6,6 +6,7 @@ import { } from "./ir.js"; import type { JsonValue, PrincipalContext } from "./types.js"; import { toJsonValue } from "./util.js"; +import { AGENT_INTENT_VERSION } from "./version.js"; const principal: PrincipalContext = { tenantId: "example-retail", @@ -19,7 +20,7 @@ export function runExample(kernel: AgenticKernel): JsonValue { operation: AgentOperation, ): IntentExecutionResult => executeIntent(kernel, { - protocolVersion: "0.1", + protocolVersion: AGENT_INTENT_VERSION, requestId: `example-${key}`, idempotencyKey: key, principal, diff --git a/src/index.ts b/src/index.ts index d3d0e6b..50049d8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -44,3 +44,10 @@ export { traceEndpointKey, } from "./explain.js"; export type * from "./types.js"; +export { + AGENT_INTENT_VERSION, + LEGACY_AGENT_INTENT_VERSION, + PACKAGE_VERSION, + SUPPORTED_AGENT_INTENT_VERSIONS, +} from "./version.js"; +export type { AgentIntentVersion } from "./version.js"; diff --git a/src/ir.ts b/src/ir.ts index 263ea54..cc4d0da 100644 --- a/src/ir.ts +++ b/src/ir.ts @@ -7,6 +7,10 @@ import { } from "./kernel.js"; import type { ExecutionReceipt, JsonValue } from "./types.js"; import { sha256, stableStringify } from "./util.js"; +import { + SUPPORTED_AGENT_INTENT_VERSIONS, + type AgentIntentVersion, +} from "./version.js"; const nonEmptyString = z.string().trim().min(1); const isoTimestamp = z.iso.datetime({ offset: true }); @@ -320,6 +324,8 @@ export const agentOperationSchema = z.discriminatedUnion("op", [ .object({ op: z.literal("list_effects"), instanceId: nonEmptyString.optional(), + afterEffectId: nonEmptyString.optional(), + limit: z.number().int().min(1).max(100).optional(), }) .strict(), z @@ -332,7 +338,7 @@ export const agentOperationSchema = z.discriminatedUnion("op", [ export const intentEnvelopeSchema = z .object({ - protocolVersion: z.literal("0.1"), + protocolVersion: z.enum(SUPPORTED_AGENT_INTENT_VERSIONS), requestId: nonEmptyString, idempotencyKey: nonEmptyString.optional(), principal: principalSchema, @@ -344,7 +350,7 @@ export type AgentOperation = z.infer; export type IntentEnvelope = z.infer; export interface IntentExecutionResult { - protocolVersion: "0.1"; + protocolVersion: AgentIntentVersion; requestId: string; status: "ok"; operation: AgentOperation["op"]; @@ -373,10 +379,18 @@ export function executeIntent( envelope.idempotencyKey ?? envelope.requestId }`; const requestHash = sha256( - stableStringify({ - principal: envelope.principal, - operation: envelope.operation, - }), + stableStringify( + envelope.protocolVersion === "0.1" + ? { + principal: envelope.principal, + operation: envelope.operation, + } + : { + protocolVersion: envelope.protocolVersion, + principal: envelope.principal, + operation: envelope.operation, + }, + ), ); return kernel.transaction(() => { @@ -386,7 +400,11 @@ export function executeIntent( requestHash, ); if (replay) { - return { ...replay, idempotentReplay: true }; + return { + ...replay, + requestId: envelope.requestId, + idempotentReplay: true, + }; } const rawResult = executeOperation(kernel, envelope); @@ -400,7 +418,7 @@ export function executeIntent( evidence, ); const response: IntentExecutionResult = { - protocolVersion: "0.1", + protocolVersion: envelope.protocolVersion, requestId: envelope.requestId, status: "ok", operation: envelope.operation.op, @@ -492,6 +510,10 @@ function executeOperation( return kernel.agency.listEffects( principal.tenantId, operation.instanceId, + { + afterEffectId: operation.afterEffectId, + limit: operation.limit, + }, ); case "process_timers": return kernel.retail.processTimers(principal, operation.asOf); diff --git a/src/kernel.ts b/src/kernel.ts index cf4767e..85bab15 100644 --- a/src/kernel.ts +++ b/src/kernel.ts @@ -20,8 +20,9 @@ import type { AssertionRecord, AssertionStatus, CreateWorkflowInput, - EffectRecord, + EffectListQuery, EffectOutcomeInput, + EffectRecord, EffectStatus, EntityInput, EntityRecord, @@ -67,6 +68,10 @@ import { toJsonValue, typedValueText, } from "./util.js"; +import { + AGENT_INTENT_VERSION, + SUPPORTED_AGENT_INTENT_VERSIONS, +} from "./version.js"; interface AssertionDbRow extends SqlRow { tenant_id: string; @@ -1732,21 +1737,56 @@ export class AgenticKernel { return mapWorkflow(row); } - public listEffects(tenantId: string, instanceId?: string): EffectRecord[] { - const rows = instanceId - ? this.store.all( - `SELECT * FROM effect_intents - WHERE tenant_id = ? AND instance_id = ? - ORDER BY created_at`, - tenantId, - instanceId, - ) - : this.store.all( - `SELECT * FROM effect_intents - WHERE tenant_id = ? - ORDER BY created_at`, - tenantId, + public listEffects( + tenantId: string, + instanceId?: string, + query: EffectListQuery = {}, + ): EffectRecord[] { + const conditions = ["tenant_id = ?"]; + const values: SqlValue[] = [tenantId]; + if (instanceId) { + conditions.push("instance_id = ?"); + values.push(instanceId); + } + if (query.afterEffectId) { + const cursorConditions = ["tenant_id = ?", "effect_id = ?"]; + const cursorValues: SqlValue[] = [tenantId, query.afterEffectId]; + if (instanceId) { + cursorConditions.push("instance_id = ?"); + cursorValues.push(instanceId); + } + const cursor = this.store.get( + `SELECT * FROM effect_intents + WHERE ${cursorConditions.join(" AND ")}`, + ...cursorValues, + ); + if (!cursor) { + throw new KernelError( + "not_found", + `Effect cursor ${query.afterEffectId} was not found`, ); + } + conditions.push( + "(created_at > ? OR (created_at = ? AND effect_id > ?))", + ); + values.push( + cursor.created_at, + cursor.created_at, + cursor.effect_id, + ); + } + const limit = + query.limit ?? (query.afterEffectId ? 100 : undefined); + if (limit !== undefined) { + values.push(clamp(limit, 1, 100)); + } + const rows = this.store.all( + `SELECT * FROM effect_intents + WHERE ${conditions.join(" AND ")} + ORDER BY created_at, effect_id + ${limit === undefined ? "" : "LIMIT ?"}`, + ...values, + ); return rows.map(mapEffect); } @@ -1875,7 +1915,8 @@ export class AgenticKernel { public catalog(): LayeredCatalogDescription { return { - protocolVersion: "0.1", + protocolVersion: AGENT_INTENT_VERSION, + supportedProtocolVersions: [...SUPPORTED_AGENT_INTENT_VERSIONS], storage: "Node.js embedded SQLite with replaceable storage boundary", operations: [...DEVELOPMENT_OPERATION_NAMES], operationLayers: operationLayerCatalog( @@ -1919,7 +1960,7 @@ export class AgenticKernel { limitations: [ "single-process local storage", "feature-hash embeddings are plumbing, not semantic model quality", - "one operation per Agent IR v0.1 envelope", + "one operation per Agent Intent 1.0 envelope", "local principal identity is caller-asserted", ], }; diff --git a/src/layers.ts b/src/layers.ts index 9cd3c84..91a548e 100644 --- a/src/layers.ts +++ b/src/layers.ts @@ -9,6 +9,7 @@ import type { AssertionRecord, CreateWorkflowInput, EffectOutcomeInput, + EffectListQuery, EffectRecord, EntityInput, EntityRecord, @@ -325,8 +326,9 @@ export class AgencyLayer { public listEffects( tenantId: string, instanceId?: string, + query: EffectListQuery = {}, ): EffectRecord[] { - return this.kernel.listEffects(tenantId, instanceId); + return this.kernel.listEffects(tenantId, instanceId, query); } } diff --git a/src/mcp.ts b/src/mcp.ts index 18a0c76..20fcda7 100644 --- a/src/mcp.ts +++ b/src/mcp.ts @@ -8,6 +8,10 @@ import { lineageEndpointSchema, type AgentOperation, } from "./ir.js"; +import { + AGENT_INTENT_VERSION, + PACKAGE_VERSION, +} from "./version.js"; const principalFields = { tenantId: z.string().trim().min(1), @@ -18,7 +22,7 @@ const principalFields = { export function createMcpServer(kernel: AgenticKernel): McpServer { const server = new McpServer({ name: "agentic-data-kernel", - version: "0.1.0", + version: PACKAGE_VERSION, }); server.registerResource( @@ -45,7 +49,7 @@ export function createMcpServer(kernel: AgenticKernel): McpServer { { title: "Execute Agent Intent", description: - "Validate and execute one Agent Intent IR v0.1 operation with an execution receipt.", + "Validate and execute one Agent Intent operation with an execution receipt.", inputSchema: { envelope: z.unknown(), }, @@ -219,7 +223,7 @@ function envelope( idempotencyKey?: string, ): object { return { - protocolVersion: "0.1", + protocolVersion: AGENT_INTENT_VERSION, requestId: randomUUID(), ...(idempotencyKey ? { idempotencyKey } : {}), principal: { diff --git a/src/production/artifact-reconciliation.ts b/src/production/artifact-reconciliation.ts index aa3702d..878c254 100644 --- a/src/production/artifact-reconciliation.ts +++ b/src/production/artifact-reconciliation.ts @@ -5,7 +5,7 @@ export async function reconcileArtifactFiles( database: ProductionDatabase, artifactStore: EncryptedArtifactStore, minimumAgeMs = 60 * 60 * 1_000, -): Promise<{ scanned: number; removed: string[] }> { +): Promise<{ scanned: number; verified: number; removed: string[] }> { const capability = await database.query<{ can_bypass_rls: boolean }>( `SELECT (rolsuper OR rolbypassrls) AS can_bypass_rls FROM pg_roles @@ -16,10 +16,42 @@ export async function reconcileArtifactFiles( "Artifact reconciliation requires an administrative BYPASSRLS connection", ); } - const referenced = await database.query<{ storage_key: string }>( - "SELECT storage_key FROM agentic.artifacts", + const referenced = await database.query<{ + tenant_id: string; + artifact_id: string; + media_type: string; + content_hash: string; + storage_key: string; + encryption_key_id: string; + }>( + `SELECT + tenant_id, + artifact_id, + media_type, + content_hash, + storage_key, + encryption_key_id + FROM agentic.artifacts + WHERE status = 'active'`, ); const referencedKeys = new Set(referenced.rows.map((row) => row.storage_key)); + for (const artifact of referenced.rows) { + try { + await artifactStore.get({ + tenantId: artifact.tenant_id, + artifactId: artifact.artifact_id, + mediaType: artifact.media_type, + contentHash: artifact.content_hash, + storageKey: artifact.storage_key, + encryptionKeyId: artifact.encryption_key_id, + }); + } catch (error) { + throw new Error( + `Artifact ${artifact.artifact_id} failed integrity verification`, + { cause: error }, + ); + } + } const files = await artifactStore.listStoredFiles(); const cutoff = Date.now() - minimumAgeMs; const removed: string[] = []; @@ -32,5 +64,9 @@ export async function reconcileArtifactFiles( removed.push(file.storageKey); } } - return { scanned: files.length, removed }; + return { + scanned: files.length, + verified: referenced.rows.length, + removed, + }; } diff --git a/src/production/artifacts.ts b/src/production/artifacts.ts index 0df2f5d..1be54c4 100644 --- a/src/production/artifacts.ts +++ b/src/production/artifacts.ts @@ -14,6 +14,7 @@ import { rm, stat, } from "node:fs/promises"; +import { platform } from "node:os"; import { dirname, join, posix, relative, resolve } from "node:path"; import type { ArtifactKeyringConfig } from "./config.js"; @@ -93,6 +94,7 @@ export class EncryptedArtifactStore { try { await link(temporary, path); await rm(temporary, { force: true }); + await syncDirectory(dirname(path)); return { ...descriptor, created: true }; } catch (error) { await rm(temporary, { force: true }); @@ -127,11 +129,14 @@ export class EncryptedArtifactStore { const encrypted = await readFile(path); const proof = createHash("sha256").update(encrypted).digest("hex"); await rm(path, { force: true }); + await syncDirectory(dirname(path)); return proof; } public async removeIfPresent(storageKey: string): Promise { - await rm(this.pathFor(storageKey), { force: true }); + const path = this.pathFor(storageKey); + await rm(path, { force: true }); + await syncDirectory(dirname(path)); } public async listStoredFiles(): Promise< @@ -172,6 +177,18 @@ export class EncryptedArtifactStore { } } +async function syncDirectory(directory: string): Promise { + if (platform() === "win32") { + return; + } + const handle = await open(directory, "r"); + try { + await handle.sync(); + } finally { + await handle.close(); + } +} + function encrypt( plaintext: Buffer, descriptor: ArtifactDescriptor, diff --git a/src/production/auth.ts b/src/production/auth.ts index cf4c363..b8870a5 100644 --- a/src/production/auth.ts +++ b/src/production/auth.ts @@ -127,23 +127,65 @@ export async function authenticateToken( [parsed.keyId], ); const row = result.rows[0]; - if (!row || !row.tenant_active || row.revoked_at !== null) { - throw new AuthenticationError("Invalid or revoked API key"); - } - if (row.expires_at && row.expires_at.getTime() <= Date.now()) { - throw new AuthenticationError("API key has expired"); - } + const activeRow = requireActiveApiKey(row); const supplied = Buffer.from( await deriveTokenHash(token, config.authPepper, parsed.keyId), "hex", ); - const expected = Buffer.from(row.token_hash, "hex"); + const expected = Buffer.from(activeRow.token_hash, "hex"); if ( supplied.length !== expected.length || !timingSafeEqual(supplied, expected) ) { throw new AuthenticationError("Invalid or revoked API key"); } + return principalFromActiveRow(activeRow, purpose); +} + +export async function revalidatePrincipal( + client: PoolClient, + principal: AuthenticatedPrincipal, +): Promise { + const result = await client.query( + `SELECT + k.key_id, + k.tenant_id, + k.principal_id, + k.token_hash, + k.scopes, + k.purposes, + k.expires_at, + k.revoked_at, + t.active AS tenant_active + FROM agentic_auth.api_keys k + JOIN agentic_auth.tenants t ON t.tenant_id = k.tenant_id + WHERE k.key_id = $1 + AND k.tenant_id = $2 + AND k.principal_id = $3`, + [principal.keyId, principal.tenantId, principal.principalId], + ); + return principalFromActiveRow( + requireActiveApiKey(result.rows[0]), + principal.purpose, + ); +} + +function requireActiveApiKey( + row: ApiKeyRow | undefined, +): ApiKeyRow { + if (!row || !row.tenant_active || row.revoked_at !== null) { + throw new AuthenticationError("Invalid or revoked API key"); + } + if (row.expires_at && row.expires_at.getTime() <= Date.now()) { + throw new AuthenticationError("API key has expired"); + } + return row; +} + +function principalFromActiveRow( + row: ApiKeyRow, + purpose: string, +): AuthenticatedPrincipal { const purposes = new Set(row.purposes); if (!purposes.has("*") && !purposes.has(purpose)) { throw new AuthorizationError(`Purpose ${purpose} is not allowed`); diff --git a/src/production/bootstrap.ts b/src/production/bootstrap.ts index e7b1233..8959062 100644 --- a/src/production/bootstrap.ts +++ b/src/production/bootstrap.ts @@ -275,6 +275,51 @@ export async function bootstrapRuntimeRole( } } +export async function assertRuntimeRoleSafe( + database: ProductionDatabase, +): Promise { + const result = await database.query<{ safe: boolean }>( + `SELECT ( + runtime_role.rolname = 'agentic_app' + AND runtime_role.rolcanlogin + AND NOT runtime_role.rolsuper + AND NOT runtime_role.rolcreatedb + AND NOT runtime_role.rolcreaterole + AND NOT runtime_role.rolinherit + AND NOT runtime_role.rolreplication + AND NOT runtime_role.rolbypassrls + AND NOT EXISTS ( + SELECT 1 + FROM pg_auth_members membership + WHERE membership.member = runtime_role.oid + ) + AND NOT EXISTS ( + SELECT 1 + FROM pg_auth_members membership + WHERE membership.roleid = runtime_role.oid + AND ( + membership.inherit_option + OR membership.set_option + OR NOT membership.admin_option + ) + ) + AND NOT EXISTS ( + SELECT 1 + FROM pg_shdepend dependency + WHERE dependency.refobjid = runtime_role.oid + AND dependency.deptype = 'o' + ) + ) AS safe + FROM pg_roles runtime_role + WHERE runtime_role.rolname = current_user`, + ); + if (result.rows[0]?.safe !== true) { + throw new Error( + "Production runtime requires the restricted agentic_app database role", + ); + } +} + function createScramVerifier(password: string): string { const salt = randomBytes(16); const saltedPassword = pbkdf2Sync( diff --git a/src/production/catalog.ts b/src/production/catalog.ts index 714a246..2a07f54 100644 --- a/src/production/catalog.ts +++ b/src/production/catalog.ts @@ -4,6 +4,10 @@ import { PRODUCTION_OPERATION_NAMES, } from "../layers.js"; import type { EmbeddingSpace } from "./embeddings.js"; +import { + AGENT_INTENT_VERSION, + SUPPORTED_AGENT_INTENT_VERSIONS, +} from "../version.js"; export function productionCatalog( embeddingSpace?: EmbeddingSpace, @@ -13,7 +17,8 @@ export function productionCatalog( embeddingSpace?: EmbeddingSpace; } { return { - protocolVersion: "0.1", + protocolVersion: AGENT_INTENT_VERSION, + supportedProtocolVersions: [...SUPPORTED_AGENT_INTENT_VERSIONS], profile: "postgres-production", storage: "PostgreSQL 18 with pgvector and forced tenant row-level security", operations: [...PRODUCTION_OPERATION_NAMES], @@ -69,7 +74,7 @@ export function productionCatalog( "single PostgreSQL primary", "one active embedding space per deployment", "indexed vector dimensions are limited to 2000", - "one operation per Agent IR v0.1 envelope", + "one operation per Agent Intent 1.0 envelope", "TLS termination is expected at a trusted reverse proxy", ], ...(embeddingSpace ? { embeddingSpace } : {}), diff --git a/src/production/cli.ts b/src/production/cli.ts index 2c37041..307900f 100644 --- a/src/production/cli.ts +++ b/src/production/cli.ts @@ -5,7 +5,10 @@ import { createApiKey, revokeApiKey, } from "./auth.js"; -import { bootstrapRuntimeRole } from "./bootstrap.js"; +import { + assertRuntimeRoleSafe, + bootstrapRuntimeRole, +} from "./bootstrap.js"; import { formatTraceExplanation, normalizeTraceDepth, @@ -34,6 +37,7 @@ import { } from "./migrations.js"; import { startProductionMcpServer } from "./mcp.js"; import { createProductionRuntime } from "./runtime.js"; +import { startWorkerMonitor } from "./worker-monitor.js"; async function main(): Promise { const [command = "help", ...args] = process.argv.slice(2); @@ -116,47 +120,69 @@ async function main(): Promise { case "serve": { const config = loadProductionConfig(); const runtime = createProductionRuntime(config); - await assertRuntimeReady(runtime.database, config); - const server = await startProductionHttpServer({ - config, - database: runtime.database, - kernel: runtime.kernel, - metrics: runtime.metrics, - logger: runtime.logger, - }); - runtime.logger.info( - { host: config.host, port: config.port }, - "Production server started", - ); - await waitForServerShutdown(server, runtime.database); + let server: import("node:http").Server | null = null; + try { + await assertRuntimeReady(runtime.database, config); + server = await startProductionHttpServer({ + config, + database: runtime.database, + kernel: runtime.kernel, + metrics: runtime.metrics, + logger: runtime.logger, + }); + runtime.logger.info( + { host: config.host, port: config.port }, + "Production server started", + ); + await waitForServerShutdown( + server, + config.shutdownTimeoutMs, + ); + } finally { + if (server?.listening) { + await closeServer(server, config.shutdownTimeoutMs); + } + await runtime.database.close(); + } return; } case "worker": { const config = loadProductionConfig(); const runtime = createProductionRuntime(config); - await assertRuntimeReady(runtime.database, config); - const worker = new EffectWorker( - runtime.database, - new SecureHttpEffectTransport( - config.effectAllowedHosts, - config.effectTimeoutMs, - ), - config, - runtime.metrics, - runtime.logger, - ); - if (args.includes("--once")) { - const worked = await worker.runOnce(); - print({ worked }); + let monitor: import("node:http").Server | null = null; + try { + await assertRuntimeReady(runtime.database, config); + const worker = new EffectWorker( + runtime.database, + new SecureHttpEffectTransport( + config.effectAllowedHosts, + config.effectTimeoutMs, + ), + config, + runtime.metrics, + runtime.logger, + ); + monitor = await startWorkerMonitor( + config, + runtime.database, + runtime.metrics, + ); + if (args.includes("--once")) { + const worked = await worker.runOnce(); + print({ worked }); + return; + } + const controller = new AbortController(); + process.once("SIGINT", () => controller.abort()); + process.once("SIGTERM", () => controller.abort()); + runtime.logger.info("Effect worker started"); + await worker.run(controller.signal); + } finally { + if (monitor?.listening) { + await closeServer(monitor, config.shutdownTimeoutMs); + } await runtime.database.close(); - return; } - const controller = new AbortController(); - process.once("SIGINT", () => controller.abort()); - process.once("SIGTERM", () => controller.abort()); - runtime.logger.info("Effect worker started"); - await worker.run(controller.signal); - await runtime.database.close(); return; } case "reconcile-artifacts": { @@ -184,25 +210,34 @@ async function main(): Promise { case "mcp": { const config = loadProductionConfig(); const runtime = createProductionRuntime(config); - await assertRuntimeReady(runtime.database, config); - const token = requiredEnvironment("AGENTIC_DATA_API_KEY", 1); - const purpose = requiredEnvironment("AGENTIC_DATA_PURPOSE", 1); - const principal = await authenticateToken( - runtime.database, - config, - token, - purpose, - ); - const server = await startProductionMcpServer( - runtime.kernel, - principal, - ); - const close = async (): Promise => { - await server.close(); + let server: Awaited< + ReturnType + > | null = null; + try { + await assertRuntimeReady(runtime.database, config); + const token = requiredEnvironment("AGENTIC_DATA_API_KEY", 1); + const purpose = requiredEnvironment("AGENTIC_DATA_PURPOSE", 1); + const principal = await authenticateToken( + runtime.database, + config, + token, + purpose, + ); + server = await startProductionMcpServer( + runtime.kernel, + principal, + ); + await waitForMcpShutdown( + server, + config.shutdownTimeoutMs, + ); + server = null; + } finally { + if (server) { + await server.close(); + } await runtime.database.close(); - }; - process.once("SIGINT", () => void close()); - process.once("SIGTERM", () => void close()); + } return; } case "explain": { @@ -269,6 +304,7 @@ async function assertRuntimeReady( database: ProductionDatabase, config: ReturnType, ): Promise { + await assertRuntimeRoleSafe(database); await assertMigrationsApplied(database); await assertEmbeddingSpaceConfigured( database, @@ -278,16 +314,90 @@ async function assertRuntimeReady( async function waitForServerShutdown( server: import("node:http").Server, - database: ProductionDatabase, + timeoutMs: number, +): Promise { + await waitForShutdownSignal(); + await closeServer(server, timeoutMs); +} + +async function waitForShutdownSignal(): Promise { + await new Promise((resolve) => { + const finish = (): void => { + process.off("SIGINT", finish); + process.off("SIGTERM", finish); + resolve(); + }; + process.once("SIGINT", finish); + process.once("SIGTERM", finish); + }); +} + +async function closeServer( + server: import("node:http").Server, + timeoutMs: number, ): Promise { await new Promise((resolve) => { + let closing = false; + let finished = false; + let timeout: NodeJS.Timeout | null = null; + const finish = (): void => { + if (finished) { + return; + } + finished = true; + if (timeout) { + clearTimeout(timeout); + } + resolve(); + }; const shutdown = (): void => { - server.close(() => resolve()); + if (closing) { + return; + } + closing = true; + server.close(finish); + server.closeIdleConnections(); + timeout = setTimeout(() => { + server.closeAllConnections(); + finish(); + }, timeoutMs); + timeout.unref(); + }; + shutdown(); + }); +} + +async function waitForMcpShutdown( + server: Awaited>, + timeoutMs: number, +): Promise { + await new Promise((resolve) => { + let closing = false; + let finished = false; + const finish = (): void => { + if (finished) { + return; + } + finished = true; + process.off("SIGINT", shutdown); + process.off("SIGTERM", shutdown); + resolve(); + }; + const shutdown = (): void => { + if (closing) { + return; + } + closing = true; + const timeout = setTimeout(finish, timeoutMs); + timeout.unref(); + void server.close().finally(() => { + clearTimeout(timeout); + finish(); + }); }; process.once("SIGINT", shutdown); process.once("SIGTERM", shutdown); }); - await database.close(); } function option(args: string[], name: string): string | undefined { diff --git a/src/production/config.ts b/src/production/config.ts index 7c52c63..3ba4f46 100644 --- a/src/production/config.ts +++ b/src/production/config.ts @@ -17,7 +17,7 @@ const optionalEnvironmentValue = z.preprocess( const baseSchema = z.object({ DATABASE_URL: z.string().url(), - DATABASE_SSL: z.enum(["disable", "require"]).default("disable"), + DATABASE_SSL: z.enum(["disable", "require"]), DATABASE_CA_CERT_BASE64: optionalEnvironmentValue, DATABASE_POOL_SIZE: z.coerce.number().int().min(1).max(100).default(20), DATABASE_STATEMENT_TIMEOUT_MS: z.coerce @@ -28,7 +28,7 @@ const baseSchema = z.object({ .default(30_000), }); -const serverSchema = baseSchema.extend({ +const serverObjectSchema = baseSchema.extend({ AUTH_PEPPER: z.string().min(32), ARTIFACT_KEYRING: z.string().min(1), ARTIFACT_CURRENT_KEY_ID: z.string().trim().min(1), @@ -101,6 +101,38 @@ const serverSchema = baseSchema.extend({ .min(1) .max(100_000) .default(600), + TRUSTED_PROXY_HOPS: z.coerce + .number() + .int() + .min(0) + .max(5) + .default(0), + SHUTDOWN_TIMEOUT_MS: z.coerce + .number() + .int() + .min(1_000) + .max(60_000) + .default(10_000), + WORKER_MONITOR_HOST: z.string().trim().min(1).default("127.0.0.1"), + WORKER_MONITOR_PORT: z.coerce + .number() + .int() + .min(1) + .max(65_535) + .default(4319), +}); +const serverSchema = serverObjectSchema.superRefine((value, context) => { + if ( + value.EFFECT_LEASE_SECONDS * 1_000 < + value.EFFECT_TIMEOUT_MS + 5_000 + ) { + context.addIssue({ + code: "custom", + path: ["EFFECT_LEASE_SECONDS"], + message: + "EFFECT_LEASE_SECONDS must exceed EFFECT_TIMEOUT_MS by at least 5 seconds", + }); + } }); export interface DatabaseConfig { @@ -145,6 +177,10 @@ export interface ProductionConfig extends DatabaseConfig { | "silent"; maxBodyBytes: number; rateLimitPerMinute: number; + trustedProxyHops: number; + shutdownTimeoutMs: number; + workerMonitorHost: string; + workerMonitorPort: number; } export function loadDatabaseConfig( @@ -178,9 +214,9 @@ export function loadEmbeddingSpaceConfig( ): EmbeddingSpace { const parsed = parse( z.object({ - EMBEDDING_MODEL: serverSchema.shape.EMBEDDING_MODEL, - EMBEDDING_VERSION: serverSchema.shape.EMBEDDING_VERSION, - EMBEDDING_DIMENSIONS: serverSchema.shape.EMBEDDING_DIMENSIONS, + EMBEDDING_MODEL: serverObjectSchema.shape.EMBEDDING_MODEL, + EMBEDDING_VERSION: serverObjectSchema.shape.EMBEDDING_VERSION, + EMBEDDING_DIMENSIONS: serverObjectSchema.shape.EMBEDDING_DIMENSIONS, }), environment, ); @@ -236,6 +272,10 @@ export function loadProductionConfig( logLevel: parsed.LOG_LEVEL, maxBodyBytes: parsed.MAX_BODY_BYTES, rateLimitPerMinute: parsed.RATE_LIMIT_PER_MINUTE, + trustedProxyHops: parsed.TRUSTED_PROXY_HOPS, + shutdownTimeoutMs: parsed.SHUTDOWN_TIMEOUT_MS, + workerMonitorHost: parsed.WORKER_MONITOR_HOST, + workerMonitorPort: parsed.WORKER_MONITOR_PORT, }; } diff --git a/src/production/database.ts b/src/production/database.ts index 12ebdd2..e98a013 100644 --- a/src/production/database.ts +++ b/src/production/database.ts @@ -30,6 +30,8 @@ export class ProductionDatabase { max: config.databasePoolSize, idleTimeoutMillis: 30_000, connectionTimeoutMillis: 10_000, + statement_timeout: config.statementTimeoutMs, + query_timeout: config.statementTimeoutMs + 1_000, application_name: "agentic-data-kernel", ssl: config.databaseSsl ? { diff --git a/src/production/effects.ts b/src/production/effects.ts index 01eaf2a..76f9848 100644 --- a/src/production/effects.ts +++ b/src/production/effects.ts @@ -66,13 +66,13 @@ export interface EffectTransport { idempotencyKey: string; targetUrl: string; request: JsonValue; - }): Promise; + }, signal?: AbortSignal): Promise; reconcile(effect: { effectId: string; authorizationFence: string; idempotencyKey: string; statusUrl: string; - }): Promise; + }, signal?: AbortSignal): Promise; } export interface EffectRunFilter { @@ -92,7 +92,7 @@ export class SecureHttpEffectTransport implements EffectTransport { idempotencyKey: string; targetUrl: string; request: JsonValue; - }): Promise { + }, signal?: AbortSignal): Promise { try { const { response, parsed } = await performPinnedRequest( effect.targetUrl, @@ -108,6 +108,7 @@ export class SecureHttpEffectTransport implements EffectTransport { }, body: JSON.stringify(effect.request), }, + signal, ); if (response.ok) { if (!hasProviderReference(parsed)) { @@ -167,7 +168,7 @@ export class SecureHttpEffectTransport implements EffectTransport { authorizationFence: string; idempotencyKey: string; statusUrl: string; - }): Promise { + }, signal?: AbortSignal): Promise { try { const { response, parsed } = await performPinnedRequest( effect.statusUrl, @@ -181,6 +182,7 @@ export class SecureHttpEffectTransport implements EffectTransport { "x-agentic-authorization-fence": effect.authorizationFence, }, }, + signal, ); if (response.ok) { const status = providerStatus(parsed); @@ -246,6 +248,8 @@ export class SecureHttpEffectTransport implements EffectTransport { } export class EffectWorker { + private tenantCursor = 0; + public constructor( private readonly database: ProductionDatabase, private readonly transport: EffectTransport, @@ -257,7 +261,14 @@ export class EffectWorker { private readonly logger: Logger, ) {} - public async runOnce(filter: EffectRunFilter = {}): Promise { + public async runOnce( + filter: EffectRunFilter = {}, + signal?: AbortSignal, + ): Promise { + this.metrics.set( + "agentic_worker_last_poll_timestamp_seconds", + Date.now() / 1_000, + ); const tenants = filter.tenantId ? { rows: [{ tenant_id: filter.tenantId }] } : await this.database.query<{ tenant_id: string }>( @@ -266,7 +277,23 @@ export class EffectWorker { WHERE active = TRUE ORDER BY tenant_id`, ); - for (const tenant of tenants.rows) { + const start = + tenants.rows.length === 0 + ? 0 + : this.tenantCursor % tenants.rows.length; + const orderedTenants = [ + ...tenants.rows.slice(start), + ...tenants.rows.slice(0, start), + ]; + for ( + let offset = 0; + offset < orderedTenants.length; + offset += 1 + ) { + const tenant = orderedTenants[offset]; + if (!tenant) { + continue; + } const effect = await this.leaseNext( tenant.tenant_id, filter.effectId, @@ -274,21 +301,29 @@ export class EffectWorker { if (!effect) { continue; } + this.tenantCursor = + (start + offset + 1) % orderedTenants.length; const started = performance.now(); const delivery = effect.reconciliation_mode - ? await this.transport.reconcile({ - effectId: effect.effect_id, - authorizationFence: effect.authorization_fence, - idempotencyKey: effect.idempotency_key, - statusUrl: effect.status_url, - }) - : await this.transport.deliver({ - effectId: effect.effect_id, - authorizationFence: effect.authorization_fence, - idempotencyKey: effect.idempotency_key, - targetUrl: effect.target_url, - request: effect.request_json, - }); + ? await this.transport.reconcile( + { + effectId: effect.effect_id, + authorizationFence: effect.authorization_fence, + idempotencyKey: effect.idempotency_key, + statusUrl: effect.status_url, + }, + signal, + ) + : await this.transport.deliver( + { + effectId: effect.effect_id, + authorizationFence: effect.authorization_fence, + idempotencyKey: effect.idempotency_key, + targetUrl: effect.target_url, + request: effect.request_json, + }, + signal, + ); await this.finalize(effect, delivery); this.metrics.increment("agentic_effect_attempts_total", { status: delivery.status, @@ -299,14 +334,23 @@ export class EffectWorker { performance.now() - started, { status: delivery.status }, ); + this.metrics.increment("agentic_worker_polls_total", { + result: "worked", + }); return true; } + if (orderedTenants.length > 0) { + this.tenantCursor = (start + 1) % orderedTenants.length; + } + this.metrics.increment("agentic_worker_polls_total", { + result: "idle", + }); return false; } public async run(signal: AbortSignal): Promise { while (!signal.aborted) { - const worked = await this.runOnce(); + const worked = await this.runOnce({}, signal); if (!worked) { await wait(500, signal); } @@ -382,6 +426,7 @@ export class EffectWorker { candidate.authorization_fence ?? randomUUID(); const reconciliationMode = candidate.status === "reconciling" || + candidate.status === "dispatching" || candidate.attempt_count >= this.config.effectMaxAttempts; const leased = await client.query( `UPDATE agentic.effect_intents @@ -760,6 +805,7 @@ async function performPinnedRequest( headers: Record; body?: string; }, + signal?: AbortSignal, ): Promise<{ response: UndiciResponse; parsed: JsonValue }> { const target = await validateOutboundUrl(urlValue, allowedHosts); const dispatcher = new Agent({ @@ -785,7 +831,9 @@ async function performPinnedRequest( headers: request.headers, body: request.body, redirect: "error", - signal: AbortSignal.timeout(timeoutMs), + signal: signal + ? AbortSignal.any([signal, AbortSignal.timeout(timeoutMs)]) + : AbortSignal.timeout(timeoutMs), dispatcher, }); const parsed = parseJsonBody( @@ -956,14 +1004,19 @@ async function insertHistory( function wait(milliseconds: number, signal: AbortSignal): Promise { return new Promise((resolve) => { - const timeout = setTimeout(resolve, milliseconds); - signal.addEventListener( - "abort", - () => { - clearTimeout(timeout); - resolve(); - }, - { once: true }, - ); + const finish = (): void => { + signal.removeEventListener("abort", abort); + resolve(); + }; + const timeout = setTimeout(finish, milliseconds); + const abort = (): void => { + clearTimeout(timeout); + finish(); + }; + if (signal.aborted) { + abort(); + return; + } + signal.addEventListener("abort", abort, { once: true }); }); } diff --git a/src/production/embeddings.ts b/src/production/embeddings.ts index 3356d1e..156d68c 100644 --- a/src/production/embeddings.ts +++ b/src/production/embeddings.ts @@ -78,6 +78,7 @@ export class OpenAiCompatibleEmbeddingProvider input: texts, dimensions: this.dimensions, }), + redirect: "error", signal: AbortSignal.timeout(this.timeoutMs), }); if (!response.ok) { diff --git a/src/production/http.ts b/src/production/http.ts index 0ac54d8..a080817 100644 --- a/src/production/http.ts +++ b/src/production/http.ts @@ -1,5 +1,6 @@ import { randomUUID } from "node:crypto"; import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; +import { isIP } from "node:net"; import type { Logger } from "pino"; import { KernelError } from "../kernel.js"; import { @@ -20,6 +21,7 @@ import { import type { ProductionKernel } from "./kernel.js"; import type { MetricsRegistry } from "./metrics.js"; import { embeddingSpaceStatus } from "./embedding-space.js"; +import { assertRuntimeRoleSafe } from "./bootstrap.js"; export interface ProductionHttpDependencies { config: ProductionConfig; @@ -29,6 +31,28 @@ export interface ProductionHttpDependencies { logger: Logger; } +export function resolveClientAddress( + remoteAddress: string | undefined, + forwardedFor: string | string[] | undefined, + trustedProxyHops: number, +): string { + const remote = remoteAddress ?? "unknown"; + if (trustedProxyHops <= 0) { + return remote; + } + const value = Array.isArray(forwardedFor) + ? forwardedFor.join(",") + : forwardedFor; + if (!value) { + return remote; + } + const addresses = value + .split(",") + .map((address) => address.trim()) + .filter((address) => isIP(address) !== 0); + return addresses[addresses.length - trustedProxyHops] ?? remote; +} + export async function startProductionHttpServer({ config, database, @@ -36,6 +60,7 @@ export async function startProductionHttpServer({ metrics, logger, }: ProductionHttpDependencies): Promise { + await assertRuntimeRoleSafe(database); const limiter = new FixedWindowRateLimiter(config.rateLimitPerMinute); const anonymousLimiter = new FixedWindowRateLimiter( Math.max(config.rateLimitPerMinute * 5, 1_000), @@ -89,7 +114,11 @@ export async function startProductionHttpServer({ return; } - const remoteAddress = request.socket.remoteAddress ?? "unknown"; + const remoteAddress = resolveClientAddress( + request.socket.remoteAddress, + request.headers["x-forwarded-for"], + config.trustedProxyHops, + ); if (!anonymousLimiter.consume(remoteAddress)) { response.setHeader("retry-after", "60"); sendJson(response, 429, { @@ -98,6 +127,7 @@ export async function startProductionHttpServer({ }); return; } + const principal = await authenticateRequest( request, database, diff --git a/src/production/index.ts b/src/production/index.ts index 4178df2..05a13d8 100644 --- a/src/production/index.ts +++ b/src/production/index.ts @@ -7,7 +7,10 @@ export { requireScope, revokeApiKey, } from "./auth.js"; -export { bootstrapRuntimeRole } from "./bootstrap.js"; +export { + assertRuntimeRoleSafe, + bootstrapRuntimeRole, +} from "./bootstrap.js"; export type { RuntimeRoleBootstrapResult } from "./bootstrap.js"; export type { AuthenticatedPrincipal, @@ -59,6 +62,7 @@ export { ProductionKernel } from "./kernel.js"; export { createProductionMcpServer } from "./mcp.js"; export { startProductionHttpServer } from "./http.js"; export { createProductionRuntime } from "./runtime.js"; +export { startWorkerMonitor } from "./worker-monitor.js"; export { SyntheticRemediationTransport, runSreScenario, @@ -97,3 +101,10 @@ export type { RetailCompatibilityOperation, RetailCompatibilityOperationName, } from "../layers.js"; +export { + AGENT_INTENT_VERSION, + LEGACY_AGENT_INTENT_VERSION, + PACKAGE_VERSION, + SUPPORTED_AGENT_INTENT_VERSIONS, +} from "../version.js"; +export type { AgentIntentVersion } from "../version.js"; diff --git a/src/production/kernel.ts b/src/production/kernel.ts index 91e115e..fe4b394 100644 --- a/src/production/kernel.ts +++ b/src/production/kernel.ts @@ -54,8 +54,10 @@ import { typedValueText, } from "../util.js"; import type { EncryptedArtifactStore, StoredArtifact } from "./artifacts.js"; +import { assertRuntimeRoleSafe } from "./bootstrap.js"; import { operationScope, + revalidatePrincipal as revalidateAuthenticatedPrincipal, requireScope, type AuthenticatedPrincipal, } from "./auth.js"; @@ -268,6 +270,18 @@ export class ProductionKernel { return { ...this.activeEmbeddingSpace }; } + public async revalidatePrincipal( + principal: AuthenticatedPrincipal, + ): Promise { + return this.database.withTenantTransaction(principal, (client) => + revalidateAuthenticatedPrincipal(client, principal), + ); + } + + public async assertRuntimeRoleSafe(): Promise { + await assertRuntimeRoleSafe(this.database); + } + public async execute( principal: AuthenticatedPrincipal, input: unknown, @@ -283,27 +297,45 @@ export class ProductionKernel { "Effect outcomes are accepted only from the effect worker", ); } - requireScope(principal, operationScope(envelope.operation.op)); + const activePrincipal = await this.revalidatePrincipal(principal); + requireScope( + activePrincipal, + operationScope(envelope.operation.op), + ); const requestHash = sha256( - stableStringify({ - principal: envelope.principal, - operation: envelope.operation, - }), + stableStringify( + envelope.protocolVersion === "0.1" + ? { + principal: envelope.principal, + operation: envelope.operation, + } + : { + protocolVersion: envelope.protocolVersion, + principal: envelope.principal, + operation: envelope.operation, + }, + ), ); const operationKey = envelope.idempotencyKey ?? envelope.requestId; const started = performance.now(); const existing = await this.database.withTenantTransaction( - principal, + activePrincipal, async (client) => { + const validatedPrincipal = + await revalidateAuthenticatedPrincipal(client, activePrincipal); + requireScope( + validatedPrincipal, + operationScope(envelope.operation.op), + ); await this.lockIdempotency( client, - principal, + validatedPrincipal, operationKey, ); return this.getIdempotency( client, - principal, + validatedPrincipal, operationKey, requestHash, ); @@ -319,15 +351,19 @@ export class ProductionKernel { performance.now() - started, { operation: envelope.operation.op }, ); - return { ...existing, idempotentReplay: true }; + return { + ...existing, + requestId: envelope.requestId, + idempotentReplay: true, + }; } const preparedArtifact = envelope.operation.op === "put_artifact" - ? await this.prepareArtifact(principal, envelope.operation) + ? await this.prepareArtifact(activePrincipal, envelope.operation) : null; const preparedAssertion = envelope.operation.op === "assert" - ? await this.prepareAssertion(principal, envelope.operation) + ? await this.prepareAssertion(activePrincipal, envelope.operation) : null; let searchEmbedding: number[] | null = null; if (envelope.operation.op === "search") { @@ -345,26 +381,39 @@ export class ProductionKernel { try { const execution = await this.database.withTenantWriteTransaction( - principal, + activePrincipal, async (client) => { + const validatedPrincipal = + await revalidateAuthenticatedPrincipal( + client, + activePrincipal, + ); + requireScope( + validatedPrincipal, + operationScope(envelope.operation.op), + ); await this.lockIdempotency( client, - principal, + validatedPrincipal, operationKey, ); const replay = await this.getIdempotency( client, - principal, + validatedPrincipal, operationKey, requestHash, ); if (replay) { - return { ...replay, idempotentReplay: true }; + return { + ...replay, + requestId: envelope.requestId, + idempotentReplay: true, + }; } const rawResult = await this.executeOperation( client, - principal, + validatedPrincipal, envelope.operation, preparedArtifact, preparedAssertion, @@ -374,14 +423,14 @@ export class ProductionKernel { const evidenceManifest = operationEvidence(rawResult); const receipt = await this.recordReceipt( client, - principal, + validatedPrincipal, envelope.requestId, envelope.operation.op, result, evidenceManifest, ); const response: IntentExecutionResult = { - protocolVersion: "0.1", + protocolVersion: envelope.protocolVersion, requestId: envelope.requestId, status: "ok", operation: envelope.operation.op, @@ -394,8 +443,8 @@ export class ProductionKernel { tenant_id, principal_id, operation_key, request_hash, result_json ) VALUES ($1, $2, $3, $4, $5)`, [ - principal.tenantId, - principal.principalId, + validatedPrincipal.tenantId, + validatedPrincipal.principalId, operationKey, requestHash, response, @@ -431,7 +480,8 @@ export class ProductionKernel { principal: AuthenticatedPrincipal, operation: Extract, ): Promise { - requireScope(principal, "data:read"); + const activePrincipal = await this.revalidatePrincipal(principal); + requireScope(activePrincipal, "data:read"); const embedding = (await this.embeddings.embed([operation.text]))[0]; if (!embedding) { throw new Error("Embedding provider returned no search vector"); @@ -440,8 +490,21 @@ export class ProductionKernel { embedding, this.activeEmbeddingSpace.dimensions, ); - return this.database.withTenantTransaction(principal, (client) => - this.search(client, operation, embedding), + return this.database.withTenantTransaction( + activePrincipal, + async (client) => { + const active = await revalidateAuthenticatedPrincipal( + client, + activePrincipal, + ); + requireScope(active, "data:read"); + return this.search( + client, + active.tenantId, + operation, + embedding, + ); + }, ); } @@ -449,9 +512,16 @@ export class ProductionKernel { principal: AuthenticatedPrincipal, operation: Extract, ): Promise { - requireScope(principal, "data:read"); - return this.database.withTenantTransaction(principal, (client) => - this.resolve(client, operation), + return this.database.withTenantTransaction( + principal, + async (client) => { + const active = await revalidateAuthenticatedPrincipal( + client, + principal, + ); + requireScope(active, "data:read"); + return this.resolve(client, active.tenantId, operation); + }, ); } @@ -460,16 +530,21 @@ export class ProductionKernel { target: LineageEndpoint, maxDepth = 4, ): Promise { - requireScope(principal, "data:read"); return this.database.withTenantTransaction( principal, - (client) => - this.explainTrace( + async (client) => { + const active = await revalidateAuthenticatedPrincipal( client, - principal.tenantId, + principal, + ); + requireScope(active, "data:read"); + return this.explainTrace( + client, + active.tenantId, target, maxDepth, - ), + ); + }, "REPEATABLE READ", ); } @@ -478,9 +553,20 @@ export class ProductionKernel { principal: AuthenticatedPrincipal, instanceId: string, ): Promise { - requireScope(principal, "data:read"); - return this.database.withTenantTransaction(principal, (client) => - this.getMachineRecord(client, principal.tenantId, instanceId), + return this.database.withTenantTransaction( + principal, + async (client) => { + const active = await revalidateAuthenticatedPrincipal( + client, + principal, + ); + requireScope(active, "data:read"); + return this.getMachineRecord( + client, + active.tenantId, + instanceId, + ); + }, ); } @@ -642,12 +728,21 @@ export class ProductionKernel { preparedAssertion, ); case "resolve": - return this.resolve(client, operation); + return this.resolve( + client, + principal.tenantId, + operation, + ); case "search": if (!searchEmbedding) { throw new Error("Search embedding was not prepared"); } - return this.search(client, operation, searchEmbedding); + return this.search( + client, + principal.tenantId, + operation, + searchEmbedding, + ); case "add_lineage": return this.addLineage(client, principal, operation); case "explain": @@ -689,7 +784,7 @@ export class ProductionKernel { return this.listEffects( client, principal.tenantId, - operation.instanceId, + operation, ); default: return assertNever(operation); @@ -952,6 +1047,7 @@ export class ProductionKernel { private async resolve( client: PoolClient, + tenantId: string, operation: Extract, ): Promise { const current = await currentSystemTime(client); @@ -964,6 +1060,7 @@ export class ProductionKernel { "validAt", ); const values: unknown[] = [ + tenantId, operation.subjectEntityId, operation.predicate, systemAt, @@ -974,12 +1071,13 @@ export class ProductionKernel { : ""; const result = await client.query( `SELECT * FROM agentic.assertions - WHERE subject_entity_id = $1 - AND predicate = $2 - AND system_from <= $3 - AND (system_to IS NULL OR system_to > $3) - AND valid_from <= $4 - AND (valid_to IS NULL OR valid_to > $4) + WHERE tenant_id = $1 + AND subject_entity_id = $2 + AND predicate = $3 + AND system_from <= $4 + AND (system_to IS NULL OR system_to > $4) + AND valid_from <= $5 + AND (valid_to IS NULL OR valid_to > $5) AND status NOT IN ('quarantined', 'deleted') ${perspectiveClause} ORDER BY authority DESC, system_from DESC`, @@ -996,6 +1094,7 @@ export class ProductionKernel { private async search( client: PoolClient, + tenantId: string, operation: Extract, embedding: number[], ): Promise { @@ -1025,6 +1124,7 @@ export class ProductionKernel { [String(efSearch), String(this.config.hnswMaxScanTuples)], ); const query = buildHybridSearchQuery({ + tenantId, ...this.activeEmbeddingSpace, embedding, operation, @@ -2207,7 +2307,8 @@ export class ProductionKernel { FROM agentic.timers WHERE tenant_id = $1 AND status = 'pending' AND due_at <= $2 ORDER BY due_at - FOR UPDATE`, + LIMIT 100 + FOR UPDATE SKIP LOCKED`, [principal.tenantId, asOf], ); const changed: MachineRecord[] = []; @@ -2332,21 +2433,51 @@ export class ProductionKernel { private async listEffects( client: PoolClient, tenantId: string, - instanceId: string | undefined, + operation: Extract, ): Promise { - const result = instanceId - ? await client.query( - `SELECT * FROM agentic.effect_intents - WHERE tenant_id = $1 AND instance_id = $2 - ORDER BY created_at`, - [tenantId, instanceId], - ) - : await client.query( - `SELECT * FROM agentic.effect_intents - WHERE tenant_id = $1 - ORDER BY created_at`, - [tenantId], + if (operation.afterEffectId) { + const cursorResult = await client.query<{ effect_id: string }>( + `SELECT effect_id FROM agentic.effect_intents + WHERE tenant_id = $1 + AND effect_id = $2 + AND ($3::TEXT IS NULL OR instance_id = $3)`, + [ + tenantId, + operation.afterEffectId, + operation.instanceId ?? null, + ], + ); + if (!cursorResult.rows[0]) { + throw new KernelError( + "not_found", + `Effect cursor ${operation.afterEffectId} was not found`, ); + } + } + const limit = + operation.limit ?? (operation.afterEffectId ? 100 : undefined); + const result = await client.query( + `SELECT * FROM agentic.effect_intents + WHERE tenant_id = $1 + AND ($2::TEXT IS NULL OR instance_id = $2) + AND ( + $3::TEXT IS NULL + OR (created_at, effect_id) > ( + SELECT cursor.created_at, cursor.effect_id + FROM agentic.effect_intents AS cursor + WHERE cursor.tenant_id = $1 + AND cursor.effect_id = $3 + ) + ) + ORDER BY created_at, effect_id + ${limit === undefined ? "" : "LIMIT $4"}`, + [ + tenantId, + operation.instanceId ?? null, + operation.afterEffectId ?? null, + ...(limit === undefined ? [] : [limit]), + ], + ); return result.rows.map(mapEffect); } diff --git a/src/production/load.ts b/src/production/load.ts index ef4e424..a3c65a9 100644 --- a/src/production/load.ts +++ b/src/production/load.ts @@ -1,3 +1,5 @@ +import { AGENT_INTENT_VERSION } from "../version.js"; + export interface LoadOptions { baseUrl: string; token: string; @@ -43,7 +45,7 @@ export async function runLoad(options: LoadOptions): Promise { "x-agent-purpose": options.purpose, }, body: JSON.stringify({ - protocolVersion: "0.1", + protocolVersion: AGENT_INTENT_VERSION, requestId: `load-${Date.now()}-${index}`, principal: { tenantId: options.tenantId, diff --git a/src/production/logger.ts b/src/production/logger.ts index 7ed9684..0c36f8a 100644 --- a/src/production/logger.ts +++ b/src/production/logger.ts @@ -1,5 +1,6 @@ import pino, { type Logger } from "pino"; import type { ProductionConfig } from "./config.js"; +import { PACKAGE_VERSION } from "../version.js"; export function createLogger( config: Pick, @@ -20,7 +21,7 @@ export function createLogger( }, base: { service: "agentic-data-kernel", - version: "0.2.0", + version: PACKAGE_VERSION, }, timestamp: pino.stdTimeFunctions.isoTime, }); diff --git a/src/production/mcp.ts b/src/production/mcp.ts index 6eeabb8..f633da8 100644 --- a/src/production/mcp.ts +++ b/src/production/mcp.ts @@ -9,6 +9,10 @@ import { import type { AuthenticatedPrincipal } from "./auth.js"; import { productionCatalog } from "./catalog.js"; import type { ProductionKernel } from "./kernel.js"; +import { + AGENT_INTENT_VERSION, + PACKAGE_VERSION, +} from "../version.js"; export function createProductionMcpServer( kernel: ProductionKernel, @@ -16,7 +20,7 @@ export function createProductionMcpServer( ): McpServer { const server = new McpServer({ name: "agentic-data-kernel-production", - version: "0.2.0", + version: PACKAGE_VERSION, }); server.registerResource( "agentic-data-production-catalog", @@ -26,19 +30,22 @@ export function createProductionMcpServer( description: "Authenticated production operations and guarantees", mimeType: "application/json", }, - async (uri) => ({ - contents: [ - { - uri: uri.href, - mimeType: "application/json", - text: JSON.stringify( - productionCatalog(kernel.embeddingSpace()), - null, - 2, - ), - }, - ], - }), + async (uri) => { + await kernel.revalidatePrincipal(principal); + return { + contents: [ + { + uri: uri.href, + mimeType: "application/json", + text: JSON.stringify( + productionCatalog(kernel.embeddingSpace()), + null, + 2, + ), + }, + ], + }; + }, ); server.registerTool( "execute_operation", @@ -54,7 +61,7 @@ export function createProductionMcpServer( async ({ operation, idempotencyKey }) => toolResult( await kernel.execute(principal, { - protocolVersion: "0.1", + protocolVersion: AGENT_INTENT_VERSION, requestId: randomUUID(), ...(idempotencyKey ? { idempotencyKey } : {}), principal: { @@ -164,6 +171,7 @@ export async function startProductionMcpServer( kernel: ProductionKernel, principal: AuthenticatedPrincipal, ): Promise { + await kernel.assertRuntimeRoleSafe(); const server = createProductionMcpServer(kernel, principal); await server.connect(new StdioServerTransport()); console.error("Authenticated Agentic Data MCP server running on stdio"); diff --git a/src/production/metrics.ts b/src/production/metrics.ts index d1515b4..10d4e3f 100644 --- a/src/production/metrics.ts +++ b/src/production/metrics.ts @@ -1,24 +1,61 @@ +const durationBoundaries = [ + 1, + 5, + 10, + 25, + 50, + 100, + 250, + 500, + 1_000, + 2_500, + 5_000, + 10_000, + 30_000, + 60_000, +]; + export class MetricsRegistry { private readonly counters = new Map(); - private readonly durationBuckets = new Map(); + private readonly gauges = new Map(); + private readonly histograms = new Map< + string, + { buckets: number[]; count: number; sum: number } + >(); public increment(name: string, labels: Record = {}): void { const key = metricKey(name, labels); this.counters.set(key, (this.counters.get(key) ?? 0) + 1); } + public set( + name: string, + value: number, + labels: Record = {}, + ): void { + this.gauges.set(metricKey(name, labels), value); + } + public observe( name: string, milliseconds: number, labels: Record = {}, ): void { const key = metricKey(name, labels); - const values = this.durationBuckets.get(key) ?? []; - values.push(milliseconds); - if (values.length > 10_000) { - values.splice(0, values.length - 10_000); + const histogram = this.histograms.get(key) ?? { + buckets: durationBoundaries.map(() => 0), + count: 0, + sum: 0, + }; + for (let index = 0; index < durationBoundaries.length; index += 1) { + const boundary = durationBoundaries[index]; + if (boundary !== undefined && milliseconds <= boundary) { + histogram.buckets[index] = (histogram.buckets[index] ?? 0) + 1; + } } - this.durationBuckets.set(key, values); + histogram.count += 1; + histogram.sum += milliseconds; + this.histograms.set(key, histogram); } public render(): string { @@ -27,19 +64,35 @@ export class MetricsRegistry { const { name, labels } = parseMetricKey(key); lines.push(`${sanitizeMetricName(name)}${renderLabels(labels)} ${value}`); } - for (const [key, values] of [...this.durationBuckets.entries()].sort()) { + for (const [key, value] of [...this.gauges.entries()].sort()) { + const { name, labels } = parseMetricKey(key); + lines.push(`${sanitizeMetricName(name)}${renderLabels(labels)} ${value}`); + } + for (const [key, histogram] of [...this.histograms.entries()].sort()) { const { name, labels } = parseMetricKey(key); - const sorted = [...values].sort((left, right) => left - right); - const sum = values.reduce((total, value) => total + value, 0); const metricName = sanitizeMetricName(name); + for (let index = 0; index < durationBoundaries.length; index += 1) { + const boundary = durationBoundaries[index]; + if (boundary === undefined) { + continue; + } + lines.push( + `${metricName}_bucket${renderLabels([ + ...labels, + ["le", String(boundary)], + ])} ${histogram.buckets[index] ?? 0}`, + ); + } lines.push( - `${metricName}_count${renderLabels(labels)} ${values.length}`, - `${metricName}_sum${renderLabels(labels)} ${sum}`, - `${metricName}_p50${renderLabels(labels)} ${percentile(sorted, 0.5)}`, - `${metricName}_p95${renderLabels(labels)} ${percentile(sorted, 0.95)}`, - `${metricName}_p99${renderLabels(labels)} ${percentile(sorted, 0.99)}`, + `${metricName}_bucket${renderLabels([ + ...labels, + ["le", "+Inf"], + ])} ${histogram.count}`, + `${metricName}_sum${renderLabels(labels)} ${histogram.sum}`, + `${metricName}_count${renderLabels(labels)} ${histogram.count}`, ); } + return `${lines.join("\n")}\n`; } } @@ -62,7 +115,8 @@ function renderLabels(labels: Array<[string, string]>): string { if (labels.length === 0) { return ""; } - return `{${labels + return `{${[...labels] + .sort(([left], [right]) => left.localeCompare(right)) .map( ([key, value]) => `${sanitizeMetricName(key)}="${value.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`, @@ -73,14 +127,3 @@ function renderLabels(labels: Array<[string, string]>): string { function sanitizeMetricName(value: string): string { return value.replace(/[^a-zA-Z0-9_:]/g, "_"); } - -function percentile(sorted: number[], fraction: number): number { - if (sorted.length === 0) { - return 0; - } - const index = Math.min( - sorted.length - 1, - Math.max(0, Math.ceil(sorted.length * fraction) - 1), - ); - return sorted[index] ?? 0; -} diff --git a/src/production/migrations.ts b/src/production/migrations.ts index c5eb744..83baa1d 100644 --- a/src/production/migrations.ts +++ b/src/production/migrations.ts @@ -214,15 +214,41 @@ export async function migrationStatus( export async function assertMigrationsApplied( database: ProductionDatabase, ): Promise { - const result = await database.query<{ applied: boolean }>( - `SELECT EXISTS ( - SELECT 1 - FROM agentic.schema_migrations - WHERE version = '003' - ) AS applied`, + const expectedFiles = (await readdir(postgresMigrationDirectory)) + .filter((name) => /^\d+_.+\.sql$/.test(name)) + .sort(); + const expected = new Map(); + for (const file of expectedFiles) { + const version = file.split("_", 1)[0] ?? file; + const source = await readFile( + join(postgresMigrationDirectory, file), + "utf8", + ); + expected.set( + version, + createHash("sha256").update(source).digest("hex"), + ); + } + const result = await database.query( + `SELECT version, checksum + FROM agentic.schema_migrations + ORDER BY version`, ); - if (result.rows[0]?.applied !== true) { - throw new Error("Required PostgreSQL migrations are not applied"); + const actual = new Map( + result.rows.map((migration) => [ + migration.version, + migration.checksum, + ]), + ); + if ( + actual.size !== expected.size || + [...expected].some( + ([version, checksum]) => actual.get(version) !== checksum, + ) + ) { + throw new Error( + "PostgreSQL schema is missing, newer than, or incompatible with this runtime", + ); } } diff --git a/src/production/search.ts b/src/production/search.ts index e08c912..2fc439a 100644 --- a/src/production/search.ts +++ b/src/production/search.ts @@ -7,6 +7,7 @@ import { } from "./embeddings.js"; export interface HybridSearchQueryInput extends EmbeddingSpace { + tenantId: string; embedding: number[]; operation: Extract; systemAt: string; @@ -33,6 +34,7 @@ export function buildHybridSearchQuery( } const vectorType = `vector(${space.dimensions})`; const candidateFilters = ` + AND assertion.tenant_id = $14 AND assertion.system_from <= $3 AND ( assertion.system_to IS NULL @@ -80,6 +82,7 @@ export function buildHybridSearchQuery( ) OR edge.object_entity_id = reachable.entity_id WHERE reachable.depth < $6 + AND edge.tenant_id = $14 AND edge.system_from <= $3 AND (edge.system_to IS NULL OR edge.system_to > $3) AND edge.valid_from <= $4 @@ -169,6 +172,7 @@ export function buildHybridSearchQuery( space.version, input.candidateLimit, input.resultLimit, + input.tenantId, ], }; } diff --git a/src/production/sre-scenario.ts b/src/production/sre-scenario.ts index 7e50e08..0d421a8 100644 --- a/src/production/sre-scenario.ts +++ b/src/production/sre-scenario.ts @@ -7,6 +7,7 @@ import type { import type { JsonValue, } from "../types.js"; +import { AGENT_INTENT_VERSION } from "../version.js"; import { authenticateToken, createApiKey, @@ -795,7 +796,7 @@ async function execute( operation: AgentOperation, ): Promise { return kernel.execute(principal, { - protocolVersion: "0.1", + protocolVersion: AGENT_INTENT_VERSION, requestId: `${runId}-${step}-${randomUUID()}`, idempotencyKey: `${runId}-${step}`, principal: { diff --git a/src/production/worker-monitor.ts b/src/production/worker-monitor.ts new file mode 100644 index 0000000..519d492 --- /dev/null +++ b/src/production/worker-monitor.ts @@ -0,0 +1,99 @@ +import { createServer, type Server } from "node:http"; +import type { ProductionConfig } from "./config.js"; +import type { ProductionDatabase } from "./database.js"; +import type { MetricsRegistry } from "./metrics.js"; + +export async function startWorkerMonitor( + config: Pick< + ProductionConfig, + "workerMonitorHost" | "workerMonitorPort" + >, + database: ProductionDatabase, + metrics: MetricsRegistry, +): Promise { + const server = createServer( + { + requestTimeout: 5_000, + headersTimeout: 5_000, + keepAliveTimeout: 2_000, + maxHeaderSize: 8_192, + }, + async (request, response) => { + try { + const path = new URL( + request.url ?? "/", + `http://${config.workerMonitorHost}:${config.workerMonitorPort}`, + ).pathname; + if (request.method === "GET" && path === "/health/live") { + send(response, 200, '{"status":"ok"}', "application/json"); + return; + } + if (request.method === "GET" && path === "/health/ready") { + const healthy = await database.health(); + send( + response, + healthy ? 200 : 503, + JSON.stringify({ + status: healthy ? "ready" : "not_ready", + }), + "application/json", + ); + return; + } + if (request.method === "GET" && path === "/metrics") { + send( + response, + 200, + metrics.render(), + "text/plain; version=0.0.4", + ); + return; + } + send( + response, + 404, + '{"error":"not_found"}', + "application/json", + ); + } catch { + send( + response, + 503, + '{"status":"not_ready"}', + "application/json", + ); + } + }, + ); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen( + config.workerMonitorPort, + config.workerMonitorHost, + () => { + server.off("error", reject); + resolve(); + }, + ); + }); + return server; +} + +function send( + response: import("node:http").ServerResponse, + status: number, + body: string, + contentType: string, +): void { + if (response.headersSent) { + response.end(); + return; + } + response.writeHead(status, { + "cache-control": "no-store", + "content-type": contentType, + "content-length": Buffer.byteLength(body), + "x-content-type-options": "nosniff", + }); + response.end(body); +} diff --git a/src/test/agency.test.ts b/src/test/agency.test.ts index d3de887..e8fc9f3 100644 --- a/src/test/agency.test.ts +++ b/src/test/agency.test.ts @@ -19,6 +19,95 @@ const principal: PrincipalContext = { purpose: "incident-response", }; +test("effect listing preserves legacy unbounded reads and supports pagination", () => { + const store = new SqliteStore(":memory:"); + const kernel = new AgenticKernel(store); + const timestamp = "2026-01-01T00:00:00.000Z"; + try { + store.run( + `INSERT INTO machine_instances ( + tenant_id, instance_id, machine_type, state, data_json, revision, + terminal, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + principal.tenantId, + "incident:pagination", + "incident_response", + "ready", + "{}", + 1, + 0, + timestamp, + timestamp, + ); + store.run( + `INSERT INTO machine_history ( + tenant_id, instance_id, revision, event_id, transition_name, + prior_state, new_state, data_json, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + principal.tenantId, + "incident:pagination", + 1, + "event:pagination", + "create", + "none", + "ready", + "{}", + timestamp, + ); + for (let index = 0; index < 101; index += 1) { + const suffix = String(index).padStart(3, "0"); + store.run( + `INSERT INTO effect_intents ( + tenant_id, effect_id, instance_id, originating_revision, + effect_name, effect_type, outcome_handler, target, status_url, + request_json, idempotency_key, status, attempt_count, + created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + principal.tenantId, + `effect:${suffix}`, + "incident:pagination", + 1, + `effect_${suffix}`, + "test.effect", + "none", + "https://effects.example.com/apply", + "https://effects.example.com/status", + "{}", + `effect-key-${suffix}`, + "planned", + 0, + timestamp, + timestamp, + ); + } + + assert.equal( + kernel.listEffects(principal.tenantId, "incident:pagination").length, + 101, + ); + const firstPage = kernel.listEffects( + principal.tenantId, + "incident:pagination", + { limit: 100 }, + ); + assert.equal(firstPage.length, 100); + const cursor = firstPage[99]?.effectId; + assert.ok(cursor); + const secondPage = kernel.listEffects( + principal.tenantId, + "incident:pagination", + { + afterEffectId: cursor, + limit: 100, + }, + ); + assert.equal(secondPage.length, 1); + assert.equal(secondPage[0]?.effectId, "effect:100"); + } finally { + store.close(); + } +}); + test("generic workflows, effects, and lineage preserve agency history", () => { const store = new SqliteStore(":memory:"); const kernel = new AgenticKernel(store); @@ -28,6 +117,7 @@ test("generic workflows, effects, and lineage preserve agency history", () => { entityType: "service", canonicalName: "API Service", }); + kernel.putArtifact(principal, { artifactId: "artifact:alert", mediaType: "application/json", diff --git a/src/test/http.test.ts b/src/test/http.test.ts index e0e8dd8..e40d1d2 100644 --- a/src/test/http.test.ts +++ b/src/test/http.test.ts @@ -15,7 +15,7 @@ test("HTTP endpoint executes typed intents", async () => { const catalogResponse = await fetch(`${base}/v1/catalog`); assert.equal(catalogResponse.status, 200); const catalog = (await catalogResponse.json()) as { protocolVersion: string }; - assert.equal(catalog.protocolVersion, "0.1"); + assert.equal(catalog.protocolVersion, "1.0"); const executeResponse = await fetch(`${base}/v1/execute`, { method: "POST", diff --git a/src/test/ir.test.ts b/src/test/ir.test.ts index 0012e3b..e15a90d 100644 --- a/src/test/ir.test.ts +++ b/src/test/ir.test.ts @@ -27,10 +27,15 @@ test("intent execution returns a receipt and replays idempotently", () => { }; const first = executeIntent(kernel, envelope); - const replay = executeIntent(kernel, envelope); + const replay = executeIntent(kernel, { + ...envelope, + requestId: "request-2", + }); assert.equal(first.idempotentReplay, false); assert.equal(replay.idempotentReplay, true); assert.equal(first.receipt.receiptId, replay.receipt.receiptId); + assert.equal(replay.requestId, "request-2"); + assert.equal(replay.receipt.requestId, "request-1"); assert.deepEqual(first.result, replay.result); assert.throws( () => @@ -50,6 +55,38 @@ test("intent execution returns a receipt and replays idempotently", () => { store.close(); }); +test("Agent Intent 1.0 is stable while 0.1 remains compatible", () => { + const store = new SqliteStore(":memory:"); + const kernel = new AgenticKernel(store); + try { + const result = executeIntent(kernel, { + protocolVersion: "1.0", + requestId: "stable-request", + principal: { + tenantId: "stable-tenant", + principalId: "stable-agent", + purpose: "test", + }, + operation: { + op: "put_entity", + entity: { + entityId: "entity:stable", + entityType: "test", + canonicalName: "Stable Entity", + }, + }, + }); + assert.equal(result.protocolVersion, "1.0"); + assert.equal(kernel.catalog().protocolVersion, "1.0"); + assert.deepEqual( + kernel.catalog().supportedProtocolVersions, + ["0.1", "1.0"], + ); + } finally { + store.close(); + } +}); + test("human SQL surface rejects writes", () => { const store = new SqliteStore(":memory:"); const kernel = new AgenticKernel(store); diff --git a/src/test/mcp.test.ts b/src/test/mcp.test.ts index c75ec03..77050f5 100644 --- a/src/test/mcp.test.ts +++ b/src/test/mcp.test.ts @@ -32,7 +32,7 @@ test("MCP exposes catalog and executes an intent", async () => { }); const catalogText = resource.contents[0]; assert.ok(catalogText && "text" in catalogText); - assert.match(catalogText.text, /\"protocolVersion\": \"0.1\"/); + assert.match(catalogText.text, /\"protocolVersion\": \"1.0\"/); const result = await client.callTool({ name: "execute_intent", diff --git a/src/test/production.test.ts b/src/test/production.test.ts index 58fb906..561e507 100644 --- a/src/test/production.test.ts +++ b/src/test/production.test.ts @@ -1,6 +1,16 @@ import assert from "node:assert/strict"; +import { + spawn, + type ChildProcessWithoutNullStreams, +} from "node:child_process"; import { createHash, randomUUID } from "node:crypto"; -import { mkdtempSync, readFileSync, readdirSync, rmSync } from "node:fs"; +import { + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + writeFileSync, +} from "node:fs"; import type { AddressInfo } from "node:net"; import type { PeerCertificate } from "node:tls"; import { tmpdir } from "node:os"; @@ -20,12 +30,16 @@ import { revokeApiKey, type AuthenticatedPrincipal, } from "../production/auth.js"; -import { bootstrapRuntimeRole } from "../production/bootstrap.js"; +import { + assertRuntimeRoleSafe, + bootstrapRuntimeRole, +} from "../production/bootstrap.js"; import { reconcileArtifactFiles } from "../production/artifact-reconciliation.js"; import { configuredEmbeddingSpace, loadDatabaseConfig, loadEmbeddingSpaceConfig, + loadProductionConfig, type ProductionConfig, } from "../production/config.js"; import { ProductionDatabase } from "../production/database.js"; @@ -45,17 +59,22 @@ import { SecureHttpEffectTransport, type EffectTransport, } from "../production/effects.js"; -import { startProductionHttpServer } from "../production/http.js"; +import { + resolveClientAddress, + startProductionHttpServer, +} from "../production/http.js"; import { ProductionKernel } from "../production/kernel.js"; import { createLogger } from "../production/logger.js"; import { MetricsRegistry } from "../production/metrics.js"; import { + assertMigrationsApplied, migratePostgres, postgresMigrationDirectory, } from "../production/migrations.js"; import { createProductionMcpServer } from "../production/mcp.js"; import { EncryptedArtifactStore } from "../production/artifacts.js"; import { buildHybridSearchQuery } from "../production/search.js"; +import { startWorkerMonitor } from "../production/worker-monitor.js"; const databaseUrl = process.env.PRODUCTION_TEST_DATABASE_URL; const migrationDatabaseUrl = @@ -104,6 +123,31 @@ test( } finally { await appClient.end(); } + const runtimeDatabase = new ProductionDatabase({ + databaseUrl: appUrl.toString(), + databaseSsl: false, + databasePoolSize: 2, + statementTimeoutMs: 30_000, + }); + try { + await assertRuntimeRoleSafe(runtimeDatabase); + } finally { + await runtimeDatabase.close(); + } + const administrativeDatabase = new ProductionDatabase({ + databaseUrl: migrationDatabaseUrl, + databaseSsl: false, + databasePoolSize: 2, + statementTimeoutMs: 30_000, + }); + try { + await assert.rejects( + () => assertRuntimeRoleSafe(administrativeDatabase), + /restricted agentic_app/, + ); + } finally { + await administrativeDatabase.close(); + } const client = new PgClient({ connectionString: migrationDatabaseUrl }); await client.connect(); try { @@ -276,6 +320,7 @@ test("database TLS accepts an explicit base64 PEM trust bundle", async () => { assert.deepEqual( loadDatabaseConfig({ DATABASE_URL: "postgresql://example:password@database.example/test", + DATABASE_SSL: "disable", DATABASE_CA_CERT_BASE64: "", }), { @@ -347,6 +392,7 @@ test("database TLS accepts an explicit base64 PEM trust bundle", async () => { databasePoolSize: 1, statementTimeoutMs: 30_000, }); + try { const ssl = database.pool.options.ssl; assert.ok( @@ -368,6 +414,148 @@ test("database TLS accepts an explicit base64 PEM trust bundle", async () => { } }); +test("effect leases outlive transport timeouts", () => { + const environment = { + DATABASE_URL: + "postgresql://example:password@database.example/test", + DATABASE_SSL: "disable", + AUTH_PEPPER: "a".repeat(32), + ARTIFACT_KEYRING: JSON.stringify({ + v1: Buffer.alloc(32, 1).toString("base64"), + }), + ARTIFACT_CURRENT_KEY_ID: "v1", + EMBEDDING_BASE_URL: "https://embeddings.example.com/v1", + EMBEDDING_API_KEY: "test-key", + EFFECT_TIMEOUT_MS: "15000", + EFFECT_LEASE_SECONDS: "19", + }; + assert.throws( + () => loadProductionConfig(environment), + /must exceed EFFECT_TIMEOUT_MS/, + ); + assert.equal( + loadProductionConfig({ + ...environment, + EFFECT_LEASE_SECONDS: "20", + }).effectLeaseSeconds, + 20, + ); + }); + + test("metrics render Prometheus histograms and gauges", () => { + const metrics = new MetricsRegistry(); + metrics.set("agentic_worker_last_poll_timestamp_seconds", 123); + metrics.observe("agentic_effect_duration_ms", 10, { + status: "succeeded", + }); + + for (let index = 0; index < 10_000; index += 1) { + metrics.observe("agentic_effect_duration_ms", 5, { + status: "succeeded", + }); + } + const rendered = metrics.render(); + assert.match( + rendered, + /agentic_worker_last_poll_timestamp_seconds 123/, + ); + assert.match( + rendered, + /agentic_effect_duration_ms_bucket\{le="10",status="succeeded"\} 10001/, + ); + assert.match( + rendered, + /agentic_effect_duration_ms_count\{status="succeeded"\} 10001/, + ); + }); + +test("trusted proxy address selection counts hops from the right", () => { + assert.equal( + resolveClientAddress( + "127.0.0.1", + "198.51.100.10, 192.0.2.20", + 0, + ), + "127.0.0.1", + ); + assert.equal( + resolveClientAddress( + "127.0.0.1", + "198.51.100.10, 192.0.2.20", + 1, + ), + "192.0.2.20", + ); + assert.equal( + resolveClientAddress( + "127.0.0.1", + "198.51.100.10, invalid, 192.0.2.20", + 2, + ), + "198.51.100.10", + ); + assert.equal( + resolveClientAddress("127.0.0.1", "198.51.100.10", 2), + "127.0.0.1", + ); +}); + + test( + "worker monitor exposes private health and metrics endpoints", + { skip: !databaseUrl }, + async () => { + assert.ok(databaseUrl); + const database = new ProductionDatabase({ + databaseUrl, + databaseSsl: false, + databasePoolSize: 2, + statementTimeoutMs: 30_000, + }); + const metrics = new MetricsRegistry(); + metrics.set("agentic_worker_last_poll_timestamp_seconds", 123); + const server = await startWorkerMonitor( + { + workerMonitorHost: "127.0.0.1", + workerMonitorPort: 0, + }, + database, + metrics, + ); + const address = server.address() as AddressInfo; + let databaseClosed = false; + try { + const live = await fetch( + `http://127.0.0.1:${address.port}/health/live`, + ); + assert.equal(live.status, 200); + const ready = await fetch( + `http://127.0.0.1:${address.port}/health/ready`, + ); + assert.equal(ready.status, 200); + const metricResponse = await fetch( + `http://127.0.0.1:${address.port}/metrics`, + ); + assert.match( + await metricResponse.text(), + /agentic_worker_last_poll_timestamp_seconds 123/, + ); + await database.close(); + databaseClosed = true; + const unavailable = await fetch( + `http://127.0.0.1:${address.port}/health/ready`, + ); + assert.equal(unavailable.status, 503); + } finally { + await new Promise((resolve) => + server.close(() => resolve()), + ); + if (!databaseClosed) { + await database.close(); + } + } + }, + ); + test( "runtime role bootstrap rejects inherited role capabilities", { skip: !migrationDatabaseUrl }, @@ -530,6 +718,37 @@ test( }, ); +test( + "runtime rejects a newer PostgreSQL migration set", + { skip: !migrationDatabaseUrl }, + async () => { + assert.ok(migrationDatabaseUrl); + const database = new ProductionDatabase({ + databaseUrl: migrationDatabaseUrl, + databaseSsl: false, + databasePoolSize: 2, + statementTimeoutMs: 30_000, + }); + try { + await database.query( + `INSERT INTO agentic.schema_migrations ( + version, file_name, checksum, applied_at + ) VALUES ('999', '999_future.sql', $1, clock_timestamp())`, + ["f".repeat(64)], + ); + await assert.rejects( + () => assertMigrationsApplied(database), + /newer than, or incompatible/, + ); + } finally { + await database.query( + "DELETE FROM agentic.schema_migrations WHERE version = '999'", + ); + await database.close(); + } + }, +); + test( "embedding migration preserves an existing 1536-dimensional space", { skip: !databaseUrl || !migrationDatabaseUrl }, @@ -950,6 +1169,7 @@ test( 0, ); assert.equal(reconciled.removed.length, 1); + assert.equal(reconciled.verified, 1); assert.equal(listFiles(artifactDirectory).length, 1); } finally { await administrator.close(); @@ -997,6 +1217,61 @@ test( sourceArtifactId: "artifact:secret", }, }); + await execute(kernel, principalB, "assert-b", { + op: "assert", + assertion: { + assertionId: "assertion:weight", + subjectEntityId: "product:1", + predicate: "packaged_weight", + object: { type: "number", value: 99, unit: "kg" }, + kind: "reported_fact", + }, + }); + if (migrationDatabaseUrl) { + const administrativeDatabase = new ProductionDatabase( + testConfig(migrationDatabaseUrl, artifactDirectory), + ); + const administrativeKernel = new ProductionKernel( + administrativeDatabase, + artifactStore, + embeddings, + config, + metrics, + logger, + ); + try { + const administrativeResolution = + await administrativeKernel.resolveReadOnly(principalA, { + op: "resolve", + subjectEntityId: "product:1", + predicate: "packaged_weight", + policy: "latest", + }); + assert.equal( + administrativeResolution.selected?.tenantId, + principalA.tenantId, + ); + assert.ok( + administrativeResolution.candidates.every( + (assertion) => + assertion.tenantId === principalA.tenantId, + ), + ); + const administrativeSearch = + await administrativeKernel.searchReadOnly(principalA, { + op: "search", + text: "product weight", + predicate: "packaged_weight", + }); + assert.ok( + administrativeSearch.every( + (hit) => hit.assertion.tenantId === principalA.tenantId, + ), + ); + } finally { + await administrativeDatabase.close(); + } + } const replayKernel = new ProductionKernel( database, artifactStore, @@ -1006,7 +1281,7 @@ test( logger, ); const replayedAssertion = await replayKernel.execute(principalA, { - protocolVersion: "0.1", + protocolVersion: "1.0", requestId: "assert-replay-during-provider-outage", idempotencyKey: "assert-a", principal: { @@ -1464,6 +1739,119 @@ test( reconcilingEffectId, ); assert.equal(field(reconciledEffect, "status"), "succeeded"); + const crashEffectResult = await execute( + kernel, + principalA, + "generic-crash-effect", + { + op: "request_effect", + instanceId: "incident:generic", + expectedRevision: 2, + effectName: "rollback_api_crash", + effectType: "deployment.rollback", + target: "https://payments.example.com/rollback", + statusUrl: "https://payments.example.com/status/rollback_crash", + request: { deployment: "api-v44" }, + idempotencyKey: "rollback-api-v44", + decisionAssertionId: "assertion:generic-decision", + policyAssertionId: "assertion:generic-policy", + }, + ); + const crashEffectId = field( + crashEffectResult.result, + "effectId", + ); + let crashDeliveries = 0; + let crashReconciliations = 0; + let providerApplied = false; + const crashWorker = new EffectWorker( + database, + { + deliver: async () => { + crashDeliveries += 1; + providerApplied = true; + throw new Error("simulated process loss after provider apply"); + }, + reconcile: async () => { + assert.equal(providerApplied, true); + crashReconciliations += 1; + return { + status: "succeeded", + responseStatus: 200, + outcome: { providerReference: "rollback-44" }, + }; + }, + }, + { effectLeaseSeconds: 0, effectMaxAttempts: 3 }, + metrics, + logger, + ); + await assert.rejects( + () => + crashWorker.runOnce({ + tenantId: principalA.tenantId, + effectId: crashEffectId, + }), + /simulated process loss/, + ); + assert.equal( + await crashWorker.runOnce({ + tenantId: principalA.tenantId, + effectId: crashEffectId, + }), + true, + ); + assert.equal(crashDeliveries, 1); + assert.equal(crashReconciliations, 1); + const crashEffects = await execute( + kernel, + principalA, + "generic-crash-effects", + { + op: "list_effects", + instanceId: "incident:generic", + }, + ); + assert.equal( + field( + findArrayItemByField( + crashEffects.result, + "effectId", + crashEffectId, + ), + "status", + ), + "succeeded", + ); + const firstEffectPage = await execute( + kernel, + principalA, + "generic-effects-page-1", + { + op: "list_effects", + instanceId: "incident:generic", + limit: 1, + }, + ); + const firstEffectId = field( + arrayItem(firstEffectPage.result, 0), + "effectId", + ); + const secondEffectPage = await execute( + kernel, + principalA, + "generic-effects-page-2", + { + op: "list_effects", + instanceId: "incident:generic", + afterEffectId: firstEffectId, + limit: 1, + }, + ); + assert.notEqual( + field(arrayItem(secondEffectPage.result, 0), "effectId"), + firstEffectId, + ); const reconciledBudget = await database.query<{ reserved: string; spent: string; @@ -1506,8 +1894,8 @@ test( effectType: "deployment.rollback", target: "https://payments.example.com/rollback", statusUrl: "https://payments.example.com/status/rollback_cancelled", - request: { deployment: "api-v44" }, - idempotencyKey: "rollback-api-v44", + request: { deployment: "api-v45" }, + idempotencyKey: "rollback-api-v45-cancelled", decisionAssertionId: "assertion:generic-decision", policyAssertionId: "assertion:generic-policy", budgetAmount: "5", @@ -1754,6 +2142,158 @@ test( "confirmed", ); + if (migrationDatabaseUrl) { + await execute(kernel, principalA, "artifact-integrity-missing", { + op: "put_artifact", + artifact: { + artifactId: "artifact:integrity-missing", + mediaType: "text/plain", + content: "integrity test", + sourceIdentity: "integrity-test", + }, + }); + const descriptor = await database.withTenantTransaction( + principalA, + (client) => + client.query<{ storage_key: string }>( + `SELECT storage_key + FROM agentic.artifacts + WHERE artifact_id = 'artifact:integrity-missing'`, + ), + ); + const storageKey = descriptor.rows[0]?.storage_key; + assert.ok(storageKey); + rmSync( + join(artifactDirectory, ...storageKey.split("/")), + { force: true }, + ); + const administrator = new ProductionDatabase( + testConfig(migrationDatabaseUrl, artifactDirectory), + ); + try { + await assert.rejects( + () => + reconcileArtifactFiles( + administrator, + artifactStore, + 0, + ), + /failed integrity verification/, + ); + } finally { + await administrator.close(); + } + await database.withTenantTransaction(principalA, (client) => + client.query( + `DELETE FROM agentic.artifacts + WHERE artifact_id = 'artifact:integrity-missing'`, + ), + ); + + await execute(kernel, principalA, "artifact-integrity-corrupt", { + op: "put_artifact", + artifact: { + artifactId: "artifact:integrity-corrupt", + mediaType: "text/plain", + content: "corruption test", + sourceIdentity: "integrity-test", + }, + }); + const corruptDescriptor = await database.withTenantTransaction( + principalA, + (client) => + client.query<{ storage_key: string }>( + `SELECT storage_key + FROM agentic.artifacts + WHERE artifact_id = 'artifact:integrity-corrupt'`, + ), + ); + const corruptStorageKey = + corruptDescriptor.rows[0]?.storage_key; + assert.ok(corruptStorageKey); + writeFileSync( + join(artifactDirectory, ...corruptStorageKey.split("/")), + "corrupt", + ); + const corruptAdministrator = new ProductionDatabase( + testConfig(migrationDatabaseUrl, artifactDirectory), + ); + try { + await assert.rejects( + () => + reconcileArtifactFiles( + corruptAdministrator, + artifactStore, + 0, + ), + /failed integrity verification/, + ); + } finally { + await corruptAdministrator.close(); + } + await database.withTenantTransaction(principalA, (client) => + client.query( + `DELETE FROM agentic.artifacts + WHERE artifact_id = 'artifact:integrity-corrupt'`, + ), + ); + rmSync( + join(artifactDirectory, ...corruptStorageKey.split("/")), + { force: true }, + ); + + await execute(kernel, principalA, "artifact-integrity-key", { + op: "put_artifact", + artifact: { + artifactId: "artifact:integrity-key", + mediaType: "text/plain", + content: "key availability test", + sourceIdentity: "integrity-test", + }, + }); + const keyDescriptor = await database.withTenantTransaction( + principalA, + (client) => + client.query<{ storage_key: string }>( + `SELECT storage_key + FROM agentic.artifacts + WHERE artifact_id = 'artifact:integrity-key'`, + ), + ); + const keyStorageKey = keyDescriptor.rows[0]?.storage_key; + assert.ok(keyStorageKey); + const unavailableKeyStore = new EncryptedArtifactStore( + artifactDirectory, + { + currentKeyId: "v2", + keys: new Map([["v2", Buffer.alloc(32, 8)]]), + }, + ); + const keyAdministrator = new ProductionDatabase( + testConfig(migrationDatabaseUrl, artifactDirectory), + ); + try { + await assert.rejects( + () => + reconcileArtifactFiles( + keyAdministrator, + unavailableKeyStore, + 0, + ), + /failed integrity verification/, + ); + } finally { + await keyAdministrator.close(); + } + await database.withTenantTransaction(principalA, (client) => + client.query( + `DELETE FROM agentic.artifacts + WHERE artifact_id = 'artifact:integrity-key'`, + ), + ); + await artifactStore.removeIfPresent(keyStorageKey); + } + await assert.rejects( () => execute(kernel, principalA, "future-timer", { @@ -1764,7 +2304,7 @@ test( ); await testAuthenticatedHttp(config, database, kernel, metrics, logger, keyA.token, principalA); - await testProductionMcp(kernel, principalA); + await testProductionMcp(database, kernel, principalA); } finally { await cleanupTenant(database, principalA); await cleanupTenant(database, principalB); @@ -2208,6 +2748,7 @@ test( ); const query = buildHybridSearchQuery({ + tenantId: principal.tenantId, ...configuredEmbeddingSpace(config), embedding: vector(1, 768), operation: { @@ -2278,30 +2819,815 @@ test( }, ); -test("OpenAI-compatible embedding provider validates real response shape", async () => { - const server = createServer(async (request, response) => { - assert.equal(request.headers.authorization, "Bearer test-key"); - const chunks: Buffer[] = []; - for await (const chunk of request) { - chunks.push(Buffer.from(chunk)); - } - const body = JSON.parse(Buffer.concat(chunks).toString("utf8")) as { - input: string[]; - dimensions: number; - }; - assert.equal(body.dimensions, 1536); - response.writeHead(200, { "content-type": "application/json" }); - response.end( - JSON.stringify({ - data: body.input.map((_, index) => ({ - index, - embedding: vector(index + 1), +test( + "production timer processing is bounded to one locked batch", + { skip: !databaseUrl }, + async () => { + assert.ok(databaseUrl); + const artifactDirectory = mkdtempSync( + join(tmpdir(), "agentic-data-timer-batch-"), + ); + const config = testConfig(databaseUrl, artifactDirectory); + const database = new ProductionDatabase(config); + const kernel = new ProductionKernel( + database, + new EncryptedArtifactStore( + artifactDirectory, + config.artifactKeyring, + ), + new TestEmbeddingProvider(), + config, + new MetricsRegistry(), + createLogger(config), + ); + const tenantId = `timer-batch-${randomUUID()}`; + const key = await createApiKey(database, config, { + tenantId, + tenantName: "Timer Batch", + principalId: "timer-agent", + scopes: ["data:read", "workflows:run"], + purposes: ["test"], + effectBudgetCurrency: "USD", + effectBudgetLimit: "0", + }); + const principal = await authenticateToken( + database, + config, + key.token, + "test", + ); + try { + await database.withTenantWriteTransaction( + principal, + async (client) => { + await client.query( + `INSERT INTO agentic.inventory ( + tenant_id, sku, location, quantity_on_hand, + quantity_reserved, version + ) VALUES ($1, 'timer-sku', 'timer-location', 101, 101, 1)`, + [tenantId], + ); + await client.query( + `INSERT INTO agentic.machine_instances ( + tenant_id, instance_id, machine_type, state, data_json, + revision, terminal, updated_at + ) + SELECT + $1, + 'order:timer-' || sequence, + 'retail_order', + 'reserved', + jsonb_build_object( + 'orderId', 'timer-' || sequence, + 'sku', 'timer-sku', + 'location', 'timer-location', + 'quantity', 1, + 'reservationExpiresAt', '2025-01-01T00:00:00.000Z' + ), + 1, + FALSE, + agentic.next_system_time() + FROM generate_series(1, 101) AS series(sequence)`, + [tenantId], + ); + await client.query( + `INSERT INTO agentic.machine_history ( + tenant_id, instance_id, revision, event_id, transition_name, + prior_state, new_state, data_json, created_at + ) + SELECT + tenant_id, + instance_id, + 1, + 'event:' || instance_id, + 'reserve_inventory', + 'new', + 'reserved', + data_json, + created_at + FROM agentic.machine_instances + WHERE tenant_id = $1`, + [tenantId], + ); + await client.query( + `INSERT INTO agentic.timers ( + tenant_id, timer_id, instance_id, originating_revision, + timer_name, due_at, status, updated_at + ) + SELECT + tenant_id, + 'timer:' || instance_id, + instance_id, + 1, + 'reservation_expiry', + clock_timestamp() - INTERVAL '1 minute', + 'pending', + agentic.next_system_time() + FROM agentic.machine_instances + WHERE tenant_id = $1`, + [tenantId], + ); + }, + ); + const result = await execute(kernel, principal, "timer-batch", { + op: "process_timers", + }); + assert.ok(Array.isArray(result.result)); + assert.equal(result.result.length, 100); + const remaining = await database.withTenantTransaction( + principal, + (client) => + client.query<{ + pending: string; + reserved: number; + }>( + `SELECT + ( + SELECT count(*)::TEXT + FROM agentic.timers + WHERE tenant_id = $1 AND status = 'pending' + ) AS pending, + ( + SELECT quantity_reserved + FROM agentic.inventory + WHERE tenant_id = $1 + AND sku = 'timer-sku' + AND location = 'timer-location' + ) AS reserved`, + [tenantId], + ), + ); + assert.deepEqual(remaining.rows[0], { + pending: "1", + reserved: 1, + }); + } finally { + await cleanupTenant(database, principal); + await database.withSystemTransaction(async (client) => { + await client.query( + "DELETE FROM agentic_auth.api_keys WHERE tenant_id = $1", + [tenantId], + ); + await client.query( + "DELETE FROM agentic_auth.tenants WHERE tenant_id = $1", + [tenantId], + ); + }); + await database.close(); + rmSync(artifactDirectory, { recursive: true, force: true }); + } + }, +); + +test( + "effect workers rotate fairly across active tenants", + { skip: !databaseUrl }, + async () => { + assert.ok(databaseUrl); + const artifactDirectory = mkdtempSync( + join(tmpdir(), "agentic-data-worker-fairness-"), + ); + const config = testConfig(databaseUrl, artifactDirectory); + const database = new ProductionDatabase(config); + const metrics = new MetricsRegistry(); + const logger = createLogger(config); + const kernel = new ProductionKernel( + database, + new EncryptedArtifactStore( + artifactDirectory, + config.artifactKeyring, + ), + new TestEmbeddingProvider(), + config, + metrics, + logger, + ); + const suffix = randomUUID(); + const tenantA = `00-fair-a-${suffix}`; + const tenantB = `01-fair-b-${suffix}`; + const principals: AuthenticatedPrincipal[] = []; + const effectTenants = new Map(); + try { + for (const [tenantId, count] of [ + [tenantA, 2], + [tenantB, 1], + ] as const) { + const key = await createApiKey(database, config, { + tenantId, + tenantName: tenantId, + principalId: "fairness-agent", + scopes: [ + "data:read", + "data:write", + "effects:write", + "workflows:run", + ], + purposes: ["test"], + effectBudgetCurrency: "USD", + effectBudgetLimit: "10", + }); + const principal = await authenticateToken( + database, + config, + key.token, + "test", + ); + principals.push(principal); + await execute(kernel, principal, `${tenantId}-entity`, { + op: "put_entity", + entity: { + entityId: "service:fairness", + entityType: "service", + canonicalName: "Fairness Service", + }, + }); + await execute(kernel, principal, `${tenantId}-decision`, { + op: "assert", + assertion: { + assertionId: "assertion:decision", + subjectEntityId: "service:fairness", + predicate: "perform_test_effect", + object: { type: "boolean", value: true }, + kind: "decision", + }, + }); + await execute(kernel, principal, `${tenantId}-policy`, { + op: "assert", + assertion: { + assertionId: "assertion:policy", + subjectEntityId: "service:fairness", + predicate: "test_effect_policy", + object: { type: "string", value: "allow" }, + kind: "directive", + }, + }); + await execute(kernel, principal, `${tenantId}-workflow`, { + op: "create_workflow", + instanceId: "incident:fairness", + workflowType: "test", + initialState: "ready", + data: {}, + }); + for (let index = 0; index < count; index += 1) { + const effect = await execute( + kernel, + principal, + `${tenantId}-effect-${index}`, + { + op: "request_effect", + instanceId: "incident:fairness", + expectedRevision: 1, + effectName: `fairness_${index}`, + effectType: "test.effect", + target: "https://payments.example.com/apply", + statusUrl: "https://payments.example.com/status", + request: { index }, + idempotencyKey: `${tenantId}-provider-${index}`, + decisionAssertionId: "assertion:decision", + policyAssertionId: "assertion:policy", + budgetAmount: "1", + currency: "USD", + }, + ); + effectTenants.set( + field(effect.result, "effectId"), + tenantId, + ); + } + } + const deliveredTenants: string[] = []; + const worker = new EffectWorker( + database, + { + deliver: async ({ effectId }) => { + const tenantId = effectTenants.get(effectId); + assert.ok(tenantId); + deliveredTenants.push(tenantId); + return { + status: "succeeded", + responseStatus: 200, + outcome: { providerReference: effectId }, + }; + }, + reconcile: async () => ({ + status: "unknown", + responseStatus: 200, + outcome: { status: "pending" }, + }), + }, + config, + metrics, + logger, + ); + assert.equal(await worker.runOnce(), true); + assert.equal(await worker.runOnce(), true); + assert.equal(await worker.runOnce(), true); + assert.deepEqual(deliveredTenants, [tenantA, tenantB, tenantA]); + + const abortEffect = await execute( + kernel, + principals[0]!, + `${tenantA}-abort-effect`, + { + op: "request_effect", + instanceId: "incident:fairness", + expectedRevision: 1, + effectName: "abortable_effect", + effectType: "test.effect", + target: "https://payments.example.com/apply", + statusUrl: "https://payments.example.com/status", + request: { mode: "abort" }, + idempotencyKey: `${tenantA}-abort-provider`, + decisionAssertionId: "assertion:decision", + policyAssertionId: "assertion:policy", + budgetAmount: "1", + currency: "USD", + }, + ); + const abortEffectId = field(abortEffect.result, "effectId"); + let deliveryStartedResolve: (() => void) | undefined; + const deliveryStarted = new Promise((resolve) => { + deliveryStartedResolve = resolve; + }); + let reconciliationStartedResolve: (() => void) | undefined; + const reconciliationStarted = new Promise((resolve) => { + reconciliationStartedResolve = resolve; + }); + let abortDeliveries = 0; + let abortReconciliations = 0; + const abortingWorker = new EffectWorker( + database, + { + deliver: async (_effect, signal) => { + abortDeliveries += 1; + deliveryStartedResolve?.(); + await waitForAbort(signal); + return { + status: "unknown", + responseStatus: null, + outcome: { reason: "shutdown" }, + }; + }, + reconcile: async (_effect, signal) => { + abortReconciliations += 1; + reconciliationStartedResolve?.(); + await waitForAbort(signal); + return { + status: "unknown", + responseStatus: null, + outcome: { reason: "shutdown" }, + }; + }, + }, + { effectLeaseSeconds: 30, effectMaxAttempts: 1 }, + metrics, + logger, + ); + const deliveryController = new AbortController(); + const deliveryRun = abortingWorker.runOnce( + { tenantId: tenantA, effectId: abortEffectId }, + deliveryController.signal, + ); + await deliveryStarted; + deliveryController.abort(); + assert.equal(await deliveryRun, true); + await database.withTenantTransaction(principals[0]!, (client) => + client.query( + `UPDATE agentic.effect_intents + SET next_attempt_at = clock_timestamp() + WHERE effect_id = $1`, + [abortEffectId], + ), + ); + const reconciliationController = new AbortController(); + const reconciliationRun = abortingWorker.runOnce( + { tenantId: tenantA, effectId: abortEffectId }, + reconciliationController.signal, + ); + await reconciliationStarted; + reconciliationController.abort(); + assert.equal(await reconciliationRun, true); + assert.equal(abortDeliveries, 1); + assert.equal(abortReconciliations, 1); + await database.withTenantTransaction(principals[0]!, (client) => + client.query( + `UPDATE agentic.effect_intents + SET next_attempt_at = clock_timestamp() + WHERE effect_id = $1`, + [abortEffectId], + ), + ); + const recoveringWorker = new EffectWorker( + database, + { + deliver: async () => { + throw new Error("Reconciliation must not redeliver"); + }, + reconcile: async () => { + abortReconciliations += 1; + return { + status: "succeeded", + responseStatus: 200, + outcome: { providerReference: abortEffectId }, + }; + }, + }, + { effectLeaseSeconds: 30, effectMaxAttempts: 1 }, + metrics, + logger, + ); + assert.equal( + await recoveringWorker.runOnce({ + tenantId: tenantA, + effectId: abortEffectId, + }), + true, + ); + assert.equal(abortReconciliations, 2); + const recovered = await execute( + kernel, + principals[0]!, + `${tenantA}-abort-result`, + { + op: "list_effects", + instanceId: "incident:fairness", + }, + ); + assert.equal( + field( + findArrayItemByField( + recovered.result, + "effectId", + abortEffectId, + ), + "status", + ), + "succeeded", + ); + } finally { + for (const principal of principals) { + await cleanupTenant(database, principal); + } + await database.withSystemTransaction(async (client) => { + await client.query( + "DELETE FROM agentic_auth.api_keys WHERE tenant_id = ANY($1)", + [[tenantA, tenantB]], + ); + await client.query( + "DELETE FROM agentic_auth.tenants WHERE tenant_id = ANY($1)", + [[tenantA, tenantB]], + ); + }); + await database.close(); + rmSync(artifactDirectory, { recursive: true, force: true }); + } + }, +); + +test( + "SIGTERM drains an active production HTTP request", + { + skip: + !databaseUrl || + !migrationDatabaseUrl || + process.platform === "win32", + }, + async () => { + assert.ok(databaseUrl); + assert.ok(migrationDatabaseUrl); + const artifactDirectory = mkdtempSync( + join(tmpdir(), "agentic-data-http-shutdown-"), + ); + const config = testConfig(databaseUrl, artifactDirectory); + await migratePostgres( + testConfig(migrationDatabaseUrl, artifactDirectory), + undefined, + configuredEmbeddingSpace(config), + ); + const database = new ProductionDatabase(config); + const kernel = new ProductionKernel( + database, + new EncryptedArtifactStore( + artifactDirectory, + config.artifactKeyring, + ), + new TestEmbeddingProvider(), + config, + new MetricsRegistry(), + createLogger(config), + ); + const tenantId = `http-shutdown-${randomUUID()}`; + const key = await createApiKey(database, config, { + tenantId, + tenantName: "HTTP Shutdown", + principalId: "shutdown-agent", + scopes: ["data:read", "data:write"], + purposes: ["test"], + effectBudgetCurrency: "USD", + effectBudgetLimit: "0", + }); + const principal = await authenticateToken( + database, + config, + key.token, + "test", + ); + await execute(kernel, principal, "shutdown-entity", { + op: "put_entity", + entity: { + entityId: "service:shutdown", + entityType: "service", + canonicalName: "Shutdown Service", + }, + }); + + let releaseEmbeddingResolve: (() => void) | undefined; + const releaseEmbedding = new Promise((resolve) => { + releaseEmbeddingResolve = resolve; + }); + let embeddingStartedResolve: (() => void) | undefined; + const embeddingStarted = new Promise((resolve) => { + embeddingStartedResolve = resolve; + }); + const embeddingServer = createServer(async (request, response) => { + for await (const _chunk of request) { + // Drain the request before waiting so the client can finish sending. + } + embeddingStartedResolve?.(); + await releaseEmbedding; + response.writeHead(200, { "content-type": "application/json" }); + response.end( + JSON.stringify({ + data: [{ index: 0, embedding: vector(1) }], + model: config.embeddingModel, + }), + ); + }); + await new Promise((resolve) => + embeddingServer.listen(0, "127.0.0.1", resolve), + ); + const embeddingPort = + (embeddingServer.address() as AddressInfo).port; + const applicationPort = await reservePort(); + let child: ChildProcessWithoutNullStreams | null = null; + try { + const spawned = spawn( + process.execPath, + [join(process.cwd(), "dist", "production", "cli.js"), "serve"], + { + cwd: process.cwd(), + env: { + ...process.env, + DATABASE_URL: databaseUrl, + DATABASE_SSL: "disable", + AUTH_PEPPER: config.authPepper, + ARTIFACT_KEYRING: JSON.stringify({ + v1: config.artifactKeyring.keys.get("v1")?.toString("base64"), + }), + ARTIFACT_CURRENT_KEY_ID: "v1", + ARTIFACT_DIR: artifactDirectory, + EMBEDDING_BASE_URL: + `http://127.0.0.1:${embeddingPort}/v1`, + EMBEDDING_API_KEY: "test-embedding-key", + EMBEDDING_MODEL: config.embeddingModel, + EMBEDDING_VERSION: config.embeddingVersion, + EMBEDDING_DIMENSIONS: String(config.embeddingDimensions), + HOST: "127.0.0.1", + PORT: String(applicationPort), + LOG_LEVEL: "silent", + TRUSTED_PROXY_HOPS: "0", + SHUTDOWN_TIMEOUT_MS: "5000", + }, + stdio: ["pipe", "pipe", "pipe"], + }, + ); + child = spawned; + spawned.stdin.end(); + let output = ""; + spawned.stdout.on("data", (chunk) => { + output += chunk.toString(); + }); + spawned.stderr.on("data", (chunk) => { + output += chunk.toString(); + }); + const exit = new Promise<{ + code: number | null; + signal: NodeJS.Signals | null; + }>((resolve) => { + spawned.once("exit", (code, signal) => + resolve({ code, signal }), + ); + }); + await waitForEndpoint( + `http://127.0.0.1:${applicationPort}/health/live`, + spawned, + () => output, + ); + const request = fetch( + `http://127.0.0.1:${applicationPort}/v1/execute`, + { + method: "POST", + headers: { + authorization: ["Bearer", key.token].join(" "), + "content-type": "application/json", + "x-agent-purpose": "test", + }, + body: JSON.stringify({ + protocolVersion: "1.0", + requestId: `shutdown-request-${randomUUID()}`, + principal: { + tenantId, + principalId: principal.principalId, + purpose: "test", + }, + operation: { + op: "assert", + assertion: { + assertionId: "assertion:shutdown", + subjectEntityId: "service:shutdown", + predicate: "shutdown_test", + object: { type: "boolean", value: true }, + kind: "observation", + }, + }, + }), + }, + ); + await withTimeout(embeddingStarted, 10_000, "embedding request"); + assert.equal(spawned.kill("SIGTERM"), true); + await new Promise((resolve) => setTimeout(resolve, 100)); + releaseEmbeddingResolve?.(); + assert.equal((await request).status, 200); + const exited = await withTimeout(exit, 10_000, "server shutdown"); + assert.deepEqual(exited, { code: 0, signal: null }); + } finally { + releaseEmbeddingResolve?.(); + if (child?.exitCode === null && child.signalCode === null) { + child.kill("SIGKILL"); + } + await new Promise((resolve) => + embeddingServer.close(() => resolve()), + ); + await cleanupTenant(database, principal); + await database.withSystemTransaction(async (client) => { + await client.query( + "DELETE FROM agentic_auth.api_keys WHERE tenant_id = $1", + [tenantId], + ); + await client.query( + "DELETE FROM agentic_auth.tenants WHERE tenant_id = $1", + [tenantId], + ); + }); + await database.close(); + rmSync(artifactDirectory, { recursive: true, force: true }); + } + }, +); + +test( + "production startup failure closes the database pool", + { + skip: + !migrationDatabaseUrl || + process.platform === "win32", + }, + async () => { + assert.ok(migrationDatabaseUrl); + const artifactDirectory = mkdtempSync( + join(tmpdir(), "agentic-data-startup-failure-"), + ); + const config = testConfig( + migrationDatabaseUrl, + artifactDirectory, + ); + const child = spawn( + process.execPath, + [join(process.cwd(), "dist", "production", "cli.js"), "serve"], + { + cwd: process.cwd(), + env: { + ...process.env, + DATABASE_URL: migrationDatabaseUrl, + DATABASE_SSL: "disable", + AUTH_PEPPER: config.authPepper, + ARTIFACT_KEYRING: JSON.stringify({ + v1: config.artifactKeyring.keys.get("v1")?.toString("base64"), + }), + ARTIFACT_CURRENT_KEY_ID: "v1", + ARTIFACT_DIR: artifactDirectory, + EMBEDDING_BASE_URL: "https://embeddings.example.com/v1", + EMBEDDING_API_KEY: "test-embedding-key", + EMBEDDING_MODEL: config.embeddingModel, + EMBEDDING_VERSION: config.embeddingVersion, + EMBEDDING_DIMENSIONS: String(config.embeddingDimensions), + HOST: "127.0.0.1", + PORT: String(await reservePort()), + LOG_LEVEL: "silent", + }, + stdio: ["pipe", "pipe", "pipe"], + }, + ); + child.stdin.end(); + let output = ""; + child.stdout.on("data", (chunk) => { + output += chunk.toString(); + }); + child.stderr.on("data", (chunk) => { + output += chunk.toString(); + }); + try { + const exited = await withTimeout( + new Promise<{ + code: number | null; + signal: NodeJS.Signals | null; + }>((resolve) => { + child.once("exit", (code, signal) => + resolve({ code, signal }), + ); + }), + 10_000, + "startup failure", + ); + assert.deepEqual(exited, { code: 1, signal: null }); + assert.match(output, /restricted agentic_app database role/); + } finally { + if (child.exitCode === null && child.signalCode === null) { + child.kill("SIGKILL"); + } + rmSync(artifactDirectory, { recursive: true, force: true }); + } + }, +); + +test("OpenAI-compatible embedding provider validates real response shape", async () => { + const server = createServer(async (request, response) => { + assert.equal(request.headers.authorization, "Bearer test-key"); + const chunks: Buffer[] = []; + for await (const chunk of request) { + chunks.push(Buffer.from(chunk)); + } + const body = JSON.parse(Buffer.concat(chunks).toString("utf8")) as { + input: string[]; + dimensions: number; + }; + assert.equal(body.dimensions, 1536); + response.writeHead(200, { "content-type": "application/json" }); + response.end( + JSON.stringify({ + data: body.input.map((_, index) => ({ + index, + embedding: vector(index + 1), })), model: "test-model", }), ); }); + await test("embedding provider rejects redirects without forwarding content", async () => { + let forwarded = 0; + const target = createServer((_request, response) => { + forwarded += 1; + response.writeHead(200, { "content-type": "application/json" }); + response.end("{}"); + }); + await new Promise((resolve) => + target.listen(0, "127.0.0.1", resolve), + ); + const targetPort = (target.address() as AddressInfo).port; + const redirect = createServer((_request, response) => { + response.writeHead(307, { + location: `http://127.0.0.1:${targetPort}/embeddings`, + }); + response.end(); + }); + await new Promise((resolve) => + redirect.listen(0, "127.0.0.1", resolve), + ); + const redirectPort = (redirect.address() as AddressInfo).port; + try { + const provider = new OpenAiCompatibleEmbeddingProvider( + `http://127.0.0.1:${redirectPort}`, + "test-key", + "test-model", + 1536, + 5_000, + ); + await assert.rejects( + () => provider.embed(["sensitive artifact content"]), + /redirect|fetch failed/i, + ); + assert.equal(forwarded, 0); + } finally { + await new Promise((resolve) => + redirect.close(() => resolve()), + ); + await new Promise((resolve) => + target.close(() => resolve()), + ); + } + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); const port = (server.address() as AddressInfo).port; try { @@ -2470,6 +3796,10 @@ function testConfig( logLevel: "silent", maxBodyBytes: 1_000_000, rateLimitPerMinute: 1_000, + trustedProxyHops: 0, + shutdownTimeoutMs: 10_000, + workerMonitorHost: "127.0.0.1", + workerMonitorPort: 4319, }; } @@ -2480,7 +3810,7 @@ async function execute( operation: AgentOperation, ) { return kernel.execute(principal, { - protocolVersion: "0.1", + protocolVersion: "1.0", requestId: `${key}-${randomUUID()}`, idempotencyKey: key, principal: { @@ -2544,6 +3874,76 @@ function findArrayItemByField( return match; } +function waitForAbort(signal: AbortSignal | undefined): Promise { + if (!signal) { + throw new Error("Expected an abort signal"); + } + if (signal.aborted) { + return Promise.resolve(); + } + return new Promise((resolve) => { + signal.addEventListener("abort", () => resolve(), { once: true }); + }); +} + +async function reservePort(): Promise { + const server = createServer(); + await new Promise((resolve) => + server.listen(0, "127.0.0.1", resolve), + ); + const port = (server.address() as AddressInfo).port; + await new Promise((resolve) => server.close(() => resolve())); + return port; +} + +async function waitForEndpoint( + url: string, + child: ChildProcessWithoutNullStreams, + output: () => string, +): Promise { + const deadline = Date.now() + 10_000; + while (Date.now() < deadline) { + if (child.exitCode !== null || child.signalCode !== null) { + throw new Error( + `Production server exited before readiness: ${output()}`, + ); + } + try { + const response = await fetch(url); + if (response.ok) { + return; + } + } catch { + // Startup connection failures are expected until the listener is ready. + } + await new Promise((resolve) => setTimeout(resolve, 100)); + } + throw new Error(`Production server did not become ready: ${output()}`); +} + +async function withTimeout( + promise: Promise, + timeoutMs: number, + description: string, +): Promise { + let timeout: NodeJS.Timeout | undefined; + try { + return await Promise.race([ + promise, + new Promise((_resolve, reject) => { + timeout = setTimeout( + () => reject(new Error(`Timed out waiting for ${description}`)), + timeoutMs, + ); + }), + ]); + } finally { + if (timeout) { + clearTimeout(timeout); + } + } +} + function listFiles(directory: string): string[] { const files: string[] = []; for (const entry of readdirSync(directory, { @@ -2664,6 +4064,7 @@ async function cleanupTenant( } async function testProductionMcp( + database: ProductionDatabase, kernel: ProductionKernel, principal: AuthenticatedPrincipal, ): Promise { @@ -2688,6 +4089,18 @@ async function testProductionMcp( arguments: { instanceId: "order:order-1" }, }); assert.ok(Array.isArray(result.content)); + await database.withTenantWriteTransaction(principal, (dbClient) => + revokeApiKey(dbClient, principal.keyId), + ); + const revokedResult = await client.callTool({ + name: "get_machine", + arguments: { instanceId: "order:order-1" }, + }); + assert.equal(revokedResult.isError, true); + assert.match( + JSON.stringify(revokedResult.content), + /Invalid or revoked API key/, + ); } finally { await client.close(); await server.close(); diff --git a/src/test/sre-scenario.test.ts b/src/test/sre-scenario.test.ts index de1fdfa..8eef613 100644 --- a/src/test/sre-scenario.test.ts +++ b/src/test/sre-scenario.test.ts @@ -249,6 +249,10 @@ function testConfig( logLevel: "silent", maxBodyBytes: 1_000_000, rateLimitPerMinute: 1_000, + trustedProxyHops: 0, + shutdownTimeoutMs: 10_000, + workerMonitorHost: "127.0.0.1", + workerMonitorPort: 4319, }; } diff --git a/src/types.ts b/src/types.ts index 81591f3..1e3dc13 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,3 +1,5 @@ +import type { AgentIntentVersion } from "./version.js"; + export type JsonPrimitive = string | number | boolean | null; export type JsonValue = | JsonPrimitive @@ -247,6 +249,11 @@ export interface EffectRecord { updatedAt: string; } +export interface EffectListQuery { + afterEffectId?: string; + limit?: number; +} + export type LineageRelation = | "evidence_for" | "supports" @@ -388,7 +395,8 @@ export interface OperationLayerCatalog { } export interface CatalogDescription { - protocolVersion: "0.1"; + protocolVersion: AgentIntentVersion; + supportedProtocolVersions: AgentIntentVersion[]; storage: string; operations: string[]; operationLayers?: OperationLayerCatalog; diff --git a/src/version.ts b/src/version.ts new file mode 100644 index 0000000..3f0ea47 --- /dev/null +++ b/src/version.ts @@ -0,0 +1,11 @@ +export const PACKAGE_VERSION = "0.3.0-alpha.5"; +export const AGENT_INTENT_VERSION = "1.0" as const; +export const LEGACY_AGENT_INTENT_VERSION = "0.1" as const; +export const SUPPORTED_AGENT_INTENT_VERSIONS = [ + LEGACY_AGENT_INTENT_VERSION, + AGENT_INTENT_VERSION, +] as const; + +export type AgentIntentVersion = + (typeof SUPPORTED_AGENT_INTENT_VERSIONS)[number]; + From cd59ab40c5121061fd1b93c197444724284b17a0 Mon Sep 17 00:00:00 2001 From: Jason Doyle Date: Thu, 3 Sep 2026 23:29:40 -0700 Subject: [PATCH 2/7] Refresh stable benchmark evidence --- README.md | 4 ++-- benchmarks/sre/results/report.md | 14 +++++++------- benchmarks/sre/results/summary.json | 26 +++++++++++++------------- 3 files changed, 22 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 5a4ce22..4091ef4 100644 --- a/README.md +++ b/README.md @@ -143,12 +143,12 @@ stale or inconsistent published evidence. | Application-authored tables | 8 | 0 | | Total operated tables | 8 | 18 | | Median database footprint | 540,672 bytes | 1,572,864 bytes | -| Informational median runtime | 58.12 ms | 876.17 ms | +| Informational median runtime | 68.48 ms | 893.78 ms | The result is deliberately not presented as a universal win: - PostgreSQL matches the kernel on correctness. -- The smaller adapter reuses 929 lines of shipped scenario code and a 13,750 +- The smaller adapter reuses 930 lines of shipped scenario code and a 14,962 line dependency. It is not an equal from-scratch implementation comparison. - The kernel operates more tables, uses more storage, and takes substantially longer in this deterministic smoke run. diff --git a/benchmarks/sre/results/report.md b/benchmarks/sre/results/report.md index a4e3793..397acc4 100644 --- a/benchmarks/sre/results/report.md +++ b/benchmarks/sre/results/report.md @@ -2,9 +2,9 @@ Generated from `summary.json`. -Source revision: `6163d2bc1f9a86a6947b30cd59523e3b0fb14246` +Source revision: `f495273a2d47d51634e4748ae600ad251ce7a543` -Source hash: `8ee99637371fc2d2492fcd107c03e72c0fb4d5b028a75dcc55496008dfcbd757` +Source hash: `f399fa26748192a140f47613d7ca887c8db737b82b7ef900e7ee62dee19188d9` ## Correctness @@ -23,12 +23,12 @@ Both variants must resolve every run with one delivery and one reconciliation. | Agentic Data Kernel adapter | 43 | 0 | 18 | The adapter delegates to the shipped SRE scenario, which contains -929 nonblank TypeScript source lines inside the -dependency. The full kernel dependency contains 14181 +930 nonblank TypeScript source lines inside the +dependency. The full kernel dependency contains 14962 nonblank TypeScript source lines. The benchmark runner and engine-specific audit verification contain -1439 nonblank TypeScript source lines. +1443 nonblank TypeScript source lines. They are excluded from both application columns. Dependency and harness code is not application-authored, but it remains code that must be understood, operated, or upgraded. @@ -44,8 +44,8 @@ operated, or upgraded. | Variant | Median milliseconds | | --- | ---: | -| Conventional PostgreSQL | 105.72 | -| Agentic Data Kernel | 1164.60 | +| Conventional PostgreSQL | 68.48 | +| Agentic Data Kernel | 893.78 | Runtime is not a headline metric. The variants perform different work and this deterministic smoke benchmark is not a latency study. diff --git a/benchmarks/sre/results/summary.json b/benchmarks/sre/results/summary.json index ad20010..fd6c75f 100644 --- a/benchmarks/sre/results/summary.json +++ b/benchmarks/sre/results/summary.json @@ -4,8 +4,8 @@ "environment": { "node": "v22.22.2", "postgres": "18.6 (Debian 18.6-1.pgdg12+2)", - "commit": "6163d2bc1f9a86a6947b30cd59523e3b0fb14246", - "sourceHash": "8ee99637371fc2d2492fcd107c03e72c0fb4d5b028a75dcc55496008dfcbd757" + "commit": "f495273a2d47d51634e4748ae600ad251ce7a543", + "sourceHash": "f399fa26748192a140f47613d7ca887c8db737b82b7ef900e7ee62dee19188d9" }, "runs": [ { @@ -31,7 +31,7 @@ "provider reconciliation": true, "verification and terminal state": true }, - "durationMs": 97.3474 + "durationMs": 72.3872 }, "operatedTables": 8, "databaseBytes": 540672 @@ -59,7 +59,7 @@ "provider reconciliation": true, "verification and terminal state": true }, - "durationMs": 1164.5978 + "durationMs": 964.778 }, "operatedTables": 18, "databaseBytes": 1572864 @@ -87,7 +87,7 @@ "provider reconciliation": true, "verification and terminal state": true }, - "durationMs": 153.6588999999999 + "durationMs": 51.30340000000024 }, "operatedTables": 8, "databaseBytes": 540672 @@ -115,7 +115,7 @@ "provider reconciliation": true, "verification and terminal state": true }, - "durationMs": 3162.3774 + "durationMs": 893.7844 }, "operatedTables": 18, "databaseBytes": 1572864 @@ -143,7 +143,7 @@ "provider reconciliation": true, "verification and terminal state": true }, - "durationMs": 105.72109999999975 + "durationMs": 68.48119999999972 }, "operatedTables": 8, "databaseBytes": 540672 @@ -171,7 +171,7 @@ "provider reconciliation": true, "verification and terminal state": true }, - "durationMs": 1130.8733000000002 + "durationMs": 866.1862999999998 }, "operatedTables": 18, "databaseBytes": 1572864 @@ -265,20 +265,20 @@ "nonblankLines": 43, "authoredTables": 0, "operatedTables": 18, - "scenarioSourceLines": 929, - "dependencySourceLines": 14181 + "scenarioSourceLines": 930, + "dependencySourceLines": 14962 } }, "benchmarkHarness": { - "nonblankLines": 1439 + "nonblankLines": 1443 }, "databaseBytes": { "conventionalPostgresMedian": 540672, "agenticDataKernelMedian": 1572864 }, "runtimeMillisecondsInformational": { - "conventionalPostgresMedian": 105.72109999999975, - "agenticDataKernelMedian": 1164.5978 + "conventionalPostgresMedian": 68.48119999999972, + "agenticDataKernelMedian": 893.7844 }, "explanationQuestions": 9, "claims": { From 07bf161eb2b94343f5301ab1e8a9bcf09f6a69e8 Mon Sep 17 00:00:00 2001 From: Jason Doyle Date: Thu, 3 Sep 2026 23:30:01 -0700 Subject: [PATCH 3/7] Clean release files --- scripts/test-backup-restore.ps1 | 1 - scripts/validate-version.mjs | 1 - src/version.ts | 1 - 3 files changed, 3 deletions(-) diff --git a/scripts/test-backup-restore.ps1 b/scripts/test-backup-restore.ps1 index 2c5b1da..cb7d2f3 100644 --- a/scripts/test-backup-restore.ps1 +++ b/scripts/test-backup-restore.ps1 @@ -45,4 +45,3 @@ try { } } } - diff --git a/scripts/validate-version.mjs b/scripts/validate-version.mjs index 32fe4d2..3823875 100644 --- a/scripts/validate-version.mjs +++ b/scripts/validate-version.mjs @@ -11,4 +11,3 @@ if (match?.[1] !== packageManifest.version) { ); } console.log(`Validated source version ${packageManifest.version}`); - diff --git a/src/version.ts b/src/version.ts index 3f0ea47..659010f 100644 --- a/src/version.ts +++ b/src/version.ts @@ -8,4 +8,3 @@ export const SUPPORTED_AGENT_INTENT_VERSIONS = [ export type AgentIntentVersion = (typeof SUPPORTED_AGENT_INTENT_VERSIONS)[number]; - From 878f8ed61260a17e10fd23e7a9c32cafb74e5445 Mon Sep 17 00:00:00 2001 From: Jason Doyle Date: Thu, 3 Sep 2026 23:30:22 -0700 Subject: [PATCH 4/7] Refresh stable benchmark evidence --- README.md | 2 +- benchmarks/sre/results/report.md | 8 ++++---- benchmarks/sre/results/summary.json | 20 ++++++++++---------- 3 files changed, 15 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 4091ef4..6c0f2a9 100644 --- a/README.md +++ b/README.md @@ -143,7 +143,7 @@ stale or inconsistent published evidence. | Application-authored tables | 8 | 0 | | Total operated tables | 8 | 18 | | Median database footprint | 540,672 bytes | 1,572,864 bytes | -| Informational median runtime | 68.48 ms | 893.78 ms | +| Informational median runtime | 52.52 ms | 894.92 ms | The result is deliberately not presented as a universal win: diff --git a/benchmarks/sre/results/report.md b/benchmarks/sre/results/report.md index 397acc4..84caac4 100644 --- a/benchmarks/sre/results/report.md +++ b/benchmarks/sre/results/report.md @@ -2,9 +2,9 @@ Generated from `summary.json`. -Source revision: `f495273a2d47d51634e4748ae600ad251ce7a543` +Source revision: `07bf161eb2b94343f5301ab1e8a9bcf09f6a69e8` -Source hash: `f399fa26748192a140f47613d7ca887c8db737b82b7ef900e7ee62dee19188d9` +Source hash: `fc2462d4bd7a75f73b71eb2cf3551e9164af5c87591131a89c5a4e9b1b94976b` ## Correctness @@ -44,8 +44,8 @@ operated, or upgraded. | Variant | Median milliseconds | | --- | ---: | -| Conventional PostgreSQL | 68.48 | -| Agentic Data Kernel | 893.78 | +| Conventional PostgreSQL | 52.52 | +| Agentic Data Kernel | 894.92 | Runtime is not a headline metric. The variants perform different work and this deterministic smoke benchmark is not a latency study. diff --git a/benchmarks/sre/results/summary.json b/benchmarks/sre/results/summary.json index fd6c75f..20ad9f5 100644 --- a/benchmarks/sre/results/summary.json +++ b/benchmarks/sre/results/summary.json @@ -4,8 +4,8 @@ "environment": { "node": "v22.22.2", "postgres": "18.6 (Debian 18.6-1.pgdg12+2)", - "commit": "f495273a2d47d51634e4748ae600ad251ce7a543", - "sourceHash": "f399fa26748192a140f47613d7ca887c8db737b82b7ef900e7ee62dee19188d9" + "commit": "07bf161eb2b94343f5301ab1e8a9bcf09f6a69e8", + "sourceHash": "fc2462d4bd7a75f73b71eb2cf3551e9164af5c87591131a89c5a4e9b1b94976b" }, "runs": [ { @@ -31,7 +31,7 @@ "provider reconciliation": true, "verification and terminal state": true }, - "durationMs": 72.3872 + "durationMs": 67.00579999999997 }, "operatedTables": 8, "databaseBytes": 540672 @@ -59,7 +59,7 @@ "provider reconciliation": true, "verification and terminal state": true }, - "durationMs": 964.778 + "durationMs": 982.1896 }, "operatedTables": 18, "databaseBytes": 1572864 @@ -87,7 +87,7 @@ "provider reconciliation": true, "verification and terminal state": true }, - "durationMs": 51.30340000000024 + "durationMs": 49.897199999999884 }, "operatedTables": 8, "databaseBytes": 540672 @@ -115,7 +115,7 @@ "provider reconciliation": true, "verification and terminal state": true }, - "durationMs": 893.7844 + "durationMs": 894.9191999999998 }, "operatedTables": 18, "databaseBytes": 1572864 @@ -143,7 +143,7 @@ "provider reconciliation": true, "verification and terminal state": true }, - "durationMs": 68.48119999999972 + "durationMs": 52.52059999999983 }, "operatedTables": 8, "databaseBytes": 540672 @@ -171,7 +171,7 @@ "provider reconciliation": true, "verification and terminal state": true }, - "durationMs": 866.1862999999998 + "durationMs": 855.6321999999996 }, "operatedTables": 18, "databaseBytes": 1572864 @@ -277,8 +277,8 @@ "agenticDataKernelMedian": 1572864 }, "runtimeMillisecondsInformational": { - "conventionalPostgresMedian": 68.48119999999972, - "agenticDataKernelMedian": 893.7844 + "conventionalPostgresMedian": 52.52059999999983, + "agenticDataKernelMedian": 894.9191999999998 }, "explanationQuestions": 9, "claims": { From dd9b422a18c0c5d64dc358c73a8bebaf2605d2d7 Mon Sep 17 00:00:00 2001 From: Jason Doyle Date: Thu, 3 Sep 2026 23:43:15 -0700 Subject: [PATCH 5/7] Separate API key verifier data --- src/production/auth.ts | 46 ++++++++++++++++++++++++++---------------- 1 file changed, 29 insertions(+), 17 deletions(-) diff --git a/src/production/auth.ts b/src/production/auth.ts index b8870a5..a99c442 100644 --- a/src/production/auth.ts +++ b/src/production/auth.ts @@ -32,11 +32,10 @@ export interface CreateApiKeyInput { expiresAt?: string; } -interface ApiKeyRow { +interface ActiveApiKeyRow { key_id: string; tenant_id: string; principal_id: string; - token_hash: string; scopes: string[]; purposes: string[]; expires_at: Date | null; @@ -44,6 +43,10 @@ interface ApiKeyRow { tenant_active: boolean; } +interface AuthenticationApiKeyRow extends ActiveApiKeyRow { + token_hash: string; +} + export interface AuthenticatedPrincipal extends TenantContext { purposes: Set; } @@ -110,7 +113,7 @@ export async function authenticateToken( purpose: string, ): Promise { const parsed = parseToken(token); - const result = await database.query( + const result = await database.query( `SELECT k.key_id, k.tenant_id, @@ -127,31 +130,42 @@ export async function authenticateToken( [parsed.keyId], ); const row = result.rows[0]; - const activeRow = requireActiveApiKey(row); + assertActiveApiKey(row); const supplied = Buffer.from( await deriveTokenHash(token, config.authPepper, parsed.keyId), "hex", ); - const expected = Buffer.from(activeRow.token_hash, "hex"); + const expected = Buffer.from(row.token_hash, "hex"); if ( supplied.length !== expected.length || !timingSafeEqual(supplied, expected) ) { throw new AuthenticationError("Invalid or revoked API key"); } - return principalFromActiveRow(activeRow, purpose); + return principalFromActiveRow( + { + key_id: row.key_id, + tenant_id: row.tenant_id, + principal_id: row.principal_id, + scopes: row.scopes, + purposes: row.purposes, + expires_at: row.expires_at, + revoked_at: row.revoked_at, + tenant_active: row.tenant_active, + }, + purpose, + ); } export async function revalidatePrincipal( client: PoolClient, principal: AuthenticatedPrincipal, ): Promise { - const result = await client.query( + const result = await client.query( `SELECT k.key_id, k.tenant_id, k.principal_id, - k.token_hash, k.scopes, k.purposes, k.expires_at, @@ -164,26 +178,24 @@ export async function revalidatePrincipal( AND k.principal_id = $3`, [principal.keyId, principal.tenantId, principal.principalId], ); - return principalFromActiveRow( - requireActiveApiKey(result.rows[0]), - principal.purpose, - ); + const row = result.rows[0]; + assertActiveApiKey(row); + return principalFromActiveRow(row, principal.purpose); } -function requireActiveApiKey( - row: ApiKeyRow | undefined, -): ApiKeyRow { +function assertActiveApiKey( + row: ActiveApiKeyRow | undefined, +): asserts row is ActiveApiKeyRow { if (!row || !row.tenant_active || row.revoked_at !== null) { throw new AuthenticationError("Invalid or revoked API key"); } if (row.expires_at && row.expires_at.getTime() <= Date.now()) { throw new AuthenticationError("API key has expired"); } - return row; } function principalFromActiveRow( - row: ApiKeyRow, + row: ActiveApiKeyRow, purpose: string, ): AuthenticatedPrincipal { const purposes = new Set(row.purposes); From 97171ad33d36e14ac6544042cf7b85b3b664213e Mon Sep 17 00:00:00 2001 From: Jason Doyle Date: Thu, 3 Sep 2026 23:43:42 -0700 Subject: [PATCH 6/7] Refresh stable benchmark evidence --- README.md | 4 ++-- benchmarks/sre/results/report.md | 10 +++++----- benchmarks/sre/results/summary.json | 22 +++++++++++----------- 3 files changed, 18 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 6c0f2a9..44e31e8 100644 --- a/README.md +++ b/README.md @@ -143,12 +143,12 @@ stale or inconsistent published evidence. | Application-authored tables | 8 | 0 | | Total operated tables | 8 | 18 | | Median database footprint | 540,672 bytes | 1,572,864 bytes | -| Informational median runtime | 52.52 ms | 894.92 ms | +| Informational median runtime | 50.65 ms | 911.14 ms | The result is deliberately not presented as a universal win: - PostgreSQL matches the kernel on correctness. -- The smaller adapter reuses 930 lines of shipped scenario code and a 14,962 +- The smaller adapter reuses 930 lines of shipped scenario code and a 14,973 line dependency. It is not an equal from-scratch implementation comparison. - The kernel operates more tables, uses more storage, and takes substantially longer in this deterministic smoke run. diff --git a/benchmarks/sre/results/report.md b/benchmarks/sre/results/report.md index 84caac4..cf72373 100644 --- a/benchmarks/sre/results/report.md +++ b/benchmarks/sre/results/report.md @@ -2,9 +2,9 @@ Generated from `summary.json`. -Source revision: `07bf161eb2b94343f5301ab1e8a9bcf09f6a69e8` +Source revision: `dd9b422a18c0c5d64dc358c73a8bebaf2605d2d7` -Source hash: `fc2462d4bd7a75f73b71eb2cf3551e9164af5c87591131a89c5a4e9b1b94976b` +Source hash: `d9a990f81466c513ec346e0f9b75de922cc3b08ee62fb7d87c9acc40cbe037e4` ## Correctness @@ -24,7 +24,7 @@ Both variants must resolve every run with one delivery and one reconciliation. The adapter delegates to the shipped SRE scenario, which contains 930 nonblank TypeScript source lines inside the -dependency. The full kernel dependency contains 14962 +dependency. The full kernel dependency contains 14973 nonblank TypeScript source lines. The benchmark runner and engine-specific audit verification contain @@ -44,8 +44,8 @@ operated, or upgraded. | Variant | Median milliseconds | | --- | ---: | -| Conventional PostgreSQL | 52.52 | -| Agentic Data Kernel | 894.92 | +| Conventional PostgreSQL | 50.65 | +| Agentic Data Kernel | 911.14 | Runtime is not a headline metric. The variants perform different work and this deterministic smoke benchmark is not a latency study. diff --git a/benchmarks/sre/results/summary.json b/benchmarks/sre/results/summary.json index 20ad9f5..b525530 100644 --- a/benchmarks/sre/results/summary.json +++ b/benchmarks/sre/results/summary.json @@ -4,8 +4,8 @@ "environment": { "node": "v22.22.2", "postgres": "18.6 (Debian 18.6-1.pgdg12+2)", - "commit": "07bf161eb2b94343f5301ab1e8a9bcf09f6a69e8", - "sourceHash": "fc2462d4bd7a75f73b71eb2cf3551e9164af5c87591131a89c5a4e9b1b94976b" + "commit": "dd9b422a18c0c5d64dc358c73a8bebaf2605d2d7", + "sourceHash": "d9a990f81466c513ec346e0f9b75de922cc3b08ee62fb7d87c9acc40cbe037e4" }, "runs": [ { @@ -31,7 +31,7 @@ "provider reconciliation": true, "verification and terminal state": true }, - "durationMs": 67.00579999999997 + "durationMs": 56.28770000000003 }, "operatedTables": 8, "databaseBytes": 540672 @@ -59,7 +59,7 @@ "provider reconciliation": true, "verification and terminal state": true }, - "durationMs": 982.1896 + "durationMs": 983.6014 }, "operatedTables": 18, "databaseBytes": 1572864 @@ -87,7 +87,7 @@ "provider reconciliation": true, "verification and terminal state": true }, - "durationMs": 49.897199999999884 + "durationMs": 50.64720000000011 }, "operatedTables": 8, "databaseBytes": 540672 @@ -115,7 +115,7 @@ "provider reconciliation": true, "verification and terminal state": true }, - "durationMs": 894.9191999999998 + "durationMs": 880.0989 }, "operatedTables": 18, "databaseBytes": 1572864 @@ -143,7 +143,7 @@ "provider reconciliation": true, "verification and terminal state": true }, - "durationMs": 52.52059999999983 + "durationMs": 49.38270000000011 }, "operatedTables": 8, "databaseBytes": 540672 @@ -171,7 +171,7 @@ "provider reconciliation": true, "verification and terminal state": true }, - "durationMs": 855.6321999999996 + "durationMs": 911.1373000000003 }, "operatedTables": 18, "databaseBytes": 1572864 @@ -266,7 +266,7 @@ "authoredTables": 0, "operatedTables": 18, "scenarioSourceLines": 930, - "dependencySourceLines": 14962 + "dependencySourceLines": 14973 } }, "benchmarkHarness": { @@ -277,8 +277,8 @@ "agenticDataKernelMedian": 1572864 }, "runtimeMillisecondsInformational": { - "conventionalPostgresMedian": 52.52059999999983, - "agenticDataKernelMedian": 894.9191999999998 + "conventionalPostgresMedian": 50.64720000000011, + "agenticDataKernelMedian": 911.1373000000003 }, "explanationQuestions": 9, "claims": { From 7a2aa3897da8a82ae6a816a5d712d97803e35054 Mon Sep 17 00:00:00 2001 From: Jason Doyle Date: Thu, 3 Sep 2026 23:59:09 -0700 Subject: [PATCH 7/7] Speed deterministic container builds --- Dockerfile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index 8679b9a..4e2869c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,12 +2,12 @@ FROM node:22-bookworm-slim AS build WORKDIR /app COPY package.json package-lock.json ./ -RUN npm ci +RUN npm ci --no-audit --no-fund COPY tsconfig.json tsconfig.examples.json tsconfig.examples.build.json ./ COPY src ./src COPY examples ./examples RUN npm run build -RUN npm prune --omit=dev +RUN npm prune --omit=dev --no-audit --no-fund FROM node:22-bookworm-slim AS runtime