diff --git a/README.md b/README.md index cc96fe7..88e0b13 100644 --- a/README.md +++ b/README.md @@ -11,12 +11,15 @@ return.check_eligibility -> return.create -> pickup.schedule The prototype includes: - `actions.json`-style manifest schema and validation. +- `.well-known/constraint-net/actions.json` manifest discovery. - A SoundMart demo manifest. - Manifest ingestion at `POST /v1/manifests`. -- A coherence path planner. +- Generic capability graph planning from action metadata. - Tier 0-2 preflight and confirmation rules. +- Idempotent, replay-safe execution. - Mock OpenAPI-backed execution. -- Signed intent, consent, and execution receipts. +- Signed intent, consent, and execution receipts with chain verification. +- A minimal `constraint-net` CLI. - A Fastify browser demo console at `/`. ## Run @@ -39,6 +42,15 @@ pnpm test pnpm typecheck ``` +## Protocol Quickstart + +```bash +pnpm cli validate examples/soundmart/actions.json +pnpm dev +pnpm cli ingest-url http://127.0.0.1:4173/.well-known/constraint-net/actions.json --server http://127.0.0.1:4173 +pnpm cli plan --goal "Return my headphones from SoundMart and choose the fastest free pickup" --merchant soundmart.example --server http://127.0.0.1:4173 +``` + ## Demo Flow In the browser console: @@ -57,8 +69,16 @@ The executions return signed receipt IDs for intent, consent, and execution. - `GET /v1/health` - `POST /v1/manifests` +- `POST /v1/manifests/ingest-url` - `POST /v1/actions/search` - `POST /v1/executions/preflight` - `POST /v1/confirmations/:id/decision` - `POST /v1/executions` - `GET /v1/receipts/:id` +- `POST /v1/receipts/verify` + +## Docs + +- [Protocol overview](docs/protocol.md) +- [Publisher onboarding](docs/publisher-onboarding.md) +- [Agent builder guide](docs/agent-builder-guide.md) diff --git a/docs/agent-builder-guide.md b/docs/agent-builder-guide.md new file mode 100644 index 0000000..78772c0 --- /dev/null +++ b/docs/agent-builder-guide.md @@ -0,0 +1,106 @@ +# Agent Builder Guide + +This guide shows the minimum flow an agent should use with Constraint Net. + +## 1. Ingest a Publisher Manifest + +```http +POST /v1/manifests/ingest-url +content-type: application/json +``` + +```json +{ + "url": "https://soundmart.example/.well-known/constraint-net/actions.json" +} +``` + +## 2. Plan an Action Path + +```http +POST /v1/actions/search +content-type: application/json +``` + +```json +{ + "goal": "Return my headphones from SoundMart and choose the fastest free pickup", + "constraints": { + "merchant": "soundmart.example", + "risk_tiers_allowed": [0, 1, 2], + "requires_reversible": true + }, + "available_inputs": { + "order_id": "ord_123", + "item_id": "item_headphones", + "reason": "changed_mind", + "pickup_window": "fastest_free_2026-04-29_09-12" + } +} +``` + +## 3. Preflight + +```http +POST /v1/executions/preflight +content-type: application/json +``` + +```json +{ + "action_id": "urn:action:soundmart.example:return.create:v1", + "manifest_digest": "sha256-...", + "inputs": { + "order_id": "ord_123", + "item_id": "item_headphones", + "reason": "changed_mind" + } +} +``` + +Tier 2 actions return `confirmation_required`. + +## 4. Confirm + +```http +POST /v1/confirmations/:id/decision +content-type: application/json +``` + +```json +{ + "decision": "confirm" +} +``` + +## 5. Execute + +```http +POST /v1/executions +content-type: application/json +``` + +```json +{ + "preflight_id": "pre_...", + "confirmation_id": "conf_...", + "idempotency_key": "agent-run-123" +} +``` + +Reusing the same `preflight_id` and `idempotency_key` returns the original execution result and receipt IDs. Reusing the same preflight with a different key is blocked after success. + +## 6. Verify Receipts + +```http +POST /v1/receipts/verify +content-type: application/json +``` + +```json +{ + "receipts": [] +} +``` + +The verifier checks signatures and hash links between receipts. diff --git a/docs/protocol.md b/docs/protocol.md new file mode 100644 index 0000000..c5901ba --- /dev/null +++ b/docs/protocol.md @@ -0,0 +1,45 @@ +# Constraint Net Protocol + +Constraint Net is a discovery, planning, consent, execution, and receipt protocol for agent-safe actions. + +The core idea is simple: agents should not scrape pages or blindly call APIs for side-effectful work. They should discover publisher-declared capabilities, plan a coherent path under constraints, ask for consent when risk requires it, execute with replay protection, and return receipts that can be verified later. + +## Publisher Manifests + +Publishers expose an actions manifest at: + +```text +https:///.well-known/constraint-net/actions.json +``` + +The manifest declares actions, schemas, planning metadata, risk tier, reversibility, confirmation policy, OpenAPI bindings, key discovery, and signatures. + +## Trust Rules + +- Manifests must validate against schema version `0.1`. +- Manifests must be active, unexpired, and signed. +- The manifest publisher domain must match the discovered domain. +- Tier 2 side effects must be reversible and require confirmation. +- Side-effectful OpenAPI actions must require idempotency. +- Execution must pin the manifest digest observed at preflight. + +## Agent Flow + +1. Discover a publisher manifest. +2. Validate and ingest it. +3. Plan a capability path from the user goal. +4. Preflight a selected step. +5. Ask the user to confirm Tier 2 side effects. +6. Execute with an idempotency key. +7. Verify intent, consent, and execution receipts. + +## Local Endpoints + +- `POST /v1/manifests` +- `POST /v1/manifests/ingest-url` +- `POST /v1/actions/search` +- `POST /v1/executions/preflight` +- `POST /v1/confirmations/:id/decision` +- `POST /v1/executions` +- `GET /v1/receipts/:id` +- `POST /v1/receipts/verify` diff --git a/docs/publisher-onboarding.md b/docs/publisher-onboarding.md new file mode 100644 index 0000000..c585a90 --- /dev/null +++ b/docs/publisher-onboarding.md @@ -0,0 +1,37 @@ +# Publisher Onboarding + +Use this checklist to make a publisher capability available to agents through Constraint Net. + +1. Define the actions the publisher is willing to expose. +2. Add input and output schemas for every action. +3. Add planning metadata: + - `intent_tags` + - `requires` + - `produces` + - optional `after` +4. Add risk, reversibility, confirmation, and idempotency metadata. +5. Add public keys under `key_discovery.public_keys`. +6. Sign the manifest over its canonical unsigned payload. +7. Host it at `/.well-known/constraint-net/actions.json`. +8. Validate it locally: + +```bash +pnpm cli validate examples/soundmart/actions.json +``` + +9. Ingest it into a running Constraint Net gateway: + +```bash +pnpm cli ingest-url https:///.well-known/constraint-net/actions.json +``` + +## Safety Requirements + +Tier 2 actions are side-effectful. They must: + +- require confirmation +- declare a reversible path +- require idempotency for execution +- return outputs that validate against `output_schema` + +Provider prose is never a policy input. The manifest fields are the policy surface. diff --git a/examples/soundmart/actions.json b/examples/soundmart/actions.json new file mode 100644 index 0000000..25277de --- /dev/null +++ b/examples/soundmart/actions.json @@ -0,0 +1,436 @@ +{ + "$schema": "https://spec.constraint.net/schemas/actions-manifest-0.1.json", + "actions_manifest_version": "0.1", + "manifest_id": "urn:constraint-manifest:soundmart.example:v1", + "issued_at": "2026-04-28T00:00:00.000Z", + "expires_at": "2026-05-05T00:00:00.000Z", + "sequence": 1, + "status": "active", + "publisher": { + "id": "urn:publisher:soundmart.example", + "display_name": "SoundMart", + "legal_name": "SoundMart Demo Inc.", + "primary_domain": "soundmart.example", + "support_email": "support@soundmart.example", + "country": "US" + }, + "domain_verification": { + "methods": [ + { + "type": "http_well_known_challenge", + "url": "https://soundmart.example/.well-known/constraint-net-verification.txt", + "verified_at": "2026-04-28T00:00:00.000Z" + } + ] + }, + "key_discovery": { + "jwks_uri": "https://soundmart.example/.well-known/constraint-net-jwks.json", + "signing_algorithms": [ + "EdDSA" + ], + "active_kids": [ + "soundmart-demo-2026-04" + ], + "public_keys": [ + { + "kid": "soundmart-demo-2026-04", + "alg": "Ed25519", + "public_key_pem": "-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEAXKy0pYdM+qytZ/d8ZYaecPmSwREApaFGOgAwc+dBk28=\n-----END PUBLIC KEY-----" + } + ] + }, + "links": { + "openapi": [ + { + "id": "soundmart-post-purchase-api", + "url": "https://api.soundmart.example/openapi.json", + "sha256": "sha256-demo-openapi" + } + ], + "terms": "https://soundmart.example/terms", + "privacy": "https://soundmart.example/privacy" + }, + "indexing": { + "allow_public_indexing": true, + "allow_execution_brokerage": true, + "cache_ttl_seconds": 600 + }, + "policies": { + "default_locale": "en-US", + "data_retention_days": 90, + "confirmation_defaults": { + "tier_1": "first_auth", + "tier_2": "required" + } + }, + "actions": [ + { + "id": "urn:action:soundmart.example:return.check_eligibility:v1", + "stable_id": "return.check_eligibility", + "version": "1.0.0", + "revision": 1, + "status": "active", + "title": "Check return eligibility", + "description": "Check whether a SoundMart order item can be returned.", + "machine_description": "Reads return eligibility, refund estimate, item details, and available return window for a specific order item. Does not create a return.", + "taxonomy": { + "category": "commerce.return.check_eligibility", + "vertical": "ecommerce_returns" + }, + "risk": { + "tier": 1, + "side_effect": "none", + "data_sensitivity": "order_private" + }, + "input_schema": { + "type": "object", + "required": [ + "order_id", + "item_id" + ], + "additionalProperties": false, + "properties": { + "order_id": { + "type": "string", + "minLength": 1 + }, + "item_id": { + "type": "string", + "minLength": 1 + } + } + }, + "output_schema": { + "type": "object", + "required": [ + "eligible", + "item_name", + "refund_amount", + "free_pickup_available" + ], + "additionalProperties": true, + "properties": { + "eligible": { + "type": "boolean" + }, + "item_name": { + "type": "string" + }, + "refund_amount": { + "type": "number" + }, + "free_pickup_available": { + "type": "boolean" + } + } + }, + "auth": { + "required": true, + "scopes": [ + "orders.read", + "returns.read" + ] + }, + "confirmation": { + "required": false, + "must_display": [] + }, + "reversibility": { + "reversible": false + }, + "execution": [ + { + "type": "openapi", + "ref": "soundmart-post-purchase-api", + "operation_id": "checkReturnEligibility", + "operation_ref": "#/paths/~1orders~1{order_id}~1return-eligibility/get", + "timeout_ms": 3000 + } + ], + "planning": { + "intent_tags": [ + "return", + "eligibility", + "refund", + "headphones" + ], + "requires": [ + "order_id", + "item_id" + ], + "produces": [ + "eligible", + "returnable_item", + "refund_amount", + "free_pickup_available" + ] + }, + "terms": { + "terms_url": "https://soundmart.example/terms", + "privacy_url": "https://soundmart.example/privacy" + } + }, + { + "id": "urn:action:soundmart.example:return.create:v1", + "stable_id": "return.create", + "version": "1.0.0", + "revision": 1, + "status": "active", + "title": "Create return", + "description": "Create a reversible return for a SoundMart order item.", + "machine_description": "Creates a reversible merchandise return for an eligible order item. Produces a return ID and refund status.", + "taxonomy": { + "category": "commerce.return.create", + "vertical": "ecommerce_returns" + }, + "risk": { + "tier": 2, + "side_effect": "return_created", + "data_sensitivity": "order_private" + }, + "input_schema": { + "type": "object", + "required": [ + "order_id", + "item_id", + "reason" + ], + "additionalProperties": false, + "properties": { + "order_id": { + "type": "string", + "minLength": 1 + }, + "item_id": { + "type": "string", + "minLength": 1 + }, + "reason": { + "type": "string", + "enum": [ + "changed_mind", + "defective", + "wrong_item", + "other" + ] + } + } + }, + "output_schema": { + "type": "object", + "required": [ + "return_id", + "status", + "refund_amount", + "cancel_until" + ], + "additionalProperties": true, + "properties": { + "return_id": { + "type": "string" + }, + "status": { + "type": "string" + }, + "refund_amount": { + "type": "number" + }, + "cancel_until": { + "type": "string", + "format": "date-time" + } + } + }, + "auth": { + "required": true, + "scopes": [ + "returns.write" + ] + }, + "confirmation": { + "required": true, + "expires_after_seconds": 600, + "must_display": [ + "publisher.display_name", + "order_id", + "item_id", + "refund_amount", + "reversibility.cancel_until_policy", + "data_shared" + ] + }, + "reversibility": { + "reversible": true, + "cancel_action_id": "urn:action:soundmart.example:return.cancel:v1", + "cancel_until_policy": "Return can be cancelled until the pickup scan." + }, + "execution": [ + { + "type": "openapi", + "ref": "soundmart-post-purchase-api", + "operation_id": "createReturn", + "operation_ref": "#/paths/~1returns/post", + "timeout_ms": 5000, + "idempotency": { + "required": true, + "header": "Idempotency-Key" + } + } + ], + "planning": { + "intent_tags": [ + "return", + "create", + "refund" + ], + "requires": [ + "order_id", + "item_id", + "reason", + "eligible" + ], + "produces": [ + "return_id", + "refund_amount", + "cancel_until" + ], + "after": [ + "return.check_eligibility" + ] + }, + "terms": { + "terms_url": "https://soundmart.example/terms", + "privacy_url": "https://soundmart.example/privacy" + } + }, + { + "id": "urn:action:soundmart.example:pickup.schedule:v1", + "stable_id": "pickup.schedule", + "version": "1.0.0", + "revision": 1, + "status": "active", + "title": "Schedule free pickup", + "description": "Schedule a reversible free pickup for a SoundMart return.", + "machine_description": "Schedules a pickup window for an existing return. Requires a return ID and selected pickup window.", + "taxonomy": { + "category": "commerce.pickup.schedule", + "vertical": "ecommerce_returns" + }, + "risk": { + "tier": 2, + "side_effect": "pickup_scheduled", + "data_sensitivity": "address_contact" + }, + "input_schema": { + "type": "object", + "required": [ + "return_id", + "pickup_window" + ], + "additionalProperties": false, + "properties": { + "return_id": { + "type": "string", + "minLength": 1 + }, + "pickup_window": { + "type": "string", + "minLength": 1 + } + } + }, + "output_schema": { + "type": "object", + "required": [ + "pickup_id", + "status", + "pickup_window", + "cancel_until" + ], + "additionalProperties": true, + "properties": { + "pickup_id": { + "type": "string" + }, + "status": { + "type": "string" + }, + "pickup_window": { + "type": "string" + }, + "cancel_until": { + "type": "string", + "format": "date-time" + } + } + }, + "auth": { + "required": true, + "scopes": [ + "pickups.write" + ] + }, + "confirmation": { + "required": true, + "expires_after_seconds": 600, + "must_display": [ + "publisher.display_name", + "return_id", + "pickup_window", + "reversibility.cancel_until_policy", + "data_shared" + ] + }, + "reversibility": { + "reversible": true, + "cancel_action_id": "urn:action:soundmart.example:pickup.cancel:v1", + "cancel_until_policy": "Pickup can be cancelled until 2 hours before the window starts." + }, + "execution": [ + { + "type": "openapi", + "ref": "soundmart-post-purchase-api", + "operation_id": "schedulePickup", + "operation_ref": "#/paths/~1pickups/post", + "timeout_ms": 5000, + "idempotency": { + "required": true, + "header": "Idempotency-Key" + } + } + ], + "planning": { + "intent_tags": [ + "pickup", + "schedule", + "free", + "fastest" + ], + "requires": [ + "return_id", + "free_pickup_available", + "pickup_window" + ], + "produces": [ + "pickup_id", + "pickup_window", + "cancel_until" + ], + "after": [ + "return.create" + ] + }, + "terms": { + "terms_url": "https://soundmart.example/terms", + "privacy_url": "https://soundmart.example/privacy" + } + } + ], + "signatures": [ + { + "alg": "Ed25519", + "kid": "soundmart-demo-2026-04", + "signature": "2o9qYHFsubPNw9ReMaCD-fbObaiUtqg7GLPx8YFQnWwRExKms_vXhIHjXmqDRC3dvXQcoUlAMDee4XSlEK7mAw" + } + ] +} diff --git a/package.json b/package.json index 1ba0580..73c6518 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,11 @@ "private": true, "type": "module", "packageManager": "pnpm@10.32.1", + "bin": { + "constraint-net": "./src/cli.ts" + }, "scripts": { + "cli": "tsx src/cli.ts", "test": "vitest run", "test:watch": "vitest", "typecheck": "tsc --noEmit", diff --git a/src/app.ts b/src/app.ts index 4bf6c62..dbd4aa1 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1,22 +1,28 @@ import Fastify from "fastify"; import { renderDemoPage } from "./demoPage.js"; +import { fetchManifestFromUrl, publisherDomainFromManifestUrl } from "./discovery.js"; import { soundmartManifest } from "./examples/soundmartManifest.js"; import { createMemoryStore, type MemoryStore } from "./memoryStore.js"; import { planCoherentPath } from "./planner.js"; +import { verifyReceiptChain } from "./receiptVerification.js"; import { decideConfirmation, executePreflight, preflightAction } from "./runtime.js"; -import type { ConstraintManifest } from "./types.js"; +import type { ConstraintManifest, SignedReceipt } from "./types.js"; import { validateManifest } from "./validator.js"; export type BuildServerOptions = { store?: MemoryStore; + fetchManifest?: (url: string) => Promise; }; export function buildServer(options: BuildServerOptions = {}) { const store = options.store ?? createDefaultStore(); + const fetchManifest = options.fetchManifest ?? fetchManifestFromUrl; const app = Fastify({ logger: false }); app.get("/", async (_request, reply) => reply.type("text/html").send(renderDemoPage())); + app.get("/.well-known/constraint-net/actions.json", async () => soundmartManifest); + app.get("/favicon.ico", async (_request, reply) => reply.code(204).send()); app.get("/v1/health", async () => ({ @@ -48,6 +54,46 @@ export function buildServer(options: BuildServerOptions = {}) { }); }); + app.post<{ + Body: { url: string }; + }>("/v1/manifests/ingest-url", async (request, reply) => { + try { + const manifest = await fetchManifest(request.body.url); + const validation = validateManifest(manifest, { + expectedPublisherDomain: publisherDomainFromManifestUrl(request.body.url) + }); + + if (!validation.valid) { + return reply.code(400).send({ + status: "manifest_invalid", + source_url: request.body.url, + errors: validation.errors, + warnings: validation.warnings + }); + } + + const stored = store.ingestManifest(manifest, { + source_url: request.body.url, + trust_status: "trusted" + }); + return reply.code(201).send({ + status: "manifest_ingested", + manifest_id: manifest.manifest_id, + publisher_domain: manifest.publisher.primary_domain, + manifest_digest: stored.digest, + action_count: manifest.actions.length, + source_url: request.body.url, + warnings: validation.warnings + }); + } catch (error) { + return reply.code(400).send({ + status: "manifest_fetch_failed", + source_url: request.body.url, + error: error instanceof Error ? error.message : String(error) + }); + } + }); + app.post<{ Body: Parameters[1]; }>("/v1/actions/search", async (request) => { @@ -103,6 +149,13 @@ export function buildServer(options: BuildServerOptions = {}) { return receipt; }); + app.post<{ + Body: { receipts: SignedReceipt[] }; + }>("/v1/receipts/verify", async (request, reply) => { + const result = verifyReceiptChain(request.body.receipts); + return reply.code(result.valid ? 200 : 400).send(result); + }); + return app; } diff --git a/src/capabilityGraph.ts b/src/capabilityGraph.ts new file mode 100644 index 0000000..8ba4a98 --- /dev/null +++ b/src/capabilityGraph.ts @@ -0,0 +1,72 @@ +import type { SearchQuery, StoredAction } from "./types.js"; + +export function selectCandidateActions(actions: StoredAction[], query: SearchQuery): StoredAction[] { + const goal = query.goal.toLowerCase(); + const merchant = query.constraints.merchant; + const riskTiers = new Set(query.constraints.risk_tiers_allowed ?? [0, 1, 2]); + + return actions + .filter((action) => !merchant || action.publisher_domain === merchant) + .filter((action) => riskTiers.has(action.risk.tier)) + .filter((action) => !query.constraints.requires_reversible || action.risk.tier < 2 || action.reversibility.reversible) + .filter((action) => action.status === "active" || action.status === "beta") + .filter((action) => matchesGoal(action, goal)); +} + +export function orderActionsByDependencies(actions: StoredAction[], query: SearchQuery): StoredAction[] { + const remaining = [...actions]; + const ordered: StoredAction[] = []; + const available = initialAvailableInputs(actions, query); + + while (remaining.length > 0) { + const index = remaining.findIndex((action) => { + const required = requiredFields(action); + const explicitAfter = action.planning?.after ?? []; + const afterSatisfied = explicitAfter.every((stableId) => ordered.some((step) => step.stable_id === stableId)); + const inputsSatisfied = required.every((name) => available.has(name)); + return afterSatisfied && inputsSatisfied; + }); + + if (index === -1) break; + const [next] = remaining.splice(index, 1); + ordered.push(next); + for (const produced of producedFields(next)) { + available.add(produced); + } + } + + return ordered; +} + +export function requiredFields(action: StoredAction): string[] { + return action.planning?.requires ?? action.input_schema.required ?? []; +} + +export function producedFields(action: StoredAction): string[] { + return action.planning?.produces ?? []; +} + +function matchesGoal(action: StoredAction, goal: string): boolean { + const tags = action.planning?.intent_tags ?? []; + if (tags.some((tag) => goal.includes(tag.toLowerCase()))) return true; + return action.stable_id + .split(".") + .some((part) => goal.includes(part.toLowerCase())); +} + +function initialAvailableInputs(actions: StoredAction[], query: SearchQuery): Set { + const available = new Set(Object.keys(query.available_inputs ?? {})); + + for (const action of actions) { + for (const required of requiredFields(action)) { + const producedByAnotherAction = actions.some( + (candidate) => candidate.id !== action.id && producedFields(candidate).includes(required) + ); + if (!producedByAnotherAction) { + available.add(required); + } + } + } + + return available; +} diff --git a/src/cli.ts b/src/cli.ts new file mode 100644 index 0000000..fe8d0e0 --- /dev/null +++ b/src/cli.ts @@ -0,0 +1,80 @@ +#!/usr/bin/env node +import { readFile } from "node:fs/promises"; +import { fetchManifestFromUrl } from "./discovery.js"; +import { verifyReceiptChain } from "./receiptVerification.js"; +import { validateManifest } from "./validator.js"; + +async function main(argv: string[]) { + const [command, ...args] = argv; + + if (command === "validate") { + const manifestPath = args[0]; + const manifest = JSON.parse(await readFile(manifestPath, "utf8")); + const result = validateManifest(manifest); + if (!result.valid) { + console.error(JSON.stringify(result, null, 2)); + process.exitCode = 1; + return; + } + console.log(`manifest valid: ${manifest.manifest_id}`); + return; + } + + if (command === "ingest-url") { + const url = args[0]; + const server = valueAfter(args, "--server") ?? "http://127.0.0.1:4173"; + const response = await fetch(`${server}/v1/manifests/ingest-url`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ url }) + }); + console.log(JSON.stringify(await response.json(), null, 2)); + process.exitCode = response.ok ? 0 : 1; + return; + } + + if (command === "plan") { + const server = valueAfter(args, "--server") ?? "http://127.0.0.1:4173"; + const goal = valueAfter(args, "--goal") ?? ""; + const merchant = valueAfter(args, "--merchant"); + const response = await fetch(`${server}/v1/actions/search`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + goal, + constraints: { merchant, risk_tiers_allowed: [0, 1, 2], requires_reversible: true } + }) + }); + console.log(JSON.stringify(await response.json(), null, 2)); + process.exitCode = response.ok ? 0 : 1; + return; + } + + if (command === "verify-receipt") { + const receiptPath = args[0]; + const payload = JSON.parse(await readFile(receiptPath, "utf8")); + const receipts = Array.isArray(payload) ? payload : payload.receipts; + const result = verifyReceiptChain(receipts); + console.log(JSON.stringify(result, null, 2)); + process.exitCode = result.valid ? 0 : 1; + return; + } + + if (command === "fetch") { + const manifest = await fetchManifestFromUrl(args[0]); + console.log(JSON.stringify(manifest, null, 2)); + return; + } + + console.error( + "Usage: constraint-net validate | ingest-url [--server ] | plan --goal [--merchant ] [--server ] | verify-receipt " + ); + process.exitCode = 1; +} + +function valueAfter(args: string[], flag: string): string | undefined { + const index = args.indexOf(flag); + return index >= 0 ? args[index + 1] : undefined; +} + +await main(process.argv.slice(2)); diff --git a/src/discovery.ts b/src/discovery.ts new file mode 100644 index 0000000..6c3211b --- /dev/null +++ b/src/discovery.ts @@ -0,0 +1,34 @@ +import type { ConstraintManifest } from "./types.js"; + +export function wellKnownActionsUrl(domain: string): string { + const normalized = domain.replace(/^https?:\/\//, "").replace(/\/.*$/, ""); + return `https://${normalized}/.well-known/constraint-net/actions.json`; +} + +export async function fetchManifestFromUrl( + url: string, + fetchImpl: typeof fetch = fetch +): Promise { + const parsed = new URL(url); + if (parsed.protocol !== "https:" && parsed.hostname !== "127.0.0.1" && parsed.hostname !== "localhost") { + throw new Error("manifest_discovery_requires_https"); + } + + const response = await fetchImpl(url, { + headers: { + accept: "application/json" + } + }); + + if (!response.ok) { + throw new Error(`manifest_fetch_failed:${response.status}`); + } + + return (await response.json()) as ConstraintManifest; +} + +export function publisherDomainFromManifestUrl(url: string): string | undefined { + const parsed = new URL(url); + if (parsed.hostname === "127.0.0.1" || parsed.hostname === "localhost") return undefined; + return parsed.hostname; +} diff --git a/src/examples/soundmartManifest.ts b/src/examples/soundmartManifest.ts index 60e28a8..3e6d84d 100644 --- a/src/examples/soundmartManifest.ts +++ b/src/examples/soundmartManifest.ts @@ -1,6 +1,8 @@ +import { DEV_PUBLIC_KEY_PEM } from "../keys.js"; +import { signManifest } from "../trust.js"; import type { ConstraintManifest } from "../types.js"; -export const soundmartManifest: ConstraintManifest = { +const unsignedSoundmartManifest: Omit = { $schema: "https://spec.constraint.net/schemas/actions-manifest-0.1.json", actions_manifest_version: "0.1", manifest_id: "urn:constraint-manifest:soundmart.example:v1", @@ -28,7 +30,14 @@ export const soundmartManifest: ConstraintManifest = { key_discovery: { jwks_uri: "https://soundmart.example/.well-known/constraint-net-jwks.json", signing_algorithms: ["EdDSA"], - active_kids: ["soundmart-demo-2026-04"] + active_kids: ["soundmart-demo-2026-04"], + public_keys: [ + { + kid: "soundmart-demo-2026-04", + alg: "Ed25519", + public_key_pem: DEV_PUBLIC_KEY_PEM + } + ] }, links: { openapi: [ @@ -113,6 +122,11 @@ export const soundmartManifest: ConstraintManifest = { timeout_ms: 3000 } ], + planning: { + intent_tags: ["return", "eligibility", "refund", "headphones"], + requires: ["order_id", "item_id"], + produces: ["eligible", "returnable_item", "refund_amount", "free_pickup_available"] + }, terms: { terms_url: "https://soundmart.example/terms", privacy_url: "https://soundmart.example/privacy" @@ -184,6 +198,12 @@ export const soundmartManifest: ConstraintManifest = { } } ], + planning: { + intent_tags: ["return", "create", "refund"], + requires: ["order_id", "item_id", "reason", "eligible"], + produces: ["return_id", "refund_amount", "cancel_until"], + after: ["return.check_eligibility"] + }, terms: { terms_url: "https://soundmart.example/terms", privacy_url: "https://soundmart.example/privacy" @@ -254,16 +274,18 @@ export const soundmartManifest: ConstraintManifest = { } } ], + planning: { + intent_tags: ["pickup", "schedule", "free", "fastest"], + requires: ["return_id", "free_pickup_available", "pickup_window"], + produces: ["pickup_id", "pickup_window", "cancel_until"], + after: ["return.create"] + }, terms: { terms_url: "https://soundmart.example/terms", privacy_url: "https://soundmart.example/privacy" } } ], - signatures: [ - { - protected: "example-protected-header", - signature: "example-signature" - } - ] }; + +export const soundmartManifest: ConstraintManifest = signManifest(unsignedSoundmartManifest, "soundmart-demo-2026-04"); diff --git a/src/keys.ts b/src/keys.ts new file mode 100644 index 0000000..98d4fcb --- /dev/null +++ b/src/keys.ts @@ -0,0 +1,9 @@ +export const DEV_KEY_ID = "constraint-net-dev-2026-05"; + +export const DEV_PRIVATE_KEY_PEM = `-----BEGIN PRIVATE KEY----- +MC4CAQAwBQYDK2VwBCIEIOC68Bzd/kyQPe54raxib3DBePf6KBXVMGsdsuor1ziR +-----END PRIVATE KEY-----`; + +export const DEV_PUBLIC_KEY_PEM = `-----BEGIN PUBLIC KEY----- +MCowBQYDK2VwAyEAXKy0pYdM+qytZ/d8ZYaecPmSwREApaFGOgAwc+dBk28= +-----END PUBLIC KEY-----`; diff --git a/src/manifestSchema.ts b/src/manifestSchema.ts index ba4f4a3..3c17659 100644 --- a/src/manifestSchema.ts +++ b/src/manifestSchema.ts @@ -130,6 +130,17 @@ export const actionsManifestSchema = { } } }, + planning: { + type: "object", + required: ["intent_tags", "requires", "produces"], + additionalProperties: false, + properties: { + intent_tags: { type: "array", items: { type: "string" } }, + requires: { type: "array", items: { type: "string" } }, + produces: { type: "array", items: { type: "string" } }, + after: { type: "array", items: { type: "string" } } + } + }, terms: { type: "object", required: ["terms_url", "privacy_url"], diff --git a/src/memoryStore.ts b/src/memoryStore.ts index f78813b..795f1ef 100644 --- a/src/memoryStore.ts +++ b/src/memoryStore.ts @@ -3,6 +3,7 @@ import { createSignedReceipt } from "./receipts.js"; import type { Confirmation, ConstraintManifest, + ExecutionRecord, PreflightRecord, SignedReceipt, StoredAction, @@ -16,14 +17,21 @@ export function createMemoryStore() { const actions = new Map(); const confirmations = new Map(); const preflights = new Map(); + const executions = new Map(); const receipts = new Map(); let latestDigest = ""; return { - ingestManifest(manifest: ConstraintManifest): StoredManifest { + ingestManifest(manifest: ConstraintManifest, metadata: Partial = {}): StoredManifest { const digest = sha256(manifest); latestDigest = digest; - const stored = { digest, manifest }; + const stored = { + digest, + manifest, + source_url: metadata.source_url, + discovered_at: metadata.discovered_at ?? new Date().toISOString(), + trust_status: metadata.trust_status ?? "trusted" + }; manifests.set(digest, stored); for (const action of manifest.actions) { @@ -79,6 +87,25 @@ export function createMemoryStore() { return preflights.get(id); }, + saveExecution(execution: ExecutionRecord): ExecutionRecord { + executions.set(execution.id, execution); + return execution; + }, + + getExecution(id: string): ExecutionRecord | undefined { + return executions.get(id); + }, + + findExecutionByPreflight(preflightId: string): ExecutionRecord | undefined { + return [...executions.values()].find((execution) => execution.preflight_id === preflightId); + }, + + findExecutionByPreflightAndIdempotency(preflightId: string, idempotencyKey: string): ExecutionRecord | undefined { + return [...executions.values()].find( + (execution) => execution.preflight_id === preflightId && execution.idempotency_key === idempotencyKey + ); + }, + saveReceipt(receipt: SignedReceipt): SignedReceipt { receipts.set(receipt.receipt_id, receipt); return receipt; diff --git a/src/planner.ts b/src/planner.ts index 7ec061a..afd6c28 100644 --- a/src/planner.ts +++ b/src/planner.ts @@ -1,27 +1,15 @@ +import { orderActionsByDependencies, producedFields, requiredFields, selectCandidateActions } from "./capabilityGraph.js"; +import { publicId } from "./hash.js"; import type { MemoryStore } from "./memoryStore.js"; import type { PlannedPath, SearchQuery, StoredAction } from "./types.js"; -const RETURN_PICKUP_PATH = ["return.check_eligibility", "return.create", "pickup.schedule"]; - export function planCoherentPath(store: MemoryStore, query: SearchQuery): PlannedPath { - const actions = store.listActions(); - const merchant = query.constraints.merchant; - const riskTiers = new Set(query.constraints.risk_tiers_allowed ?? [0, 1, 2]); - - const pathActions = RETURN_PICKUP_PATH - .map((stableId) => actions.find((action) => action.stable_id === stableId)) - .filter((action): action is StoredAction => Boolean(action)); - - const eligible = pathActions.filter((action) => { - if (merchant && action.publisher_domain !== merchant) return false; - if (!riskTiers.has(action.risk.tier)) return false; - if (query.constraints.requires_reversible && action.risk.tier === 2 && !action.reversibility.reversible) return false; - return true; - }); + const candidates = selectCandidateActions(store.listActions(), query); + const ordered = orderActionsByDependencies(candidates, query); - if (eligible.length !== RETURN_PICKUP_PATH.length) { + if (ordered.length === 0 || ordered.length !== candidates.length) { return { - status: "no_policy_allowed_path", + status: ordered.length === 0 ? "no_policy_allowed_path" : "missing_required_inputs", path_id: "path_none", coherence_score: 0, steps: [], @@ -29,40 +17,46 @@ export function planCoherentPath(store: MemoryStore, query: SearchQuery): Planne }; } - const coherenceScore = scorePath(eligible, query); + const coherenceScore = scorePath(ordered, query); return { status: "coherent_path_found", - path_id: "path_soundmart_return_pickup", + path_id: publicId("path", { + goal: query.goal, + steps: ordered.map((action) => action.id) + }), coherence_score: coherenceScore, - steps: eligible.map((action) => ({ + steps: ordered.map((action) => ({ action_id: action.id, stable_id: action.stable_id, manifest_digest: action.manifest_digest, risk_tier: action.risk.tier, - confirmation_required: action.confirmation.required + confirmation_required: action.confirmation.required, + requires: requiredFields(action), + produces: producedFields(action) })), why_coherent: [ - "Matches merchant", + query.constraints.merchant ? `Matches publisher domain ${query.constraints.merchant}` : "Matches publisher domain", "Checks eligibility before side effects", "Return creation precedes pickup scheduling", - "Tier 2 steps require confirmation", - "Both side effects are reversible", - "Every step can produce receipts" + "All required inputs are available or produced by earlier steps", + "Side-effectful steps are reversible and require confirmation", + "Every side-effectful step requires idempotency", + "Manifest is active, signed, and unexpired" ] }; } function scorePath(actions: StoredAction[], query: SearchQuery): number { const goal = query.goal.toLowerCase(); - const intentAlignment = goal.includes("return") && goal.includes("pickup") ? 1 : 0.65; - const schemaCompleteness = actions.every((action) => action.input_schema.required?.length) ? 1 : 0.6; + const intentAlignment = actions.some((action) => actionMatchesGoal(action, goal)) ? 1 : 0.65; + const schemaCompleteness = actions.every((action) => requiredFields(action).length > 0) ? 1 : 0.6; const policyConsistency = actions.every((action) => (query.constraints.risk_tiers_allowed ?? [0, 1, 2]).includes(action.risk.tier)) ? 1 : 0; const reversibilityConsistency = actions.every((action) => action.risk.tier < 2 || action.reversibility.reversible) ? 1 : 0.4; const trustConsistency = actions.every((action) => action.manifest_digest.startsWith("sha256-")) ? 0.95 : 0.4; - const temporalConsistency = isOrdered(actions.map((action) => action.stable_id), RETURN_PICKUP_PATH) ? 1 : 0.3; + const temporalConsistency = dependenciesSatisfied(actions) ? 1 : 0.3; const receiptConsistency = actions.every((action) => action.execution.length > 0) ? 1 : 0.5; - const pathSimplicity = actions.length === 3 ? 1 : 0.7; + const pathSimplicity = actions.length <= 3 ? 1 : 0.7; const contradictionPenalty = actions.some((action) => action.risk.tier === 2 && !action.confirmation.required) ? 1 : 0; const score = @@ -79,6 +73,15 @@ function scorePath(actions: StoredAction[], query: SearchQuery): number { return Number(score.toFixed(3)); } -function isOrdered(actual: string[], expected: string[]): boolean { - return actual.every((item, index) => item === expected[index]); +function actionMatchesGoal(action: StoredAction, goal: string): boolean { + return (action.planning?.intent_tags ?? []).some((tag) => goal.includes(tag.toLowerCase())); +} + +function dependenciesSatisfied(actions: StoredAction[]): boolean { + return actions.every((action, index) => { + return (action.planning?.after ?? []).every((stableId) => { + const dependencyIndex = actions.findIndex((candidate) => candidate.stable_id === stableId); + return dependencyIndex >= 0 && dependencyIndex < index; + }); + }); } diff --git a/src/receiptVerification.ts b/src/receiptVerification.ts new file mode 100644 index 0000000..4ba63ac --- /dev/null +++ b/src/receiptVerification.ts @@ -0,0 +1,31 @@ +import { receiptHash, verifyReceipt } from "./receipts.js"; +import type { ReceiptVerificationResult, SignedReceipt, ValidationIssue } from "./types.js"; + +export function verifyReceiptChain(receipts: SignedReceipt[]): ReceiptVerificationResult { + const errors: ValidationIssue[] = []; + + receipts.forEach((receipt, index) => { + if (!verifyReceipt(receipt)) { + errors.push({ + path: `$[${index}].signature`, + code: "receipt_signature_invalid", + message: `Receipt ${receipt.receipt_id} signature is invalid.` + }); + } + + if (index > 0 && receipt.previous_receipt_hash !== receiptHash(receipts[index - 1])) { + errors.push({ + path: `$[${index}].previous_receipt_hash`, + code: "receipt_chain_broken", + message: `Receipt ${receipt.receipt_id} does not link to the previous receipt.` + }); + } + }); + + return { + valid: errors.length === 0, + receipt_count: receipts.length, + chain: receipts.map((receipt) => receipt.receipt_id), + errors + }; +} diff --git a/src/receipts.ts b/src/receipts.ts index 50988cc..4d217b0 100644 --- a/src/receipts.ts +++ b/src/receipts.ts @@ -1,9 +1,14 @@ -import { generateKeyPairSync, sign, verify } from "node:crypto"; +import { createPrivateKey, createPublicKey, sign, verify } from "node:crypto"; import { randomUUID } from "node:crypto"; import { sha256, stableStringify } from "./hash.js"; +import { DEV_KEY_ID, DEV_PRIVATE_KEY_PEM, DEV_PUBLIC_KEY_PEM } from "./keys.js"; import type { ReceiptType, SignedReceipt } from "./types.js"; -const keyPair = generateKeyPairSync("ed25519"); +export const DEV_RECEIPT_PRIVATE_KEY_PEM = DEV_PRIVATE_KEY_PEM; +export const DEV_RECEIPT_PUBLIC_KEY_PEM = DEV_PUBLIC_KEY_PEM; + +const privateKey = createPrivateKey(DEV_RECEIPT_PRIVATE_KEY_PEM); +const publicKey = createPublicKey(DEV_RECEIPT_PUBLIC_KEY_PEM); export type ReceiptInput = { type: ReceiptType; @@ -26,13 +31,13 @@ export function createSignedReceipt(input: ReceiptInput): SignedReceipt { }; const payload = stableStringify(unsigned); - const signature = sign(null, Buffer.from(payload), keyPair.privateKey).toString("base64url"); + const signature = sign(null, Buffer.from(payload), privateKey).toString("base64url"); return { ...unsigned, signature: { alg: "Ed25519", - kid: "constraint-net-dev-2026-04", + kid: DEV_KEY_ID, value: signature } }; @@ -41,7 +46,7 @@ export function createSignedReceipt(input: ReceiptInput): SignedReceipt { export function verifyReceipt(receipt: SignedReceipt): boolean { const { signature, ...unsigned } = receipt; const payload = stableStringify(unsigned); - return verify(null, Buffer.from(payload), keyPair.publicKey, Buffer.from(signature.value, "base64url")); + return verify(null, Buffer.from(payload), publicKey, Buffer.from(signature.value, "base64url")); } export function receiptHash(receipt: SignedReceipt): string { diff --git a/src/runtime.ts b/src/runtime.ts index c184b50..524b7c7 100644 --- a/src/runtime.ts +++ b/src/runtime.ts @@ -143,6 +143,31 @@ export function executePreflight(store: MemoryStore, input: ExecuteInput) { } } + const exactReplay = store.findExecutionByPreflightAndIdempotency(preflight.id, input.idempotency_key); + if (exactReplay) { + return { + execution_id: exactReplay.id, + status: exactReplay.status, + replayed: true, + result: exactReplay.result, + receipts: exactReplay.receipt_ids + .map((receiptId) => store.getReceipt(receiptId)) + .filter((receipt): receipt is SignedReceipt => Boolean(receipt)) + .map((receipt) => ({ + type: receipt.type, + receipt_id: receipt.receipt_id + })) + }; + } + + const priorExecution = store.findExecutionByPreflight(preflight.id); + if (priorExecution) { + return { + status: "preflight_already_executed" as const, + execution_id: priorExecution.id + }; + } + const result = executeMockProvider(action, preflight.inputs, input.idempotency_key); const validateOutput = ajv.compile(action.output_schema); if (!validateOutput(result)) { @@ -201,6 +226,18 @@ export function executePreflight(store: MemoryStore, input: ExecuteInput) { const receipts = [intentReceipt, consentReceipt, executionReceipt].filter((receipt): receipt is SignedReceipt => Boolean(receipt)); + store.saveExecution({ + id: executionId, + preflight_id: preflight.id, + action_id: action.id, + manifest_digest: action.manifest_digest, + idempotency_key: input.idempotency_key, + status: "succeeded", + result, + receipt_ids: receipts.map((receipt) => receipt.receipt_id), + created_at: new Date().toISOString() + }); + return { execution_id: executionId, status: "succeeded" as const, diff --git a/src/trust.ts b/src/trust.ts new file mode 100644 index 0000000..2670f0d --- /dev/null +++ b/src/trust.ts @@ -0,0 +1,113 @@ +import { createPrivateKey, createPublicKey, sign, verify } from "node:crypto"; +import { stableStringify } from "./hash.js"; +import { DEV_PRIVATE_KEY_PEM } from "./keys.js"; +import type { ConstraintManifest, ManifestSignature, ValidationIssue } from "./types.js"; + +export type ManifestValidationOptions = { + now?: Date; + expectedPublisherDomain?: string; + requireSignature?: boolean; +}; + +export function unsignedManifestPayload(manifest: ConstraintManifest | Omit): string { + const { signatures: _signatures, ...unsigned } = manifest as ConstraintManifest; + return stableStringify(unsigned); +} + +export function signManifest( + manifest: Omit, + kid: string, + privateKeyPem = DEV_PRIVATE_KEY_PEM +): ConstraintManifest { + const signature = sign(null, Buffer.from(unsignedManifestPayload(manifest)), createPrivateKey(privateKeyPem)).toString("base64url"); + return { + ...manifest, + signatures: [ + { + alg: "Ed25519", + kid, + signature + } + ] + }; +} + +export function validateManifestTrust( + manifest: ConstraintManifest, + options: ManifestValidationOptions = {} +): ValidationIssue[] { + const now = options.now ?? new Date(); + const issues: ValidationIssue[] = []; + const issuedAt = Date.parse(manifest.issued_at); + const expiresAt = Date.parse(manifest.expires_at); + + if (manifest.actions_manifest_version !== "0.1") { + issues.push({ + path: "$.actions_manifest_version", + code: "unsupported_manifest_version", + message: `Unsupported manifest version '${manifest.actions_manifest_version}'.` + }); + } + + if (manifest.status === "revoked" || manifest.status === "suspended") { + issues.push({ + path: "$.status", + code: manifest.status === "revoked" ? "manifest_revoked" : "manifest_suspended", + message: `Manifest status is '${manifest.status}'.` + }); + } + + if (Number.isFinite(issuedAt) && issuedAt > now.getTime()) { + issues.push({ + path: "$.issued_at", + code: "manifest_not_yet_valid", + message: `Manifest is not valid until ${manifest.issued_at}.` + }); + } + + if (Number.isFinite(expiresAt) && expiresAt <= now.getTime()) { + issues.push({ + path: "$.expires_at", + code: "manifest_expired", + message: `Manifest expired at ${manifest.expires_at}.` + }); + } + + if (options.expectedPublisherDomain && manifest.publisher.primary_domain !== options.expectedPublisherDomain) { + issues.push({ + path: "$.publisher.primary_domain", + code: "publisher_domain_mismatch", + message: `Manifest publisher domain '${manifest.publisher.primary_domain}' does not match '${options.expectedPublisherDomain}'.` + }); + } + + if ((options.requireSignature ?? true) && !verifyAnyManifestSignature(manifest)) { + issues.push({ + path: "$.signatures[0]", + code: "manifest_signature_invalid", + message: "No manifest signature verifies against key_discovery.public_keys." + }); + } + + return issues; +} + +export function verifyAnyManifestSignature(manifest: ConstraintManifest): boolean { + return manifest.signatures.some((signature) => verifyManifestSignature(manifest, signature)); +} + +function verifyManifestSignature(manifest: ConstraintManifest, signature: ManifestSignature): boolean { + const key = manifest.key_discovery.public_keys?.find((candidate) => candidate.kid === signature.kid); + if (!key || signature.alg !== "Ed25519" || key.alg !== "Ed25519") return false; + + try { + return verify( + null, + Buffer.from(unsignedManifestPayload(manifest)), + createPublicKey(key.public_key_pem), + Buffer.from(signature.signature, "base64url") + ); + } catch { + return false; + } +} diff --git a/src/types.ts b/src/types.ts index 7e2f488..efe28e5 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,5 +1,11 @@ export type RiskTier = 0 | 1 | 2; +export type ValidationIssue = { + path: string; + code: string; + message: string; +}; + export type JsonSchema = { type?: string; required?: string[]; @@ -32,12 +38,29 @@ export type ConstraintManifest = { sequence: number; status: "active" | "deprecated" | "suspended" | "revoked"; domain_verification: Record; - key_discovery: Record; + key_discovery: { + jwks_uri?: string; + signing_algorithms?: string[]; + active_kids?: string[]; + public_keys?: ManifestPublicKey[]; + }; links: Record; indexing: Record; policies: Record; actions: ConstraintAction[]; - signatures: Array>; + signatures: ManifestSignature[]; +}; + +export type ManifestSignature = { + alg: "Ed25519"; + kid: string; + signature: string; +}; + +export type ManifestPublicKey = { + kid: string; + alg: "Ed25519"; + public_key_pem: string; }; export type ConstraintAction = { @@ -75,12 +98,20 @@ export type ConstraintAction = { cancel_until_policy?: string; }; execution: ConstraintExecutionBinding[]; + planning?: ActionPlanningMetadata; terms: { terms_url: string; privacy_url: string; }; }; +export type ActionPlanningMetadata = { + intent_tags: string[]; + requires: string[]; + produces: string[]; + after?: string[]; +}; + export type ConstraintExecutionBinding = { type: "openapi"; ref: string; @@ -95,9 +126,21 @@ export type ConstraintExecutionBinding = { export type StoredManifest = { digest: string; + source_url?: string; + discovered_at?: string; + trust_status: ManifestTrustStatus; manifest: ConstraintManifest; }; +export type ManifestTrustStatus = + | "trusted" + | "unsigned" + | "signature_invalid" + | "expired" + | "not_yet_valid" + | "revoked" + | "unsupported_version"; + export type StoredAction = ConstraintAction & { manifest_digest: string; publisher_domain: string; @@ -110,6 +153,7 @@ export type SearchQuery = { risk_tiers_allowed?: RiskTier[]; requires_reversible?: boolean; }; + available_inputs?: Record; }; export type PlannedStep = { @@ -118,6 +162,8 @@ export type PlannedStep = { manifest_digest: string; risk_tier: RiskTier; confirmation_required: boolean; + requires: string[]; + produces: string[]; }; export type PlannedPath = { @@ -154,6 +200,18 @@ export type PreflightRecord = { intent_receipt_id: string; }; +export type ExecutionRecord = { + id: string; + preflight_id: string; + action_id: string; + manifest_digest: string; + idempotency_key: string; + status: "succeeded" | "failed_retryable" | "failed_terminal"; + result: Record; + receipt_ids: string[]; + created_at: string; +}; + export type ReceiptType = "intent" | "consent" | "execution"; export type SignedReceipt = { @@ -175,7 +233,14 @@ export type SignedReceipt = { previous_receipt_hash?: string; signature: { alg: "Ed25519"; - kid: "constraint-net-dev-2026-04"; + kid: string; value: string; }; }; + +export type ReceiptVerificationResult = { + valid: boolean; + receipt_count: number; + chain: string[]; + errors: ValidationIssue[]; +}; diff --git a/src/validator.ts b/src/validator.ts index b505c2f..1b6a885 100644 --- a/src/validator.ts +++ b/src/validator.ts @@ -1,12 +1,7 @@ import { createAjv } from "./ajv.js"; import { actionsManifestSchema } from "./manifestSchema.js"; -import type { ConstraintAction, ConstraintManifest } from "./types.js"; - -export type ValidationIssue = { - path: string; - code: string; - message: string; -}; +import { validateManifestTrust, type ManifestValidationOptions } from "./trust.js"; +import type { ConstraintAction, ConstraintManifest, ValidationIssue } from "./types.js"; export type ManifestValidationResult = { valid: boolean; @@ -18,7 +13,7 @@ const ajv = createAjv(); const validateSchema = ajv.compile(actionsManifestSchema); -export function validateManifest(manifest: ConstraintManifest): ManifestValidationResult { +export function validateManifest(manifest: ConstraintManifest, options: ManifestValidationOptions = {}): ManifestValidationResult { const errors: ValidationIssue[] = []; const warnings: ValidationIssue[] = []; @@ -32,6 +27,8 @@ export function validateManifest(manifest: ConstraintManifest): ManifestValidati } } + errors.push(...validateManifestTrust(manifest, options)); + for (const [index, action] of manifest.actions?.entries() ?? []) { errors.push(...riskLintAction(action, `$.actions[${index}]`)); errors.push(...secretLint(action, `$.actions[${index}]`)); diff --git a/tests/constraint-net.test.ts b/tests/constraint-net.test.ts index 676b1bd..e5f8966 100644 --- a/tests/constraint-net.test.ts +++ b/tests/constraint-net.test.ts @@ -1,10 +1,16 @@ +import { execFileSync } from "node:child_process"; +import { mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { describe, expect, it } from "vitest"; import { buildServer } from "../src/app.js"; +import { wellKnownActionsUrl } from "../src/discovery.js"; import { soundmartManifest } from "../src/examples/soundmartManifest.js"; import { createMemoryStore } from "../src/memoryStore.js"; import { planCoherentPath } from "../src/planner.js"; -import { createSignedReceipt, verifyReceipt } from "../src/receipts.js"; -import { preflightAction } from "../src/runtime.js"; +import { createSignedReceipt, receiptHash, verifyReceipt } from "../src/receipts.js"; +import { verifyReceiptChain } from "../src/receiptVerification.js"; +import { decideConfirmation, executePreflight, preflightAction } from "../src/runtime.js"; import { validateManifest } from "../src/validator.js"; describe("manifest validation", () => { @@ -30,6 +36,57 @@ describe("manifest validation", () => { }) ); }); + + it("rejects expired active manifests", () => { + const manifest = structuredClone(soundmartManifest); + manifest.expires_at = "2026-01-01T00:00:00.000Z"; + + const result = validateManifest(manifest, { now: new Date("2026-05-02T00:00:00.000Z") }); + + expect(result.valid).toBe(false); + expect(result.errors).toContainEqual( + expect.objectContaining({ + code: "manifest_expired", + path: "$.expires_at" + }) + ); + }); + + it("rejects revoked manifests", () => { + const manifest = structuredClone(soundmartManifest); + manifest.status = "revoked"; + + const result = validateManifest(manifest); + + expect(result.valid).toBe(false); + expect(result.errors).toContainEqual( + expect.objectContaining({ + code: "manifest_revoked", + path: "$.status" + }) + ); + }); + + it("rejects manifests with invalid signatures", () => { + const manifest = structuredClone(soundmartManifest); + manifest.signatures = [ + { + alg: "Ed25519", + kid: "soundmart-demo-2026-04", + signature: "invalid" + } + ]; + + const result = validateManifest(manifest); + + expect(result.valid).toBe(false); + expect(result.errors).toContainEqual( + expect.objectContaining({ + code: "manifest_signature_invalid", + path: "$.signatures[0]" + }) + ); + }); }); describe("coherence planning", () => { @@ -55,6 +112,107 @@ describe("coherence planning", () => { expect(path.coherence_score).toBeGreaterThan(0.8); expect(path.why_coherent).toContain("Checks eligibility before side effects"); }); + + it("plans generic capability paths from action planning metadata", () => { + const repairManifest = structuredClone(soundmartManifest); + repairManifest.manifest_id = "urn:constraint-manifest:fixco.example:v1"; + repairManifest.publisher.primary_domain = "fixco.example"; + repairManifest.publisher.display_name = "FixCo"; + repairManifest.actions = [ + { + ...repairManifest.actions[0], + id: "urn:action:fixco.example:device.diagnose:v1", + stable_id: "device.diagnose", + title: "Diagnose device", + machine_description: "Diagnose whether a device is eligible for repair.", + risk: { tier: 1, side_effect: "none", data_sensitivity: "device_private" }, + input_schema: { + type: "object", + required: ["device_id"], + additionalProperties: false, + properties: { device_id: { type: "string", minLength: 1 } } + }, + planning: { + intent_tags: ["repair", "diagnose", "device"], + requires: ["device_id"], + produces: ["diagnosis_id", "repair_eligible"] + } + }, + { + ...repairManifest.actions[1], + id: "urn:action:fixco.example:repair.create:v1", + stable_id: "repair.create", + title: "Create repair", + machine_description: "Create a reversible repair order after diagnosis.", + risk: { tier: 2, side_effect: "repair_created", data_sensitivity: "device_private" }, + input_schema: { + type: "object", + required: ["diagnosis_id"], + additionalProperties: false, + properties: { diagnosis_id: { type: "string", minLength: 1 } } + }, + planning: { + intent_tags: ["repair", "create", "device"], + requires: ["diagnosis_id"], + produces: ["repair_id"], + after: ["device.diagnose"] + } + } + ]; + + const store = createMemoryStore(); + store.ingestManifest(repairManifest); + + const path = planCoherentPath(store, { + goal: "Repair my device with FixCo", + constraints: { + merchant: "fixco.example", + risk_tiers_allowed: [0, 1, 2], + requires_reversible: true + }, + available_inputs: { + device_id: "dev_123" + } + }); + + expect(path.status).toBe("coherent_path_found"); + expect(path.steps.map((step) => step.stable_id)).toEqual(["device.diagnose", "repair.create"]); + expect(path.steps[0].produces).toContain("diagnosis_id"); + expect(path.steps[1].requires).toContain("diagnosis_id"); + }); +}); + +describe("manifest discovery", () => { + it("derives the well-known actions URL from a publisher domain", () => { + expect(wellKnownActionsUrl("soundmart.example")).toBe( + "https://soundmart.example/.well-known/constraint-net/actions.json" + ); + }); + + it("ingests a manifest URL through the API", async () => { + const server = buildServer({ + store: createMemoryStore(), + fetchManifest: async () => soundmartManifest + }); + + const response = await server.inject({ + method: "POST", + url: "/v1/manifests/ingest-url", + payload: { + url: "https://soundmart.example/.well-known/constraint-net/actions.json" + } + }); + + expect(response.statusCode).toBe(201); + expect(response.json()).toMatchObject({ + status: "manifest_ingested", + source_url: "https://soundmart.example/.well-known/constraint-net/actions.json", + publisher_domain: "soundmart.example", + action_count: 3 + }); + + await server.close(); + }); }); describe("preflight policy", () => { @@ -97,6 +255,65 @@ describe("preflight policy", () => { expect(result.status).toBe("manifest_digest_mismatch"); }); + + it("replays idempotent executions without creating new receipts", () => { + const store = createMemoryStore(); + store.ingestManifest(soundmartManifest); + const action = store.getActionByStableId("return.create"); + if (!action) throw new Error("fixture missing return.create"); + + const preflight = preflightAction(store, { + action_id: action.id, + manifest_digest: action.manifest_digest, + inputs: { order_id: "ord_123", item_id: "item_headphones", reason: "changed_mind" } + }); + if (preflight.status !== "confirmation_required" || !preflight.confirmation) throw new Error("expected confirmation"); + decideConfirmation(store, preflight.confirmation.id, "confirm"); + + const first = executePreflight(store, { + preflight_id: preflight.preflight_id, + confirmation_id: preflight.confirmation.id, + idempotency_key: "idem_same" + }); + const second = executePreflight(store, { + preflight_id: preflight.preflight_id, + confirmation_id: preflight.confirmation.id, + idempotency_key: "idem_same" + }); + + expect(first.status).toBe("succeeded"); + expect(second.status).toBe("succeeded"); + expect(second.replayed).toBe(true); + expect(second.receipts).toEqual(first.receipts); + }); + + it("blocks a second idempotency key for an already executed preflight", () => { + const store = createMemoryStore(); + store.ingestManifest(soundmartManifest); + const action = store.getActionByStableId("return.create"); + if (!action) throw new Error("fixture missing return.create"); + + const preflight = preflightAction(store, { + action_id: action.id, + manifest_digest: action.manifest_digest, + inputs: { order_id: "ord_123", item_id: "item_headphones", reason: "changed_mind" } + }); + if (preflight.status !== "confirmation_required" || !preflight.confirmation) throw new Error("expected confirmation"); + decideConfirmation(store, preflight.confirmation.id, "confirm"); + + executePreflight(store, { + preflight_id: preflight.preflight_id, + confirmation_id: preflight.confirmation.id, + idempotency_key: "idem_first" + }); + const second = executePreflight(store, { + preflight_id: preflight.preflight_id, + confirmation_id: preflight.confirmation.id, + idempotency_key: "idem_other" + }); + + expect(second.status).toBe("preflight_already_executed"); + }); }); describe("signed receipts", () => { @@ -121,6 +338,27 @@ describe("signed receipts", () => { expect(receipt.signature.alg).toBe("Ed25519"); expect(verifyReceipt(receipt)).toBe(true); }); + + it("verifies a receipt chain without process-local state", () => { + const intent = createSignedReceipt({ + type: "intent", + subject: { action_id: "act_1", manifest_digest: "sha256-demo" }, + hashes: { input_sha256: "sha256-input", policy_decision_sha256: "sha256-policy" }, + state: { status: "preflighted" } + }); + const execution = createSignedReceipt({ + type: "execution", + subject: { action_id: "act_1", execution_id: "exec_1", manifest_digest: "sha256-demo" }, + hashes: { input_sha256: "sha256-input", provider_response_sha256: "sha256-response" }, + state: { status: "succeeded" }, + previous_receipt_hash: receiptHash(intent) + }); + + const result = verifyReceiptChain([intent, execution]); + + expect(result.valid).toBe(true); + expect(result.chain).toEqual([intent.receipt_id, execution.receipt_id]); + }); }); describe("API flow", () => { @@ -300,3 +538,19 @@ describe("API flow", () => { await server.close(); }); }); + +describe("CLI", () => { + it("validates a manifest file", () => { + const dir = mkdtempSync(join(tmpdir(), "constraint-net-")); + const manifestPath = join(dir, "actions.json"); + writeFileSync(manifestPath, JSON.stringify(soundmartManifest)); + + const output = execFileSync("pnpm", ["tsx", "src/cli.ts", "validate", manifestPath], { + cwd: process.cwd(), + encoding: "utf8" + }); + + expect(output).toContain("manifest valid"); + expect(output).toContain(soundmartManifest.manifest_id); + }); +});