From b7371470a9f7e71b3a602ab84cc3de220b820551 Mon Sep 17 00:00:00 2001 From: callumalpass Date: Tue, 28 Jul 2026 21:47:00 +1000 Subject: [PATCH] Define first-class event and action interoperability --- .gitignore | 3 + 05-data-contracts.md | 82 +- 16-conformance.md | 4 +- README.md | 12 +- .../v0.3/_contracts/tasknotes.task.md | 3 +- .../tasknotes-migration/v0.3/mdbase-pack.yaml | 2 +- interop/0.1.md | 457 +++++ packages/interop-rs/Cargo.lock | 1509 +++++++++++++++++ packages/interop-rs/Cargo.toml | 21 + packages/interop-rs/README.md | 15 + .../schemas/data-contract.schema.json | 163 ++ .../interop-rs/schemas/profile.schema.json | 456 +++++ packages/interop-rs/src/lib.rs | 614 +++++++ packages/interop/LICENSE | 21 + packages/interop/README.md | 58 + packages/interop/conformance/v0.1.yml | 31 + packages/interop/package-lock.json | 628 +++++++ packages/interop/package.json | 50 + .../scripts/generate-schema-module.mjs | 33 + packages/interop/src/bridge.ts | 1472 ++++++++++++++++ packages/interop/src/contracts.ts | 160 ++ packages/interop/src/errors.ts | 35 + packages/interop/src/generated-schemas.ts | 1079 ++++++++++++ packages/interop/src/index.ts | 25 + packages/interop/src/schemas.ts | 47 + packages/interop/src/types.ts | 413 +++++ packages/interop/test/bridge.test.mjs | 488 ++++++ packages/interop/tsconfig.json | 17 + .../src/generated-schemas.ts | 242 ++- schemas/interop/v0.1/README.md | 9 + .../v0.1/action-cancellation.schema.json | 6 + .../v0.1/action-invocation.schema.json | 6 + .../interop/v0.1/action-outcome.schema.json | 6 + .../action-provider-declaration.schema.json | 6 + .../interop/v0.1/action-request.schema.json | 6 + .../v0.1/conformance-claim.schema.json | 6 + .../v0.1/event-source-declaration.schema.json | 6 + schemas/interop/v0.1/event.schema.json | 6 + schemas/interop/v0.1/profile.schema.json | 456 +++++ schemas/v0.3/README.md | 2 +- schemas/v0.3/conformance-claim.schema.json | 15 +- schemas/v0.3/data-contract.schema.json | 89 +- scripts/check_v03_tests.py | 87 +- site/build.mjs | 7 +- tests/v0.3/README.md | 11 +- tests/v0.3/data-contracts/data-contracts.yaml | 7 +- .../event-action-interop.yaml | 217 +++ .../valid-all-profile-dependencies.yml | 2 + .../conflicting-tasknotes.task.md | 3 +- .../json-pointer-contact.contract.md | 3 +- tests/v0.3/manifest.yaml | 21 +- tests/v0.3/schema/schema-artifacts.yaml | 2 +- 52 files changed, 9031 insertions(+), 88 deletions(-) create mode 100644 interop/0.1.md create mode 100644 packages/interop-rs/Cargo.lock create mode 100644 packages/interop-rs/Cargo.toml create mode 100644 packages/interop-rs/README.md create mode 100644 packages/interop-rs/schemas/data-contract.schema.json create mode 100644 packages/interop-rs/schemas/profile.schema.json create mode 100644 packages/interop-rs/src/lib.rs create mode 100644 packages/interop/LICENSE create mode 100644 packages/interop/README.md create mode 100644 packages/interop/conformance/v0.1.yml create mode 100644 packages/interop/package-lock.json create mode 100644 packages/interop/package.json create mode 100644 packages/interop/scripts/generate-schema-module.mjs create mode 100644 packages/interop/src/bridge.ts create mode 100644 packages/interop/src/contracts.ts create mode 100644 packages/interop/src/errors.ts create mode 100644 packages/interop/src/generated-schemas.ts create mode 100644 packages/interop/src/index.ts create mode 100644 packages/interop/src/schemas.ts create mode 100644 packages/interop/src/types.ts create mode 100644 packages/interop/test/bridge.test.mjs create mode 100644 packages/interop/tsconfig.json create mode 100644 schemas/interop/v0.1/README.md create mode 100644 schemas/interop/v0.1/action-cancellation.schema.json create mode 100644 schemas/interop/v0.1/action-invocation.schema.json create mode 100644 schemas/interop/v0.1/action-outcome.schema.json create mode 100644 schemas/interop/v0.1/action-provider-declaration.schema.json create mode 100644 schemas/interop/v0.1/action-request.schema.json create mode 100644 schemas/interop/v0.1/conformance-claim.schema.json create mode 100644 schemas/interop/v0.1/event-source-declaration.schema.json create mode 100644 schemas/interop/v0.1/event.schema.json create mode 100644 schemas/interop/v0.1/profile.schema.json create mode 100644 tests/v0.3/event-action-interop/event-action-interop.yaml diff --git a/.gitignore b/.gitignore index be6050e..9ec65bd 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,6 @@ POST.md __pycache__/ *.py[cod] /.ops/ +/packages/interop/node_modules/ +/packages/interop/dist/ +/packages/interop-rs/target/ diff --git a/05-data-contracts.md b/05-data-contracts.md index a74fd1d..ce65dbe 100644 --- a/05-data-contracts.md +++ b/05-data-contracts.md @@ -1,9 +1,9 @@ -# 05A. Data Contracts +# 05A. First-Class Contracts ## Why Data Contracts Exist -A type describes one collection's records. A data contract describes the -portable meaning that one or more independently designed types agree to expose. +A contract describes one portable interface independently from the local type +or application that implements it. For example, `personal_task`, `work_task`, and `task` can all implement `tasknotes.task`. Their filenames, additional fields, matching rules, and local @@ -11,24 +11,29 @@ presentation can differ. An application can discover the shared contract, understand each type's field mapping, and operate without requiring every collection to use one canonical type name. -Data contracts are passive data interoperability. They do not describe -executable actions, events, providers, permissions, or workflows. Those are -runtime contracts and are defined in Chapter 13. +`mdbase.contract` is the shared identity and JSON Schema substrate for passive +record views, events, and actions. `contract_type` discriminates their +subject-specific schema fields. This chapter defines the shared artifact and +record implementation rules. The optional +[event/action interoperability profile](./interop/0.1.md) defines executable +source/provider declarations and message exchange. Contracts never grant +permission. ## Three Portable Artifacts -The complete data-contract model has three intentionally small parts: +The complete collection contract model has three intentionally small parts: -1. An `mdbase.contract` artifact defines a versioned interface and optional - binding schema using JSON Schema 2020-12. -2. A type's `implements` entry maps that interface to the type and supplies - contract-specific binding data. +1. An `mdbase.contract` artifact defines a versioned subject-specific + interface using JSON Schema 2020-12. +2. For a `record` contract, a type's `implements` entry maps that interface to + the type and supplies contract-specific binding data. 3. An optional `mdbase.type-pack` manifest groups contracts, types, and their referenced schemas for transactional installation. -An application requirement, authorization grant, or network protocol is not a -fourth collection artifact. Such systems consume the verified facts exposed by -the collection. +Event sources and action providers make runtime declarations because they are +executable, instance-specific implementations rather than record types. +Requirements, authorization grants, and transports are not collection +contract artifacts. ## Contract Files @@ -39,12 +44,13 @@ validates against `schemas/v0.3/data-contract.schema.json`. ```markdown --- kind: mdbase.contract +contract_type: record id: example.task version: 1.0.0 name: Example task description: A small portable task interface. -schema: +record_schema: dialect: json-schema-2020-12 value: $schema: "https://json-schema.org/draft/2020-12/schema" @@ -80,11 +86,16 @@ frontmatter schemas. `id` is a lower-case namespaced identifier. `version` is an exact semantic version. A type implementation never names a version range. -`schema` validates the normalized contract view produced from a record. +`record_schema` validates the normalized contract view produced from a record. `binding_schema`, when present, validates implementation-specific semantic configuration. Both use the same JSON Schema profile and reference rules as type schemas. +An event contract instead requires `data_schema` and may declare +`source_schema`. An action contract requires `input_schema` and may declare +`output_schema`, `error_schema`, `provider_schema`, and `behavior`. Subject +fields belonging to another `contract_type` are invalid. + Contract files are control files, not records. They do not participate in ordinary record scans, queries, links, or runtime workflow discovery. @@ -94,7 +105,7 @@ During collection load, a data-contract-aware implementation: 1. scans the configured contracts folder recursively 2. validates every candidate against the built-in data-contract schema -3. resolves and compiles `schema` and `binding_schema` +3. resolves and compiles the schemas selected by `contract_type` 4. registers each contract by the exact pair `(id, version)` 5. computes its contract digest 6. validates every type `implements` entry against the resulting registry @@ -110,8 +121,10 @@ contract files they require, usually in a type pack. ## Type Implementations -`implements` belongs in the type file because an implementation is a claim -about each record that matches that one type. +`implements` belongs in the type file because a record implementation is a +claim about each record that matches that one type. A type can implement only a +`record` contract. Event sources and action providers declare implementations +through the interoperability profile instead. ```yaml implements: @@ -144,10 +157,11 @@ is direct: core does not rename values, coerce values, run expressions, or apply hidden transforms. A type MUST NOT contain two implementations of the same contract ID and -version. Field mappings MUST address fields declared by the resolved contract -schema and the resolved type schema. Every unconditional top-level field named -by the contract schema's `required` array MUST be mapped, either by the matching -one-segment field path or by the matching one-token JSON Pointer. +version. Field mappings MUST address fields declared by the resolved +`record_schema` and the resolved type schema. Every unconditional top-level +field named by the contract's `record_schema.required` array MUST be mapped, +either by the matching one-segment field path or by the matching one-token JSON +Pointer. When a contract has a `binding_schema`, the implementation's `binding` value, or an empty object when omitted, MUST validate against it. When a contract has no @@ -161,14 +175,14 @@ contract-discovery, conformance, or authorization meaning. To construct a contract view, a tool starts with a record's effective frontmatter and copies every mapped value to its contract field reference. Missing optional values remain missing. The resulting object is validated against the -contract's `schema`. +contract's `record_schema`. Contract validation complements rather than replaces type validation: - the type schema validates raw persisted frontmatter - collection semantics construct the effective record - the field map constructs a normalized contract view -- the contract schema validates that view +- the contract's `record_schema` validates that view A record can therefore satisfy its type schema and still produce `data_contract_record_invalid` for one declared implementation. Implementations @@ -191,19 +205,23 @@ storage wrappers or reference paths: ```json { "kind": "mdbase.contract", + "contract_type": "record", "id": "...", "version": "...", - "schema": {}, + "record_schema": {}, "binding_schema": {} } ``` -`binding_schema` is omitted when absent. Human-facing `name`, `description`, -Markdown body, `x-*` metadata, schema wrapper dialects, and local `ref` paths -do not affect portable identity. Consequently, an inline schema and a local -referenced schema with identical resolved JSON values have the same contract -digest, while changing the bytes at a stable reference path changes the -digest. +The digest object contains the subject-specific schema keys selected by +`contract_type`: `record_schema` and `binding_schema`; `data_schema` and +`source_schema`; or `input_schema`, `output_schema`, `error_schema`, +`provider_schema`, and `behavior`. Absent optional members are omitted. +Human-facing `name`, `description`, Markdown body, `x-*` metadata, schema +wrapper dialects, and local `ref` paths do not affect portable identity. +Consequently, an inline schema and a local referenced schema with identical +resolved JSON values have the same contract digest, while changing the bytes +at a stable reference path changes the digest. The implementation digest is SHA-256 over RFC 8785 bytes for: diff --git a/16-conformance.md b/16-conformance.md index e133f8b..965b040 100644 --- a/16-conformance.md +++ b/16-conformance.md @@ -16,6 +16,7 @@ queries, writes, runtime preflight, workflow execution, and watching. | Links | parse, resolve, validate, and traverse links | | Core Write | create, update, delete, rename, and batch records | | Lifecycle | apply standard managed-field policy during writes | +| Event/Action Interoperability | exchange CloudEvents and admitted action invocations through independently claimable roles | | Runtime Contracts | load contracts, compose registries, and preflight runtime references | | Workflow | execute workflows through runtime action handlers | | Watch | report ordered collection changes after consistent state | @@ -32,7 +33,8 @@ Normative profile IDs and dependencies are: | `links` | `collection_semantics`, `cel` | | `core_write` | `collection_semantics` | | `lifecycle` | `core_write`, `cel` | -| `runtime_contracts/0.1` | none | +| `event_action_interop/0.1` | none | +| `runtime_contracts/0.1` | `event_action_interop/0.1` | | `workflow/0.1` | `runtime_contracts/0.1`, `cel` | | `watch` | `core_read` | diff --git a/README.md b/README.md index f55111a..928e332 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,9 @@ The current specification is **v0.3.0**. - ordinary Markdown view records for saved queries and advisory presentation - consistent create, read, update, delete, rename, and batch operations - lifecycle policies for IDs, timestamps, slugs, and managed values -- optional runtime contracts for events, actions, capabilities, and workflows +- first-class record, event, and action contracts with one identity and digest model +- optional CloudEvents-based application interoperability +- optional durable runtime execution for workflows, timers, and recovery - conformance profiles that show which features each tool supports ## A Collection At A Glance @@ -107,12 +109,14 @@ order_by: | Goal | Start here | | --- | --- | | Understand the model | [Overview](./00-overview.md) and [Concepts](./01-concepts.md) | -| Create a collection | [Collection Layout](./02-collection-layout.md), [Configuration](./04-configuration.md), [Type Files](./05-type-files.md), and [Data Contracts](./05-data-contracts.md) | +| Create a collection | [Collection Layout](./02-collection-layout.md), [Configuration](./04-configuration.md), [Type Files](./05-type-files.md), and [First-Class Contracts](./05-data-contracts.md) | | Validate, query, or save views over records | [JSON Schema Profile](./06-json-schema-profile.md), [CEL Profile](./10-cel-profile.md), and [Querying](./11-querying.md) | | Add links or managed fields | [Links](./08-links.md) and [Lifecycle](./09-lifecycle.md) | -| Define automation | [Runtime Contracts](./13-runtime-contracts.md) and [Workflows](./14-workflows.md) | +| Connect applications | [Event and Action Interoperability](./interop/0.1.md) | +| Define durable automation | [Runtime Contracts](./13-runtime-contracts.md) and [Workflows](./14-workflows.md) | | Migrate a v0.2 collection | [Migrations And Compatibility](./15-migrations-and-compatibility.md) | -| Build a conforming tool | [Conformance](./16-conformance.md), [data contracts](./05-data-contracts.md), [canonical schemas](./schemas/v0.3/), and [test fixtures](./tests/v0.3/) | +| Exchange typed events and actions | [Event/action interoperability profile 0.1](./interop/0.1.md) and [canonical interoperability schemas](./schemas/interop/v0.1/) | +| Build a conforming tool | [Conformance](./16-conformance.md), [first-class contracts](./05-data-contracts.md), [canonical schemas](./schemas/v0.3/), and [test fixtures](./tests/v0.3/) | ## Implementations diff --git a/examples/v0.3/tasknotes-migration/v0.3/_contracts/tasknotes.task.md b/examples/v0.3/tasknotes-migration/v0.3/_contracts/tasknotes.task.md index fec1ba2..bead2f4 100644 --- a/examples/v0.3/tasknotes-migration/v0.3/_contracts/tasknotes.task.md +++ b/examples/v0.3/tasknotes-migration/v0.3/_contracts/tasknotes.task.md @@ -1,11 +1,12 @@ --- kind: mdbase.contract +contract_type: record id: tasknotes.task version: 0.2.0 name: TaskNotes task description: Portable task fields and binding semantics used by TaskNotes. -schema: +record_schema: dialect: json-schema-2020-12 value: $schema: "https://json-schema.org/draft/2020-12/schema" diff --git a/examples/v0.3/tasknotes-migration/v0.3/mdbase-pack.yaml b/examples/v0.3/tasknotes-migration/v0.3/mdbase-pack.yaml index f647700..45bb1b2 100644 --- a/examples/v0.3/tasknotes-migration/v0.3/mdbase-pack.yaml +++ b/examples/v0.3/tasknotes-migration/v0.3/mdbase-pack.yaml @@ -7,7 +7,7 @@ resources: - kind: contract source: _contracts/tasknotes.task.md target: _contracts/tasknotes.task.md - digest: sha256:7e6b8ddb30982b88c71d5e14936f363b44185733082048487c0adcb6c1bc6787 + digest: sha256:22a1e45daf5d78e5f6ee8c4c678b37a63d76c8190cfbf8e3cecc84481e5076f3 - kind: type source: _types/task.md target: _types/task.md diff --git a/interop/0.1.md b/interop/0.1.md new file mode 100644 index 0000000..4de801b --- /dev/null +++ b/interop/0.1.md @@ -0,0 +1,457 @@ +# mdbase Event and Action Interoperability Profile 0.1 + +## 1. Purpose + +This optional profile lets independently developed applications exchange typed +events and invoke typed actions without adopting one workflow engine or durable +runtime. + +The profile defines: + +- event and action contract kinds built on `mdbase.contract`; +- a CloudEvents 1.0 event envelope; +- action request, admitted invocation, cancellation, and outcome envelopes; +- event-source and action-provider declarations; +- discovery, version resolution, validation, authorization boundaries, and + duplicate semantics; +- role-specific conformance; +- transport capabilities and the baseline bridge behavior. + +The profile does not define workflows, schedules, retries, leases, persistence, +or a universal authorization policy. + +The profile identifier is `event_action_interop/0.1`. Envelope +`profile_version` values use `0.1`; event envelopes carry the CloudEvents +extension `mdbaseprofile: "0.1"`. + +The normative schemas are in `schemas/interop/v0.1/`. + +## 2. Layering + +The layers have separate responsibilities: + +| Layer | Responsibility | +| --- | --- | +| Contract | identity, exact semantic version, canonical digest, schema, and meaning | +| Interoperability profile | message envelopes, roles, resolution, and boundary validation | +| Transport binding | physical delivery and declared delivery capabilities | +| Application runtime | application-specific behavior | +| Durable runtime | optional workflow state, retries, timers, leases, and recovery | + +A contract demonstrates data compatibility. It does not grant authority. +Registration, application installation, and conformance claims do not grant +authority either. + +## 3. Conformance Roles + +Implementations claim only roles they implement: + +| Role | Required behavior | +| --- | --- | +| `event_source` | register exact event artifacts and publish valid events | +| `event_consumer` | subscribe by compatible requirement and validate delivered events | +| `action_caller` | create requests and handle portable outcomes | +| `action_provider` | register exact action artifacts and execute selected invocations | +| `bridge` | resolve, authorize, validate, route, and expose execution evidence | + +Transport bindings may define additional roles. A role claim never implies +another role. + +## 4. First-Class Contract Artifacts + +All passive records, events, and actions use the same control-file envelope: + +```yaml +kind: mdbase.contract +contract_type: event +id: tasknotes.task.completed +version: 1.0.0 +name: Task completed +description: A TaskNotes task transitioned from incomplete to complete. +data_schema: + dialect: json-schema-2020-12 + value: + type: object + required: [task_id, completed_at] + additionalProperties: false + properties: + task_id: { type: string, minLength: 1 } + completed_at: { type: string, format: date-time } +``` + +`contract_type` is one of: + +- `record`, with `record_schema` and optional `binding_schema`; +- `event`, with `data_schema` and optional `source_schema`; +- `action`, with `input_schema`, optional `output_schema`, optional + `error_schema`, optional `provider_schema`, and optional `behavior`. + +Every schema uses the mdbase JSON Schema 2020-12 profile. Event `data`, action +`input`, successful action `output`, declared action error details, and +implementation `binding` values validate against the relevant exact artifact. + +Record `implements` entries remain in type files. Event sources and action +providers make runtime implementation declarations because their +implementations are executable and instance-specific. + +### 4.1 Digest + +The contract digest is SHA-256 over RFC 8785 canonical JSON containing: + +- `kind`, `contract_type`, `id`, and `version`; +- the fully resolved subject-specific schemas; +- action `behavior` when present. + +Names, descriptions, Markdown bodies, `x-*` metadata, wrapper dialect keys, and +local schema reference paths do not affect the digest. + +The digest input uses the schema field names defined for the selected +`contract_type` and omits absent optional fields. A bridge MUST fail with +`contract_digest_conflict` when one `(id, version)` pair is presented with +different contract bytes. + +## 5. Requirements and Exact References + +An author-facing contract requirement contains: + +```json +{ "id": "canvas.card.create", "version": "^1.0.0" } +``` + +`version` is a SemVer range. `digest` may additionally pin one artifact. + +Execution evidence contains an exact reference: + +```json +{ + "id": "canvas.card.create", + "version": "1.2.0", + "digest": "sha256:..." +} +``` + +A bridge resolves a requirement to one exact artifact before event delivery or +action admission. It MUST NOT change a pinned artifact or selected provider +after admission. + +## 6. Implementation Identity + +Portable implementation identity has four distinct layers: + +```yaml +application: canvas-bases +implementation: canvas-bases.obsidian +version: 1.4.0 +instance_id: vault-01 +``` + +- `application` names the product or package; +- `implementation` names a concrete implementation line; +- `version` names its build version; +- `instance_id`, when present, distinguishes a live installation. + +Declarations and outcomes additionally expose a declaration digest. A +transport binding owns the authenticated principal and authorization evidence. +The portable identity is not itself authentication. + +## 7. Events + +### 7.1 Event Contracts and Source Declarations + +An event contract defines the schema and meaning of `data`. It does not name +one owner. Any number of authorized sources may implement the same exact event +contract. + +An event-source declaration identifies one implementation, its author-declared +compatible requirement, its exact resolved artifact, optional binding data, +and ordering capabilities. Binding data validates against `source_schema` when +the contract supplies one. + +### 7.2 CloudEvents Envelope + +Events use CloudEvents 1.0 structured JSON. The standard attributes retain +their CloudEvents meanings: + +```json +{ + "specversion": "1.0", + "id": "evt_01...", + "source": "urn:mdbase:app:tasknotes:tasknotes.obsidian", + "type": "tasknotes.task.completed", + "time": "2026-07-28T11:30:00.000Z", + "subject": "urn:mdbase:record:vault-01:task-123", + "datacontenttype": "application/json", + "dataschema": "urn:mdbase:contract:tasknotes.task.completed:1.0.0:sha256:...", + "mdbaseprofile": "0.1", + "mdbasecontractversion": "1.0.0", + "mdbasecontractdigest": "sha256:...", + "mdbaseapplication": "tasknotes", + "mdbaseimplementation": "tasknotes.obsidian", + "mdbaseimplementationversion": "5.0.0", + "correlationid": "flow_01...", + "causationid": "req_01...", + "data": { + "task_id": "task-123", + "completed_at": "2026-07-28T11:30:00.000Z" + } +} +``` + +`source` and `id` together identify a logical event across redelivery. +`time` is when the domain occurrence happened. Transport receipt time remains +transport metadata. `subject` is an optional stable URI reference. + +`type`, `mdbasecontractversion`, and `mdbasecontractdigest` identify the exact +event artifact. `dataschema` is its canonical URN. Bridges MUST reject an +inconsistent combination. + +`correlationid` groups related activity. `causationid` identifies the event, +request, or invocation that directly caused this event. + +Consumers subscribe by contract ID and compatible version range. Every +authorized matching consumer receives the same logical event. Adding a +consumer does not alter another consumer's subscription. + +Events have no global ordering guarantee. Bindings declare `none`, `source`, +or `subject` ordering scopes. + +## 8. Actions + +Events multicast. Actions select exactly one provider. + +### 8.1 Action Contracts and Provider Declarations + +An action contract defines the requested effect and its input, successful +output, declared portable errors, provider binding, and behavior. It does not +name or authorize a provider. + +An action-provider declaration identifies: + +- the provider implementation; +- a stable handler ID; +- an author-declared compatible contract requirement; +- the exact resolved artifact; +- optional provider binding; +- cancellation and request-deduplication support; +- optional operational limits. + +Provider binding validates against `provider_schema`. + +### 8.2 Request + +A request is logical caller intent: + +```yaml +kind: mdbase.action.request +profile_version: "0.1" +request_id: req_01... +contract: + id: canvas.card.create + version: ^1.0.0 +caller: + application: tasknotes-workflows + implementation: tasknotes-workflows.obsidian + version: 1.0.0 +created_at: 2026-07-28T11:30:01Z +correlation_id: flow_01... +causation_id: evt_01... +idempotency_key: flow_01:canvas-card +input: + canvas: Projects/Report.canvas + title: Prepare report +``` + +An optional requested-provider selector may pin an application, +implementation, or instance. An optional `deadline` is an RFC 3339 timestamp. +An authorization-context reference is bridge supplied and opaque. + +The request is not evidence that a provider was selected or that an effect +occurred. + +### 8.3 Admitted Invocation + +Before dispatch, a bridge records an admitted invocation containing: + +- `request_id`, preserving logical intent; +- a new `invocation_id`; +- an exact contract version and digest; +- the selected provider identity and declaration digest; +- the selected handler ID; +- an `attempt_id`; +- admitted input and trace context; +- admission time and applicable deadline. + +The bridge validates the action input and authorization before admission. +Provider selection and the exact contract MUST NOT change after admission. + +The base bridge creates one attempt. A durable binding or runtime may create +later attempts but preserves the request and invocation identities and gives +each concrete handler attempt a distinct `attempt_id`. + +### 8.4 Outcome + +Every terminal result has a distinct `outcome_id` and identifies its request, +invocation, attempt, exact contract, provider, and completion time. + +Statuses are: + +- `succeeded`; +- `rejected`; +- `failed`; +- `cancelled`; +- `outcome_indeterminate`. + +Successful output validates against `output_schema` when present. Every +unsuccessful outcome carries a portable error. A bridge MUST turn an invalid +provider output into `failed` with `invalid_action_output`; it MUST NOT deliver +invalid output as success. + +Transport acceptance is not an action outcome. + +### 8.5 Selection + +A bridge: + +1. finds exact action artifacts satisfying the request; +2. applies an optional requested-provider selector; +3. removes unauthorized or unavailable providers; +4. selects the one remaining provider; or +5. returns `no_provider`, `requested_provider_unavailable`, or + `ambiguous_provider`. + +Providers are never unioned or broadcast. A bridge MUST NOT silently select the +first registered provider. Adding a provider cannot redirect an admitted +request or an explicitly pinned caller. + +## 9. Validation and Authorization + +At every boundary, a bridge validates: + +1. the portable envelope; +2. the exact contract identity and digest; +3. source or caller identity attributed by the active binding; +4. implementation declarations and bindings; +5. event data, action input, output, or declared error details; +6. active authorization. + +Authorization is separate from compatibility. A bridge may authorize by +principal, role, contract, exact implementation, collection/subject scope, +capability, approval, or resource limit. Credentials and sensitive grants stay +in transport-private state; portable envelopes carry only an opaque reference +when necessary. + +Unauthorized publish, subscribe, provider registration, and invocation fail +closed. + +## 10. Duplicate, Idempotency, and Indeterminate Outcomes + +The profile does not claim universal exactly-once behavior. + +- Consumers deduplicate events by `(source, id)` when duplicate processing is + harmful. +- Requests preserve `request_id` across transport retry. +- An idempotency key is meaningful only when the action contract permits it and + the selected provider declares request deduplication. +- A duplicate request to such a provider returns its recorded terminal outcome + during the declared retention window. +- Without declared deduplication, the bridge rejects a duplicate request rather + than inventing exactly-once behavior. +- If the caller cannot know whether an external effect occurred, the terminal + status is `outcome_indeterminate`. + +Retry policy belongs to the caller, binding, or durable runtime. + +## 11. Cancellation and Deadlines + +A caller may request cancellation by `request_id`. Cancellation is +cooperative. The bridge verifies caller ownership and selected-provider +support, then signals the current attempt. + +Possible races are represented honestly: + +- cancellation before admission returns `cancelled`; +- a cooperatively cancelled attempt returns `cancelled`; +- a completed action keeps its completed outcome; +- an effect whose completion cannot be determined returns + `outcome_indeterminate`; +- a provider without cancellation support returns + `cancellation_unsupported`. + +A bridge supporting deadlines rejects admission after the deadline and signals +a cooperative provider when the deadline expires during an attempt. A binding +that cannot enforce a required deadline fails +`unsupported_transport_capability`. + +## 12. Transport Capabilities + +A binding describes: + +- delivery: `ephemeral`, `at_least_once`, `durable_cursor`, `offline_queue`; +- ordering: `none`, `source`, `subject`; +- cancellation and deadline support; +- provider discovery; +- maximum payload size; +- outcome retention; +- request deduplication; +- cross-process identity. + +An application that requires an unavailable capability fails clearly before +exchange. + +The baseline Obsidian in-process binding is ephemeral, process-local, and +cooperatively attributed. Obsidian does not isolate plugins as security +principals, so this binding cannot defend against a malicious plugin running in +the same process. It still prevents accidental cross-application registration +through opaque client handles and validates host-attributed identities at each +boundary. + +## 13. Baseline Bridge API + +The behavior-based API is: + +```text +connect one implementation identity +describe contracts and implementations +register event source +publish event +subscribe to events +register action provider +invoke action +receive action outcome +cancel action +dispose client registration +``` + +Language SDKs may use idiomatic names. A client handle scopes every +subscription and provider registration to one implementation and removes them +on disposal. + +## 14. Standard Errors + +Portable error codes are defined by +`schemas/interop/v0.1/profile.schema.json#/$defs/portableError`. + +Transport-private diagnostics may be nested in safe `details`, but they do not +replace the portable category. Error messages MUST NOT expose credentials, +private authorization policy, or provider-internal stack traces. + +## 15. Relationship to Runtime and Connect + +The mdbase runtime profile consumes these contract artifacts and envelopes. It +does not define a second event or action system. Workflow triggers subscribe to +event requirements; workflow steps create action requests; durable dispatch +records the admitted invocation and outcome. + +Connect may implement a durable authorized binding using the same envelopes. +Connect control-plane wake-up signals may remain opaque: private event data can +stay at the collection authority and be retrieved only by an authorized +application. + +## 16. Conformance + +Required scenarios are enumerated in +`tests/v0.3/event-action-interop/event-action-interop.yaml`. Claims validate +against `schemas/interop/v0.1/conformance-claim.schema.json`. + +The profile is behavior based. Implementations may use any package structure or +programming language. diff --git a/packages/interop-rs/Cargo.lock b/packages/interop-rs/Cargo.lock new file mode 100644 index 0000000..f6bb4c6 --- /dev/null +++ b/packages/interop-rs/Cargo.lock @@ -0,0 +1,1509 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.4", + "once_cell", + "serde", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bit-set" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytecount" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "clap" +version = "4.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91e0c145792ef73a6ad36d27c75ac09f1832222a3c209689d90f534685ee5b7" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f09628afdcc538b57f3c6341e9c8e9970f18e4a481690a64974d7023bd33548b" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "fancy-regex" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "531e46835a22af56d1e3b66f04844bed63158bc094a628bec1d321d9b4c44bf2" +dependencies = [ + "bit-set", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fraction" +version = "0.15.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e076045bb43dac435333ed5f04caf35c7463631d0dae2deb2638d94dd0a5b872" +dependencies = [ + "lazy_static", + "num", +] + +[[package]] +name = "futures-channel" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" + +[[package]] +name = "futures-io" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a" + +[[package]] +name = "futures-sink" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307" + +[[package]] +name = "futures-task" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" + +[[package]] +name = "futures-util" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" +dependencies = [ + "futures-core", + "futures-io", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "http" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "iso8601" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74a0559b45528cf0732d911524974977a5749f477d7dd99652830ffdaf53c4d1" +dependencies = [ + "nom", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "jsonschema" +version = "0.18.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa0f4bea31643be4c6a678e9aa4ae44f0db9e5609d5ca9dc9083d06eb3e9a27a" +dependencies = [ + "ahash", + "anyhow", + "base64", + "bytecount", + "clap", + "fancy-regex", + "fraction", + "getrandom 0.2.17", + "iso8601", + "itoa", + "memchr", + "num-cmp", + "once_cell", + "parking_lot", + "percent-encoding", + "regex", + "reqwest", + "serde", + "serde_json", + "time", + "url", + "uuid", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "mdbase-interop" +version = "0.1.0-rc.2" +dependencies = [ + "hex", + "jsonschema", + "serde", + "serde_jcs", + "serde_json", + "sha2", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "mio" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +dependencies = [ + "libc", + "wasi", + "windows-sys", +] + +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-cmp" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63335b2e2c34fae2fb0aa2cecfd9f0832a1e24b3b32ecec612c3426d46dc8aaa" + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "ryu-js" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6518fc26bced4d53678a22d6e423e9d8716377def84545fe328236e3af070e7f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "serde_jcs" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cacecf649bc1a7c5f0e299cc813977c6a78116abda2b93b1ee01735b71ead9a8" +dependencies = [ + "ryu-js", + "serde", + "serde_json", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "time" +version = "0.3.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e1d5e639ff6bab73cb6885cc7e7b1de96c3f32c68ec55f3952614bec1092244" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "libc", + "mio", + "pin-project-lite", + "socket2", + "windows-sys", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5a105cd7b140f6eeec8acff2ea38135d3cab283ada58540f629fe51e46696eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fe976fb70c78cd64cccfe3a6fc142244e8a77b70959b30faf9d0ac37ee228eb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/packages/interop-rs/Cargo.toml b/packages/interop-rs/Cargo.toml new file mode 100644 index 0000000..f6ed66d --- /dev/null +++ b/packages/interop-rs/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "mdbase-interop" +version = "0.1.0-rc.2" +edition = "2021" +license = "MIT" +description = "Rust models and validation for mdbase event/action interoperability profile 0.1." +repository = "https://github.com/mdbase-dev/mdbase-spec" +homepage = "https://mdbase.dev/interop/" +include = ["src/**", "schemas/**", "README.md", "LICENSE"] + +[lib] +name = "mdbase_interop" +path = "src/lib.rs" + +[dependencies] +hex = "0.4" +jsonschema = { version = "0.18", features = ["draft202012"] } +serde = { version = "1", features = ["derive"] } +serde_jcs = "0.1" +serde_json = "1" +sha2 = "0.10" diff --git a/packages/interop-rs/README.md b/packages/interop-rs/README.md new file mode 100644 index 0000000..c7f0ff9 --- /dev/null +++ b/packages/interop-rs/README.md @@ -0,0 +1,15 @@ +# `mdbase-interop` + +Rust models and boundary validation for mdbase event/action interoperability +profile `0.1`. + +The crate embeds release copies of the canonical schemas, validates +CloudEvents, action envelopes, source/provider declarations, cancellation, and +conformance claims, validates first-class contract artifacts, computes exact +contract digests, and validates event data against the resolved event contract. + +It is not a bridge, workflow engine, transport, or authorization system. + +```bash +cargo test --manifest-path packages/interop-rs/Cargo.toml +``` diff --git a/packages/interop-rs/schemas/data-contract.schema.json b/packages/interop-rs/schemas/data-contract.schema.json new file mode 100644 index 0000000..d20a200 --- /dev/null +++ b/packages/interop-rs/schemas/data-contract.schema.json @@ -0,0 +1,163 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://mdbase.dev/schemas/v0.3/data-contract.schema.json", + "title": "mdbase v0.3 contract frontmatter", + "type": "object", + "required": ["kind", "contract_type", "id", "version"], + "properties": { + "kind": { + "const": "mdbase.contract" + }, + "contract_type": { + "enum": ["record", "event", "action"] + }, + "id": { + "$ref": "#/$defs/contractId" + }, + "version": { + "$ref": "#/$defs/semanticVersion" + }, + "name": { + "type": "string", + "minLength": 1 + }, + "description": { + "type": "string" + }, + "record_schema": { + "$ref": "#/$defs/schemaWrapper" + }, + "binding_schema": { + "$ref": "#/$defs/schemaWrapper" + }, + "data_schema": { + "$ref": "#/$defs/schemaWrapper" + }, + "source_schema": { + "$ref": "#/$defs/schemaWrapper" + }, + "input_schema": { + "$ref": "#/$defs/schemaWrapper" + }, + "output_schema": { + "$ref": "#/$defs/schemaWrapper" + }, + "error_schema": { + "$ref": "#/$defs/schemaWrapper" + }, + "provider_schema": { + "$ref": "#/$defs/schemaWrapper" + }, + "behavior": { + "$ref": "#/$defs/actionBehavior" + } + }, + "patternProperties": { + "^x-[A-Za-z][A-Za-z0-9._:-]{0,127}$": true + }, + "oneOf": [ + { + "properties": { + "contract_type": { "const": "record" }, + "record_schema": true, + "binding_schema": true, + "data_schema": false, + "source_schema": false, + "input_schema": false, + "output_schema": false, + "error_schema": false, + "provider_schema": false, + "behavior": false + }, + "required": ["record_schema"] + }, + { + "properties": { + "contract_type": { "const": "event" }, + "record_schema": false, + "binding_schema": false, + "data_schema": true, + "source_schema": true, + "input_schema": false, + "output_schema": false, + "error_schema": false, + "provider_schema": false, + "behavior": false + }, + "required": ["data_schema"] + }, + { + "properties": { + "contract_type": { "const": "action" }, + "record_schema": false, + "binding_schema": false, + "data_schema": false, + "source_schema": false, + "input_schema": true, + "output_schema": true, + "error_schema": true, + "provider_schema": true, + "behavior": true + }, + "required": ["input_schema"] + } + ], + "additionalProperties": false, + "$defs": { + "contractId": { + "type": "string", + "minLength": 3, + "maxLength": 128, + "pattern": "^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)+$" + }, + "semanticVersion": { + "type": "string", + "pattern": "^(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)(?:-[0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*)?(?:\\+[0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*)?$" + }, + "schemaWrapper": { + "type": "object", + "required": ["dialect"], + "properties": { + "dialect": { + "const": "json-schema-2020-12" + }, + "value": { + "type": "object" + }, + "ref": { + "type": "string", + "minLength": 1 + } + }, + "oneOf": [ + { + "required": ["value"], + "properties": { + "value": true, + "ref": false + } + }, + { + "required": ["ref"], + "properties": { + "ref": true, + "value": false + } + } + ], + "additionalProperties": false + }, + "actionBehavior": { + "type": "object", + "properties": { + "idempotency": { + "enum": ["none", "optional", "required"] + }, + "cancellation": { + "enum": ["none", "cooperative"] + } + }, + "additionalProperties": false + } + } +} diff --git a/packages/interop-rs/schemas/profile.schema.json b/packages/interop-rs/schemas/profile.schema.json new file mode 100644 index 0000000..4ba0146 --- /dev/null +++ b/packages/interop-rs/schemas/profile.schema.json @@ -0,0 +1,456 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://mdbase.dev/schemas/interop/v0.1/profile.schema.json", + "title": "mdbase event and action interoperability profile 0.1", + "oneOf": [ + { "$ref": "#/$defs/event" }, + { "$ref": "#/$defs/actionRequest" }, + { "$ref": "#/$defs/actionInvocation" }, + { "$ref": "#/$defs/actionOutcome" }, + { "$ref": "#/$defs/actionCancellation" }, + { "$ref": "#/$defs/eventSourceDeclaration" }, + { "$ref": "#/$defs/actionProviderDeclaration" }, + { "$ref": "#/$defs/conformanceClaim" } + ], + "$defs": { + "contractId": { + "type": "string", + "minLength": 3, + "maxLength": 128, + "pattern": "^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)+$" + }, + "semanticVersion": { + "type": "string", + "pattern": "^(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)(?:-[0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*)?(?:\\+[0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*)?$" + }, + "semanticVersionRequirement": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "digest": { + "type": "string", + "pattern": "^sha256:[0-9a-f]{64}$" + }, + "portableId": { + "type": "string", + "minLength": 1, + "maxLength": 256, + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:@/-]*$" + }, + "exactContract": { + "type": "object", + "required": ["id", "version", "digest"], + "properties": { + "id": { "$ref": "#/$defs/contractId" }, + "version": { "$ref": "#/$defs/semanticVersion" }, + "digest": { "$ref": "#/$defs/digest" } + }, + "additionalProperties": false + }, + "contractRequirement": { + "type": "object", + "required": ["id", "version"], + "properties": { + "id": { "$ref": "#/$defs/contractId" }, + "version": { "$ref": "#/$defs/semanticVersionRequirement" }, + "digest": { "$ref": "#/$defs/digest" } + }, + "additionalProperties": false + }, + "implementationIdentity": { + "type": "object", + "required": ["application", "implementation", "version"], + "properties": { + "application": { "$ref": "#/$defs/portableId" }, + "implementation": { "$ref": "#/$defs/portableId" }, + "version": { "$ref": "#/$defs/semanticVersion" }, + "instance_id": { "$ref": "#/$defs/portableId" } + }, + "additionalProperties": false + }, + "transportCapabilities": { + "type": "object", + "required": ["delivery", "ordering", "cancellation", "deadlines"], + "properties": { + "delivery": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "enum": ["ephemeral", "at_least_once", "durable_cursor", "offline_queue"] + } + }, + "ordering": { + "type": "array", + "uniqueItems": true, + "items": { + "enum": ["none", "source", "subject"] + } + }, + "cancellation": { "type": "boolean" }, + "deadlines": { "type": "boolean" }, + "provider_discovery": { "type": "boolean" }, + "max_payload_bytes": { "type": "integer", "minimum": 1 }, + "outcome_retention_seconds": { "type": "integer", "minimum": 0 }, + "request_deduplication": { "type": "boolean" }, + "cross_process_identity": { "type": "boolean" } + }, + "additionalProperties": false + }, + "extensionValue": { + "oneOf": [ + { "type": "null" }, + { "type": "boolean" }, + { "type": "integer" }, + { "type": "number" }, + { "type": "string" } + ] + }, + "event": { + "title": "mdbase CloudEvents event envelope", + "type": "object", + "required": [ + "specversion", + "id", + "source", + "type", + "time", + "datacontenttype", + "dataschema", + "data", + "mdbaseprofile", + "mdbasecontractversion", + "mdbasecontractdigest", + "mdbaseapplication", + "mdbaseimplementation", + "mdbaseimplementationversion" + ], + "properties": { + "specversion": { "const": "1.0" }, + "id": { "$ref": "#/$defs/portableId" }, + "source": { "type": "string", "format": "uri-reference", "minLength": 1 }, + "type": { "$ref": "#/$defs/contractId" }, + "time": { "type": "string", "format": "date-time" }, + "subject": { "type": "string", "format": "uri-reference", "minLength": 1 }, + "datacontenttype": { "const": "application/json" }, + "dataschema": { "type": "string", "format": "uri", "minLength": 1 }, + "data": true, + "mdbaseprofile": { "const": "0.1" }, + "mdbasecontractversion": { "$ref": "#/$defs/semanticVersion" }, + "mdbasecontractdigest": { "$ref": "#/$defs/digest" }, + "mdbaseapplication": { "$ref": "#/$defs/portableId" }, + "mdbaseimplementation": { "$ref": "#/$defs/portableId" }, + "mdbaseimplementationversion": { "$ref": "#/$defs/semanticVersion" }, + "mdbaseinstanceid": { "$ref": "#/$defs/portableId" }, + "correlationid": { "$ref": "#/$defs/portableId" }, + "causationid": { "$ref": "#/$defs/portableId" } + }, + "propertyNames": { "pattern": "^[a-z0-9]+$" }, + "additionalProperties": { "$ref": "#/$defs/extensionValue" } + }, + "actionRequest": { + "title": "mdbase action request", + "type": "object", + "required": [ + "kind", + "profile_version", + "request_id", + "contract", + "caller", + "created_at", + "input" + ], + "properties": { + "kind": { "const": "mdbase.action.request" }, + "profile_version": { "const": "0.1" }, + "request_id": { "$ref": "#/$defs/portableId" }, + "contract": { "$ref": "#/$defs/contractRequirement" }, + "caller": { "$ref": "#/$defs/implementationIdentity" }, + "created_at": { "type": "string", "format": "date-time" }, + "correlation_id": { "$ref": "#/$defs/portableId" }, + "causation_id": { "$ref": "#/$defs/portableId" }, + "subject": { "type": "string", "format": "uri-reference", "minLength": 1 }, + "idempotency_key": { "type": "string", "minLength": 1, "maxLength": 512 }, + "deadline": { "type": "string", "format": "date-time" }, + "requested_provider": { + "type": "object", + "properties": { + "application": { "$ref": "#/$defs/portableId" }, + "implementation": { "$ref": "#/$defs/portableId" }, + "instance_id": { "$ref": "#/$defs/portableId" } + }, + "minProperties": 1, + "additionalProperties": false + }, + "authorization_context": { + "type": "string", + "format": "uri-reference", + "minLength": 1 + }, + "input": true + }, + "additionalProperties": false + }, + "actionInvocation": { + "title": "mdbase admitted action invocation", + "type": "object", + "required": [ + "kind", + "profile_version", + "invocation_id", + "attempt_id", + "request_id", + "contract", + "caller", + "provider", + "provider_declaration_digest", + "handler_id", + "admitted_at", + "input" + ], + "properties": { + "kind": { "const": "mdbase.action.invocation" }, + "profile_version": { "const": "0.1" }, + "invocation_id": { "$ref": "#/$defs/portableId" }, + "attempt_id": { "$ref": "#/$defs/portableId" }, + "request_id": { "$ref": "#/$defs/portableId" }, + "contract": { "$ref": "#/$defs/exactContract" }, + "caller": { "$ref": "#/$defs/implementationIdentity" }, + "provider": { "$ref": "#/$defs/implementationIdentity" }, + "provider_declaration_digest": { "$ref": "#/$defs/digest" }, + "handler_id": { "$ref": "#/$defs/portableId" }, + "admitted_at": { "type": "string", "format": "date-time" }, + "correlation_id": { "$ref": "#/$defs/portableId" }, + "causation_id": { "$ref": "#/$defs/portableId" }, + "subject": { "type": "string", "format": "uri-reference", "minLength": 1 }, + "idempotency_key": { "type": "string", "minLength": 1, "maxLength": 512 }, + "deadline": { "type": "string", "format": "date-time" }, + "authorization_context": { + "type": "string", + "format": "uri-reference", + "minLength": 1 + }, + "input": true + }, + "additionalProperties": false + }, + "portableError": { + "type": "object", + "required": ["code", "message"], + "properties": { + "code": { + "enum": [ + "unknown_contract", + "unsupported_contract_version", + "contract_digest_conflict", + "invalid_event_data", + "invalid_action_input", + "invalid_action_output", + "no_provider", + "ambiguous_provider", + "requested_provider_unavailable", + "unauthorized", + "capability_denied", + "request_rejected", + "deadline_exceeded", + "cancellation_unsupported", + "cancelled", + "handler_failure", + "outcome_indeterminate", + "transport_unavailable", + "unsupported_transport_capability" + ] + }, + "message": { "type": "string", "minLength": 1 }, + "details": true, + "retryable": { "type": "boolean" } + }, + "additionalProperties": false + }, + "actionOutcome": { + "title": "mdbase action outcome", + "type": "object", + "required": [ + "kind", + "profile_version", + "outcome_id", + "request_id", + "invocation_id", + "attempt_id", + "contract", + "provider", + "provider_declaration_digest", + "status", + "completed_at" + ], + "properties": { + "kind": { "const": "mdbase.action.outcome" }, + "profile_version": { "const": "0.1" }, + "outcome_id": { "$ref": "#/$defs/portableId" }, + "request_id": { "$ref": "#/$defs/portableId" }, + "invocation_id": { "$ref": "#/$defs/portableId" }, + "attempt_id": { "$ref": "#/$defs/portableId" }, + "contract": { "$ref": "#/$defs/exactContract" }, + "provider": { "$ref": "#/$defs/implementationIdentity" }, + "provider_declaration_digest": { "$ref": "#/$defs/digest" }, + "status": { + "enum": ["succeeded", "rejected", "failed", "cancelled", "outcome_indeterminate"] + }, + "completed_at": { "type": "string", "format": "date-time" }, + "output": true, + "error": { "$ref": "#/$defs/portableError" } + }, + "allOf": [ + { + "if": { + "properties": { "status": { "const": "succeeded" } }, + "required": ["status"] + }, + "then": { + "required": ["output"], + "not": { "required": ["error"] } + }, + "else": { + "required": ["error"], + "not": { "required": ["output"] } + } + } + ], + "additionalProperties": false + }, + "actionCancellation": { + "title": "mdbase action cancellation request", + "type": "object", + "required": ["kind", "profile_version", "cancellation_id", "request_id", "caller", "requested_at"], + "properties": { + "kind": { "const": "mdbase.action.cancel" }, + "profile_version": { "const": "0.1" }, + "cancellation_id": { "$ref": "#/$defs/portableId" }, + "request_id": { "$ref": "#/$defs/portableId" }, + "caller": { "$ref": "#/$defs/implementationIdentity" }, + "requested_at": { "type": "string", "format": "date-time" }, + "reason": { "type": "string", "maxLength": 1024 } + }, + "additionalProperties": false + }, + "eventSourceDeclaration": { + "title": "mdbase event-source declaration", + "type": "object", + "required": [ + "kind", + "profile_version", + "declaration_id", + "declaration_digest", + "source", + "contracts" + ], + "properties": { + "kind": { "const": "mdbase.event-source" }, + "profile_version": { "const": "0.1" }, + "declaration_id": { "$ref": "#/$defs/portableId" }, + "declaration_digest": { "$ref": "#/$defs/digest" }, + "source": { "$ref": "#/$defs/implementationIdentity" }, + "contracts": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "required": ["requirement", "resolved"], + "properties": { + "requirement": { "$ref": "#/$defs/contractRequirement" }, + "resolved": { "$ref": "#/$defs/exactContract" }, + "binding": true, + "ordering": { + "type": "array", + "uniqueItems": true, + "items": { "enum": ["none", "source", "subject"] } + } + }, + "additionalProperties": false + } + } + }, + "additionalProperties": false + }, + "actionProviderDeclaration": { + "title": "mdbase action-provider declaration", + "type": "object", + "required": [ + "kind", + "profile_version", + "declaration_id", + "declaration_digest", + "provider", + "handlers" + ], + "properties": { + "kind": { "const": "mdbase.action-provider" }, + "profile_version": { "const": "0.1" }, + "declaration_id": { "$ref": "#/$defs/portableId" }, + "declaration_digest": { "$ref": "#/$defs/digest" }, + "provider": { "$ref": "#/$defs/implementationIdentity" }, + "handlers": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "required": ["handler_id", "requirement", "resolved"], + "properties": { + "handler_id": { "$ref": "#/$defs/portableId" }, + "requirement": { "$ref": "#/$defs/contractRequirement" }, + "resolved": { "$ref": "#/$defs/exactContract" }, + "binding": true, + "idempotency": { + "type": "object", + "required": ["mode"], + "properties": { + "mode": { "enum": ["none", "request"] }, + "retention_seconds": { "type": "integer", "minimum": 1 } + }, + "additionalProperties": false + }, + "cancellation": { "enum": ["none", "cooperative"] }, + "max_concurrency": { "type": "integer", "minimum": 1 } + }, + "additionalProperties": false + } + } + }, + "additionalProperties": false + }, + "conformanceClaim": { + "title": "mdbase interoperability conformance claim", + "type": "object", + "required": ["kind", "profile_version", "implementation", "roles", "transport"], + "properties": { + "kind": { "const": "mdbase.interop.conformance" }, + "profile_version": { "const": "0.1" }, + "implementation": { "$ref": "#/$defs/implementationIdentity" }, + "roles": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "enum": ["event_source", "event_consumer", "action_caller", "action_provider", "bridge"] + } + }, + "transport": { "$ref": "#/$defs/transportCapabilities" }, + "evidence": { + "type": "array", + "items": { + "type": "object", + "required": ["scenario", "result"], + "properties": { + "scenario": { "type": "string", "minLength": 1 }, + "result": { "const": "pass" }, + "uri": { "type": "string", "format": "uri-reference" } + }, + "additionalProperties": false + } + } + }, + "additionalProperties": false + } + } +} diff --git a/packages/interop-rs/src/lib.rs b/packages/interop-rs/src/lib.rs new file mode 100644 index 0000000..1a8c8fc --- /dev/null +++ b/packages/interop-rs/src/lib.rs @@ -0,0 +1,614 @@ +use jsonschema::{Draft, JSONSchema}; +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; +use sha2::{Digest, Sha256}; + +pub const PROFILE_VERSION: &str = "0.1"; +pub const CLOUDEVENTS_SPEC_VERSION: &str = "1.0"; + +const CONTRACT_SCHEMA: &str = include_str!("../schemas/data-contract.schema.json"); +const PROFILE_SCHEMA: &str = include_str!("../schemas/profile.schema.json"); + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ExactContractReference { + pub id: String, + pub version: String, + pub digest: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ContractRequirement { + pub id: String, + pub version: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub digest: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ImplementationIdentity { + pub application: String, + pub implementation: String, + pub version: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub instance_id: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct CloudEvent { + pub specversion: String, + pub id: String, + pub source: String, + #[serde(rename = "type")] + pub event_type: String, + pub time: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub subject: Option, + pub datacontenttype: String, + pub dataschema: String, + pub data: Value, + pub mdbaseprofile: String, + pub mdbasecontractversion: String, + pub mdbasecontractdigest: String, + pub mdbaseapplication: String, + pub mdbaseimplementation: String, + pub mdbaseimplementationversion: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub mdbaseinstanceid: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub correlationid: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub causationid: Option, + #[serde(flatten)] + pub extensions: Map, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ActionRequest { + pub kind: String, + pub profile_version: String, + pub request_id: String, + pub contract: ContractRequirement, + pub caller: ImplementationIdentity, + pub created_at: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub correlation_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub causation_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub subject: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub idempotency_key: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub deadline: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub requested_provider: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub authorization_context: Option, + pub input: Value, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ProviderSelector { + #[serde(skip_serializing_if = "Option::is_none")] + pub application: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub implementation: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub instance_id: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ActionInvocation { + pub kind: String, + pub profile_version: String, + pub invocation_id: String, + pub attempt_id: String, + pub request_id: String, + pub contract: ExactContractReference, + pub caller: ImplementationIdentity, + pub provider: ImplementationIdentity, + pub provider_declaration_digest: String, + pub handler_id: String, + pub admitted_at: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub correlation_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub causation_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub subject: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub idempotency_key: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub deadline: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub authorization_context: Option, + pub input: Value, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PortableError { + pub code: String, + pub message: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub details: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub retryable: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ActionOutcome { + pub kind: String, + pub profile_version: String, + pub outcome_id: String, + pub request_id: String, + pub invocation_id: String, + pub attempt_id: String, + pub contract: ExactContractReference, + pub provider: ImplementationIdentity, + pub provider_declaration_digest: String, + pub status: String, + pub completed_at: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub output: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct EventSourceContractDeclaration { + pub requirement: ContractRequirement, + pub resolved: ExactContractReference, + #[serde(skip_serializing_if = "Option::is_none")] + pub binding: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub ordering: Option>, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct EventSourceDeclaration { + pub kind: String, + pub profile_version: String, + pub declaration_id: String, + pub declaration_digest: String, + pub source: ImplementationIdentity, + pub contracts: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ProviderIdempotency { + pub mode: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub retention_seconds: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ActionProviderHandlerDeclaration { + pub handler_id: String, + pub requirement: ContractRequirement, + pub resolved: ExactContractReference, + #[serde(skip_serializing_if = "Option::is_none")] + pub binding: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub idempotency: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub cancellation: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub max_concurrency: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ActionProviderDeclaration { + pub kind: String, + pub profile_version: String, + pub declaration_id: String, + pub declaration_digest: String, + pub provider: ImplementationIdentity, + pub handlers: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ActionCancellation { + pub kind: String, + pub profile_version: String, + pub cancellation_id: String, + pub request_id: String, + pub caller: ImplementationIdentity, + pub requested_at: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub reason: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct TransportCapabilities { + pub delivery: Vec, + pub ordering: Vec, + pub cancellation: bool, + pub deadlines: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub provider_discovery: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub max_payload_bytes: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub outcome_retention_seconds: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub request_deduplication: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub cross_process_identity: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ConformanceEvidence { + pub scenario: String, + pub result: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub uri: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct InteropConformanceClaim { + pub kind: String, + pub profile_version: String, + pub implementation: ImplementationIdentity, + pub roles: Vec, + pub transport: TransportCapabilities, + #[serde(skip_serializing_if = "Option::is_none")] + pub evidence: Option>, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ValidationIssue { + pub instance_path: String, + pub message: String, +} + +pub fn validate_contract_artifact(value: &Value) -> Result<(), Vec> { + validate_with_schema(contract_schema(), value) +} + +pub fn validate_profile_envelope(value: &Value) -> Result<(), Vec> { + validate_with_schema(profile_schema(), value) +} + +pub fn contract_digest(artifact: &Value) -> Result { + validate_contract_artifact(artifact) + .map_err(|issues| format!("invalid contract artifact: {}", format_issues(&issues)))?; + let contract_type = required_string(artifact, "contract_type")?; + let mut portable = Map::new(); + for key in ["kind", "contract_type", "id", "version"] { + portable.insert( + key.to_string(), + artifact + .get(key) + .cloned() + .ok_or_else(|| format!("missing {key}"))?, + ); + } + let fields: &[&str] = match contract_type { + "record" => &["record_schema", "binding_schema"], + "event" => &["data_schema", "source_schema"], + "action" => &[ + "input_schema", + "output_schema", + "error_schema", + "provider_schema", + "behavior", + ], + _ => return Err(format!("unsupported contract_type {contract_type}")), + }; + for field in fields { + let Some(value) = artifact.get(*field) else { + continue; + }; + if *field == "behavior" { + portable.insert((*field).to_string(), value.clone()); + continue; + } + let resolved = value + .get("value") + .cloned() + .ok_or_else(|| format!("{field} must be resolved inline before digest calculation"))?; + portable.insert((*field).to_string(), resolved); + } + let canonical = + serde_jcs::to_vec(&Value::Object(portable)).map_err(|error| error.to_string())?; + Ok(format!("sha256:{}", hex::encode(Sha256::digest(canonical)))) +} + +pub fn validate_event( + contract: &Value, + event: &Value, +) -> Result> { + if let Err(issues) = validate_profile_envelope(event) { + return Err(issues); + } + if contract.get("contract_type").and_then(Value::as_str) != Some("event") { + return Err(vec![issue( + "/", + "resolved artifact is not an event contract", + )]); + } + if let Err(issues) = validate_contract_artifact(contract) { + return Err(issues); + } + let digest = contract_digest(contract).map_err(|message| vec![issue("/", &message)])?; + let reference = ExactContractReference { + id: required_string(contract, "id") + .map_err(|message| vec![issue("/id", &message)])? + .to_string(), + version: required_string(contract, "version") + .map_err(|message| vec![issue("/version", &message)])? + .to_string(), + digest, + }; + let evidence_matches = event.get("type").and_then(Value::as_str) == Some(reference.id.as_str()) + && event.get("mdbasecontractversion").and_then(Value::as_str) + == Some(reference.version.as_str()) + && event.get("mdbasecontractdigest").and_then(Value::as_str) + == Some(reference.digest.as_str()); + if !evidence_matches { + return Err(vec![issue("/", "event contract evidence is inconsistent")]); + } + let data_schema = contract.pointer("/data_schema/value").ok_or_else(|| { + vec![issue( + "/data_schema", + "event data_schema must be resolved inline", + )] + })?; + let data = event + .get("data") + .ok_or_else(|| vec![issue("/data", "event data is required")])?; + if let Err(mut issues) = validate_with_value_schema(data_schema, data) { + for issue in &mut issues { + issue.instance_path = format!("/data{}", issue.instance_path); + } + return Err(issues); + } + Ok(reference) +} + +pub fn validate_action_request(value: &Value) -> Result> { + validate_kind_and_deserialize(value, "mdbase.action.request") +} + +pub fn validate_action_invocation(value: &Value) -> Result> { + validate_kind_and_deserialize(value, "mdbase.action.invocation") +} + +pub fn validate_action_outcome(value: &Value) -> Result> { + validate_kind_and_deserialize(value, "mdbase.action.outcome") +} + +pub fn validate_event_source_declaration( + value: &Value, +) -> Result> { + validate_kind_and_deserialize(value, "mdbase.event-source") +} + +pub fn validate_action_provider_declaration( + value: &Value, +) -> Result> { + validate_kind_and_deserialize(value, "mdbase.action-provider") +} + +pub fn validate_action_cancellation( + value: &Value, +) -> Result> { + validate_kind_and_deserialize(value, "mdbase.action.cancel") +} + +pub fn validate_conformance_claim( + value: &Value, +) -> Result> { + validate_kind_and_deserialize(value, "mdbase.interop.conformance") +} + +fn validate_kind_and_deserialize(value: &Value, kind: &str) -> Result> +where + T: for<'de> Deserialize<'de>, +{ + if value.get("kind").and_then(Value::as_str) != Some(kind) { + return Err(vec![issue("/kind", &format!("expected {kind}"))]); + } + validate_profile_envelope(value)?; + serde_json::from_value(value.clone()) + .map_err(|error| vec![issue("/", &format!("model decoding failed: {error}"))]) +} + +fn contract_schema() -> &'static JSONSchema { + static SCHEMA: std::sync::OnceLock = std::sync::OnceLock::new(); + SCHEMA.get_or_init(|| compile(CONTRACT_SCHEMA)) +} + +fn profile_schema() -> &'static JSONSchema { + static SCHEMA: std::sync::OnceLock = std::sync::OnceLock::new(); + SCHEMA.get_or_init(|| compile(PROFILE_SCHEMA)) +} + +fn compile(source: &str) -> JSONSchema { + let schema: Value = serde_json::from_str(source).expect("canonical schema JSON"); + JSONSchema::options() + .with_draft(Draft::Draft202012) + .compile(&schema) + .expect("canonical schema compiles") +} + +fn validate_with_schema(schema: &JSONSchema, value: &Value) -> Result<(), Vec> { + match schema.validate(value) { + Ok(()) => Ok(()), + Err(errors) => Err(errors + .map(|error| ValidationIssue { + instance_path: error.instance_path.to_string(), + message: error.to_string(), + }) + .collect()), + } +} + +fn validate_with_value_schema(schema: &Value, value: &Value) -> Result<(), Vec> { + let compiled = JSONSchema::options() + .with_draft(Draft::Draft202012) + .compile(schema) + .map_err(|error| { + vec![issue( + "/", + &format!("embedded schema does not compile: {error}"), + )] + })?; + validate_with_schema(&compiled, value) +} + +fn required_string<'a>(value: &'a Value, field: &str) -> Result<&'a str, String> { + value + .get(field) + .and_then(Value::as_str) + .ok_or_else(|| format!("{field} must be a string")) +} + +fn issue(path: &str, message: &str) -> ValidationIssue { + ValidationIssue { + instance_path: path.to_string(), + message: message.to_string(), + } +} + +fn format_issues(issues: &[ValidationIssue]) -> String { + issues + .iter() + .map(|issue| format!("{} {}", issue.instance_path, issue.message)) + .collect::>() + .join("; ") +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn event_contract() -> Value { + json!({ + "kind": "mdbase.contract", + "contract_type": "event", + "id": "tasknotes.task.completed", + "version": "1.0.0", + "data_schema": { + "dialect": "json-schema-2020-12", + "value": { + "type": "object", + "required": ["task_id"], + "additionalProperties": false, + "properties": { "task_id": { "type": "string" } } + } + } + }) + } + + #[test] + fn digest_is_deterministic_and_excludes_human_metadata() { + let contract = event_contract(); + let mut renamed = contract.clone(); + renamed["name"] = json!("A human label"); + assert_eq!( + contract_digest(&contract).unwrap(), + contract_digest(&renamed).unwrap() + ); + } + + #[test] + fn validates_cloud_event_and_exact_data_contract() { + let contract = event_contract(); + let digest = contract_digest(&contract).unwrap(); + let event = json!({ + "specversion": "1.0", + "id": "evt_01", + "source": "urn:mdbase:app:tasknotes:tasknotes.obsidian", + "type": "tasknotes.task.completed", + "time": "2026-07-28T01:30:00Z", + "datacontenttype": "application/json", + "dataschema": format!("urn:mdbase:contract:tasknotes.task.completed:1.0.0:{digest}"), + "data": { "task_id": "task-123" }, + "mdbaseprofile": "0.1", + "mdbasecontractversion": "1.0.0", + "mdbasecontractdigest": digest, + "mdbaseapplication": "tasknotes", + "mdbaseimplementation": "tasknotes.obsidian", + "mdbaseimplementationversion": "5.0.0" + }); + let exact = validate_event(&contract, &event).unwrap(); + assert_eq!(exact.id, "tasknotes.task.completed"); + + let mut invalid = event; + invalid["data"] = json!({}); + assert!(validate_event(&contract, &invalid).is_err()); + } + + #[test] + fn decodes_action_request() { + let request = json!({ + "kind": "mdbase.action.request", + "profile_version": "0.1", + "request_id": "req_01", + "contract": { "id": "canvas.card.create", "version": "^1.0.0" }, + "caller": { + "application": "tasknotes-workflows", + "implementation": "tasknotes-workflows.obsidian", + "version": "1.0.0" + }, + "created_at": "2026-07-28T01:30:00Z", + "input": { "title": "Card" } + }); + assert_eq!( + validate_action_request(&request).unwrap().request_id, + "req_01" + ); + } + + #[test] + fn decodes_exact_provider_declaration_evidence() { + let declaration = json!({ + "kind": "mdbase.action-provider", + "profile_version": "0.1", + "declaration_id": "canvas.actions", + "declaration_digest": format!("sha256:{}", "0".repeat(64)), + "provider": { + "application": "canvas-bases", + "implementation": "canvas-bases.obsidian", + "version": "1.0.0" + }, + "handlers": [{ + "handler_id": "canvas.card.create", + "requirement": { + "id": "canvas.card.create", + "version": "^1.0.0" + }, + "resolved": { + "id": "canvas.card.create", + "version": "1.0.0", + "digest": format!("sha256:{}", "1".repeat(64)) + }, + "idempotency": { + "mode": "request", + "retention_seconds": 300 + } + }] + }); + let decoded = validate_action_provider_declaration(&declaration).unwrap(); + assert_eq!(decoded.handlers[0].resolved.version, "1.0.0"); + } + + #[test] + fn embedded_release_schemas_match_the_canonical_repository_copies() { + let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); + let canonical_contract = root.join("schemas/v0.3/data-contract.schema.json"); + let canonical_profile = root.join("schemas/interop/v0.1/profile.schema.json"); + if canonical_contract.exists() { + assert_eq!( + std::fs::read_to_string(canonical_contract).unwrap(), + CONTRACT_SCHEMA + ); + assert_eq!( + std::fs::read_to_string(canonical_profile).unwrap(), + PROFILE_SCHEMA + ); + } + } +} diff --git a/packages/interop/LICENSE b/packages/interop/LICENSE new file mode 100644 index 0000000..da8e720 --- /dev/null +++ b/packages/interop/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 mdbase contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/interop/README.md b/packages/interop/README.md new file mode 100644 index 0000000..b7b5e7f --- /dev/null +++ b/packages/interop/README.md @@ -0,0 +1,58 @@ +# `@callumalpass/mdbase-interop` + +Browser-safe TypeScript models, canonical schemas, validation helpers, and the +reference in-memory bridge for mdbase event/action interoperability profile +`0.1`. + +Events are CloudEvents 1.0 structured JSON. Actions use separate logical +request, admitted invocation, cancellation, and terminal outcome envelopes. +Event subscriptions multicast; actions resolve to exactly one provider. + +```ts +import { + InMemoryInteropBridge, + type EventContractArtifact +} from "@callumalpass/mdbase-interop"; + +const bridge = new InMemoryInteropBridge({ + authorize: ({ principal }) => principal.application.startsWith("example.") +}); + +const source = bridge.connect({ + application: "example.tasks", + implementation: "example.tasks.obsidian", + version: "1.0.0" +}); + +const completed: EventContractArtifact = { + kind: "mdbase.contract", + contract_type: "event", + id: "example.task.completed", + version: "1.0.0", + data_schema: { + dialect: "json-schema-2020-12", + value: { + type: "object", + required: ["task_id"], + additionalProperties: false, + properties: { task_id: { type: "string" } } + } + } +}; + +await source.registerEventSource({ + declaration_id: "example.tasks.events", + contracts: [{ contract: completed }] +}); +``` + +The bridge defaults to deny. A host supplies authorization independently from +contract compatibility. Client handles scope implementation identity and +remove subscriptions, source declarations, and providers on disposal. + +Runtime registration requires inline resolved schema values. Collection tools +may store `schema.ref`, but must resolve it before passing an artifact to the +bridge. + +The package has no Obsidian, Node filesystem, workflow, or persistence +dependency. diff --git a/packages/interop/conformance/v0.1.yml b/packages/interop/conformance/v0.1.yml new file mode 100644 index 0000000..1e73333 --- /dev/null +++ b/packages/interop/conformance/v0.1.yml @@ -0,0 +1,31 @@ +kind: mdbase.interop.conformance +profile_version: "0.1" +implementation: + application: mdbase-interop + implementation: mdbase-interop.typescript.in-memory + version: 0.1.0-rc.2 +roles: + - event_source + - event_consumer + - action_caller + - action_provider + - bridge +transport: + delivery: [ephemeral] + ordering: [none] + cancellation: true + deadlines: true + provider_discovery: true + request_deduplication: true + cross_process_identity: false +evidence: + - scenario: CloudEvents multicast, exact validation, and duplicate suppression + result: pass + - scenario: action admission, exact provider selection, and portable outcomes + result: pass + - scenario: provider ambiguity and explicit selection + result: pass + - scenario: default-deny authorization and transport requirements + result: pass + - scenario: request deduplication, cancellation, and unload lifecycle + result: pass diff --git a/packages/interop/package-lock.json b/packages/interop/package-lock.json new file mode 100644 index 0000000..bb8a980 --- /dev/null +++ b/packages/interop/package-lock.json @@ -0,0 +1,628 @@ +{ + "name": "@callumalpass/mdbase-interop", + "version": "0.1.0-rc.2", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@callumalpass/mdbase-interop", + "version": "0.1.0-rc.2", + "license": "MIT", + "dependencies": { + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "semver": "^7.8.5" + }, + "devDependencies": { + "@types/node": "^25.9.3", + "@types/semver": "^7.7.1", + "esbuild": "^0.25.9", + "typescript": "^6.0.3" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@types/node": { + "version": "25.9.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.5.tgz", + "integrity": "sha512-OScDchr2fwuUmWdf4kZ9h7PcJiYDVInhJizG/biAq3cAvqwYktuy/TYGGdZNMtNTFUP7rnb0NU4TUdm82kt4Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": ">=7.24.0 <7.24.7" + } + }, + "node_modules/@types/semver": { + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.7.1.tgz", + "integrity": "sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==", + "dev": true, + "license": "MIT" + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz", + "integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/packages/interop/package.json b/packages/interop/package.json new file mode 100644 index 0000000..e42c5c3 --- /dev/null +++ b/packages/interop/package.json @@ -0,0 +1,50 @@ +{ + "name": "@callumalpass/mdbase-interop", + "version": "0.1.0-rc.2", + "type": "module", + "description": "Portable mdbase event/action contracts, CloudEvents envelopes, and reference bridge.", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/mdbase-dev/mdbase-spec.git", + "directory": "packages/interop" + }, + "bugs": { + "url": "https://github.com/mdbase-dev/mdbase-spec/issues" + }, + "homepage": "https://mdbase.dev/interop/", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "require": "./dist/index.cjs" + } + }, + "files": [ + "dist", + "conformance", + "README.md", + "LICENSE" + ], + "publishConfig": { + "access": "public" + }, + "scripts": { + "generate:schemas": "node scripts/generate-schema-module.mjs", + "build": "npm run generate:schemas && tsc -p tsconfig.json && esbuild src/index.ts --bundle --platform=node --format=cjs --target=node18 --external:ajv --external:ajv/* --external:ajv-formats --external:semver --outfile=dist/index.cjs", + "test": "npm run build && node --test test/*.test.mjs" + }, + "dependencies": { + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "semver": "^7.8.5" + }, + "devDependencies": { + "@types/node": "^25.9.3", + "@types/semver": "^7.7.1", + "esbuild": "^0.25.9", + "typescript": "^6.0.3" + } +} diff --git a/packages/interop/scripts/generate-schema-module.mjs b/packages/interop/scripts/generate-schema-module.mjs new file mode 100644 index 0000000..21532ab --- /dev/null +++ b/packages/interop/scripts/generate-schema-module.mjs @@ -0,0 +1,33 @@ +import { readFile, writeFile } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const here = dirname(fileURLToPath(import.meta.url)); +const packageRoot = resolve(here, ".."); +const schemaRoot = resolve(packageRoot, "../../schemas"); +const schemas = { + contract: "v0.3/data-contract.schema.json", + profile: "interop/v0.1/profile.schema.json", + event: "interop/v0.1/event.schema.json", + actionRequest: "interop/v0.1/action-request.schema.json", + actionInvocation: "interop/v0.1/action-invocation.schema.json", + actionOutcome: "interop/v0.1/action-outcome.schema.json", + actionCancellation: "interop/v0.1/action-cancellation.schema.json", + eventSourceDeclaration: "interop/v0.1/event-source-declaration.schema.json", + actionProviderDeclaration: "interop/v0.1/action-provider-declaration.schema.json", + conformanceClaim: "interop/v0.1/conformance-claim.schema.json" +}; + +const entries = await Promise.all(Object.entries(schemas).map(async ([name, relativePath]) => { + const value = JSON.parse(await readFile(resolve(schemaRoot, relativePath), "utf8")); + return [name, value]; +})); + +const output = [ + "// Generated by scripts/generate-schema-module.mjs. Do not edit directly.", + "", + `export const GENERATED_INTEROP_SCHEMAS = ${JSON.stringify(Object.fromEntries(entries), null, 2)} as const;`, + "" +].join("\n"); + +await writeFile(resolve(packageRoot, "src/generated-schemas.ts"), output); diff --git a/packages/interop/src/bridge.ts b/packages/interop/src/bridge.ts new file mode 100644 index 0000000..ae91f51 --- /dev/null +++ b/packages/interop/src/bridge.ts @@ -0,0 +1,1472 @@ +import type { ValidateFunction } from "ajv"; +import { maxSatisfying, satisfies, valid, validRange } from "semver"; +import { + assertSchemaValue, + compileActionValidators, + compileEventValidators, + contractDigest, + portableDigest +} from "./contracts.js"; +import { ActionHandlerError, InteropError } from "./errors.js"; +import { + compileInteropSchema, + createInteropAjv, + formatSchemaErrors +} from "./schemas.js"; +import { + MDBASE_INTEROP_PROFILE_VERSION, + type ActionContractArtifact, + type ActionHandler, + type ActionInvocation, + type ActionOutcome, + type ActionProviderDeclaration, + type ActionProviderHandlerDeclaration, + type ActionProviderRegistration, + type AuthorizationRequest, + type BridgeDescription, + type BridgeDiagnostic, + type CloudEvent, + type ContractArtifact, + type ContractRequirement, + type Disposable, + type EventContractArtifact, + type EventHandler, + type EventSourceDeclaration, + type EventSourceRegistration, + type EventSubscription, + type ExactContractReference, + type ImplementationIdentity, + type InteropClient, + type InvokeActionInput, + type PortableError, + type ProviderSelector, + type PublishEventInput, + type PublishEventResult, + type RegisterActionProviderInput, + type RegisterEventSourceInput, + type TransportCapabilities +} from "./types.js"; + +interface StoredEventContract { + artifact: EventContractArtifact; + reference: ExactContractReference; + dataValidator: ValidateFunction; + sourceValidator?: ValidateFunction; +} + +interface StoredActionContract { + artifact: ActionContractArtifact; + reference: ExactContractReference; + inputValidator: ValidateFunction; + outputValidator?: ValidateFunction; + errorValidator?: ValidateFunction; + providerValidator?: ValidateFunction; +} + +type StoredContract = StoredEventContract | StoredActionContract; + +interface RegisteredEventSource { + id: string; + clientId: string; + declaration: EventSourceDeclaration; + contracts: Map; +} + +interface RegisteredActionHandler { + registrationId: string; + clientId: string; + declaration: ActionProviderDeclaration; + handlerDeclaration: ActionProviderHandlerDeclaration; + contract: StoredActionContract; + handler: ActionHandler; + active: number; +} + +interface RegisteredActionProvider { + id: string; + clientId: string; + declaration: ActionProviderDeclaration; + handlers: RegisteredActionHandler[]; +} + +interface RegisteredSubscription { + id: string; + clientId: string; + principal: ImplementationIdentity; + subscription: EventSubscription; + handler: EventHandler; +} + +interface ActiveAction { + clientId: string; + requestId: string; + requestDigest: string; + handler: RegisteredActionHandler; + invocation: ActionInvocation; + controller: AbortController; + promise: Promise; +} + +interface CompletedAction { + clientId: string; + requestDigest: string; + outcome: ActionOutcome; + reusable: boolean; + expiresAt: number; +} + +interface ClientState { + identity: ImplementationIdentity; + disposed: boolean; + sources: Set; + providers: Set; + subscriptions: Set; +} + +export interface InMemoryInteropBridgeOptions { + authorize?: (request: AuthorizationRequest) => boolean | Promise; + now?: () => Date; + idFactory?: (prefix: string) => string; + transport?: Partial; + recentEventLimit?: number; + completedRequestLimit?: number; + onDiagnostic?: (diagnostic: BridgeDiagnostic) => void; + authorizationContext?: (request: AuthorizationRequest) => string | undefined | Promise; + onInvocation?: (invocation: ActionInvocation) => void | Promise; +} + +const DEFAULT_TRANSPORT: TransportCapabilities = { + delivery: ["ephemeral"], + ordering: ["none"], + cancellation: true, + deadlines: true, + provider_discovery: true, + request_deduplication: true, + cross_process_identity: false +}; + +const RESERVED_EVENT_ATTRIBUTES = new Set([ + "specversion", + "id", + "source", + "type", + "time", + "subject", + "datacontenttype", + "dataschema", + "data", + "mdbaseprofile", + "mdbasecontractversion", + "mdbasecontractdigest", + "mdbaseapplication", + "mdbaseimplementation", + "mdbaseimplementationversion", + "mdbaseinstanceid", + "correlationid", + "causationid" +]); + +export class InMemoryInteropBridge implements Disposable { + readonly profileVersion = MDBASE_INTEROP_PROFILE_VERSION; + readonly transport: TransportCapabilities; + + private readonly ajv = createInteropAjv(); + private readonly contractValidator = compileInteropSchema(this.ajv, "contract"); + private readonly eventValidator = compileInteropSchema(this.ajv, "event"); + private readonly actionRequestValidator = compileInteropSchema(this.ajv, "actionRequest"); + private readonly actionInvocationValidator = compileInteropSchema(this.ajv, "actionInvocation"); + private readonly actionOutcomeValidator = compileInteropSchema(this.ajv, "actionOutcome"); + private readonly eventSourceDeclarationValidator = compileInteropSchema( + this.ajv, + "eventSourceDeclaration" + ); + private readonly actionProviderDeclarationValidator = compileInteropSchema( + this.ajv, + "actionProviderDeclaration" + ); + private readonly contracts = new Map(); + private readonly clients = new Map(); + private readonly eventSources = new Map(); + private readonly actionProviders = new Map(); + private readonly subscriptions = new Map(); + private readonly activeActions = new Map(); + private readonly completedActions = new Map(); + private readonly admissionLocks = new Map>(); + private readonly recentEvents = new Map(); + private readonly authorize: NonNullable; + private readonly now: () => Date; + private readonly idFactory: (prefix: string) => string; + private readonly recentEventLimit: number; + private readonly completedRequestLimit: number; + private nextSequence = 0; + private disposed = false; + + constructor(private readonly options: InMemoryInteropBridgeOptions = {}) { + this.authorize = options.authorize ?? (() => false); + this.now = options.now ?? (() => new Date()); + this.idFactory = options.idFactory ?? ((prefix) => { + this.nextSequence += 1; + const random = typeof globalThis.crypto?.randomUUID === "function" + ? globalThis.crypto.randomUUID() + : `${this.now().getTime().toString(36)}-${this.nextSequence.toString(36)}`; + return `${prefix}_${random}`; + }); + this.recentEventLimit = Math.max(1, options.recentEventLimit ?? 1000); + this.completedRequestLimit = Math.max(1, options.completedRequestLimit ?? 1000); + this.transport = { + ...DEFAULT_TRANSPORT, + ...structuredClone(options.transport ?? {}), + delivery: [...(options.transport?.delivery ?? DEFAULT_TRANSPORT.delivery)], + ordering: [...(options.transport?.ordering ?? DEFAULT_TRANSPORT.ordering)] + }; + } + + connect(identity: ImplementationIdentity): InteropClient { + this.assertActive(); + assertIdentity(identity); + const clientId = this.idFactory("client"); + const state: ClientState = { + identity: structuredClone(identity), + disposed: false, + sources: new Set(), + providers: new Set(), + subscriptions: new Set() + }; + this.clients.set(clientId, state); + return { + identity: structuredClone(identity), + registerEventSource: (input) => this.registerEventSource(clientId, input), + publishEvent: (input) => this.publishEvent(clientId, input), + subscribeEvents: (subscription, handler) => + this.subscribeEvents(clientId, subscription, handler as EventHandler), + registerActionProvider: (input) => this.registerActionProvider(clientId, input), + invokeAction: (input) => this.invokeAction(clientId, input), + cancelAction: (requestId, reason) => this.cancelAction(clientId, requestId, reason), + dispose: () => this.disposeClient(clientId) + }; + } + + describe(): BridgeDescription { + this.assertActive(); + return { + profile_version: MDBASE_INTEROP_PROFILE_VERSION, + transport: structuredClone(this.transport), + contracts: [...this.contracts.values()] + .map(({ artifact, reference }) => ({ + artifact: structuredClone(artifact), + reference: structuredClone(reference) + })) + .sort((left, right) => + left.reference.id.localeCompare(right.reference.id) + || left.reference.version.localeCompare(right.reference.version) + ), + event_sources: [...this.eventSources.values()] + .map(({ declaration }) => structuredClone(declaration)) + .sort((left, right) => left.declaration_id.localeCompare(right.declaration_id)), + action_providers: [...this.actionProviders.values()] + .map(({ declaration }) => structuredClone(declaration)) + .sort((left, right) => left.declaration_id.localeCompare(right.declaration_id)) + }; + } + + async dispose(): Promise { + if (this.disposed) return; + this.disposed = true; + for (const clientId of [...this.clients.keys()]) await this.disposeClient(clientId, true); + this.contracts.clear(); + this.recentEvents.clear(); + this.completedActions.clear(); + } + + private async registerEventSource( + clientId: string, + input: RegisterEventSourceInput + ): Promise { + const client = this.requireClient(clientId); + if (input.contracts.length === 0) { + throw new InteropError("request_rejected", "An event-source declaration must include a contract."); + } + const registrationId = `${clientId}:${input.declaration_id}`; + if (this.eventSources.has(registrationId)) { + throw new InteropError("request_rejected", `Event-source declaration ${input.declaration_id} is already registered.`); + } + + const prepared = new Map; + }>(); + for (const declared of input.contracts) { + const stored = await this.prepareEventContract(declared.contract); + const requirement = normalizeRequirement(declared.requirement, stored.reference); + assertRequirementMatches(requirement, stored.reference); + await this.assertAuthorized({ + operation: "register_event_source", + principal: client.identity, + contract: stored.reference + }); + if (stored.sourceValidator && !stored.sourceValidator(declared.binding ?? {})) { + throw new InteropError( + "request_rejected", + `${stored.reference.id} source binding is invalid: ${formatSchemaErrors(stored.sourceValidator.errors)}` + ); + } + const key = contractKey(stored.reference); + if (prepared.has(key)) { + throw new InteropError("contract_digest_conflict", `Event contract ${key} is repeated by one declaration.`); + } + prepared.set(key, { + contract: stored, + requirement, + ...(declared.binding === undefined ? {} : { binding: structuredClone(declared.binding) }), + ...(declared.ordering === undefined ? {} : { ordering: [...declared.ordering] }) + }); + } + + const declarationWithoutDigest = { + kind: "mdbase.event-source" as const, + profile_version: MDBASE_INTEROP_PROFILE_VERSION, + declaration_id: input.declaration_id, + source: structuredClone(client.identity), + contracts: [...prepared.values()].map(({ contract, requirement, binding, ordering }) => ({ + requirement: structuredClone(requirement), + resolved: structuredClone(contract.reference), + ...(binding === undefined ? {} : { binding }), + ...(ordering === undefined ? {} : { ordering }) + })) + }; + const declaration: EventSourceDeclaration = { + ...declarationWithoutDigest, + declaration_digest: await portableDigest(declarationWithoutDigest) + }; + assertCanonical( + this.eventSourceDeclarationValidator, + declaration, + "request_rejected", + "Event-source declaration" + ); + this.commitContracts([...prepared.values()].map(({ contract }) => contract)); + this.eventSources.set(registrationId, { + id: registrationId, + clientId, + declaration: structuredClone(declaration), + contracts: new Map( + [...prepared.entries()].map(([key, value]) => [ + key, + { + contract: value.contract, + ...(value.binding === undefined ? {} : { binding: value.binding }) + } + ]) + ) + }); + client.sources.add(registrationId); + let active = true; + return { + declaration: structuredClone(declaration), + dispose: () => { + if (!active) return; + active = false; + this.removeEventSource(registrationId); + } + }; + } + + private async publishEvent( + clientId: string, + input: PublishEventInput + ): Promise> { + const client = this.requireClient(clientId); + const exactKey = contractKey(input.contract); + const source = [...client.sources] + .map((id) => this.eventSources.get(id)) + .find((candidate) => candidate?.contracts.has(exactKey)); + if (!source) { + throw new InteropError( + "unknown_contract", + `This client has not registered event contract ${input.contract.id} ${input.contract.version}.` + ); + } + const stored = source.contracts.get(exactKey)?.contract; + if (!stored) throw new InteropError("unknown_contract", `Event contract ${exactKey} is unavailable.`); + if (input.contract.digest && input.contract.digest !== stored.reference.digest) { + throw new InteropError("contract_digest_conflict", `Event contract ${exactKey} has a different digest.`); + } + await this.assertAuthorized({ + operation: "publish_event", + principal: client.identity, + contract: stored.reference, + ...(input.subject === undefined ? {} : { subject: input.subject }) + }); + assertPortableJson(input.data, "invalid_event_data", `Event ${stored.reference.id} data`); + assertSchemaValue( + stored.dataValidator, + input.data, + "invalid_event_data", + `Event ${stored.reference.id} data` + ); + const extensions = input.extensions ?? {}; + for (const key of Object.keys(extensions)) { + if (RESERVED_EVENT_ATTRIBUTES.has(key)) { + throw new InteropError("request_rejected", `Event extension ${key} is reserved.`); + } + } + const event: CloudEvent = { + ...structuredClone(extensions), + specversion: "1.0", + id: input.id ?? this.idFactory("evt"), + source: sourceUri(client.identity), + type: stored.reference.id, + time: input.time ?? this.now().toISOString(), + ...(input.subject === undefined ? {} : { subject: input.subject }), + datacontenttype: "application/json", + dataschema: contractUrn(stored.reference), + data: structuredClone(input.data), + mdbaseprofile: MDBASE_INTEROP_PROFILE_VERSION, + mdbasecontractversion: stored.reference.version, + mdbasecontractdigest: stored.reference.digest, + mdbaseapplication: client.identity.application, + mdbaseimplementation: client.identity.implementation, + mdbaseimplementationversion: client.identity.version, + ...(client.identity.instance_id === undefined + ? {} + : { mdbaseinstanceid: client.identity.instance_id }), + ...(input.correlation_id === undefined ? {} : { correlationid: input.correlation_id }), + ...(input.causation_id === undefined ? {} : { causationid: input.causation_id }) + }; + assertCanonical(this.eventValidator, event, "invalid_event_data", "Event envelope"); + assertPayloadSize(this.transport, event); + assertEventContractEvidence(event, stored.reference); + + const duplicateKey = `${event.source}\u0000${event.id}`; + const prior = this.recentEvents.get(duplicateKey) as CloudEvent | undefined; + if (prior) { + if (JSON.stringify(prior) !== JSON.stringify(event)) { + throw new InteropError( + "contract_digest_conflict", + `Event ${event.source} ${event.id} was reused with different content.` + ); + } + return { event: structuredClone(prior), deliveries: 0, duplicate: true }; + } + this.recentEvents.set(duplicateKey, structuredClone(event)); + trimMap(this.recentEvents, this.recentEventLimit); + + const matches = [...this.subscriptions.values()].filter(({ subscription }) => + requirementMatches(subscription.contract, stored.reference) + ); + const results = await Promise.allSettled(matches.map(async (subscription) => { + const allowed = await this.isAuthorized({ + operation: "subscribe_event", + principal: subscription.principal, + contract: stored.reference, + ...(event.subject === undefined ? {} : { subject: event.subject }) + }); + if (!allowed) return false; + await subscription.handler(structuredClone(event)); + return true; + })); + let deliveries = 0; + for (const result of results) { + if (result.status === "fulfilled" && result.value) { + deliveries += 1; + } else if (result.status === "rejected") { + this.report({ + severity: "error", + code: "event_consumer_failed", + message: `An event consumer failed while handling ${event.type}.`, + contract: stored.reference, + cause: result.reason + }); + } + } + return { event: structuredClone(event), deliveries, duplicate: false }; + } + + private async subscribeEvents( + clientId: string, + subscription: EventSubscription, + handler: EventHandler + ): Promise { + const client = this.requireClient(clientId); + assertRequirement(subscription.contract); + assertTransportRequirements(this.transport, subscription.require_transport); + await this.assertAuthorized({ + operation: "subscribe_event", + principal: client.identity, + contract: subscription.contract + }); + const id = this.idFactory("subscription"); + this.subscriptions.set(id, { + id, + clientId, + principal: structuredClone(client.identity), + subscription: structuredClone(subscription), + handler + }); + client.subscriptions.add(id); + let active = true; + return { + dispose: () => { + if (!active) return; + active = false; + this.removeSubscription(id); + } + }; + } + + private async registerActionProvider( + clientId: string, + input: RegisterActionProviderInput + ): Promise { + const client = this.requireClient(clientId); + if (input.handlers.length === 0) { + throw new InteropError("request_rejected", "An action-provider declaration must include a handler."); + } + const registrationId = `${clientId}:${input.declaration_id}`; + if (this.actionProviders.has(registrationId)) { + throw new InteropError("request_rejected", `Action-provider declaration ${input.declaration_id} is already registered.`); + } + + const prepared: Array<{ + contract: StoredActionContract; + declaration: ActionProviderHandlerDeclaration; + handler: ActionHandler; + }> = []; + const handlerIds = new Set(); + for (const supplied of input.handlers) { + if (handlerIds.has(supplied.handler_id)) { + throw new InteropError("request_rejected", `Handler ${supplied.handler_id} is repeated.`); + } + handlerIds.add(supplied.handler_id); + const stored = await this.prepareActionContract(supplied.contract); + const requirement = normalizeRequirement(supplied.requirement, stored.reference); + assertRequirementMatches(requirement, stored.reference); + await this.assertAuthorized({ + operation: "register_action_provider", + principal: client.identity, + contract: stored.reference, + provider: client.identity + }); + if (stored.providerValidator && !stored.providerValidator(supplied.binding ?? {})) { + throw new InteropError( + "request_rejected", + `${stored.reference.id} provider binding is invalid: ${formatSchemaErrors(stored.providerValidator.errors)}` + ); + } + const contractIdempotency = supplied.contract.behavior?.idempotency ?? "none"; + if (supplied.idempotency?.mode === "request" && contractIdempotency === "none") { + throw new InteropError( + "request_rejected", + `${stored.reference.id} does not permit request deduplication.` + ); + } + if ( + contractIdempotency === "required" + && supplied.idempotency?.mode !== "request" + ) { + throw new InteropError( + "request_rejected", + `${stored.reference.id} requires a provider with request deduplication.` + ); + } + const contractCancellation = supplied.contract.behavior?.cancellation ?? "none"; + if (supplied.cancellation === "cooperative" && contractCancellation !== "cooperative") { + throw new InteropError( + "request_rejected", + `${stored.reference.id} does not declare cooperative cancellation.` + ); + } + prepared.push({ + contract: stored, + declaration: { + handler_id: supplied.handler_id, + requirement, + resolved: structuredClone(stored.reference), + ...(supplied.binding === undefined ? {} : { binding: structuredClone(supplied.binding) }), + ...(supplied.idempotency === undefined + ? {} + : { idempotency: structuredClone(supplied.idempotency) }), + ...(supplied.cancellation === undefined + ? {} + : { cancellation: supplied.cancellation }), + ...(supplied.max_concurrency === undefined + ? {} + : { max_concurrency: supplied.max_concurrency }) + }, + handler: supplied.handler + }); + } + + const declarationWithoutDigest = { + kind: "mdbase.action-provider" as const, + profile_version: MDBASE_INTEROP_PROFILE_VERSION, + declaration_id: input.declaration_id, + provider: structuredClone(client.identity), + handlers: prepared.map(({ declaration }) => structuredClone(declaration)) + }; + const declaration: ActionProviderDeclaration = { + ...declarationWithoutDigest, + declaration_digest: await portableDigest(declarationWithoutDigest) + }; + assertCanonical( + this.actionProviderDeclarationValidator, + declaration, + "request_rejected", + "Action-provider declaration" + ); + this.commitContracts(prepared.map(({ contract }) => contract)); + const handlers: RegisteredActionHandler[] = prepared.map(({ contract, declaration: handlerDeclaration, handler }) => ({ + registrationId, + clientId, + declaration: structuredClone(declaration), + handlerDeclaration: structuredClone(handlerDeclaration), + contract, + handler, + active: 0 + })); + this.actionProviders.set(registrationId, { + id: registrationId, + clientId, + declaration: structuredClone(declaration), + handlers + }); + client.providers.add(registrationId); + let active = true; + return { + declaration: structuredClone(declaration), + dispose: () => { + if (!active) return; + active = false; + this.removeActionProvider(registrationId); + } + }; + } + + private async invokeAction( + clientId: string, + input: InvokeActionInput + ): Promise> { + const client = this.requireClient(clientId); + assertRequirement(input.contract); + const requestId = input.request_id ?? this.idFactory("req"); + this.cleanCompletedActions(); + assertPortableJson(input.input, "invalid_action_input", `Action ${input.contract.id} input`); + const request = { + kind: "mdbase.action.request" as const, + profile_version: MDBASE_INTEROP_PROFILE_VERSION, + request_id: requestId, + contract: structuredClone(input.contract), + caller: structuredClone(client.identity), + created_at: input.created_at ?? this.now().toISOString(), + ...(input.correlation_id === undefined ? {} : { correlation_id: input.correlation_id }), + ...(input.causation_id === undefined ? {} : { causation_id: input.causation_id }), + ...(input.subject === undefined ? {} : { subject: input.subject }), + ...(input.idempotency_key === undefined ? {} : { idempotency_key: input.idempotency_key }), + ...(input.deadline === undefined ? {} : { deadline: input.deadline }), + ...(input.requested_provider === undefined + ? {} + : { requested_provider: structuredClone(input.requested_provider) }), + input: structuredClone(input.input) + }; + assertCanonical(this.actionRequestValidator, request, "request_rejected", "Action request"); + assertPayloadSize(this.transport, request); + const releaseAdmission = await this.acquireAdmission(requestId); + try { + const requestDigest = await portableDigest(requestFingerprint(request)); + const active = this.activeActions.get(requestId); + if (active) { + assertSameRequest(clientId, requestId, requestDigest, active); + if (active.handler.handlerDeclaration.idempotency?.mode !== "request") { + throw new InteropError("request_rejected", `Action request ${requestId} is already active without deduplication.`); + } + return structuredClone(await active.promise) as ActionOutcome; + } + const completed = this.completedActions.get(requestId); + if (completed) { + assertSameRequest(clientId, requestId, requestDigest, completed); + if (!completed.reusable) { + throw new InteropError("request_rejected", `Action request ${requestId} was already completed without deduplication.`); + } + return structuredClone(completed.outcome) as ActionOutcome; + } + if (request.deadline && new Date(request.deadline).getTime() <= this.now().getTime()) { + throw new InteropError("deadline_exceeded", `Action request ${requestId} passed its deadline before admission.`); + } + if (request.deadline && !this.transport.deadlines) { + throw new InteropError( + "unsupported_transport_capability", + "The active transport cannot enforce action deadlines." + ); + } + + const candidates = this.resolveActionCandidates(request.contract, request.requested_provider); + if (candidates.length === 0) this.throwResolutionFailure(request.contract, request.requested_provider); + const authorized: RegisteredActionHandler[] = []; + for (const candidate of candidates) { + if (await this.isAuthorized({ + operation: "invoke_action", + principal: client.identity, + contract: candidate.contract.reference, + provider: candidate.declaration.provider, + ...(request.subject === undefined ? {} : { subject: request.subject }) + })) { + authorized.push(candidate); + } + } + if (authorized.length === 0) { + throw new InteropError("unauthorized", `No authorized provider can execute ${request.contract.id}.`); + } + if (authorized.length > 1) { + throw new InteropError( + "ambiguous_provider", + `Action ${request.contract.id} has ${authorized.length} eligible providers; select one explicitly.` + ); + } + const selected = authorized[0]!; + if ( + selected.handlerDeclaration.max_concurrency !== undefined + && selected.active >= selected.handlerDeclaration.max_concurrency + ) { + throw new InteropError("request_rejected", `Provider ${selected.declaration.provider.implementation} is at capacity.`); + } + const idempotency = selected.contract.artifact.behavior?.idempotency ?? "none"; + if (idempotency === "required" && !request.idempotency_key) { + throw new InteropError("request_rejected", `${selected.contract.reference.id} requires an idempotency key.`); + } + assertSchemaValue( + selected.contract.inputValidator, + request.input, + "invalid_action_input", + `Action ${selected.contract.reference.id} input` + ); + + const authorizationRequest: AuthorizationRequest = { + operation: "invoke_action", + principal: client.identity, + contract: selected.contract.reference, + provider: selected.declaration.provider, + ...(request.subject === undefined ? {} : { subject: request.subject }) + }; + const authorizationContext = await this.options.authorizationContext?.(authorizationRequest); + const invocation: ActionInvocation = { + kind: "mdbase.action.invocation", + profile_version: MDBASE_INTEROP_PROFILE_VERSION, + invocation_id: this.idFactory("inv"), + attempt_id: this.idFactory("attempt"), + request_id: requestId, + contract: structuredClone(selected.contract.reference), + caller: structuredClone(client.identity), + provider: structuredClone(selected.declaration.provider), + provider_declaration_digest: selected.declaration.declaration_digest, + handler_id: selected.handlerDeclaration.handler_id, + admitted_at: this.now().toISOString(), + ...(request.correlation_id === undefined ? {} : { correlation_id: request.correlation_id }), + ...(request.causation_id === undefined ? {} : { causation_id: request.causation_id }), + ...(request.subject === undefined ? {} : { subject: request.subject }), + ...(request.idempotency_key === undefined ? {} : { idempotency_key: request.idempotency_key }), + ...(request.deadline === undefined ? {} : { deadline: request.deadline }), + ...(authorizationContext === undefined ? {} : { authorization_context: authorizationContext }), + input: structuredClone(request.input) + }; + assertCanonical( + this.actionInvocationValidator, + invocation, + "request_rejected", + "Action invocation" + ); + await this.options.onInvocation?.(structuredClone(invocation)); + + const controller = new AbortController(); + let deadlineHandle: ReturnType | undefined; + if (invocation.deadline) { + const remaining = Math.max(0, new Date(invocation.deadline).getTime() - this.now().getTime()); + deadlineHandle = setTimeout(() => controller.abort(new InteropError( + "deadline_exceeded", + `Action request ${requestId} exceeded its deadline.` + )), remaining); + } + selected.active += 1; + const promise = this.executeAction(selected, invocation, controller) + .finally(() => { + if (deadlineHandle !== undefined) clearTimeout(deadlineHandle); + selected.active = Math.max(0, selected.active - 1); + this.activeActions.delete(requestId); + }); + this.activeActions.set(requestId, { + clientId, + requestId, + requestDigest, + handler: selected, + invocation, + controller, + promise + }); + releaseAdmission(); + const outcome = await promise; + const retention = selected.handlerDeclaration.idempotency?.retention_seconds ?? 300; + this.completedActions.set(requestId, { + clientId, + requestDigest, + outcome: structuredClone(outcome), + reusable: selected.handlerDeclaration.idempotency?.mode === "request", + expiresAt: this.now().getTime() + retention * 1000 + }); + trimMap(this.completedActions, this.completedRequestLimit); + return structuredClone(outcome) as ActionOutcome; + } finally { + releaseAdmission(); + } + } + + private async executeAction( + selected: RegisteredActionHandler, + invocation: ActionInvocation, + controller: AbortController + ): Promise { + try { + const output = await selected.handler(structuredClone(invocation.input), { + invocation: structuredClone(invocation), + signal: controller.signal + }); + if (controller.signal.aborted) { + const reason = controller.signal.reason; + if (reason instanceof InteropError && reason.code === "deadline_exceeded") { + return this.failureOutcome(invocation, "failed", reason.toPortableError()); + } + return this.failureOutcome(invocation, "cancelled", { + code: "cancelled", + message: `Action request ${invocation.request_id} was cancelled.` + }); + } + assertPortableJson( + output, + "invalid_action_output", + `Action ${selected.contract.reference.id} output` + ); + assertSchemaValue( + selected.contract.outputValidator, + output, + "invalid_action_output", + `Action ${selected.contract.reference.id} output` + ); + const outcome: ActionOutcome = { + kind: "mdbase.action.outcome", + profile_version: MDBASE_INTEROP_PROFILE_VERSION, + outcome_id: this.idFactory("outcome"), + request_id: invocation.request_id, + invocation_id: invocation.invocation_id, + attempt_id: invocation.attempt_id, + contract: structuredClone(invocation.contract), + provider: structuredClone(invocation.provider), + provider_declaration_digest: invocation.provider_declaration_digest, + status: "succeeded", + completed_at: this.now().toISOString(), + output: structuredClone(output) + }; + assertCanonical(this.actionOutcomeValidator, outcome, "invalid_action_output", "Action outcome"); + assertPayloadSize(this.transport, outcome); + return outcome; + } catch (error) { + if (error instanceof ActionHandlerError) { + if ( + selected.contract.errorValidator + && !selected.contract.errorValidator(error.error.details ?? {}) + ) { + return this.failureOutcome(invocation, "failed", { + code: "handler_failure", + message: `Provider returned invalid declared error details: ${formatSchemaErrors(selected.contract.errorValidator.errors)}` + }); + } + return this.failureOutcome(invocation, error.status, error.error); + } + if (error instanceof InteropError) { + return this.failureOutcome( + invocation, + error.code === "cancelled" + ? "cancelled" + : error.code === "outcome_indeterminate" + ? "outcome_indeterminate" + : "failed", + error.toPortableError() + ); + } + if (controller.signal.aborted || isAbortError(error)) { + const reason = controller.signal.reason; + if (reason instanceof InteropError && reason.code === "deadline_exceeded") { + return this.failureOutcome(invocation, "failed", reason.toPortableError()); + } + return this.failureOutcome(invocation, "cancelled", { + code: "cancelled", + message: `Action request ${invocation.request_id} was cancelled.` + }); + } + this.report({ + severity: "error", + code: "action_handler_failed", + message: `Provider ${invocation.provider.implementation} failed ${invocation.contract.id}.`, + principal: invocation.provider, + contract: invocation.contract, + cause: error + }); + return this.failureOutcome(invocation, "failed", { + code: "handler_failure", + message: "The selected provider failed while executing the action." + }); + } + } + + private failureOutcome( + invocation: ActionInvocation, + status: "rejected" | "failed" | "cancelled" | "outcome_indeterminate", + error: PortableError + ): ActionOutcome { + const outcome: ActionOutcome = { + kind: "mdbase.action.outcome", + profile_version: MDBASE_INTEROP_PROFILE_VERSION, + outcome_id: this.idFactory("outcome"), + request_id: invocation.request_id, + invocation_id: invocation.invocation_id, + attempt_id: invocation.attempt_id, + contract: structuredClone(invocation.contract), + provider: structuredClone(invocation.provider), + provider_declaration_digest: invocation.provider_declaration_digest, + status, + completed_at: this.now().toISOString(), + error: structuredClone(error) + }; + assertCanonical(this.actionOutcomeValidator, outcome, "invalid_action_output", "Action outcome"); + return outcome; + } + + private async cancelAction( + clientId: string, + requestId: string, + reason?: string + ): Promise { + const client = this.requireClient(clientId); + const admission = this.admissionLocks.get(requestId); + if (admission) await admission; + const active = this.activeActions.get(requestId); + if (!active) { + const completed = this.completedActions.get(requestId); + if (completed && completed.clientId !== clientId) { + throw new InteropError("unauthorized", `Action request ${requestId} belongs to another caller.`); + } + return completed ? structuredClone(completed.outcome) : null; + } + if (active.clientId !== clientId) { + throw new InteropError("unauthorized", `Action request ${requestId} belongs to another caller.`); + } + await this.assertAuthorized({ + operation: "cancel_action", + principal: client.identity, + contract: active.invocation.contract, + provider: active.invocation.provider, + ...(active.invocation.subject === undefined ? {} : { subject: active.invocation.subject }) + }); + if (!this.transport.cancellation) { + throw new InteropError( + "unsupported_transport_capability", + "The active transport cannot deliver cancellation." + ); + } + if (active.handler.handlerDeclaration.cancellation !== "cooperative") { + throw new InteropError( + "cancellation_unsupported", + `Provider ${active.invocation.provider.implementation} does not support cancellation.` + ); + } + active.controller.abort(new InteropError( + "cancelled", + reason?.trim() || `Action request ${requestId} was cancelled.` + )); + return structuredClone(await active.promise); + } + + private resolveActionCandidates( + requirement: ContractRequirement, + selector?: ProviderSelector + ): RegisteredActionHandler[] { + const all = [...this.actionProviders.values()] + .flatMap(({ handlers }) => handlers) + .filter(({ contract, declaration }) => + requirementMatches(requirement, contract.reference) + && providerMatches(selector, declaration.provider) + ); + const highest = maxSatisfying( + [...new Set(all.map(({ contract }) => contract.reference.version))], + requirement.version, + { includePrerelease: true } + ); + if (!highest) return []; + return all + .filter(({ contract }) => contract.reference.version === highest) + .sort((left, right) => + left.declaration.provider.application.localeCompare(right.declaration.provider.application) + || left.declaration.provider.implementation.localeCompare(right.declaration.provider.implementation) + || (left.declaration.provider.instance_id ?? "").localeCompare( + right.declaration.provider.instance_id ?? "" + ) + || left.handlerDeclaration.handler_id.localeCompare(right.handlerDeclaration.handler_id) + ); + } + + private throwResolutionFailure( + requirement: ContractRequirement, + selector?: ProviderSelector + ): never { + const artifacts = [...this.contracts.values()] + .filter((contract): contract is StoredActionContract => + contract.artifact.contract_type === "action" && contract.reference.id === requirement.id + ); + if (artifacts.length === 0) { + throw new InteropError("unknown_contract", `Action contract ${requirement.id} is unknown.`); + } + if (!artifacts.some(({ reference }) => requirementMatches(requirement, reference))) { + throw new InteropError( + "unsupported_contract_version", + `No ${requirement.id} artifact satisfies ${requirement.version}.` + ); + } + if (selector) { + throw new InteropError( + "requested_provider_unavailable", + `The requested provider is unavailable for ${requirement.id}.` + ); + } + throw new InteropError("no_provider", `No provider is registered for ${requirement.id}.`); + } + + private async prepareEventContract( + artifact: EventContractArtifact + ): Promise { + this.assertContractArtifact(artifact); + if (artifact.contract_type !== "event") { + throw new InteropError("unknown_contract", `${artifact.id} is not an event contract.`); + } + const reference = { + id: artifact.id, + version: artifact.version, + digest: await contractDigest(artifact) + }; + this.assertNoContractConflict(reference); + const validators = compileEventValidators(this.ajv, artifact); + return { + artifact: structuredClone(artifact), + reference, + dataValidator: validators.data, + ...(validators.source === undefined ? {} : { sourceValidator: validators.source }) + }; + } + + private async prepareActionContract( + artifact: ActionContractArtifact + ): Promise { + this.assertContractArtifact(artifact); + if (artifact.contract_type !== "action") { + throw new InteropError("unknown_contract", `${artifact.id} is not an action contract.`); + } + const reference = { + id: artifact.id, + version: artifact.version, + digest: await contractDigest(artifact) + }; + this.assertNoContractConflict(reference); + const validators = compileActionValidators(this.ajv, artifact); + return { + artifact: structuredClone(artifact), + reference, + inputValidator: validators.input, + ...(validators.output === undefined ? {} : { outputValidator: validators.output }), + ...(validators.error === undefined ? {} : { errorValidator: validators.error }), + ...(validators.provider === undefined ? {} : { providerValidator: validators.provider }) + }; + } + + private assertContractArtifact(artifact: ContractArtifact): void { + assertCanonical( + this.contractValidator, + artifact, + "contract_digest_conflict", + `Contract ${artifact.id || ""}` + ); + if (!valid(artifact.version)) { + throw new InteropError("unsupported_contract_version", `${artifact.id} version must be exact SemVer.`); + } + } + + private assertNoContractConflict(reference: ExactContractReference): void { + const existing = this.contracts.get(contractKey(reference)); + if (existing && existing.reference.digest !== reference.digest) { + throw new InteropError( + "contract_digest_conflict", + `Contract ${reference.id} ${reference.version} conflicts with the registered artifact.` + ); + } + } + + private commitContracts(contracts: StoredContract[]): void { + const staged = new Map(); + for (const contract of contracts) { + const key = contractKey(contract.reference); + const other = staged.get(key) ?? this.contracts.get(key); + if (other && other.reference.digest !== contract.reference.digest) { + throw new InteropError( + "contract_digest_conflict", + `Contract ${contract.reference.id} ${contract.reference.version} conflicts within the registration.` + ); + } + staged.set(key, contract); + } + for (const [key, contract] of staged) { + if (!this.contracts.has(key)) this.contracts.set(key, contract); + } + } + + private async disposeClient(clientId: string, bridgeDisposing = false): Promise { + const client = this.clients.get(clientId); + if (!client || client.disposed) return; + client.disposed = true; + for (const id of [...client.subscriptions]) this.removeSubscription(id); + for (const id of [...client.sources]) this.removeEventSource(id); + for (const id of [...client.providers]) this.removeActionProvider(id); + for (const active of [...this.activeActions.values()]) { + if (active.clientId === clientId && active.handler.handlerDeclaration.cancellation === "cooperative") { + active.controller.abort(new InteropError("cancelled", "The caller unloaded.")); + } + if ( + active.handler.clientId === clientId + && active.handler.handlerDeclaration.cancellation === "cooperative" + ) { + active.controller.abort(new InteropError("cancelled", "The provider unloaded.")); + } + } + this.clients.delete(clientId); + if (!bridgeDisposing) this.assertActive(); + } + + private removeEventSource(id: string): void { + const source = this.eventSources.get(id); + if (!source) return; + this.eventSources.delete(id); + this.clients.get(source.clientId)?.sources.delete(id); + } + + private removeActionProvider(id: string): void { + const provider = this.actionProviders.get(id); + if (!provider) return; + this.actionProviders.delete(id); + this.clients.get(provider.clientId)?.providers.delete(id); + for (const active of this.activeActions.values()) { + if ( + active.handler.registrationId === id + && active.handler.handlerDeclaration.cancellation === "cooperative" + ) { + active.controller.abort(new InteropError("cancelled", "The provider unloaded.")); + } + } + } + + private removeSubscription(id: string): void { + const subscription = this.subscriptions.get(id); + if (!subscription) return; + this.subscriptions.delete(id); + this.clients.get(subscription.clientId)?.subscriptions.delete(id); + } + + private requireClient(clientId: string): ClientState { + this.assertActive(); + const client = this.clients.get(clientId); + if (!client || client.disposed) throw new InteropError("transport_unavailable", "Interop client is disposed."); + return client; + } + + private assertActive(): void { + if (this.disposed) throw new InteropError("transport_unavailable", "Interop bridge is disposed."); + } + + private async assertAuthorized(request: AuthorizationRequest): Promise { + if (!await this.isAuthorized(request)) { + throw new InteropError("unauthorized", `${request.operation} is not authorized.`); + } + } + + private async isAuthorized(request: AuthorizationRequest): Promise { + try { + return await this.authorize(structuredClone(request)); + } catch (cause) { + this.report({ + severity: "error", + code: "authorization_failed", + message: `Authorization failed for ${request.operation}.`, + principal: request.principal, + cause + }); + return false; + } + } + + private cleanCompletedActions(): void { + const now = this.now().getTime(); + for (const [requestId, completed] of this.completedActions) { + if (completed.expiresAt <= now) this.completedActions.delete(requestId); + } + } + + private async acquireAdmission(requestId: string): Promise<() => void> { + const previous = this.admissionLocks.get(requestId) ?? Promise.resolve(); + let unlock!: () => void; + const current = new Promise((resolve) => { + unlock = resolve; + }); + const tail = previous.then(() => current); + this.admissionLocks.set(requestId, tail); + await previous; + let released = false; + return () => { + if (released) return; + released = true; + unlock(); + if (this.admissionLocks.get(requestId) === tail) { + this.admissionLocks.delete(requestId); + } + }; + } + + private report(diagnostic: BridgeDiagnostic): void { + this.options.onDiagnostic?.(structuredClone(diagnostic)); + } +} + +function assertCanonical( + validator: ValidateFunction, + value: unknown, + code: "contract_digest_conflict" | "invalid_event_data" | "invalid_action_output" | "request_rejected", + label: string +): void { + if (validator(value)) return; + throw new InteropError(code, `${label} is invalid: ${formatSchemaErrors(validator.errors)}`); +} + +function assertIdentity(identity: ImplementationIdentity): void { + for (const [field, value] of Object.entries(identity)) { + if (value === undefined) continue; + if (typeof value !== "string" || value.length === 0 || !/^[A-Za-z0-9][A-Za-z0-9._:@/-]*$/u.test(value)) { + throw new InteropError("request_rejected", `Implementation identity ${field} is invalid.`); + } + } + if (!valid(identity.version)) { + throw new InteropError("request_rejected", "Implementation identity version must be exact SemVer."); + } +} + +function assertRequirement(requirement: ContractRequirement): void { + if (!/^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)+$/u.test(requirement.id)) { + throw new InteropError("unknown_contract", `Contract ID ${requirement.id} is invalid.`); + } + if (!validRange(requirement.version, { includePrerelease: true })) { + throw new InteropError( + "unsupported_contract_version", + `Contract requirement ${requirement.version} is not a SemVer range.` + ); + } + if (requirement.digest && !/^sha256:[0-9a-f]{64}$/u.test(requirement.digest)) { + throw new InteropError("contract_digest_conflict", "Contract digest is invalid."); + } +} + +function normalizeRequirement( + requirement: ContractRequirement | undefined, + reference: ExactContractReference +): ContractRequirement { + const normalized = requirement ?? { + id: reference.id, + version: reference.version, + digest: reference.digest + }; + assertRequirement(normalized); + return structuredClone(normalized); +} + +function assertRequirementMatches( + requirement: ContractRequirement, + reference: ExactContractReference +): void { + if (!requirementMatches(requirement, reference)) { + throw new InteropError( + "unsupported_contract_version", + `${reference.id} ${reference.version} does not satisfy its implementation requirement.` + ); + } +} + +function requirementMatches( + requirement: ContractRequirement, + reference: ExactContractReference +): boolean { + return requirement.id === reference.id + && satisfies(reference.version, requirement.version, { includePrerelease: true }) + && (!requirement.digest || requirement.digest === reference.digest); +} + +function contractKey(reference: { id: string; version: string }): string { + return `${reference.id}@${reference.version}`; +} + +function contractUrn(reference: ExactContractReference): string { + return `urn:mdbase:contract:${reference.id}:${reference.version}:${reference.digest}`; +} + +function sourceUri(identity: ImplementationIdentity): string { + const components = [ + identity.application, + identity.implementation, + ...(identity.instance_id ? [identity.instance_id] : []) + ].map(encodeURIComponent); + return `urn:mdbase:app:${components.join(":")}`; +} + +function assertEventContractEvidence( + event: CloudEvent, + reference: ExactContractReference +): void { + if ( + event.type !== reference.id + || event.mdbasecontractversion !== reference.version + || event.mdbasecontractdigest !== reference.digest + || event.dataschema !== contractUrn(reference) + ) { + throw new InteropError("contract_digest_conflict", "Event contract evidence is inconsistent."); + } +} + +function requestFingerprint( + request: Record +): Record { + const { created_at: _createdAt, ...portableIntent } = request; + return portableIntent; +} + +function assertSameRequest( + clientId: string, + requestId: string, + requestDigest: string, + existing: { clientId: string; requestDigest: string } +): void { + if (existing.clientId !== clientId) { + throw new InteropError("unauthorized", `Action request ${requestId} belongs to another caller.`); + } + if (existing.requestDigest !== requestDigest) { + throw new InteropError( + "request_rejected", + `Action request ${requestId} was reused with different content.` + ); + } +} + +function assertPortableJson( + value: unknown, + code: "invalid_event_data" | "invalid_action_input" | "invalid_action_output", + label: string +): void { + const seen = new Set(); + const visit = (candidate: unknown, path: string): void => { + if ( + candidate === null + || typeof candidate === "boolean" + || typeof candidate === "string" + ) { + return; + } + if (typeof candidate === "number") { + if (Number.isFinite(candidate)) return; + throw new InteropError(code, `${label}${path} must be a finite JSON number.`); + } + if (typeof candidate !== "object") { + throw new InteropError(code, `${label}${path} is not a JSON value.`); + } + if (seen.has(candidate)) { + throw new InteropError(code, `${label}${path} contains a cycle.`); + } + seen.add(candidate); + if (Array.isArray(candidate)) { + candidate.forEach((child, index) => visit(child, `${path}/${index}`)); + } else { + const prototype = Object.getPrototypeOf(candidate); + if (prototype !== Object.prototype && prototype !== null) { + throw new InteropError(code, `${label}${path} must be a plain JSON object.`); + } + for (const [key, child] of Object.entries(candidate as Record)) { + visit(child, `${path}/${key}`); + } + } + seen.delete(candidate); + }; + visit(value, ""); +} + +function assertPayloadSize( + transport: TransportCapabilities, + value: unknown +): void { + if (transport.max_payload_bytes === undefined) return; + const serialized = JSON.stringify(value); + const bytes = new TextEncoder().encode(serialized).byteLength; + if (bytes > transport.max_payload_bytes) { + throw new InteropError( + "unsupported_transport_capability", + `The portable envelope is ${bytes} bytes; the active transport allows ${transport.max_payload_bytes}.` + ); + } +} + +function providerMatches( + selector: ProviderSelector | undefined, + provider: ImplementationIdentity +): boolean { + if (!selector) return true; + return (selector.application === undefined || selector.application === provider.application) + && (selector.implementation === undefined || selector.implementation === provider.implementation) + && (selector.instance_id === undefined || selector.instance_id === provider.instance_id); +} + +function assertTransportRequirements( + actual: TransportCapabilities, + required: Partial | undefined +): void { + if (!required) return; + if (required.delivery?.some((capability) => !actual.delivery.includes(capability))) { + throw new InteropError("unsupported_transport_capability", "The active transport lacks a required delivery capability."); + } + if (required.ordering?.some((capability) => !actual.ordering.includes(capability))) { + throw new InteropError("unsupported_transport_capability", "The active transport lacks a required ordering capability."); + } + for (const key of ["cancellation", "deadlines", "provider_discovery", "request_deduplication", "cross_process_identity"] as const) { + if (required[key] === true && actual[key] !== true) { + throw new InteropError("unsupported_transport_capability", `The active transport lacks ${key}.`); + } + } + if ( + required.max_payload_bytes !== undefined + && (actual.max_payload_bytes === undefined || actual.max_payload_bytes < required.max_payload_bytes) + ) { + throw new InteropError("unsupported_transport_capability", "The active transport payload limit is too small."); + } +} + +function trimMap(map: Map, limit: number): void { + while (map.size > limit) { + const first = map.keys().next().value as K | undefined; + if (first === undefined) return; + map.delete(first); + } +} + +function isAbortError(error: unknown): boolean { + return error instanceof Error && error.name === "AbortError"; +} diff --git a/packages/interop/src/contracts.ts b/packages/interop/src/contracts.ts new file mode 100644 index 0000000..e72c991 --- /dev/null +++ b/packages/interop/src/contracts.ts @@ -0,0 +1,160 @@ +import type { Ajv2020, ValidateFunction } from "ajv/dist/2020.js"; +import type { + ActionContractArtifact, + ContractArtifact, + EventContractArtifact, + ExactContractReference, + InlineSchema, + SchemaWrapper +} from "./types.js"; +import { InteropError } from "./errors.js"; +import { formatSchemaErrors } from "./schemas.js"; + +export function resolvedSchema(wrapper: SchemaWrapper, label: string): Record { + if (!("value" in wrapper)) { + throw new InteropError( + "contract_digest_conflict", + `${label} must be resolved to an inline JSON Schema before runtime registration.` + ); + } + return structuredClone(wrapper.value); +} + +export function contractDigestInput(artifact: ContractArtifact): Record { + const base: Record = { + kind: artifact.kind, + contract_type: artifact.contract_type, + id: artifact.id, + version: artifact.version + }; + switch (artifact.contract_type) { + case "record": + base.record_schema = resolvedSchema(artifact.record_schema, "record_schema"); + if (artifact.binding_schema) { + base.binding_schema = resolvedSchema(artifact.binding_schema, "binding_schema"); + } + break; + case "event": + base.data_schema = resolvedSchema(artifact.data_schema, "data_schema"); + if (artifact.source_schema) { + base.source_schema = resolvedSchema(artifact.source_schema, "source_schema"); + } + break; + case "action": + base.input_schema = resolvedSchema(artifact.input_schema, "input_schema"); + if (artifact.output_schema) { + base.output_schema = resolvedSchema(artifact.output_schema, "output_schema"); + } + if (artifact.error_schema) { + base.error_schema = resolvedSchema(artifact.error_schema, "error_schema"); + } + if (artifact.provider_schema) { + base.provider_schema = resolvedSchema(artifact.provider_schema, "provider_schema"); + } + if (artifact.behavior) base.behavior = structuredClone(artifact.behavior); + break; + } + return base; +} + +export async function contractDigest(artifact: ContractArtifact): Promise { + return portableDigest(contractDigestInput(artifact)); +} + +export async function portableDigest(value: unknown): Promise { + return `sha256:${await sha256(canonicalize(value))}`; +} + +export async function exactContractReference( + artifact: ContractArtifact +): Promise { + return { + id: artifact.id, + version: artifact.version, + digest: await contractDigest(artifact) + }; +} + +export function compileEventValidators( + ajv: Ajv2020, + artifact: EventContractArtifact +): { + data: ValidateFunction; + source?: ValidateFunction; +} { + return { + data: compileSchema(ajv, artifact.data_schema, `${artifact.id} data_schema`), + ...(artifact.source_schema + ? { source: compileSchema(ajv, artifact.source_schema, `${artifact.id} source_schema`) } + : {}) + }; +} + +export function compileActionValidators( + ajv: Ajv2020, + artifact: ActionContractArtifact +): { + input: ValidateFunction; + output?: ValidateFunction; + error?: ValidateFunction; + provider?: ValidateFunction; +} { + return { + input: compileSchema(ajv, artifact.input_schema, `${artifact.id} input_schema`), + ...(artifact.output_schema + ? { output: compileSchema(ajv, artifact.output_schema, `${artifact.id} output_schema`) } + : {}), + ...(artifact.error_schema + ? { error: compileSchema(ajv, artifact.error_schema, `${artifact.id} error_schema`) } + : {}), + ...(artifact.provider_schema + ? { provider: compileSchema(ajv, artifact.provider_schema, `${artifact.id} provider_schema`) } + : {}) + }; +} + +export function assertSchemaValue( + validator: ValidateFunction | undefined, + value: unknown, + code: "invalid_event_data" | "invalid_action_input" | "invalid_action_output", + label: string +): void { + if (!validator || validator(value)) return; + throw new InteropError(code, `${label} failed JSON Schema validation: ${formatSchemaErrors(validator.errors)}`); +} + +function compileSchema( + ajv: Ajv2020, + wrapper: SchemaWrapper, + label: string +): ValidateFunction { + try { + return ajv.compile(resolvedSchema(wrapper, label)); + } catch (error) { + throw new InteropError( + "contract_digest_conflict", + `${label} could not be compiled: ${error instanceof Error ? error.message : String(error)}` + ); + } +} + +export function inlineSchema(value: Record): InlineSchema { + return { dialect: "json-schema-2020-12", value }; +} + +function canonicalize(value: unknown): string { + if (value === null || typeof value !== "object") return JSON.stringify(value); + if (Array.isArray(value)) return `[${value.map(canonicalize).join(",")}]`; + const entries = Object.entries(value as Record) + .filter(([, child]) => child !== undefined) + .sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0); + return `{${entries.map(([key, child]) => `${JSON.stringify(key)}:${canonicalize(child)}`).join(",")}}`; +} + +async function sha256(value: string): Promise { + const bytes = new TextEncoder().encode(value); + const digest = await globalThis.crypto.subtle.digest("SHA-256", bytes); + return [...new Uint8Array(digest)] + .map((byte) => byte.toString(16).padStart(2, "0")) + .join(""); +} diff --git a/packages/interop/src/errors.ts b/packages/interop/src/errors.ts new file mode 100644 index 0000000..cd123bf --- /dev/null +++ b/packages/interop/src/errors.ts @@ -0,0 +1,35 @@ +import type { + ActionOutcomeStatus, + PortableError, + PortableErrorCode +} from "./types.js"; + +export class InteropError extends Error { + constructor( + readonly code: PortableErrorCode, + message: string, + readonly details?: unknown + ) { + super(message); + this.name = "InteropError"; + } + + toPortableError(retryable?: boolean): PortableError { + return { + code: this.code, + message: this.message, + ...(this.details === undefined ? {} : { details: this.details }), + ...(retryable === undefined ? {} : { retryable }) + }; + } +} + +export class ActionHandlerError extends Error { + constructor( + readonly status: Exclude, + readonly error: PortableError + ) { + super(error.message); + this.name = "ActionHandlerError"; + } +} diff --git a/packages/interop/src/generated-schemas.ts b/packages/interop/src/generated-schemas.ts new file mode 100644 index 0000000..6e7e69c --- /dev/null +++ b/packages/interop/src/generated-schemas.ts @@ -0,0 +1,1079 @@ +// Generated by scripts/generate-schema-module.mjs. Do not edit directly. + +export const GENERATED_INTEROP_SCHEMAS = { + "contract": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://mdbase.dev/schemas/v0.3/data-contract.schema.json", + "title": "mdbase v0.3 contract frontmatter", + "type": "object", + "required": [ + "kind", + "contract_type", + "id", + "version" + ], + "properties": { + "kind": { + "const": "mdbase.contract" + }, + "contract_type": { + "enum": [ + "record", + "event", + "action" + ] + }, + "id": { + "$ref": "#/$defs/contractId" + }, + "version": { + "$ref": "#/$defs/semanticVersion" + }, + "name": { + "type": "string", + "minLength": 1 + }, + "description": { + "type": "string" + }, + "record_schema": { + "$ref": "#/$defs/schemaWrapper" + }, + "binding_schema": { + "$ref": "#/$defs/schemaWrapper" + }, + "data_schema": { + "$ref": "#/$defs/schemaWrapper" + }, + "source_schema": { + "$ref": "#/$defs/schemaWrapper" + }, + "input_schema": { + "$ref": "#/$defs/schemaWrapper" + }, + "output_schema": { + "$ref": "#/$defs/schemaWrapper" + }, + "error_schema": { + "$ref": "#/$defs/schemaWrapper" + }, + "provider_schema": { + "$ref": "#/$defs/schemaWrapper" + }, + "behavior": { + "$ref": "#/$defs/actionBehavior" + } + }, + "patternProperties": { + "^x-[A-Za-z][A-Za-z0-9._:-]{0,127}$": true + }, + "oneOf": [ + { + "properties": { + "contract_type": { + "const": "record" + }, + "record_schema": true, + "binding_schema": true, + "data_schema": false, + "source_schema": false, + "input_schema": false, + "output_schema": false, + "error_schema": false, + "provider_schema": false, + "behavior": false + }, + "required": [ + "record_schema" + ] + }, + { + "properties": { + "contract_type": { + "const": "event" + }, + "record_schema": false, + "binding_schema": false, + "data_schema": true, + "source_schema": true, + "input_schema": false, + "output_schema": false, + "error_schema": false, + "provider_schema": false, + "behavior": false + }, + "required": [ + "data_schema" + ] + }, + { + "properties": { + "contract_type": { + "const": "action" + }, + "record_schema": false, + "binding_schema": false, + "data_schema": false, + "source_schema": false, + "input_schema": true, + "output_schema": true, + "error_schema": true, + "provider_schema": true, + "behavior": true + }, + "required": [ + "input_schema" + ] + } + ], + "additionalProperties": false, + "$defs": { + "contractId": { + "type": "string", + "minLength": 3, + "maxLength": 128, + "pattern": "^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)+$" + }, + "semanticVersion": { + "type": "string", + "pattern": "^(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)(?:-[0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*)?(?:\\+[0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*)?$" + }, + "schemaWrapper": { + "type": "object", + "required": [ + "dialect" + ], + "properties": { + "dialect": { + "const": "json-schema-2020-12" + }, + "value": { + "type": "object" + }, + "ref": { + "type": "string", + "minLength": 1 + } + }, + "oneOf": [ + { + "required": [ + "value" + ], + "properties": { + "value": true, + "ref": false + } + }, + { + "required": [ + "ref" + ], + "properties": { + "ref": true, + "value": false + } + } + ], + "additionalProperties": false + }, + "actionBehavior": { + "type": "object", + "properties": { + "idempotency": { + "enum": [ + "none", + "optional", + "required" + ] + }, + "cancellation": { + "enum": [ + "none", + "cooperative" + ] + } + }, + "additionalProperties": false + } + } + }, + "profile": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://mdbase.dev/schemas/interop/v0.1/profile.schema.json", + "title": "mdbase event and action interoperability profile 0.1", + "oneOf": [ + { + "$ref": "#/$defs/event" + }, + { + "$ref": "#/$defs/actionRequest" + }, + { + "$ref": "#/$defs/actionInvocation" + }, + { + "$ref": "#/$defs/actionOutcome" + }, + { + "$ref": "#/$defs/actionCancellation" + }, + { + "$ref": "#/$defs/eventSourceDeclaration" + }, + { + "$ref": "#/$defs/actionProviderDeclaration" + }, + { + "$ref": "#/$defs/conformanceClaim" + } + ], + "$defs": { + "contractId": { + "type": "string", + "minLength": 3, + "maxLength": 128, + "pattern": "^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)+$" + }, + "semanticVersion": { + "type": "string", + "pattern": "^(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)(?:-[0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*)?(?:\\+[0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*)?$" + }, + "semanticVersionRequirement": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "digest": { + "type": "string", + "pattern": "^sha256:[0-9a-f]{64}$" + }, + "portableId": { + "type": "string", + "minLength": 1, + "maxLength": 256, + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:@/-]*$" + }, + "exactContract": { + "type": "object", + "required": [ + "id", + "version", + "digest" + ], + "properties": { + "id": { + "$ref": "#/$defs/contractId" + }, + "version": { + "$ref": "#/$defs/semanticVersion" + }, + "digest": { + "$ref": "#/$defs/digest" + } + }, + "additionalProperties": false + }, + "contractRequirement": { + "type": "object", + "required": [ + "id", + "version" + ], + "properties": { + "id": { + "$ref": "#/$defs/contractId" + }, + "version": { + "$ref": "#/$defs/semanticVersionRequirement" + }, + "digest": { + "$ref": "#/$defs/digest" + } + }, + "additionalProperties": false + }, + "implementationIdentity": { + "type": "object", + "required": [ + "application", + "implementation", + "version" + ], + "properties": { + "application": { + "$ref": "#/$defs/portableId" + }, + "implementation": { + "$ref": "#/$defs/portableId" + }, + "version": { + "$ref": "#/$defs/semanticVersion" + }, + "instance_id": { + "$ref": "#/$defs/portableId" + } + }, + "additionalProperties": false + }, + "transportCapabilities": { + "type": "object", + "required": [ + "delivery", + "ordering", + "cancellation", + "deadlines" + ], + "properties": { + "delivery": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "enum": [ + "ephemeral", + "at_least_once", + "durable_cursor", + "offline_queue" + ] + } + }, + "ordering": { + "type": "array", + "uniqueItems": true, + "items": { + "enum": [ + "none", + "source", + "subject" + ] + } + }, + "cancellation": { + "type": "boolean" + }, + "deadlines": { + "type": "boolean" + }, + "provider_discovery": { + "type": "boolean" + }, + "max_payload_bytes": { + "type": "integer", + "minimum": 1 + }, + "outcome_retention_seconds": { + "type": "integer", + "minimum": 0 + }, + "request_deduplication": { + "type": "boolean" + }, + "cross_process_identity": { + "type": "boolean" + } + }, + "additionalProperties": false + }, + "extensionValue": { + "oneOf": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "integer" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "event": { + "title": "mdbase CloudEvents event envelope", + "type": "object", + "required": [ + "specversion", + "id", + "source", + "type", + "time", + "datacontenttype", + "dataschema", + "data", + "mdbaseprofile", + "mdbasecontractversion", + "mdbasecontractdigest", + "mdbaseapplication", + "mdbaseimplementation", + "mdbaseimplementationversion" + ], + "properties": { + "specversion": { + "const": "1.0" + }, + "id": { + "$ref": "#/$defs/portableId" + }, + "source": { + "type": "string", + "format": "uri-reference", + "minLength": 1 + }, + "type": { + "$ref": "#/$defs/contractId" + }, + "time": { + "type": "string", + "format": "date-time" + }, + "subject": { + "type": "string", + "format": "uri-reference", + "minLength": 1 + }, + "datacontenttype": { + "const": "application/json" + }, + "dataschema": { + "type": "string", + "format": "uri", + "minLength": 1 + }, + "data": true, + "mdbaseprofile": { + "const": "0.1" + }, + "mdbasecontractversion": { + "$ref": "#/$defs/semanticVersion" + }, + "mdbasecontractdigest": { + "$ref": "#/$defs/digest" + }, + "mdbaseapplication": { + "$ref": "#/$defs/portableId" + }, + "mdbaseimplementation": { + "$ref": "#/$defs/portableId" + }, + "mdbaseimplementationversion": { + "$ref": "#/$defs/semanticVersion" + }, + "mdbaseinstanceid": { + "$ref": "#/$defs/portableId" + }, + "correlationid": { + "$ref": "#/$defs/portableId" + }, + "causationid": { + "$ref": "#/$defs/portableId" + } + }, + "propertyNames": { + "pattern": "^[a-z0-9]+$" + }, + "additionalProperties": { + "$ref": "#/$defs/extensionValue" + } + }, + "actionRequest": { + "title": "mdbase action request", + "type": "object", + "required": [ + "kind", + "profile_version", + "request_id", + "contract", + "caller", + "created_at", + "input" + ], + "properties": { + "kind": { + "const": "mdbase.action.request" + }, + "profile_version": { + "const": "0.1" + }, + "request_id": { + "$ref": "#/$defs/portableId" + }, + "contract": { + "$ref": "#/$defs/contractRequirement" + }, + "caller": { + "$ref": "#/$defs/implementationIdentity" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "correlation_id": { + "$ref": "#/$defs/portableId" + }, + "causation_id": { + "$ref": "#/$defs/portableId" + }, + "subject": { + "type": "string", + "format": "uri-reference", + "minLength": 1 + }, + "idempotency_key": { + "type": "string", + "minLength": 1, + "maxLength": 512 + }, + "deadline": { + "type": "string", + "format": "date-time" + }, + "requested_provider": { + "type": "object", + "properties": { + "application": { + "$ref": "#/$defs/portableId" + }, + "implementation": { + "$ref": "#/$defs/portableId" + }, + "instance_id": { + "$ref": "#/$defs/portableId" + } + }, + "minProperties": 1, + "additionalProperties": false + }, + "authorization_context": { + "type": "string", + "format": "uri-reference", + "minLength": 1 + }, + "input": true + }, + "additionalProperties": false + }, + "actionInvocation": { + "title": "mdbase admitted action invocation", + "type": "object", + "required": [ + "kind", + "profile_version", + "invocation_id", + "attempt_id", + "request_id", + "contract", + "caller", + "provider", + "provider_declaration_digest", + "handler_id", + "admitted_at", + "input" + ], + "properties": { + "kind": { + "const": "mdbase.action.invocation" + }, + "profile_version": { + "const": "0.1" + }, + "invocation_id": { + "$ref": "#/$defs/portableId" + }, + "attempt_id": { + "$ref": "#/$defs/portableId" + }, + "request_id": { + "$ref": "#/$defs/portableId" + }, + "contract": { + "$ref": "#/$defs/exactContract" + }, + "caller": { + "$ref": "#/$defs/implementationIdentity" + }, + "provider": { + "$ref": "#/$defs/implementationIdentity" + }, + "provider_declaration_digest": { + "$ref": "#/$defs/digest" + }, + "handler_id": { + "$ref": "#/$defs/portableId" + }, + "admitted_at": { + "type": "string", + "format": "date-time" + }, + "correlation_id": { + "$ref": "#/$defs/portableId" + }, + "causation_id": { + "$ref": "#/$defs/portableId" + }, + "subject": { + "type": "string", + "format": "uri-reference", + "minLength": 1 + }, + "idempotency_key": { + "type": "string", + "minLength": 1, + "maxLength": 512 + }, + "deadline": { + "type": "string", + "format": "date-time" + }, + "authorization_context": { + "type": "string", + "format": "uri-reference", + "minLength": 1 + }, + "input": true + }, + "additionalProperties": false + }, + "portableError": { + "type": "object", + "required": [ + "code", + "message" + ], + "properties": { + "code": { + "enum": [ + "unknown_contract", + "unsupported_contract_version", + "contract_digest_conflict", + "invalid_event_data", + "invalid_action_input", + "invalid_action_output", + "no_provider", + "ambiguous_provider", + "requested_provider_unavailable", + "unauthorized", + "capability_denied", + "request_rejected", + "deadline_exceeded", + "cancellation_unsupported", + "cancelled", + "handler_failure", + "outcome_indeterminate", + "transport_unavailable", + "unsupported_transport_capability" + ] + }, + "message": { + "type": "string", + "minLength": 1 + }, + "details": true, + "retryable": { + "type": "boolean" + } + }, + "additionalProperties": false + }, + "actionOutcome": { + "title": "mdbase action outcome", + "type": "object", + "required": [ + "kind", + "profile_version", + "outcome_id", + "request_id", + "invocation_id", + "attempt_id", + "contract", + "provider", + "provider_declaration_digest", + "status", + "completed_at" + ], + "properties": { + "kind": { + "const": "mdbase.action.outcome" + }, + "profile_version": { + "const": "0.1" + }, + "outcome_id": { + "$ref": "#/$defs/portableId" + }, + "request_id": { + "$ref": "#/$defs/portableId" + }, + "invocation_id": { + "$ref": "#/$defs/portableId" + }, + "attempt_id": { + "$ref": "#/$defs/portableId" + }, + "contract": { + "$ref": "#/$defs/exactContract" + }, + "provider": { + "$ref": "#/$defs/implementationIdentity" + }, + "provider_declaration_digest": { + "$ref": "#/$defs/digest" + }, + "status": { + "enum": [ + "succeeded", + "rejected", + "failed", + "cancelled", + "outcome_indeterminate" + ] + }, + "completed_at": { + "type": "string", + "format": "date-time" + }, + "output": true, + "error": { + "$ref": "#/$defs/portableError" + } + }, + "allOf": [ + { + "if": { + "properties": { + "status": { + "const": "succeeded" + } + }, + "required": [ + "status" + ] + }, + "then": { + "required": [ + "output" + ], + "not": { + "required": [ + "error" + ] + } + }, + "else": { + "required": [ + "error" + ], + "not": { + "required": [ + "output" + ] + } + } + } + ], + "additionalProperties": false + }, + "actionCancellation": { + "title": "mdbase action cancellation request", + "type": "object", + "required": [ + "kind", + "profile_version", + "cancellation_id", + "request_id", + "caller", + "requested_at" + ], + "properties": { + "kind": { + "const": "mdbase.action.cancel" + }, + "profile_version": { + "const": "0.1" + }, + "cancellation_id": { + "$ref": "#/$defs/portableId" + }, + "request_id": { + "$ref": "#/$defs/portableId" + }, + "caller": { + "$ref": "#/$defs/implementationIdentity" + }, + "requested_at": { + "type": "string", + "format": "date-time" + }, + "reason": { + "type": "string", + "maxLength": 1024 + } + }, + "additionalProperties": false + }, + "eventSourceDeclaration": { + "title": "mdbase event-source declaration", + "type": "object", + "required": [ + "kind", + "profile_version", + "declaration_id", + "declaration_digest", + "source", + "contracts" + ], + "properties": { + "kind": { + "const": "mdbase.event-source" + }, + "profile_version": { + "const": "0.1" + }, + "declaration_id": { + "$ref": "#/$defs/portableId" + }, + "declaration_digest": { + "$ref": "#/$defs/digest" + }, + "source": { + "$ref": "#/$defs/implementationIdentity" + }, + "contracts": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "required": [ + "requirement", + "resolved" + ], + "properties": { + "requirement": { + "$ref": "#/$defs/contractRequirement" + }, + "resolved": { + "$ref": "#/$defs/exactContract" + }, + "binding": true, + "ordering": { + "type": "array", + "uniqueItems": true, + "items": { + "enum": [ + "none", + "source", + "subject" + ] + } + } + }, + "additionalProperties": false + } + } + }, + "additionalProperties": false + }, + "actionProviderDeclaration": { + "title": "mdbase action-provider declaration", + "type": "object", + "required": [ + "kind", + "profile_version", + "declaration_id", + "declaration_digest", + "provider", + "handlers" + ], + "properties": { + "kind": { + "const": "mdbase.action-provider" + }, + "profile_version": { + "const": "0.1" + }, + "declaration_id": { + "$ref": "#/$defs/portableId" + }, + "declaration_digest": { + "$ref": "#/$defs/digest" + }, + "provider": { + "$ref": "#/$defs/implementationIdentity" + }, + "handlers": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "required": [ + "handler_id", + "requirement", + "resolved" + ], + "properties": { + "handler_id": { + "$ref": "#/$defs/portableId" + }, + "requirement": { + "$ref": "#/$defs/contractRequirement" + }, + "resolved": { + "$ref": "#/$defs/exactContract" + }, + "binding": true, + "idempotency": { + "type": "object", + "required": [ + "mode" + ], + "properties": { + "mode": { + "enum": [ + "none", + "request" + ] + }, + "retention_seconds": { + "type": "integer", + "minimum": 1 + } + }, + "additionalProperties": false + }, + "cancellation": { + "enum": [ + "none", + "cooperative" + ] + }, + "max_concurrency": { + "type": "integer", + "minimum": 1 + } + }, + "additionalProperties": false + } + } + }, + "additionalProperties": false + }, + "conformanceClaim": { + "title": "mdbase interoperability conformance claim", + "type": "object", + "required": [ + "kind", + "profile_version", + "implementation", + "roles", + "transport" + ], + "properties": { + "kind": { + "const": "mdbase.interop.conformance" + }, + "profile_version": { + "const": "0.1" + }, + "implementation": { + "$ref": "#/$defs/implementationIdentity" + }, + "roles": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "enum": [ + "event_source", + "event_consumer", + "action_caller", + "action_provider", + "bridge" + ] + } + }, + "transport": { + "$ref": "#/$defs/transportCapabilities" + }, + "evidence": { + "type": "array", + "items": { + "type": "object", + "required": [ + "scenario", + "result" + ], + "properties": { + "scenario": { + "type": "string", + "minLength": 1 + }, + "result": { + "const": "pass" + }, + "uri": { + "type": "string", + "format": "uri-reference" + } + }, + "additionalProperties": false + } + } + }, + "additionalProperties": false + } + } + }, + "event": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://mdbase.dev/schemas/interop/v0.1/event.schema.json", + "title": "mdbase CloudEvents event envelope", + "$ref": "profile.schema.json#/$defs/event" + }, + "actionRequest": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://mdbase.dev/schemas/interop/v0.1/action-request.schema.json", + "title": "mdbase action request", + "$ref": "profile.schema.json#/$defs/actionRequest" + }, + "actionInvocation": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://mdbase.dev/schemas/interop/v0.1/action-invocation.schema.json", + "title": "mdbase admitted action invocation", + "$ref": "profile.schema.json#/$defs/actionInvocation" + }, + "actionOutcome": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://mdbase.dev/schemas/interop/v0.1/action-outcome.schema.json", + "title": "mdbase action outcome", + "$ref": "profile.schema.json#/$defs/actionOutcome" + }, + "actionCancellation": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://mdbase.dev/schemas/interop/v0.1/action-cancellation.schema.json", + "title": "mdbase action cancellation request", + "$ref": "profile.schema.json#/$defs/actionCancellation" + }, + "eventSourceDeclaration": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://mdbase.dev/schemas/interop/v0.1/event-source-declaration.schema.json", + "title": "mdbase event-source declaration", + "$ref": "profile.schema.json#/$defs/eventSourceDeclaration" + }, + "actionProviderDeclaration": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://mdbase.dev/schemas/interop/v0.1/action-provider-declaration.schema.json", + "title": "mdbase action-provider declaration", + "$ref": "profile.schema.json#/$defs/actionProviderDeclaration" + }, + "conformanceClaim": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://mdbase.dev/schemas/interop/v0.1/conformance-claim.schema.json", + "title": "mdbase interoperability conformance claim", + "$ref": "profile.schema.json#/$defs/conformanceClaim" + } +} as const; diff --git a/packages/interop/src/index.ts b/packages/interop/src/index.ts new file mode 100644 index 0000000..8e8b7e5 --- /dev/null +++ b/packages/interop/src/index.ts @@ -0,0 +1,25 @@ +export { + ActionHandlerError, + InteropError +} from "./errors.js"; +export { + contractDigest, + contractDigestInput, + exactContractReference, + inlineSchema, + portableDigest, + resolvedSchema +} from "./contracts.js"; +export { + compileInteropSchema, + createInteropAjv, + formatSchemaErrors, + getInteropSchemas +} from "./schemas.js"; +export { + InMemoryInteropBridge +} from "./bridge.js"; +export type { + InMemoryInteropBridgeOptions +} from "./bridge.js"; +export * from "./types.js"; diff --git a/packages/interop/src/schemas.ts b/packages/interop/src/schemas.ts new file mode 100644 index 0000000..c98db0a --- /dev/null +++ b/packages/interop/src/schemas.ts @@ -0,0 +1,47 @@ +import { Ajv2020, type ErrorObject, type ValidateFunction } from "ajv/dist/2020.js"; +import addFormatsImport from "ajv-formats"; +import { GENERATED_INTEROP_SCHEMAS } from "./generated-schemas.js"; + +type SchemaName = keyof typeof GENERATED_INTEROP_SCHEMAS; + +const addFormats = addFormatsImport as unknown as (ajv: Ajv2020) => Ajv2020; + +export function getInteropSchemas(): Record> { + return Object.fromEntries( + Object.entries(GENERATED_INTEROP_SCHEMAS).map(([name, schema]) => [ + name, + structuredClone(schema) + ]) + ) as unknown as Record>; +} + +export function createInteropAjv(): Ajv2020 { + const ajv = new Ajv2020({ + allErrors: true, + strict: false, + validateFormats: true + }); + addFormats(ajv); + const schemas = getInteropSchemas(); + ajv.addSchema(schemas.profile); + for (const [name, schema] of Object.entries(schemas)) { + if (name !== "profile") ajv.addSchema(schema); + } + return ajv; +} + +export function compileInteropSchema( + ajv: Ajv2020, + name: Exclude +): ValidateFunction { + const id = String(GENERATED_INTEROP_SCHEMAS[name].$id); + const validator = ajv.getSchema(id); + if (!validator) throw new Error(`Canonical interoperability schema is unavailable: ${name}`); + return validator; +} + +export function formatSchemaErrors(errors: ErrorObject[] | null | undefined): string { + return (errors ?? []) + .map((error) => `${error.instancePath || "/"} ${error.message ?? error.keyword}`) + .join("; "); +} diff --git a/packages/interop/src/types.ts b/packages/interop/src/types.ts new file mode 100644 index 0000000..ae90247 --- /dev/null +++ b/packages/interop/src/types.ts @@ -0,0 +1,413 @@ +export const MDBASE_INTEROP_PROFILE_VERSION = "0.1" as const; +export const CLOUDEVENTS_SPEC_VERSION = "1.0" as const; + +export type JsonSchema = Record; + +export interface InlineSchema { + dialect: "json-schema-2020-12"; + value: JsonSchema; +} + +export interface ReferencedSchema { + dialect: "json-schema-2020-12"; + ref: string; +} + +export type SchemaWrapper = InlineSchema | ReferencedSchema; + +interface ContractArtifactBase { + kind: "mdbase.contract"; + id: string; + version: string; + name?: string; + description?: string; + [extension: `x-${string}`]: unknown; +} + +export interface RecordContractArtifact extends ContractArtifactBase { + contract_type: "record"; + record_schema: SchemaWrapper; + binding_schema?: SchemaWrapper; +} + +export interface EventContractArtifact extends ContractArtifactBase { + contract_type: "event"; + data_schema: SchemaWrapper; + source_schema?: SchemaWrapper; +} + +export interface ActionContractArtifact extends ContractArtifactBase { + contract_type: "action"; + input_schema: SchemaWrapper; + output_schema?: SchemaWrapper; + error_schema?: SchemaWrapper; + provider_schema?: SchemaWrapper; + behavior?: { + idempotency?: "none" | "optional" | "required"; + cancellation?: "none" | "cooperative"; + }; +} + +export type ContractArtifact = + | RecordContractArtifact + | EventContractArtifact + | ActionContractArtifact; + +export interface ContractRequirement { + id: string; + version: string; + digest?: string; +} + +export interface ExactContractReference { + id: string; + version: string; + digest: string; +} + +export interface ImplementationIdentity { + application: string; + implementation: string; + version: string; + instance_id?: string; +} + +export interface CloudEvent { + specversion: "1.0"; + id: string; + source: string; + type: string; + time: string; + subject?: string; + datacontenttype: "application/json"; + dataschema: string; + data: T; + mdbaseprofile: "0.1"; + mdbasecontractversion: string; + mdbasecontractdigest: string; + mdbaseapplication: string; + mdbaseimplementation: string; + mdbaseimplementationversion: string; + mdbaseinstanceid?: string; + correlationid?: string; + causationid?: string; + [extension: string]: unknown; +} + +export interface EventSourceContractInput { + contract: EventContractArtifact; + requirement?: ContractRequirement; + binding?: unknown; + ordering?: Array<"none" | "source" | "subject">; +} + +export interface RegisterEventSourceInput { + declaration_id: string; + contracts: EventSourceContractInput[]; +} + +export interface EventSourceContractDeclaration { + requirement: ContractRequirement; + resolved: ExactContractReference; + binding?: unknown; + ordering?: Array<"none" | "source" | "subject">; +} + +export interface EventSourceDeclaration { + kind: "mdbase.event-source"; + profile_version: "0.1"; + declaration_id: string; + declaration_digest: string; + source: ImplementationIdentity; + contracts: EventSourceContractDeclaration[]; +} + +export interface PublishEventInput { + id?: string; + contract: { + id: string; + version: string; + digest?: string; + }; + time?: string; + subject?: string; + correlation_id?: string; + causation_id?: string; + data: T; + extensions?: Record; +} + +export interface PublishEventResult { + event: CloudEvent; + deliveries: number; + duplicate: boolean; +} + +export interface EventSubscription { + contract: ContractRequirement; + require_transport?: Partial; +} + +export type EventHandler = (event: CloudEvent) => void | Promise; + +export interface ActionRequest { + kind: "mdbase.action.request"; + profile_version: "0.1"; + request_id: string; + contract: ContractRequirement; + caller: ImplementationIdentity; + created_at: string; + correlation_id?: string; + causation_id?: string; + subject?: string; + idempotency_key?: string; + deadline?: string; + requested_provider?: ProviderSelector; + authorization_context?: string; + input: T; +} + +export interface ProviderSelector { + application?: string; + implementation?: string; + instance_id?: string; +} + +export interface ActionInvocation { + kind: "mdbase.action.invocation"; + profile_version: "0.1"; + invocation_id: string; + attempt_id: string; + request_id: string; + contract: ExactContractReference; + caller: ImplementationIdentity; + provider: ImplementationIdentity; + provider_declaration_digest: string; + handler_id: string; + admitted_at: string; + correlation_id?: string; + causation_id?: string; + subject?: string; + idempotency_key?: string; + deadline?: string; + authorization_context?: string; + input: T; +} + +export type PortableErrorCode = + | "unknown_contract" + | "unsupported_contract_version" + | "contract_digest_conflict" + | "invalid_event_data" + | "invalid_action_input" + | "invalid_action_output" + | "no_provider" + | "ambiguous_provider" + | "requested_provider_unavailable" + | "unauthorized" + | "capability_denied" + | "request_rejected" + | "deadline_exceeded" + | "cancellation_unsupported" + | "cancelled" + | "handler_failure" + | "outcome_indeterminate" + | "transport_unavailable" + | "unsupported_transport_capability"; + +export interface PortableError { + code: PortableErrorCode; + message: string; + details?: unknown; + retryable?: boolean; +} + +export type ActionOutcomeStatus = + | "succeeded" + | "rejected" + | "failed" + | "cancelled" + | "outcome_indeterminate"; + +interface ActionOutcomeBase { + kind: "mdbase.action.outcome"; + profile_version: "0.1"; + outcome_id: string; + request_id: string; + invocation_id: string; + attempt_id: string; + contract: ExactContractReference; + provider: ImplementationIdentity; + provider_declaration_digest: string; + completed_at: string; +} + +export interface SuccessfulActionOutcome extends ActionOutcomeBase { + status: "succeeded"; + output: T; +} + +export interface UnsuccessfulActionOutcome extends ActionOutcomeBase { + status: Exclude; + error: PortableError; +} + +export type ActionOutcome = + | SuccessfulActionOutcome + | UnsuccessfulActionOutcome; + +export interface InvokeActionInput { + request_id?: string; + contract: ContractRequirement; + created_at?: string; + correlation_id?: string; + causation_id?: string; + subject?: string; + idempotency_key?: string; + deadline?: string; + requested_provider?: ProviderSelector; + input: T; +} + +export interface ActionHandlerContext { + invocation: ActionInvocation; + signal: AbortSignal; +} + +export type ActionHandler = ( + input: unknown, + context: ActionHandlerContext +) => unknown | Promise; + +export interface ActionProviderHandlerInput { + handler_id: string; + contract: ActionContractArtifact; + requirement?: ContractRequirement; + binding?: unknown; + idempotency?: { + mode: "none" | "request"; + retention_seconds?: number; + }; + cancellation?: "none" | "cooperative"; + max_concurrency?: number; + handler: ActionHandler; +} + +export interface RegisterActionProviderInput { + declaration_id: string; + handlers: ActionProviderHandlerInput[]; +} + +export interface ActionProviderHandlerDeclaration { + handler_id: string; + requirement: ContractRequirement; + resolved: ExactContractReference; + binding?: unknown; + idempotency?: { + mode: "none" | "request"; + retention_seconds?: number; + }; + cancellation?: "none" | "cooperative"; + max_concurrency?: number; +} + +export interface ActionProviderDeclaration { + kind: "mdbase.action-provider"; + profile_version: "0.1"; + declaration_id: string; + declaration_digest: string; + provider: ImplementationIdentity; + handlers: ActionProviderHandlerDeclaration[]; +} + +export interface ActionCancellation { + kind: "mdbase.action.cancel"; + profile_version: "0.1"; + cancellation_id: string; + request_id: string; + caller: ImplementationIdentity; + requested_at: string; + reason?: string; +} + +export type InteropRole = + | "event_source" + | "event_consumer" + | "action_caller" + | "action_provider" + | "bridge"; + +export interface TransportCapabilities { + delivery: Array<"ephemeral" | "at_least_once" | "durable_cursor" | "offline_queue">; + ordering: Array<"none" | "source" | "subject">; + cancellation: boolean; + deadlines: boolean; + provider_discovery?: boolean; + max_payload_bytes?: number; + outcome_retention_seconds?: number; + request_deduplication?: boolean; + cross_process_identity?: boolean; +} + +export type AuthorizationOperation = + | "register_event_source" + | "publish_event" + | "subscribe_event" + | "register_action_provider" + | "invoke_action" + | "cancel_action"; + +export interface AuthorizationRequest { + operation: AuthorizationOperation; + principal: ImplementationIdentity; + contract?: ContractRequirement | ExactContractReference; + provider?: ImplementationIdentity; + subject?: string; +} + +export interface BridgeDiagnostic { + severity: "warning" | "error"; + code: string; + message: string; + principal?: ImplementationIdentity; + contract?: ExactContractReference; + cause?: unknown; +} + +export interface BridgeDescription { + profile_version: "0.1"; + transport: TransportCapabilities; + contracts: Array<{ + artifact: ContractArtifact; + reference: ExactContractReference; + }>; + event_sources: EventSourceDeclaration[]; + action_providers: ActionProviderDeclaration[]; +} + +export interface Disposable { + dispose(): void | Promise; +} + +export interface InteropClient extends Disposable { + readonly identity: ImplementationIdentity; + registerEventSource(input: RegisterEventSourceInput): Promise; + publishEvent(input: PublishEventInput): Promise>; + subscribeEvents( + subscription: EventSubscription, + handler: EventHandler + ): Promise; + registerActionProvider(input: RegisterActionProviderInput): Promise; + invokeAction( + input: InvokeActionInput + ): Promise>; + cancelAction(requestId: string, reason?: string): Promise; +} + +export interface EventSourceRegistration extends Disposable { + declaration: EventSourceDeclaration; +} + +export interface ActionProviderRegistration extends Disposable { + declaration: ActionProviderDeclaration; +} diff --git a/packages/interop/test/bridge.test.mjs b/packages/interop/test/bridge.test.mjs new file mode 100644 index 0000000..7eafd29 --- /dev/null +++ b/packages/interop/test/bridge.test.mjs @@ -0,0 +1,488 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + ActionHandlerError, + InMemoryInteropBridge, + InteropError, + contractDigest +} from "../dist/index.js"; + +const completedEvent = { + kind: "mdbase.contract", + contract_type: "event", + id: "tasknotes.task.completed", + version: "1.0.0", + data_schema: schema({ + type: "object", + required: ["task_id", "completed_at"], + additionalProperties: false, + properties: { + task_id: { type: "string", minLength: 1 }, + completed_at: { type: "string", format: "date-time" } + } + }) +}; + +const createCardAction = { + kind: "mdbase.contract", + contract_type: "action", + id: "canvas.card.create", + version: "1.0.0", + input_schema: schema({ + type: "object", + required: ["canvas", "title"], + additionalProperties: false, + properties: { + canvas: { type: "string", minLength: 1 }, + title: { type: "string", minLength: 1 } + } + }), + output_schema: schema({ + type: "object", + required: ["card_id"], + additionalProperties: false, + properties: { card_id: { type: "string", minLength: 1 } } + }), + behavior: { + idempotency: "optional", + cancellation: "cooperative" + } +}; + +test("CloudEvents multicast to every compatible consumer and suppress redelivery", async () => { + const bridge = testBridge(); + const source = bridge.connect(identity("tasknotes")); + const first = bridge.connect(identity("consumer-one")); + const second = bridge.connect(identity("consumer-two")); + await source.registerEventSource({ + declaration_id: "tasknotes.events", + contracts: [{ contract: completedEvent, requirement: { id: completedEvent.id, version: "^1.0.0" } }] + }); + const deliveries = []; + await first.subscribeEvents( + { contract: { id: completedEvent.id, version: ">=1.0.0 <2.0.0" } }, + (event) => deliveries.push(["first", event]) + ); + await second.subscribeEvents( + { contract: { id: completedEvent.id, version: "^1.0.0" } }, + (event) => deliveries.push(["second", event]) + ); + + const input = { + id: "evt_01", + contract: { id: completedEvent.id, version: "1.0.0" }, + time: "2026-07-28T01:30:00.000Z", + subject: "urn:mdbase:record:vault:task-123", + correlation_id: "flow_01", + causation_id: "req_01", + data: { + task_id: "task-123", + completed_at: "2026-07-28T01:30:00.000Z" + } + }; + const published = await source.publishEvent(input); + + assert.equal(published.deliveries, 2); + assert.equal(published.duplicate, false); + assert.deepEqual(deliveries.map(([name]) => name), ["first", "second"]); + assert.equal(published.event.specversion, "1.0"); + assert.equal(published.event.type, completedEvent.id); + assert.equal(published.event.correlationid, "flow_01"); + assert.equal(published.event.causationid, "req_01"); + assert.match(published.event.dataschema, /^urn:mdbase:contract:/); + assert.equal(published.event.mdbasecontractdigest, await contractDigest(completedEvent)); + + const duplicate = await source.publishEvent(input); + assert.equal(duplicate.duplicate, true); + assert.equal(duplicate.deliveries, 0); + assert.equal(deliveries.length, 2); + await bridge.dispose(); +}); + +test("event data and contract conflicts fail closed", async () => { + const bridge = testBridge(); + const source = bridge.connect(identity("tasknotes")); + await source.registerEventSource({ + declaration_id: "tasknotes.events", + contracts: [{ contract: completedEvent }] + }); + await assert.rejects( + source.publishEvent({ + contract: { id: completedEvent.id, version: completedEvent.version }, + data: { task_id: "missing-completed-at" } + }), + errorWithCode("invalid_event_data") + ); + + const conflicting = structuredClone(completedEvent); + conflicting.data_schema.value.properties.task_id.minLength = 10; + const other = bridge.connect(identity("other-source")); + await assert.rejects( + other.registerEventSource({ + declaration_id: "other.events", + contracts: [{ contract: conflicting }] + }), + errorWithCode("contract_digest_conflict") + ); + await bridge.dispose(); +}); + +test("action admission pins exact contract and one explicitly selected provider", async () => { + const invocations = []; + const bridge = testBridge({ onInvocation: (invocation) => invocations.push(invocation) }); + const caller = bridge.connect(identity("tasknotes-workflows")); + const first = bridge.connect(identity("canvas-bases-one")); + const second = bridge.connect(identity("canvas-bases-two")); + await registerCardProvider(first, "provider-one", "one"); + await registerCardProvider(second, "provider-two", "two"); + + await assert.rejects( + caller.invokeAction({ + contract: { id: createCardAction.id, version: "^1.0.0" }, + input: { canvas: "Projects/Report.canvas", title: "Prepare report" } + }), + errorWithCode("ambiguous_provider") + ); + + const outcome = await caller.invokeAction({ + request_id: "req_card_01", + contract: { id: createCardAction.id, version: "^1.0.0" }, + requested_provider: { application: "canvas-bases-one" }, + correlation_id: "flow_01", + causation_id: "evt_01", + input: { canvas: "Projects/Report.canvas", title: "Prepare report" } + }); + + assert.equal(outcome.status, "succeeded"); + assert.deepEqual(outcome.output, { card_id: "one" }); + assert.equal(invocations.length, 1); + assert.equal(invocations[0].request_id, "req_card_01"); + assert.equal(invocations[0].provider.application, "canvas-bases-one"); + assert.equal(invocations[0].contract.digest, await contractDigest(createCardAction)); + assert.notEqual(invocations[0].invocation_id, invocations[0].attempt_id); + + await registerCardProvider( + bridge.connect(identity("canvas-bases-three")), + "provider-three", + "three" + ); + assert.equal(outcome.provider.application, "canvas-bases-one"); + assert.equal(invocations[0].provider.application, "canvas-bases-one"); + await bridge.dispose(); +}); + +test("providers return validated success, rejection, failure, and indeterminate outcomes", async () => { + for (const scenario of [ + { + name: "invalid output", + handler: async () => ({ wrong: true }), + status: "failed", + code: "invalid_action_output" + }, + { + name: "rejected", + handler: async () => { + throw new ActionHandlerError("rejected", { + code: "request_rejected", + message: "The board is read-only." + }); + }, + status: "rejected", + code: "request_rejected" + }, + { + name: "handler failure", + handler: async () => { + throw new Error("private stack detail"); + }, + status: "failed", + code: "handler_failure" + }, + { + name: "indeterminate", + handler: async () => { + throw new ActionHandlerError("outcome_indeterminate", { + code: "outcome_indeterminate", + message: "The external system did not confirm the effect." + }); + }, + status: "outcome_indeterminate", + code: "outcome_indeterminate" + } + ]) { + const bridge = testBridge(); + const provider = bridge.connect(identity("canvas-bases")); + const actionCaller = bridge.connect(identity("workflows")); + await provider.registerActionProvider({ + declaration_id: "canvas.actions", + handlers: [{ + handler_id: "canvas.card.create", + contract: createCardAction, + handler: scenario.handler + }] + }); + const outcome = await actionCaller.invokeAction({ + contract: { id: createCardAction.id, version: "1.0.0" }, + input: { canvas: "Board.canvas", title: "Card" } + }); + assert.equal(outcome.status, scenario.status, scenario.name); + assert.equal(outcome.error.code, scenario.code, scenario.name); + assert.doesNotMatch(outcome.error.message, /private stack detail/, scenario.name); + await bridge.dispose(); + } +}); + +test("request deduplication returns the recorded outcome only when declared", async () => { + const bridge = testBridge(); + const provider = bridge.connect(identity("canvas-bases")); + const caller = bridge.connect(identity("workflows")); + let calls = 0; + await provider.registerActionProvider({ + declaration_id: "canvas.actions", + handlers: [{ + handler_id: "canvas.card.create", + contract: createCardAction, + idempotency: { mode: "request", retention_seconds: 60 }, + handler: async () => ({ card_id: `card-${++calls}` }) + }] + }); + const request = { + request_id: "req_deduplicated", + contract: { id: createCardAction.id, version: "1.0.0" }, + idempotency_key: "flow:card", + input: { canvas: "Board.canvas", title: "Card" } + }; + const first = await caller.invokeAction(request); + const second = await caller.invokeAction(request); + + assert.deepEqual(second, first); + assert.equal(calls, 1); + await bridge.dispose(); +}); + +test("concurrent duplicate requests share one outcome and reject changed intent", async () => { + const bridge = testBridge(); + const provider = bridge.connect(identity("canvas-bases")); + const caller = bridge.connect(identity("workflows")); + let calls = 0; + let finish; + let markStarted; + const started = new Promise((resolve) => { + markStarted = resolve; + }); + await provider.registerActionProvider({ + declaration_id: "canvas.actions", + handlers: [{ + handler_id: "canvas.card.create", + contract: createCardAction, + idempotency: { mode: "request", retention_seconds: 60 }, + handler: async () => { + calls += 1; + markStarted(); + await new Promise((resolve) => { + finish = resolve; + }); + return { card_id: "card-one" }; + } + }] + }); + const request = { + request_id: "req_concurrent", + contract: { id: createCardAction.id, version: "1.0.0" }, + idempotency_key: "flow:card", + input: { canvas: "Board.canvas", title: "Card" } + }; + const first = caller.invokeAction(request); + const second = caller.invokeAction(request); + await started; + finish(); + + assert.deepEqual(await second, await first); + assert.equal(calls, 1); + await assert.rejects( + caller.invokeAction({ + ...request, + input: { ...request.input, title: "Different card" } + }), + errorWithCode("request_rejected") + ); + await bridge.dispose(); +}); + +test("portable payloads must be JSON and fit the advertised transport limit", async () => { + const bridge = testBridge({ transport: { max_payload_bytes: 900 } }); + const source = bridge.connect(identity("tasknotes")); + const provider = bridge.connect(identity("canvas-bases")); + const caller = bridge.connect(identity("workflows")); + await source.registerEventSource({ + declaration_id: "task-events", + contracts: [{ contract: completedEvent }] + }); + await registerCardProvider(provider, "canvas.actions", "card-one"); + + await assert.rejects( + source.publishEvent({ + contract: { id: completedEvent.id, version: completedEvent.version }, + data: { + task_id: "task-1", + completed_at: new Date("2026-07-28T01:30:00Z") + } + }), + errorWithCode("invalid_event_data") + ); + await assert.rejects( + caller.invokeAction({ + contract: { id: createCardAction.id, version: "1.0.0" }, + input: { canvas: "Board.canvas", title: "x".repeat(1000) } + }), + errorWithCode("unsupported_transport_capability") + ); + await bridge.dispose(); +}); + +test("required idempotency rejects providers that cannot deduplicate requests", async () => { + const bridge = testBridge(); + const provider = bridge.connect(identity("canvas-bases")); + const required = structuredClone(createCardAction); + required.behavior.idempotency = "required"; + + await assert.rejects( + provider.registerActionProvider({ + declaration_id: "canvas.actions", + handlers: [{ + handler_id: "canvas.card.create", + contract: required, + handler: async () => ({ card_id: "card-1" }) + }] + }), + errorWithCode("request_rejected") + ); + await bridge.dispose(); +}); + +test("CloudEvents reject invalid extension attribute names", async () => { + const bridge = testBridge(); + const source = bridge.connect(identity("tasknotes")); + await source.registerEventSource({ + declaration_id: "tasknotes.events", + contracts: [{ contract: completedEvent }] + }); + + await assert.rejects( + source.publishEvent({ + contract: { id: completedEvent.id, version: completedEvent.version }, + extensions: { "not-valid": "value" }, + data: { + task_id: "task-123", + completed_at: "2026-07-28T01:30:00.000Z" + } + }), + errorWithCode("invalid_event_data") + ); + await bridge.dispose(); +}); + +test("cooperative cancellation and provider unload remove live registrations", async () => { + const bridge = testBridge(); + const provider = bridge.connect(identity("canvas-bases")); + const caller = bridge.connect(identity("workflows")); + const registration = await provider.registerActionProvider({ + declaration_id: "canvas.actions", + handlers: [{ + handler_id: "canvas.card.create", + contract: createCardAction, + cancellation: "cooperative", + handler: async (_input, { signal }) => await new Promise((resolve, reject) => { + signal.addEventListener("abort", () => reject(new DOMException("Aborted", "AbortError")), { + once: true + }); + }) + }] + }); + const pending = caller.invokeAction({ + request_id: "req_cancel", + contract: { id: createCardAction.id, version: "1.0.0" }, + input: { canvas: "Board.canvas", title: "Card" } + }); + await new Promise((resolve) => setImmediate(resolve)); + const cancelled = await caller.cancelAction("req_cancel", "Workflow stopped."); + + assert.equal(cancelled.status, "cancelled"); + assert.equal((await pending).status, "cancelled"); + await registration.dispose(); + await assert.rejects( + caller.invokeAction({ + contract: { id: createCardAction.id, version: "1.0.0" }, + input: { canvas: "Board.canvas", title: "Another" } + }), + errorWithCode("no_provider") + ); + assert.equal(bridge.describe().action_providers.length, 0); + await bridge.dispose(); +}); + +test("authorization and transport requirements fail closed", async () => { + const bridge = testBridge({ + authorize: ({ operation, principal }) => + operation !== "subscribe_event" || principal.application !== "denied" + }); + const denied = bridge.connect(identity("denied")); + await assert.rejects( + denied.subscribeEvents( + { contract: { id: completedEvent.id, version: "^1.0.0" } }, + () => undefined + ), + errorWithCode("unauthorized") + ); + const allowed = bridge.connect(identity("allowed")); + await assert.rejects( + allowed.subscribeEvents( + { + contract: { id: completedEvent.id, version: "^1.0.0" }, + require_transport: { delivery: ["durable_cursor"] } + }, + () => undefined + ), + errorWithCode("unsupported_transport_capability") + ); + await bridge.dispose(); +}); + +async function registerCardProvider(client, declarationId, cardId) { + return await client.registerActionProvider({ + declaration_id: declarationId, + handlers: [{ + handler_id: "canvas.card.create", + contract: createCardAction, + idempotency: { mode: "request", retention_seconds: 60 }, + cancellation: "cooperative", + handler: async () => ({ card_id: cardId }) + }] + }); +} + +function schema(value) { + return { dialect: "json-schema-2020-12", value }; +} + +function identity(application) { + return { + application, + implementation: `${application}.obsidian`, + version: "1.0.0" + }; +} + +function testBridge(options = {}) { + let sequence = 0; + return new InMemoryInteropBridge({ + authorize: () => true, + now: () => new Date("2026-07-28T01:30:00.000Z"), + idFactory: (prefix) => `${prefix}_${++sequence}`, + ...options + }); +} + +function errorWithCode(code) { + return (error) => error instanceof InteropError && error.code === code; +} diff --git a/packages/interop/tsconfig.json b/packages/interop/tsconfig.json new file mode 100644 index 0000000..bf097ff --- /dev/null +++ b/packages/interop/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "dist", + "rootDir": "src", + "strict": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + "skipLibCheck": true + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/runtime-contracts/src/generated-schemas.ts b/packages/runtime-contracts/src/generated-schemas.ts index 6ef41eb..e40e052 100644 --- a/packages/runtime-contracts/src/generated-schemas.ts +++ b/packages/runtime-contracts/src/generated-schemas.ts @@ -175,6 +175,9 @@ export const GENERATED_CANONICAL_SCHEMAS: Record "runtime_profile_version": { "const": "0.1.0" }, + "interop_profile_version": { + "const": "0.1" + }, "profiles": { "type": "array", "minItems": 1, @@ -452,17 +455,41 @@ export const GENERATED_CANONICAL_SCHEMAS: Record "properties": { "profiles": { "contains": { - "const": "runtime_contracts/0.1" + "const": "event_action_interop/0.1" } } } }, "then": { "required": [ - "runtime_profile_version" + "interop_profile_version" ] } }, + { + "if": { + "properties": { + "profiles": { + "contains": { + "const": "runtime_contracts/0.1" + } + } + } + }, + "then": { + "required": [ + "runtime_profile_version", + "interop_profile_version" + ], + "properties": { + "profiles": { + "contains": { + "const": "event_action_interop/0.1" + } + } + } + } + }, { "if": { "properties": { @@ -531,6 +558,7 @@ export const GENERATED_CANONICAL_SCHEMAS: Record "links", "core_write", "lifecycle", + "event_action_interop/0.1", "runtime_contracts/0.1", "workflow/0.1", "watch" @@ -1555,18 +1583,25 @@ export const GENERATED_CANONICAL_SCHEMAS: Record "dataContract": { "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://mdbase.dev/schemas/v0.3/data-contract.schema.json", - "title": "mdbase v0.3 data contract frontmatter", + "title": "mdbase v0.3 contract frontmatter", "type": "object", "required": [ "kind", + "contract_type", "id", - "version", - "schema" + "version" ], "properties": { "kind": { "const": "mdbase.contract" }, + "contract_type": { + "enum": [ + "record", + "event", + "action" + ] + }, "id": { "$ref": "#/$defs/contractId" }, @@ -1580,16 +1615,192 @@ export const GENERATED_CANONICAL_SCHEMAS: Record "description": { "type": "string" }, - "schema": { + "record_schema": { "$ref": "#/$defs/schemaWrapper" }, "binding_schema": { "$ref": "#/$defs/schemaWrapper" + }, + "data_schema": { + "$ref": "#/$defs/schemaWrapper" + }, + "source_schema": { + "$ref": "#/$defs/schemaWrapper" + }, + "input_schema": { + "$ref": "#/$defs/schemaWrapper" + }, + "output_schema": { + "$ref": "#/$defs/schemaWrapper" + }, + "error_schema": { + "$ref": "#/$defs/schemaWrapper" + }, + "provider_schema": { + "$ref": "#/$defs/schemaWrapper" + }, + "behavior": { + "$ref": "#/$defs/actionBehavior" } }, "patternProperties": { "^x-[A-Za-z][A-Za-z0-9._:-]{0,127}$": true }, + "allOf": [ + { + "if": { + "properties": { + "contract_type": { + "const": "record" + } + }, + "required": [ + "contract_type" + ] + }, + "then": { + "required": [ + "record_schema" + ], + "not": { + "anyOf": [ + { + "required": [ + "data_schema" + ] + }, + { + "required": [ + "source_schema" + ] + }, + { + "required": [ + "input_schema" + ] + }, + { + "required": [ + "output_schema" + ] + }, + { + "required": [ + "error_schema" + ] + }, + { + "required": [ + "provider_schema" + ] + }, + { + "required": [ + "behavior" + ] + } + ] + } + } + }, + { + "if": { + "properties": { + "contract_type": { + "const": "event" + } + }, + "required": [ + "contract_type" + ] + }, + "then": { + "required": [ + "data_schema" + ], + "not": { + "anyOf": [ + { + "required": [ + "record_schema" + ] + }, + { + "required": [ + "binding_schema" + ] + }, + { + "required": [ + "input_schema" + ] + }, + { + "required": [ + "output_schema" + ] + }, + { + "required": [ + "error_schema" + ] + }, + { + "required": [ + "provider_schema" + ] + }, + { + "required": [ + "behavior" + ] + } + ] + } + } + }, + { + "if": { + "properties": { + "contract_type": { + "const": "action" + } + }, + "required": [ + "contract_type" + ] + }, + "then": { + "required": [ + "input_schema" + ], + "not": { + "anyOf": [ + { + "required": [ + "record_schema" + ] + }, + { + "required": [ + "binding_schema" + ] + }, + { + "required": [ + "data_schema" + ] + }, + { + "required": [ + "source_schema" + ] + } + ] + } + } + } + ], "additionalProperties": false, "$defs": { "contractId": { @@ -1640,6 +1851,25 @@ export const GENERATED_CANONICAL_SCHEMAS: Record } ], "additionalProperties": false + }, + "actionBehavior": { + "type": "object", + "properties": { + "idempotency": { + "enum": [ + "none", + "optional", + "required" + ] + }, + "cancellation": { + "enum": [ + "none", + "cooperative" + ] + } + }, + "additionalProperties": false } } }, diff --git a/schemas/interop/v0.1/README.md b/schemas/interop/v0.1/README.md new file mode 100644 index 0000000..0626941 --- /dev/null +++ b/schemas/interop/v0.1/README.md @@ -0,0 +1,9 @@ +# Interoperability profile schemas + +These schemas define mdbase event/action interoperability profile `0.1`. +`profile.schema.json` contains the shared definitions and complete envelope +union. The smaller files are stable entry points for one envelope or +declaration. + +Implementations preload the profile schema before compiling an entry-point +schema. They never fetch schema references from the network. diff --git a/schemas/interop/v0.1/action-cancellation.schema.json b/schemas/interop/v0.1/action-cancellation.schema.json new file mode 100644 index 0000000..d195904 --- /dev/null +++ b/schemas/interop/v0.1/action-cancellation.schema.json @@ -0,0 +1,6 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://mdbase.dev/schemas/interop/v0.1/action-cancellation.schema.json", + "title": "mdbase action cancellation request", + "$ref": "profile.schema.json#/$defs/actionCancellation" +} diff --git a/schemas/interop/v0.1/action-invocation.schema.json b/schemas/interop/v0.1/action-invocation.schema.json new file mode 100644 index 0000000..748c3b5 --- /dev/null +++ b/schemas/interop/v0.1/action-invocation.schema.json @@ -0,0 +1,6 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://mdbase.dev/schemas/interop/v0.1/action-invocation.schema.json", + "title": "mdbase admitted action invocation", + "$ref": "profile.schema.json#/$defs/actionInvocation" +} diff --git a/schemas/interop/v0.1/action-outcome.schema.json b/schemas/interop/v0.1/action-outcome.schema.json new file mode 100644 index 0000000..6c8248d --- /dev/null +++ b/schemas/interop/v0.1/action-outcome.schema.json @@ -0,0 +1,6 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://mdbase.dev/schemas/interop/v0.1/action-outcome.schema.json", + "title": "mdbase action outcome", + "$ref": "profile.schema.json#/$defs/actionOutcome" +} diff --git a/schemas/interop/v0.1/action-provider-declaration.schema.json b/schemas/interop/v0.1/action-provider-declaration.schema.json new file mode 100644 index 0000000..19beaf5 --- /dev/null +++ b/schemas/interop/v0.1/action-provider-declaration.schema.json @@ -0,0 +1,6 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://mdbase.dev/schemas/interop/v0.1/action-provider-declaration.schema.json", + "title": "mdbase action-provider declaration", + "$ref": "profile.schema.json#/$defs/actionProviderDeclaration" +} diff --git a/schemas/interop/v0.1/action-request.schema.json b/schemas/interop/v0.1/action-request.schema.json new file mode 100644 index 0000000..28a95e9 --- /dev/null +++ b/schemas/interop/v0.1/action-request.schema.json @@ -0,0 +1,6 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://mdbase.dev/schemas/interop/v0.1/action-request.schema.json", + "title": "mdbase action request", + "$ref": "profile.schema.json#/$defs/actionRequest" +} diff --git a/schemas/interop/v0.1/conformance-claim.schema.json b/schemas/interop/v0.1/conformance-claim.schema.json new file mode 100644 index 0000000..7625af1 --- /dev/null +++ b/schemas/interop/v0.1/conformance-claim.schema.json @@ -0,0 +1,6 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://mdbase.dev/schemas/interop/v0.1/conformance-claim.schema.json", + "title": "mdbase interoperability conformance claim", + "$ref": "profile.schema.json#/$defs/conformanceClaim" +} diff --git a/schemas/interop/v0.1/event-source-declaration.schema.json b/schemas/interop/v0.1/event-source-declaration.schema.json new file mode 100644 index 0000000..4f11f93 --- /dev/null +++ b/schemas/interop/v0.1/event-source-declaration.schema.json @@ -0,0 +1,6 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://mdbase.dev/schemas/interop/v0.1/event-source-declaration.schema.json", + "title": "mdbase event-source declaration", + "$ref": "profile.schema.json#/$defs/eventSourceDeclaration" +} diff --git a/schemas/interop/v0.1/event.schema.json b/schemas/interop/v0.1/event.schema.json new file mode 100644 index 0000000..e66cd0e --- /dev/null +++ b/schemas/interop/v0.1/event.schema.json @@ -0,0 +1,6 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://mdbase.dev/schemas/interop/v0.1/event.schema.json", + "title": "mdbase CloudEvents event envelope", + "$ref": "profile.schema.json#/$defs/event" +} diff --git a/schemas/interop/v0.1/profile.schema.json b/schemas/interop/v0.1/profile.schema.json new file mode 100644 index 0000000..4ba0146 --- /dev/null +++ b/schemas/interop/v0.1/profile.schema.json @@ -0,0 +1,456 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://mdbase.dev/schemas/interop/v0.1/profile.schema.json", + "title": "mdbase event and action interoperability profile 0.1", + "oneOf": [ + { "$ref": "#/$defs/event" }, + { "$ref": "#/$defs/actionRequest" }, + { "$ref": "#/$defs/actionInvocation" }, + { "$ref": "#/$defs/actionOutcome" }, + { "$ref": "#/$defs/actionCancellation" }, + { "$ref": "#/$defs/eventSourceDeclaration" }, + { "$ref": "#/$defs/actionProviderDeclaration" }, + { "$ref": "#/$defs/conformanceClaim" } + ], + "$defs": { + "contractId": { + "type": "string", + "minLength": 3, + "maxLength": 128, + "pattern": "^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)+$" + }, + "semanticVersion": { + "type": "string", + "pattern": "^(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)(?:-[0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*)?(?:\\+[0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*)?$" + }, + "semanticVersionRequirement": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "digest": { + "type": "string", + "pattern": "^sha256:[0-9a-f]{64}$" + }, + "portableId": { + "type": "string", + "minLength": 1, + "maxLength": 256, + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:@/-]*$" + }, + "exactContract": { + "type": "object", + "required": ["id", "version", "digest"], + "properties": { + "id": { "$ref": "#/$defs/contractId" }, + "version": { "$ref": "#/$defs/semanticVersion" }, + "digest": { "$ref": "#/$defs/digest" } + }, + "additionalProperties": false + }, + "contractRequirement": { + "type": "object", + "required": ["id", "version"], + "properties": { + "id": { "$ref": "#/$defs/contractId" }, + "version": { "$ref": "#/$defs/semanticVersionRequirement" }, + "digest": { "$ref": "#/$defs/digest" } + }, + "additionalProperties": false + }, + "implementationIdentity": { + "type": "object", + "required": ["application", "implementation", "version"], + "properties": { + "application": { "$ref": "#/$defs/portableId" }, + "implementation": { "$ref": "#/$defs/portableId" }, + "version": { "$ref": "#/$defs/semanticVersion" }, + "instance_id": { "$ref": "#/$defs/portableId" } + }, + "additionalProperties": false + }, + "transportCapabilities": { + "type": "object", + "required": ["delivery", "ordering", "cancellation", "deadlines"], + "properties": { + "delivery": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "enum": ["ephemeral", "at_least_once", "durable_cursor", "offline_queue"] + } + }, + "ordering": { + "type": "array", + "uniqueItems": true, + "items": { + "enum": ["none", "source", "subject"] + } + }, + "cancellation": { "type": "boolean" }, + "deadlines": { "type": "boolean" }, + "provider_discovery": { "type": "boolean" }, + "max_payload_bytes": { "type": "integer", "minimum": 1 }, + "outcome_retention_seconds": { "type": "integer", "minimum": 0 }, + "request_deduplication": { "type": "boolean" }, + "cross_process_identity": { "type": "boolean" } + }, + "additionalProperties": false + }, + "extensionValue": { + "oneOf": [ + { "type": "null" }, + { "type": "boolean" }, + { "type": "integer" }, + { "type": "number" }, + { "type": "string" } + ] + }, + "event": { + "title": "mdbase CloudEvents event envelope", + "type": "object", + "required": [ + "specversion", + "id", + "source", + "type", + "time", + "datacontenttype", + "dataschema", + "data", + "mdbaseprofile", + "mdbasecontractversion", + "mdbasecontractdigest", + "mdbaseapplication", + "mdbaseimplementation", + "mdbaseimplementationversion" + ], + "properties": { + "specversion": { "const": "1.0" }, + "id": { "$ref": "#/$defs/portableId" }, + "source": { "type": "string", "format": "uri-reference", "minLength": 1 }, + "type": { "$ref": "#/$defs/contractId" }, + "time": { "type": "string", "format": "date-time" }, + "subject": { "type": "string", "format": "uri-reference", "minLength": 1 }, + "datacontenttype": { "const": "application/json" }, + "dataschema": { "type": "string", "format": "uri", "minLength": 1 }, + "data": true, + "mdbaseprofile": { "const": "0.1" }, + "mdbasecontractversion": { "$ref": "#/$defs/semanticVersion" }, + "mdbasecontractdigest": { "$ref": "#/$defs/digest" }, + "mdbaseapplication": { "$ref": "#/$defs/portableId" }, + "mdbaseimplementation": { "$ref": "#/$defs/portableId" }, + "mdbaseimplementationversion": { "$ref": "#/$defs/semanticVersion" }, + "mdbaseinstanceid": { "$ref": "#/$defs/portableId" }, + "correlationid": { "$ref": "#/$defs/portableId" }, + "causationid": { "$ref": "#/$defs/portableId" } + }, + "propertyNames": { "pattern": "^[a-z0-9]+$" }, + "additionalProperties": { "$ref": "#/$defs/extensionValue" } + }, + "actionRequest": { + "title": "mdbase action request", + "type": "object", + "required": [ + "kind", + "profile_version", + "request_id", + "contract", + "caller", + "created_at", + "input" + ], + "properties": { + "kind": { "const": "mdbase.action.request" }, + "profile_version": { "const": "0.1" }, + "request_id": { "$ref": "#/$defs/portableId" }, + "contract": { "$ref": "#/$defs/contractRequirement" }, + "caller": { "$ref": "#/$defs/implementationIdentity" }, + "created_at": { "type": "string", "format": "date-time" }, + "correlation_id": { "$ref": "#/$defs/portableId" }, + "causation_id": { "$ref": "#/$defs/portableId" }, + "subject": { "type": "string", "format": "uri-reference", "minLength": 1 }, + "idempotency_key": { "type": "string", "minLength": 1, "maxLength": 512 }, + "deadline": { "type": "string", "format": "date-time" }, + "requested_provider": { + "type": "object", + "properties": { + "application": { "$ref": "#/$defs/portableId" }, + "implementation": { "$ref": "#/$defs/portableId" }, + "instance_id": { "$ref": "#/$defs/portableId" } + }, + "minProperties": 1, + "additionalProperties": false + }, + "authorization_context": { + "type": "string", + "format": "uri-reference", + "minLength": 1 + }, + "input": true + }, + "additionalProperties": false + }, + "actionInvocation": { + "title": "mdbase admitted action invocation", + "type": "object", + "required": [ + "kind", + "profile_version", + "invocation_id", + "attempt_id", + "request_id", + "contract", + "caller", + "provider", + "provider_declaration_digest", + "handler_id", + "admitted_at", + "input" + ], + "properties": { + "kind": { "const": "mdbase.action.invocation" }, + "profile_version": { "const": "0.1" }, + "invocation_id": { "$ref": "#/$defs/portableId" }, + "attempt_id": { "$ref": "#/$defs/portableId" }, + "request_id": { "$ref": "#/$defs/portableId" }, + "contract": { "$ref": "#/$defs/exactContract" }, + "caller": { "$ref": "#/$defs/implementationIdentity" }, + "provider": { "$ref": "#/$defs/implementationIdentity" }, + "provider_declaration_digest": { "$ref": "#/$defs/digest" }, + "handler_id": { "$ref": "#/$defs/portableId" }, + "admitted_at": { "type": "string", "format": "date-time" }, + "correlation_id": { "$ref": "#/$defs/portableId" }, + "causation_id": { "$ref": "#/$defs/portableId" }, + "subject": { "type": "string", "format": "uri-reference", "minLength": 1 }, + "idempotency_key": { "type": "string", "minLength": 1, "maxLength": 512 }, + "deadline": { "type": "string", "format": "date-time" }, + "authorization_context": { + "type": "string", + "format": "uri-reference", + "minLength": 1 + }, + "input": true + }, + "additionalProperties": false + }, + "portableError": { + "type": "object", + "required": ["code", "message"], + "properties": { + "code": { + "enum": [ + "unknown_contract", + "unsupported_contract_version", + "contract_digest_conflict", + "invalid_event_data", + "invalid_action_input", + "invalid_action_output", + "no_provider", + "ambiguous_provider", + "requested_provider_unavailable", + "unauthorized", + "capability_denied", + "request_rejected", + "deadline_exceeded", + "cancellation_unsupported", + "cancelled", + "handler_failure", + "outcome_indeterminate", + "transport_unavailable", + "unsupported_transport_capability" + ] + }, + "message": { "type": "string", "minLength": 1 }, + "details": true, + "retryable": { "type": "boolean" } + }, + "additionalProperties": false + }, + "actionOutcome": { + "title": "mdbase action outcome", + "type": "object", + "required": [ + "kind", + "profile_version", + "outcome_id", + "request_id", + "invocation_id", + "attempt_id", + "contract", + "provider", + "provider_declaration_digest", + "status", + "completed_at" + ], + "properties": { + "kind": { "const": "mdbase.action.outcome" }, + "profile_version": { "const": "0.1" }, + "outcome_id": { "$ref": "#/$defs/portableId" }, + "request_id": { "$ref": "#/$defs/portableId" }, + "invocation_id": { "$ref": "#/$defs/portableId" }, + "attempt_id": { "$ref": "#/$defs/portableId" }, + "contract": { "$ref": "#/$defs/exactContract" }, + "provider": { "$ref": "#/$defs/implementationIdentity" }, + "provider_declaration_digest": { "$ref": "#/$defs/digest" }, + "status": { + "enum": ["succeeded", "rejected", "failed", "cancelled", "outcome_indeterminate"] + }, + "completed_at": { "type": "string", "format": "date-time" }, + "output": true, + "error": { "$ref": "#/$defs/portableError" } + }, + "allOf": [ + { + "if": { + "properties": { "status": { "const": "succeeded" } }, + "required": ["status"] + }, + "then": { + "required": ["output"], + "not": { "required": ["error"] } + }, + "else": { + "required": ["error"], + "not": { "required": ["output"] } + } + } + ], + "additionalProperties": false + }, + "actionCancellation": { + "title": "mdbase action cancellation request", + "type": "object", + "required": ["kind", "profile_version", "cancellation_id", "request_id", "caller", "requested_at"], + "properties": { + "kind": { "const": "mdbase.action.cancel" }, + "profile_version": { "const": "0.1" }, + "cancellation_id": { "$ref": "#/$defs/portableId" }, + "request_id": { "$ref": "#/$defs/portableId" }, + "caller": { "$ref": "#/$defs/implementationIdentity" }, + "requested_at": { "type": "string", "format": "date-time" }, + "reason": { "type": "string", "maxLength": 1024 } + }, + "additionalProperties": false + }, + "eventSourceDeclaration": { + "title": "mdbase event-source declaration", + "type": "object", + "required": [ + "kind", + "profile_version", + "declaration_id", + "declaration_digest", + "source", + "contracts" + ], + "properties": { + "kind": { "const": "mdbase.event-source" }, + "profile_version": { "const": "0.1" }, + "declaration_id": { "$ref": "#/$defs/portableId" }, + "declaration_digest": { "$ref": "#/$defs/digest" }, + "source": { "$ref": "#/$defs/implementationIdentity" }, + "contracts": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "required": ["requirement", "resolved"], + "properties": { + "requirement": { "$ref": "#/$defs/contractRequirement" }, + "resolved": { "$ref": "#/$defs/exactContract" }, + "binding": true, + "ordering": { + "type": "array", + "uniqueItems": true, + "items": { "enum": ["none", "source", "subject"] } + } + }, + "additionalProperties": false + } + } + }, + "additionalProperties": false + }, + "actionProviderDeclaration": { + "title": "mdbase action-provider declaration", + "type": "object", + "required": [ + "kind", + "profile_version", + "declaration_id", + "declaration_digest", + "provider", + "handlers" + ], + "properties": { + "kind": { "const": "mdbase.action-provider" }, + "profile_version": { "const": "0.1" }, + "declaration_id": { "$ref": "#/$defs/portableId" }, + "declaration_digest": { "$ref": "#/$defs/digest" }, + "provider": { "$ref": "#/$defs/implementationIdentity" }, + "handlers": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "required": ["handler_id", "requirement", "resolved"], + "properties": { + "handler_id": { "$ref": "#/$defs/portableId" }, + "requirement": { "$ref": "#/$defs/contractRequirement" }, + "resolved": { "$ref": "#/$defs/exactContract" }, + "binding": true, + "idempotency": { + "type": "object", + "required": ["mode"], + "properties": { + "mode": { "enum": ["none", "request"] }, + "retention_seconds": { "type": "integer", "minimum": 1 } + }, + "additionalProperties": false + }, + "cancellation": { "enum": ["none", "cooperative"] }, + "max_concurrency": { "type": "integer", "minimum": 1 } + }, + "additionalProperties": false + } + } + }, + "additionalProperties": false + }, + "conformanceClaim": { + "title": "mdbase interoperability conformance claim", + "type": "object", + "required": ["kind", "profile_version", "implementation", "roles", "transport"], + "properties": { + "kind": { "const": "mdbase.interop.conformance" }, + "profile_version": { "const": "0.1" }, + "implementation": { "$ref": "#/$defs/implementationIdentity" }, + "roles": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "enum": ["event_source", "event_consumer", "action_caller", "action_provider", "bridge"] + } + }, + "transport": { "$ref": "#/$defs/transportCapabilities" }, + "evidence": { + "type": "array", + "items": { + "type": "object", + "required": ["scenario", "result"], + "properties": { + "scenario": { "type": "string", "minLength": 1 }, + "result": { "const": "pass" }, + "uri": { "type": "string", "format": "uri-reference" } + }, + "additionalProperties": false + } + } + }, + "additionalProperties": false + } + } +} diff --git a/schemas/v0.3/README.md b/schemas/v0.3/README.md index b75b7b4..337ac40 100644 --- a/schemas/v0.3/README.md +++ b/schemas/v0.3/README.md @@ -10,7 +10,7 @@ yet published package artifacts. | Schema | Purpose | | --- | --- | | `type-file.schema.json` | frontmatter of `_types/*.md` v0.3 type files | -| `data-contract.schema.json` | frontmatter of `_contracts/*.md` data contract files | +| `data-contract.schema.json` | first-class record, event, and action contract frontmatter in `_contracts/*.md` | | `type-pack.schema.json` | transactional type-pack manifests | | `query.schema.json` | portable query input objects | | `query-result.schema.json` | query results plus optional context, view, grouping, and summary metadata | diff --git a/schemas/v0.3/conformance-claim.schema.json b/schemas/v0.3/conformance-claim.schema.json index 7a7b7c9..79b318d 100644 --- a/schemas/v0.3/conformance-claim.schema.json +++ b/schemas/v0.3/conformance-claim.schema.json @@ -35,6 +35,7 @@ "pattern": "^0\\.3\\.0(?:-[0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*)?$" }, "runtime_profile_version": { "const": "0.1.0" }, + "interop_profile_version": { "const": "0.1" }, "profiles": { "type": "array", "minItems": 1, @@ -158,10 +159,21 @@ } } }, + { + "if": { "properties": { "profiles": { "contains": { "const": "event_action_interop/0.1" } } } }, + "then": { + "required": ["interop_profile_version"] + } + }, { "if": { "properties": { "profiles": { "contains": { "const": "runtime_contracts/0.1" } } } }, "then": { - "required": ["runtime_profile_version"] + "required": ["runtime_profile_version", "interop_profile_version"], + "properties": { + "profiles": { + "contains": { "const": "event_action_interop/0.1" } + } + } } }, { @@ -198,6 +210,7 @@ "links", "core_write", "lifecycle", + "event_action_interop/0.1", "runtime_contracts/0.1", "workflow/0.1", "watch" diff --git a/schemas/v0.3/data-contract.schema.json b/schemas/v0.3/data-contract.schema.json index cc4a985..d20a200 100644 --- a/schemas/v0.3/data-contract.schema.json +++ b/schemas/v0.3/data-contract.schema.json @@ -1,13 +1,16 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://mdbase.dev/schemas/v0.3/data-contract.schema.json", - "title": "mdbase v0.3 data contract frontmatter", + "title": "mdbase v0.3 contract frontmatter", "type": "object", - "required": ["kind", "id", "version", "schema"], + "required": ["kind", "contract_type", "id", "version"], "properties": { "kind": { "const": "mdbase.contract" }, + "contract_type": { + "enum": ["record", "event", "action"] + }, "id": { "$ref": "#/$defs/contractId" }, @@ -21,16 +24,84 @@ "description": { "type": "string" }, - "schema": { + "record_schema": { "$ref": "#/$defs/schemaWrapper" }, "binding_schema": { "$ref": "#/$defs/schemaWrapper" + }, + "data_schema": { + "$ref": "#/$defs/schemaWrapper" + }, + "source_schema": { + "$ref": "#/$defs/schemaWrapper" + }, + "input_schema": { + "$ref": "#/$defs/schemaWrapper" + }, + "output_schema": { + "$ref": "#/$defs/schemaWrapper" + }, + "error_schema": { + "$ref": "#/$defs/schemaWrapper" + }, + "provider_schema": { + "$ref": "#/$defs/schemaWrapper" + }, + "behavior": { + "$ref": "#/$defs/actionBehavior" } }, "patternProperties": { "^x-[A-Za-z][A-Za-z0-9._:-]{0,127}$": true }, + "oneOf": [ + { + "properties": { + "contract_type": { "const": "record" }, + "record_schema": true, + "binding_schema": true, + "data_schema": false, + "source_schema": false, + "input_schema": false, + "output_schema": false, + "error_schema": false, + "provider_schema": false, + "behavior": false + }, + "required": ["record_schema"] + }, + { + "properties": { + "contract_type": { "const": "event" }, + "record_schema": false, + "binding_schema": false, + "data_schema": true, + "source_schema": true, + "input_schema": false, + "output_schema": false, + "error_schema": false, + "provider_schema": false, + "behavior": false + }, + "required": ["data_schema"] + }, + { + "properties": { + "contract_type": { "const": "action" }, + "record_schema": false, + "binding_schema": false, + "data_schema": false, + "source_schema": false, + "input_schema": true, + "output_schema": true, + "error_schema": true, + "provider_schema": true, + "behavior": true + }, + "required": ["input_schema"] + } + ], "additionalProperties": false, "$defs": { "contractId": { @@ -75,6 +146,18 @@ } ], "additionalProperties": false + }, + "actionBehavior": { + "type": "object", + "properties": { + "idempotency": { + "enum": ["none", "optional", "required"] + }, + "cancellation": { + "enum": ["none", "cooperative"] + } + }, + "additionalProperties": false } } } diff --git a/scripts/check_v03_tests.py b/scripts/check_v03_tests.py index 02b2c1e..97b235f 100755 --- a/scripts/check_v03_tests.py +++ b/scripts/check_v03_tests.py @@ -68,6 +68,7 @@ "links", "core_write", "lifecycle", + "event_action_interop/0.1", "runtime_contracts/0.1", "workflow/0.1", "watch", @@ -404,14 +405,16 @@ def run_executable_test(test: dict[str, Any], setup: dict[str, Any] | None = Non return if operation == "data_contract_digest": - contract = load_markdown_frontmatter(input_data["contract"]) - actual = data_contract_digest(contract) + contract_path = resolve(input_data["contract"]) + contract = load_markdown_frontmatter(contract_path) + actual = data_contract_digest(contract, contract_path) if expect.get("digest") != actual: raise AssertionError(f"expected digest {expect.get('digest')!r}, got {actual!r}") return if operation == "data_contract_implementation_digest": - contract = load_markdown_frontmatter(input_data["contract"]) + contract_path = resolve(input_data["contract"]) + contract = load_markdown_frontmatter(contract_path) type_file = load_markdown_frontmatter(input_data["type"]) matching = [ entry @@ -421,7 +424,9 @@ def run_executable_test(test: dict[str, Any], setup: dict[str, Any] | None = Non ] if len(matching) != 1: raise AssertionError("type must have one exact implementation") - actual = data_contract_implementation_digest(contract, type_file, matching[0]) + actual = data_contract_implementation_digest( + contract, type_file, matching[0], contract_path + ) if expect.get("digest") != actual: raise AssertionError(f"expected digest {expect.get('digest')!r}, got {actual!r}") return @@ -432,7 +437,7 @@ def run_executable_test(test: dict[str, Any], setup: dict[str, Any] | None = Non for contract_path in expand_paths(input_data.get("paths", [])): contract = load_markdown_frontmatter(contract_path) key = (contract.get("id"), contract.get("version")) - digest = data_contract_digest(contract) + digest = data_contract_digest(contract, contract_path) existing = registry.get(key) if existing is not None and existing != digest: failures.append(f"data contract conflict for {key[0]}@{key[1]}") @@ -622,7 +627,8 @@ def run_data_contract_implementation_test( input_data: dict[str, Any], expect: dict[str, Any] ) -> None: failures: list[str] = [] - contract = load_markdown_frontmatter(input_data["contract"]) + contract_path = resolve(input_data["contract"]) + contract = load_markdown_frontmatter(contract_path) type_file = load_markdown_frontmatter(input_data["type"]) contract_meta = Draft202012Validator(load_json("schemas/v0.3/data-contract.schema.json")) @@ -633,9 +639,13 @@ def run_data_contract_implementation_test( assert_expected_validation_result(failures, expect) return - contract_schema = get_pointer(contract, "/schema/value") + contract_schema = resolve_schema_wrapper(contract["record_schema"], contract_path) Draft202012Validator.check_schema(contract_schema) - binding_schema = contract.get("binding_schema", {}).get("value") + binding_schema = ( + resolve_schema_wrapper(contract["binding_schema"], contract_path) + if "binding_schema" in contract + else None + ) if binding_schema is not None: Draft202012Validator.check_schema(binding_schema) @@ -699,26 +709,20 @@ def run_data_contract_implementation_test( assert_expected_validation_result(failures, expect) -def data_contract_digest(contract: dict[str, Any]) -> str: +def data_contract_digest( + contract: dict[str, Any], contract_path: Path | None = None +) -> str: payload = { key: contract[key] - for key in ("kind", "id", "version") + for key in ("kind", "contract_type", "id", "version") if key in contract } - if "schema" in contract: - schema = contract["schema"] - payload["schema"] = ( - schema["value"] - if isinstance(schema, dict) and "value" in schema - else schema - ) - if "binding_schema" in contract: - binding_schema = contract["binding_schema"] - payload["binding_schema"] = ( - binding_schema["value"] - if isinstance(binding_schema, dict) and "value" in binding_schema - else binding_schema - ) + contract_type = contract.get("contract_type") + for field in contract_schema_fields(contract_type): + if field in contract: + payload[field] = resolve_schema_wrapper(contract[field], contract_path) + if contract_type == "action" and "behavior" in contract: + payload["behavior"] = contract["behavior"] canonical = json.dumps( payload, ensure_ascii=False, @@ -733,6 +737,7 @@ def data_contract_implementation_digest( contract: dict[str, Any], type_file: dict[str, Any], implementation: dict[str, Any], + contract_path: Path | None = None, ) -> str: type_semantics = { key: type_file[key] @@ -740,7 +745,7 @@ def data_contract_implementation_digest( if key in type_file } payload = { - "contract_digest": data_contract_digest(contract), + "contract_digest": data_contract_digest(contract, contract_path), "type": type_semantics, "implementation": implementation, } @@ -754,6 +759,38 @@ def data_contract_implementation_digest( return "sha256:" + hashlib.sha256(canonical).hexdigest() +def contract_schema_fields(contract_type: Any) -> tuple[str, ...]: + if contract_type == "record": + return ("record_schema", "binding_schema") + if contract_type == "event": + return ("data_schema", "source_schema") + if contract_type == "action": + return ("input_schema", "output_schema", "error_schema", "provider_schema") + return () + + +def resolve_schema_wrapper(wrapper: Any, source_path: Path | None) -> Any: + if not isinstance(wrapper, dict): + return wrapper + if "value" in wrapper: + return wrapper["value"] + reference = wrapper.get("ref") + if not isinstance(reference, str) or source_path is None: + return wrapper + path_part, separator, fragment = reference.partition("#") + if not path_part: + raise AssertionError("schema wrapper references must name a local file") + target = (source_path.parent / path_part).resolve() + try: + target.relative_to(REPO_ROOT.resolve()) + except ValueError as error: + raise AssertionError(f"schema reference escapes the repository: {reference}") from error + schema = load_json(target) + if separator and fragment: + return get_pointer(schema, fragment) + return schema + + def parse_field_reference(field_reference: str) -> list[tuple[str, bool]]: if field_reference.startswith("/"): return [ diff --git a/site/build.mjs b/site/build.mjs index 41e3c52..ce4e4be 100644 --- a/site/build.mjs +++ b/site/build.mjs @@ -78,7 +78,8 @@ const SPEC_FILES = [ { file: '03-records-and-frontmatter.md', num: '03', title: 'Records & Frontmatter', id: 'section-03' }, { file: '04-configuration.md', num: '04', title: 'Configuration', id: 'section-04' }, { file: '05-type-files.md', num: '05', title: 'Type Files', id: 'section-05' }, - { file: '05-data-contracts.md', num: '05A', title: 'Data Contracts', id: 'section-05a' }, + { file: '05-data-contracts.md', num: '05A', title: 'Contracts', id: 'section-05a' }, + { file: 'interop/0.1.md', num: '05B', title: 'Event & Action Interoperability', id: 'section-05b' }, { file: '06-json-schema-profile.md', num: '06', title: 'JSON Schema Profile', id: 'section-06' }, { file: '07-collection-semantics.md', num: '07', title: 'Collection Semantics', id: 'section-07' }, { file: '08-links.md', num: '08', title: 'Links', id: 'section-08' }, @@ -185,6 +186,10 @@ renderer.link = function ({ href, title, text }) { resolvedHref = `#appendix-${appMatch[1]}`; } + if (href === './interop/0.1.md') { + resolvedHref = '#section-05b'; + } + const titleAttr = title ? ` title="${title}"` : ''; return `${text}`; }; diff --git a/tests/v0.3/README.md b/tests/v0.3/README.md index ba5d031..07932d3 100644 --- a/tests/v0.3/README.md +++ b/tests/v0.3/README.md @@ -4,7 +4,7 @@ This directory is the parallel v0.3 conformance suite. It does not replace the existing `tests/level-*` v0.2.x suite. v0.3 conformance claims use the atomic profiles defined by the specification. -The tests below are grouped into ten fixture sets for the rollout plan: +The tests below are grouped into eleven fixture sets for the rollout plan: 1. `schema_artifacts` 2. `migration` @@ -14,13 +14,14 @@ The tests below are grouped into ten fixture sets for the rollout plan: 6. `type_packs` 7. `cel` 8. `views` -9. `runtime_contracts` -10. `workflow_execution` +9. `event_action_interop` +10. `runtime_contracts` +11. `workflow_execution` The suite covers JSON Schema artifacts, type wrappers, first-class data contracts and projections, collection semantics, CEL host bindings, saved -views, lifecycle operations, runtime contract registries, workflow preflight, -and execution cases for available adapters. +views, lifecycle operations, portable event/action exchange, runtime contract +registries, workflow preflight, and execution cases for available adapters. Compatible v0.2 fixtures remain useful for frontmatter parsing, missing/null semantics, links, operation safety, and watch ordering. Tests tied to the earlier custom field grammar are migrated into the v0.3 fixture sets. diff --git a/tests/v0.3/data-contracts/data-contracts.yaml b/tests/v0.3/data-contracts/data-contracts.yaml index 25fd9c8..889de7c 100644 --- a/tests/v0.3/data-contracts/data-contracts.yaml +++ b/tests/v0.3/data-contracts/data-contracts.yaml @@ -93,7 +93,7 @@ groups: input: contract: "examples/v0.3/tasknotes-migration/v0.3/_contracts/tasknotes.task.md" expect: - digest: "sha256:7111c47ba7b1abed8c9823500c30ce0b80d3b3950a982d0acbc29523b5cb4d1c" + digest: "sha256:a49d25136bf3024e146017771d068cdf59abfddbcdd1bfbf8010018c7f13f476" covers: - core_read.data_contract_digest @@ -104,7 +104,7 @@ groups: contract: "examples/v0.3/tasknotes-migration/v0.3/_contracts/tasknotes.task.md" type: "examples/v0.3/tasknotes-migration/v0.3/_types/task.md" expect: - digest: "sha256:2d3a40ba07e03e20a6bd1eae2f44c267a5bb14c3e693f09629f28b7a69277601" + digest: "sha256:54a839d00a740e29bca41e3440f56c84337ffc2f4b79e0c6c618a00455959902" covers: - core_read.data_contract_implementation_digest @@ -133,9 +133,10 @@ groups: example.note.md: | --- kind: mdbase.contract + contract_type: record id: example.note version: 1.0.0 - schema: + record_schema: dialect: json-schema-2020-12 value: type: object diff --git a/tests/v0.3/event-action-interop/event-action-interop.yaml b/tests/v0.3/event-action-interop/event-action-interop.yaml new file mode 100644 index 0000000..62ff5c9 --- /dev/null +++ b/tests/v0.3/event-action-interop/event-action-interop.yaml @@ -0,0 +1,217 @@ +name: "event and action interoperability profile 0.1" +spec_version: "0.3.0" +profile_version: "0.1" +fixture_set: event_action_interop +category: event_action_interop +spec_ref: "event_action_interop/0.1" + +groups: + - name: "event contracts and CloudEvents delivery" + tests: + - id: interop-event-multicast + name: "one exact event is delivered to two compatible consumers" + operation: interop_event_multicast + input: + source: tasknotes + contract: tasknotes.task.completed + version: 1.0.0 + subscribers: + - id: workflow-a + version: "^1.0.0" + - id: workflow-b + version: ">=1.0.0 <2.0.0" + expect: + deliveries: [workflow-a, workflow-b] + cloudevents_specversion: "1.0" + exact_contract: true + covers: + - event_action_interop/0.1.first_class_event_and_action_contracts + - event_action_interop/0.1.cloudevents_event_envelope + - event_action_interop/0.1.event_multicast_and_version_resolution + + - id: interop-event-invalid-data + name: "event data is rejected against the exact artifact" + operation: interop_event_publish + input: + contract: tasknotes.task.completed + version: 1.0.0 + data: { task_id: task-123 } + expect: + valid: false + error: invalid_event_data + covers: + - event_action_interop/0.1.boundary_validation + + - name: "event correlation and causation survive delivery" + operation: interop_event_publish + input: + contract: tasknotes.task.completed + version: 1.0.0 + id: evt_01 + correlation_id: flow_01 + causation_id: req_01 + data: + task_id: task-123 + completed_at: "2026-07-28T01:30:00Z" + expect: + id: evt_01 + correlation_id: flow_01 + causation_id: req_01 + + - id: interop-event-duplicate + name: "duplicate logical event delivery is identifiable" + operation: interop_event_duplicate + input: + source: "urn:mdbase:app:tasknotes:tasknotes.obsidian" + id: evt_01 + expect: + duplicate: true + additional_deliveries: 0 + covers: + - event_action_interop/0.1.duplicate_and_indeterminate_semantics + + - name: "action admission and provider resolution" + tests: + - id: interop-action-success + name: "one caller invokes one provider with exact execution evidence" + operation: interop_action_invoke + input: + contract: + id: canvas.card.create + version: "^1.0.0" + providers: [canvas-bases] + value: + canvas: Projects/Report.canvas + title: Prepare report + expect: + status: succeeded + exact_contract: true + request_id: true + invocation_id: true + attempt_id: true + outcome_id: true + provider: canvas-bases + covers: + - event_action_interop/0.1.action_request_admission_and_outcome + - event_action_interop/0.1.exact_contract_and_provider_evidence + + - id: interop-action-ambiguous + name: "two eligible action providers are explicitly ambiguous" + operation: interop_action_invoke + input: + contract: + id: canvas.card.create + version: "^1.0.0" + providers: [canvas-bases, alternate-canvas] + expect: + valid: false + error: ambiguous_provider + covers: + - event_action_interop/0.1.explicit_provider_ambiguity + + - name: "an explicit provider selector resolves ambiguity" + operation: interop_action_invoke + input: + contract: + id: canvas.card.create + version: "^1.0.0" + providers: [canvas-bases, alternate-canvas] + requested_provider: canvas-bases + expect: + provider: canvas-bases + + - name: "adding a provider does not alter an admitted invocation" + operation: interop_action_pin_then_register + input: + initial_provider: canvas-bases + added_provider: alternate-canvas + expect: + admitted_provider: canvas-bases + outcome_provider: canvas-bases + + - id: interop-action-invalid-output + name: "invalid provider output becomes a failed outcome" + operation: interop_action_invalid_output + input: + contract: canvas.card.create + expect: + status: failed + error: invalid_action_output + covers: + - event_action_interop/0.1.boundary_validation + + - name: "rejection and handler failure remain distinct" + operation: interop_action_failures + input: {} + expect: + rejected: + status: rejected + error: request_rejected + failed: + status: failed + error: handler_failure + + - name: "authorization duplicates cancellation and lifecycle" + tests: + - id: interop-authorization-default-deny + name: "unauthorized role operations fail closed" + operation: interop_authorization + input: {} + expect: + publish: unauthorized + subscribe: unauthorized + provider_registration: unauthorized + invocation: unauthorized + covers: + - event_action_interop/0.1.independent_authorization + + - name: "contract conflict fails before registration becomes visible" + operation: interop_contract_conflict + input: {} + expect: + valid: false + error: contract_digest_conflict + atomic: true + + - name: "declared request deduplication returns the recorded outcome" + operation: interop_action_duplicate + input: + idempotency: request + expect: + handler_calls: 1 + same_outcome: true + + - name: "unknown external effect is outcome indeterminate" + operation: interop_action_indeterminate + input: {} + expect: + status: outcome_indeterminate + error: outcome_indeterminate + + - id: interop-action-cancellation + name: "unsupported and cooperative cancellation are distinguished" + operation: interop_action_cancellation + input: {} + expect: + unsupported: cancellation_unsupported + cooperative: cancelled + covers: + - event_action_interop/0.1.cancellation_and_unload + + - name: "plugin unload removes its subscriptions and providers" + operation: interop_client_dispose + input: {} + expect: + subscriptions: 0 + providers: 0 + new_invocation: no_provider + + - id: interop-transport-capabilities + name: "required transport capabilities fail before exchange" + operation: interop_transport_requirement + input: + binding: ephemeral + required: durable_cursor + expect: + valid: false + error: unsupported_transport_capability diff --git a/tests/v0.3/fixtures/conformance/valid-all-profile-dependencies.yml b/tests/v0.3/fixtures/conformance/valid-all-profile-dependencies.yml index 31d6c89..e22b73c 100644 --- a/tests/v0.3/fixtures/conformance/valid-all-profile-dependencies.yml +++ b/tests/v0.3/fixtures/conformance/valid-all-profile-dependencies.yml @@ -6,6 +6,7 @@ implementation: version: 0.0.0-test spec_version: 0.3.0 runtime_profile_version: 0.1.0 +interop_profile_version: "0.1" profiles: - core_read - collection_semantics @@ -15,6 +16,7 @@ profiles: - links - core_write - lifecycle + - event_action_interop/0.1 - runtime_contracts/0.1 - workflow/0.1 - watch diff --git a/tests/v0.3/fixtures/data-contracts/conflicting-tasknotes.task.md b/tests/v0.3/fixtures/data-contracts/conflicting-tasknotes.task.md index 0efed97..dd61b39 100644 --- a/tests/v0.3/fixtures/data-contracts/conflicting-tasknotes.task.md +++ b/tests/v0.3/fixtures/data-contracts/conflicting-tasknotes.task.md @@ -1,9 +1,10 @@ --- kind: mdbase.contract +contract_type: record id: tasknotes.task version: 0.2.0 name: Conflicting TaskNotes task -schema: +record_schema: dialect: json-schema-2020-12 value: type: object diff --git a/tests/v0.3/fixtures/data-contracts/json-pointer-contact.contract.md b/tests/v0.3/fixtures/data-contracts/json-pointer-contact.contract.md index afc0852..f5444a1 100644 --- a/tests/v0.3/fixtures/data-contracts/json-pointer-contact.contract.md +++ b/tests/v0.3/fixtures/data-contracts/json-pointer-contact.contract.md @@ -1,8 +1,9 @@ --- kind: mdbase.contract +contract_type: record id: example.typed-contact version: 1.0.0 -schema: +record_schema: dialect: json-schema-2020-12 value: type: object diff --git a/tests/v0.3/manifest.yaml b/tests/v0.3/manifest.yaml index f7bbb36..26d4112 100644 --- a/tests/v0.3/manifest.yaml +++ b/tests/v0.3/manifest.yaml @@ -91,9 +91,23 @@ claim_profiles: status: draft requires: [core_write, cel] requirements: [] - - id: runtime_contracts/0.1 + - id: event_action_interop/0.1 status: draft requires: [] + requirements: + - first_class_event_and_action_contracts + - cloudevents_event_envelope + - event_multicast_and_version_resolution + - action_request_admission_and_outcome + - exact_contract_and_provider_evidence + - explicit_provider_ambiguity + - boundary_validation + - independent_authorization + - duplicate_and_indeterminate_semantics + - cancellation_and_unload + - id: runtime_contracts/0.1 + status: draft + requires: [event_action_interop/0.1] requirements: - deterministic_registry - strict_contract_validation @@ -176,6 +190,11 @@ fixture_sets: coverage_targets: [runtime_contracts/0.1] files: - runtime-contracts/runtime-contracts.yaml + - id: event_action_interop + description: CloudEvents multicast, action admission, exact provider selection, validation, authorization, duplicates, cancellation, and unload. + coverage_targets: [event_action_interop/0.1] + files: + - event-action-interop/event-action-interop.yaml - id: workflow_execution description: durable workflow admission, execution, recovery, cancellation, journals, and timers. coverage_targets: [workflow/0.1] diff --git a/tests/v0.3/schema/schema-artifacts.yaml b/tests/v0.3/schema/schema-artifacts.yaml index e7d4202..6e75737 100644 --- a/tests/v0.3/schema/schema-artifacts.yaml +++ b/tests/v0.3/schema/schema-artifacts.yaml @@ -93,7 +93,7 @@ groups: paths: - "examples/v0.3/tasknotes-migration/v0.3/_contracts/tasknotes.task.md" pointers: - - "/schema/value" + - "/record_schema/value" - "/binding_schema/value" expect: valid: true