@openwop/openwop-conformance 1.1.1 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. package/CHANGELOG.md +90 -0
  2. package/README.md +2 -2
  3. package/api/redocly.yaml +15 -0
  4. package/coverage.md +27 -14
  5. package/fixtures/conformance-agent-low-confidence.json +7 -4
  6. package/fixtures/conformance-agent-pack-handoff-schema-validation.json +30 -0
  7. package/fixtures/conformance-agent-reasoning-streaming.json +37 -0
  8. package/fixtures/conformance-agent-reasoning.json +23 -4
  9. package/fixtures/conformance-dispatch-cancellable-child.json +27 -0
  10. package/fixtures/conformance-dispatch-cross-worker-handoff-child-a.json +27 -0
  11. package/fixtures/conformance-dispatch-cross-worker-handoff-child-b.json +25 -0
  12. package/fixtures/conformance-dispatch-cross-worker-handoff.json +60 -0
  13. package/fixtures/conformance-dispatch-deterministic-fail-child.json +30 -0
  14. package/fixtures/conformance-dispatch-input-mapping-child.json +25 -0
  15. package/fixtures/conformance-dispatch-input-mapping-no-default.json +49 -0
  16. package/fixtures/conformance-dispatch-input-mapping.json +49 -0
  17. package/fixtures/conformance-dispatch-output-mapping-child.json +27 -0
  18. package/fixtures/conformance-dispatch-output-mapping.json +49 -0
  19. package/fixtures/conformance-dispatch-per-worker-override.json +59 -0
  20. package/fixtures/conformance-subworkflow-input-mapping-child.json +27 -0
  21. package/fixtures/conformance-subworkflow-input-mapping-no-default.json +33 -0
  22. package/fixtures/conformance-subworkflow-input-mapping.json +33 -0
  23. package/fixtures.md +18 -2
  24. package/package.json +1 -1
  25. package/schemas/README.md +7 -0
  26. package/schemas/agent-ref.schema.json +1 -1
  27. package/schemas/ai-envelope.schema.json +106 -0
  28. package/schemas/capabilities.schema.json +264 -0
  29. package/schemas/core-conformance-mock-agent-config.schema.json +152 -0
  30. package/schemas/dispatch-config.schema.json +26 -0
  31. package/schemas/envelopes/clarification.request.schema.json +43 -0
  32. package/schemas/envelopes/error.schema.json +26 -0
  33. package/schemas/envelopes/schema.request.schema.json +22 -0
  34. package/schemas/envelopes/schema.response.schema.json +22 -0
  35. package/schemas/node-pack-manifest.schema.json +5 -0
  36. package/schemas/pack-lockfile.schema.json +16 -0
  37. package/schemas/run-event-payloads.schema.json +35 -1
  38. package/schemas/run-event.schema.json +2 -0
  39. package/schemas/workflow-chain-pack-manifest.schema.json +226 -0
  40. package/src/lib/driver.ts +15 -0
  41. package/src/lib/env.ts +51 -0
  42. package/src/lib/event-log-query.ts +62 -0
  43. package/src/lib/fixtures.ts +38 -1
  44. package/src/lib/host-toggle.ts +54 -0
  45. package/src/lib/multi-agent-capabilities.ts +10 -0
  46. package/src/lib/otel-scrape.ts +59 -0
  47. package/src/lib/webhook-receiver.ts +137 -0
  48. package/src/lib/workflow-chain-expansion.ts +213 -0
  49. package/src/scenarios/agentPackCatalog.test.ts +216 -0
  50. package/src/scenarios/agentPackHandoffSchemaValidation.test.ts +146 -0
  51. package/src/scenarios/agentReasoningEvents.test.ts +58 -7
  52. package/src/scenarios/agentReasoningStreaming.test.ts +193 -0
  53. package/src/scenarios/agents-run-tool-allowlist.test.ts +182 -0
  54. package/src/scenarios/ai-envelope-shape.test.ts +362 -0
  55. package/src/scenarios/aiEnvelope.capBreached.test.ts +261 -0
  56. package/src/scenarios/aiEnvelope.contractRefusal.test.ts +268 -0
  57. package/src/scenarios/aiEnvelope.correlationReplay.test.ts +284 -0
  58. package/src/scenarios/aiEnvelope.redaction.test.ts +253 -0
  59. package/src/scenarios/aiEnvelope.schemaDrift.test.ts +226 -0
  60. package/src/scenarios/aiEnvelope.trustBoundaryPropagation.test.ts +194 -0
  61. package/src/scenarios/aiEnvelope.universalKinds.test.ts +267 -0
  62. package/src/scenarios/append-ordering.test.ts +44 -0
  63. package/src/scenarios/artifact-auth.test.ts +58 -0
  64. package/src/scenarios/blob-cross-tenant-isolation.test.ts +66 -0
  65. package/src/scenarios/blob-presign-expiry.test.ts +99 -0
  66. package/src/scenarios/blob-roundtrip.test.ts +0 -0
  67. package/src/scenarios/cache-cross-tenant-isolation.test.ts +61 -0
  68. package/src/scenarios/cache-ttl-expiry.test.ts +73 -0
  69. package/src/scenarios/dispatch-cross-worker-handoff.test.ts +129 -0
  70. package/src/scenarios/dispatch-input-mapping.test.ts +163 -0
  71. package/src/scenarios/dispatch-output-mapping.test.ts +155 -0
  72. package/src/scenarios/fixtures-gating.test.ts +139 -1
  73. package/src/scenarios/fs-path-traversal.test.ts +124 -0
  74. package/src/scenarios/idempotency-key-determinism.test.ts +230 -0
  75. package/src/scenarios/interrupt-token-matrix.test.ts +126 -0
  76. package/src/scenarios/kv-atomic-increment.test.ts +74 -0
  77. package/src/scenarios/kv-cas.test.ts +75 -0
  78. package/src/scenarios/kv-cross-tenant-isolation.test.ts +85 -0
  79. package/src/scenarios/kv-ttl-expiry.test.ts +78 -0
  80. package/src/scenarios/mcp-server-elicitation-bridge.test.ts +92 -0
  81. package/src/scenarios/mcp-server-prompt-roundtrip.test.ts +80 -0
  82. package/src/scenarios/mcp-server-resource-roundtrip.test.ts +82 -0
  83. package/src/scenarios/mcp-server-sampling-bridge.test.ts +84 -0
  84. package/src/scenarios/mcp-server-tool-roundtrip.test.ts +107 -0
  85. package/src/scenarios/mcp-server-untrusted-args.test.ts +105 -0
  86. package/src/scenarios/otel-trace-propagation-subworkflow.test.ts +19 -0
  87. package/src/scenarios/pack-registry-publish.test.ts +231 -51
  88. package/src/scenarios/pause-resume.test.ts +43 -0
  89. package/src/scenarios/provider-usage.test.ts +185 -0
  90. package/src/scenarios/queue-ack-nack-dlq.test.ts +121 -0
  91. package/src/scenarios/queue-cross-tenant-isolation.test.ts +66 -0
  92. package/src/scenarios/queue-publish-consume-roundtrip.test.ts +88 -0
  93. package/src/scenarios/replay-llm-cache-key.test.ts +166 -25
  94. package/src/scenarios/search-bm25-roundtrip.test.ts +92 -0
  95. package/src/scenarios/spec-corpus-validity.test.ts +17 -1
  96. package/src/scenarios/sql-injection-rejection.test.ts +84 -0
  97. package/src/scenarios/sql-transaction-atomicity.test.ts +95 -0
  98. package/src/scenarios/stream-subscribe-from-beginning.test.ts +103 -0
  99. package/src/scenarios/subworkflow-input-mapping.test.ts +170 -0
  100. package/src/scenarios/table-cross-tenant-isolation.test.ts +65 -0
  101. package/src/scenarios/table-cursor-pagination.test.ts +85 -0
  102. package/src/scenarios/table-schema-enforcement.test.ts +84 -0
  103. package/src/scenarios/vector-knn-roundtrip.test.ts +88 -0
  104. package/src/scenarios/webhook-receiver-adversarial.test.ts +210 -0
  105. package/src/scenarios/workflow-chain-expansion.test.ts +366 -0
  106. package/src/scenarios/workflow-chain-host-expansion.test.ts +202 -0
  107. package/src/scenarios/workflow-chain-pack-manifest-validation.test.ts +232 -0
  108. package/src/scenarios/workflow-chain-pack-signature-verification.test.ts +138 -0
  109. package/src/scenarios/workflow-chain-unresolvable-typeid.test.ts +170 -0
@@ -25,6 +25,22 @@
25
25
  "type": "array",
26
26
  "description": "Resolved pack records, one per pack referenced (transitively) by the workspace's workflow definitions. Order is informational only; resolvers MUST NOT rely on order. Empty arrays are allowed (a workspace with no packs).",
27
27
  "items": { "$ref": "#/$defs/ResolvedPack" }
28
+ },
29
+ "overrides": {
30
+ "type": "object",
31
+ "description": "Optional override map per `node-packs.md` §\"Transitive dependency resolution\" §\"Override pinning\". Keys are pack names; values are exact pinned versions that resolvers MUST honor ahead of normal range resolution. The override version MUST still satisfy at least one parent's declared range — silently breaking the contract fails with `pack_dependency_conflict`. Use sparingly: security patches, conflict resolution, supply-chain pinning.",
32
+ "additionalProperties": {
33
+ "type": "string",
34
+ "minLength": 1,
35
+ "maxLength": 64,
36
+ "description": "Exact pinned version that overrides this pack's normal range resolution."
37
+ }
38
+ },
39
+ "fallbackRegistries": {
40
+ "type": "array",
41
+ "items": { "type": "string", "format": "uri" },
42
+ "uniqueItems": true,
43
+ "description": "Optional ordered list of fallback registry base URLs per `registry-operations.md` §\"Registry mirror + federation\". When a pack is not found in the primary `registry`, resolvers MUST consult each fallback in order. The first registry returning a 200 for the version manifest wins. Trust roots are per-registry — a fallback being listed here does NOT imply trust transitivity. Each pack's signature is verified against the issuing registry's `signingKeys[]` allow-list."
28
44
  }
29
45
  },
30
46
  "additionalProperties": false,
@@ -2,7 +2,7 @@
2
2
  "$schema": "https://json-schema.org/draft/2020-12/schema",
3
3
  "$id": "https://openwop.dev/spec/v1/run-event-payloads.schema.json",
4
4
  "title": "RunEventPayloads",
5
- "description": "Per-RunEventType payload schemas. The base RunEventDoc shape (run-event.schema.json) leaves `payload` permissive for forward-compat. This schema defines the canonical payload contract for each known RunEventType. Consumers MAY pin strict payload validation via `$defs.<typeId>` and `ajv.validate(schema.$defs[event.type], event.payload)`. Unknown event types MUST be tolerated (no $defs match → fold best-effort).\n\n48 variants from `run-event.schema.json#$defs.RunEventType` are covered, grouped into ~20 shape families with shared $defs. Naming convention: camelCase keys mirror dotted RunEventType names (e.g., `run.started` → `runStarted`).",
5
+ "description": "Per-RunEventType payload schemas. The base RunEventDoc shape (run-event.schema.json) leaves `payload` permissive for forward-compat. This schema defines the canonical payload contract for each known RunEventType. Consumers MAY pin strict payload validation via `$defs.<typeId>` and `ajv.validate(schema.$defs[event.type], event.payload)`. Unknown event types MUST be tolerated (no $defs match → fold best-effort).\n\n50 variants from `run-event.schema.json#$defs.RunEventType` are covered, grouped into ~20 shape families with shared $defs. Naming convention: camelCase keys mirror dotted RunEventType names (e.g., `run.started` → `runStarted`).",
6
6
  "type": "object",
7
7
  "$defs": {
8
8
  "_typeIndex": {
@@ -49,6 +49,8 @@
49
49
  "lease.handed-off": { "$ref": "#/$defs/leaseHandedOff" },
50
50
  "replay.diverged": { "$ref": "#/$defs/replayDiverged" },
51
51
  "agent.reasoned": { "$ref": "#/$defs/agentReasoned" },
52
+ "agent.reasoning.delta": { "$ref": "#/$defs/agentReasoningDelta" },
53
+ "provider.usage": { "$ref": "#/$defs/providerUsage" },
52
54
  "agent.toolCalled": { "$ref": "#/$defs/agentToolCalled" },
53
55
  "agent.toolReturned": { "$ref": "#/$defs/agentToolReturned" },
54
56
  "agent.handoff": { "$ref": "#/$defs/agentHandoff" },
@@ -564,6 +566,38 @@
564
566
  "additionalProperties": true
565
567
  },
566
568
 
569
+ "agentReasoningDelta": {
570
+ "type": "object",
571
+ "description": "RFC 0024. Incremental reasoning chunk for live-streaming UX. Emitted while a reasoning block is still open, BEFORE the corresponding `agent.reasoned` finalization. Consumers concatenate `delta` strings in arrival order to reconstruct the in-progress trace; the closing `agent.reasoned` event carries the FULL authoritative `reasoning`. Gated on `capabilities.agents.reasoning.streaming: true`. NOTE: `additionalProperties: true` mirrors the Phase-1 multi-agent-shift carve-out applied to the sibling `agentReasoned` schema — a deliberate forward-compat exception per RFC 0024 §Compatibility, not a precedent generalizable to other event payloads.",
572
+ "required": ["agentId", "delta", "sequence"],
573
+ "properties": {
574
+ "agentId": { "type": "string", "minLength": 3, "maxLength": 256, "description": "AgentRef.agentId of the reasoning agent. MUST match the eventual closing `agent.reasoned`." },
575
+ "delta": { "type": "string", "description": "New reasoning content since the previous delta event in this block (or since block open, if `sequence` is 0)." },
576
+ "sequence": { "type": "integer", "minimum": 0, "description": "Monotonically-increasing index within the current reasoning block. Starts at 0 for the first delta in a block; resets at each new block open. Consumers MAY use this to detect dropped events." },
577
+ "verbosity": { "type": "string", "enum": ["summary", "full", "off"], "description": "Verbosity mode the host resolved for this block. SHOULD match the verbosity reported on the closing `agent.reasoned`." }
578
+ },
579
+ "additionalProperties": true
580
+ },
581
+
582
+ "providerUsage": {
583
+ "type": "object",
584
+ "description": "RFC 0026. Per-call usage record emitted after every LLM provider invocation. Durably persisted in the run event log; consumed by replay, webhook subscribers, billing reconciliation. The OTel `openwop.cost.*` attribute group (per `observability.md §\"Cost attribution attributes\"`) is the observability sibling — this event type is the durable record. Replay determinism: `inputTokens` + `outputTokens` MUST replay identically; `costEstimateUsd` MAY be omitted on replay. The payload MUST NOT carry credentialRefs, hashed credential identifiers, or prompt/response substrings per `SECURITY/threat-model-secret-leakage.md §SR-1` (enforced by SECURITY invariant `provider-usage-no-credential-leak`).",
585
+ "required": ["provider", "model", "inputTokens", "outputTokens"],
586
+ "properties": {
587
+ "provider": { "type": "string", "minLength": 1, "description": "Canonical provider id (lowercase ASCII, e.g. \"anthropic\", \"openai\", \"google\"). Same value as the `openwop.cost.provider` OTel attribute." },
588
+ "model": { "type": "string", "minLength": 1, "description": "Provider-stamped model id as the model expects it. Same value used in the LLM cache-key recipe per `replay.md §A`." },
589
+ "inputTokens": { "type": "integer", "minimum": 0, "description": "Input/prompt tokens billed for this call. Matches the provider response's input-token count verbatim." },
590
+ "outputTokens": { "type": "integer", "minimum": 0, "description": "Output/completion tokens billed for this call. Matches the provider response's output-token count verbatim." },
591
+ "totalTokens": { "type": "integer", "minimum": 0, "description": "Convenience sum (inputTokens + outputTokens). Consumers MAY compute themselves; emitters MAY include for readability." },
592
+ "costEstimateUsd": { "type": "number", "minimum": 0, "description": "ADVISORY estimate in USD computed by the host's static rate table. MUST NOT be used for billing — real billing is external. Hosts SHOULD omit when no rate is known rather than emit 0." },
593
+ "currency": { "type": "string", "pattern": "^[A-Z]{3}$", "description": "ISO 4217 code when `costEstimateUsd` is non-USD; the field name stays `costEstimateUsd` for back-compat but `currency` overrides the implied denomination." },
594
+ "cacheHit": { "type": "boolean", "description": "True iff this call was served from the LLM response cache per `replay.md §\"LLM cache-key recipe\"`. When true, inputTokens/outputTokens reflect the ORIGINAL call's billed values; the cached invocation incurred zero new provider cost." },
595
+ "nodeId": { "type": "string", "description": "The node id that initiated the provider call. Required for per-node cost attribution dashboards." },
596
+ "traceId": { "type": "string", "description": "OTel trace id linking this event to the matching `openwop.cost.*` span. Lets observability backends correlate event-log entries with traces." }
597
+ },
598
+ "additionalProperties": false
599
+ },
600
+
567
601
  "agentToolCalled": {
568
602
  "type": "object",
569
603
  "description": "Multi-Agent Shift Phase 1. Emitted when an agent invokes a tool. Pairs with `agent.toolReturned` via shared `callId`.",
@@ -102,6 +102,8 @@
102
102
  "lease.handed-off",
103
103
  "replay.diverged",
104
104
  "agent.reasoned",
105
+ "agent.reasoning.delta",
106
+ "provider.usage",
105
107
  "agent.toolCalled",
106
108
  "agent.toolReturned",
107
109
  "agent.handoff",
@@ -0,0 +1,226 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://openwop.dev/spec/v1/workflow-chain-pack-manifest.schema.json",
4
+ "title": "WorkflowChainPackManifest",
5
+ "description": "Manifest for a published OpenWOP workflow-chain pack — `pack.json` at the pack root with `kind: \"workflow-chain\"`. Distinct from node-pack-manifest.schema.json. See workflow-chain-packs.md for the canonical contract and RFC 0013 for the rationale. Chain packs are workflow-edit-time abstractions: a host editor expands each declared chain inline into the parent workflow at author time, so the dispatching runtime sees only concrete `core.*` (or published-vendor) typeIds.",
6
+ "type": "object",
7
+ "required": ["name", "version", "kind", "engines", "chains"],
8
+ "properties": {
9
+ "name": {
10
+ "type": "string",
11
+ "description": "Reverse-DNS pack name per node-packs.md §Naming. Reserved scopes are identical (`core.*` / `vendor.<org>.*` / `community.<author>.*` / `private.<host>.*` / `local.*`).",
12
+ "pattern": "^(core|vendor|community|private)\\.[a-z][a-z0-9_-]*(\\.[a-z][a-zA-Z0-9_-]*)+$",
13
+ "minLength": 1,
14
+ "maxLength": 256
15
+ },
16
+ "version": {
17
+ "type": "string",
18
+ "description": "Pack-level version per Semantic Versioning 2.0.0.",
19
+ "pattern": "^\\d+\\.\\d+\\.\\d+(?:-[0-9A-Za-z.-]+)?(?:\\+[0-9A-Za-z.-]+)?$"
20
+ },
21
+ "kind": {
22
+ "type": "string",
23
+ "const": "workflow-chain",
24
+ "description": "Pack kind discriminator. MUST be the literal string `\"workflow-chain\"` for this schema. Manifests carrying `kind: \"node\"` (or omitting `kind`) validate against `node-pack-manifest.schema.json` instead."
25
+ },
26
+ "description": { "type": "string", "maxLength": 1024 },
27
+ "author": { "type": "string" },
28
+ "license": { "type": "string", "description": "SPDX license identifier (e.g., `Apache-2.0`)." },
29
+ "homepage": { "type": "string", "format": "uri" },
30
+ "repository": { "type": "string", "format": "uri" },
31
+ "keywords": {
32
+ "type": "array",
33
+ "items": { "type": "string", "maxLength": 64 },
34
+ "maxItems": 50
35
+ },
36
+ "engines": {
37
+ "type": "object",
38
+ "required": ["openwop"],
39
+ "properties": {
40
+ "openwop": {
41
+ "type": "string",
42
+ "description": "Semver range — which openwop protocol versions this pack works against."
43
+ }
44
+ },
45
+ "additionalProperties": true,
46
+ "$comment": "Open by design — packs MAY advertise extra engine constraints (`node`, `python`, etc.) that consumer hosts ignore but operator tooling consumes. Mirrors the shape used in node-pack-manifest.schema.json."
47
+ },
48
+ "dependencies": {
49
+ "type": "object",
50
+ "additionalProperties": { "type": "string" },
51
+ "description": "Other node packs whose typeIds this pack's chains reference. Map of pack name → semver range. The host editor uses this map at expansion time to verify referenced typeIds resolve."
52
+ },
53
+ "chains": {
54
+ "type": "array",
55
+ "minItems": 1,
56
+ "items": { "$ref": "#/$defs/WorkflowChain" },
57
+ "description": "Chains the pack contributes. Each MUST have a unique `chainId` within the pack."
58
+ },
59
+ "signing": { "$ref": "#/$defs/Signing" }
60
+ },
61
+ "additionalProperties": false,
62
+ "$defs": {
63
+ "WorkflowChain": {
64
+ "type": "object",
65
+ "required": ["chainId", "version", "label", "description", "parameters", "dag"],
66
+ "description": "A single workflow-chain entry — a pre-configured DAG fragment + parameter schema that the host editor expands inline at author time. See workflow-chain-packs.md §Chain entry shape.",
67
+ "properties": {
68
+ "chainId": {
69
+ "type": "string",
70
+ "description": "Canonical chain id — namespaced like a node typeId (reverse-DNS pattern). The pack's `name` prefix is recommended (e.g., pack `vendor.acme.editor-presets` exposing `vendor.acme.generatePRD`).",
71
+ "pattern": "^[a-z][a-zA-Z0-9._-]*$",
72
+ "minLength": 1,
73
+ "maxLength": 256
74
+ },
75
+ "version": {
76
+ "type": "string",
77
+ "description": "Per-chain semver. MAY differ from the pack's overall version so a single pack can ship multiple chains that evolve independently.",
78
+ "pattern": "^\\d+\\.\\d+\\.\\d+(?:-[0-9A-Za-z.-]+)?(?:\\+[0-9A-Za-z.-]+)?$"
79
+ },
80
+ "label": {
81
+ "type": "string",
82
+ "minLength": 1,
83
+ "description": "Human-readable display label for the host editor's drag-tile catalog."
84
+ },
85
+ "description": {
86
+ "type": "string",
87
+ "description": "One-paragraph description of what the chain produces. Surfaced in host editor tile hover-text."
88
+ },
89
+ "parameters": {
90
+ "type": "object",
91
+ "description": "JSON Schema 2020-12 fragment describing the parameter values the host editor MUST collect from the author at drop time. Authors-supplied values are validated against this schema before expansion proceeds; invalid input MUST be rejected with `chain_parameter_invalid`.",
92
+ "additionalProperties": true,
93
+ "$comment": "Open by design — this field IS a JSON Schema document, so it must accept any of the 30+ JSON Schema 2020-12 keywords (`type`, `properties`, `required`, `oneOf`, `allOf`, etc.). Strict closure would require importing the JSON Schema meta-schema."
94
+ },
95
+ "dag": { "$ref": "#/$defs/WorkflowDefinitionFragment" },
96
+ "outputs": {
97
+ "type": "object",
98
+ "additionalProperties": { "$ref": "#/$defs/ChainOutput" },
99
+ "description": "Declared outputs the chain surfaces to the parent workflow. Keys are output names; values declare type + description."
100
+ },
101
+ "capabilities": {
102
+ "type": "array",
103
+ "items": {
104
+ "type": "string",
105
+ "enum": ["streamable", "cacheable", "side-effectful", "mcp-exportable"]
106
+ },
107
+ "uniqueItems": true,
108
+ "description": "Capability traits to propagate to every expanded node. Hosts MUST copy this array into each expanded `WorkflowNode.capabilities` so existing capability gates apply uniformly."
109
+ }
110
+ },
111
+ "additionalProperties": false
112
+ },
113
+ "ChainOutput": {
114
+ "type": "object",
115
+ "required": ["type", "description"],
116
+ "properties": {
117
+ "type": {
118
+ "type": "string",
119
+ "description": "JSON Schema type token (`string` / `number` / `boolean` / `object` / `array`)."
120
+ },
121
+ "description": {
122
+ "type": "string",
123
+ "description": "One-line description of the output's meaning."
124
+ }
125
+ },
126
+ "additionalProperties": false
127
+ },
128
+ "WorkflowDefinitionFragment": {
129
+ "type": "object",
130
+ "required": ["nodes"],
131
+ "description": "Subset of workflow-definition.schema.json. `id`/`name`/`version`/`triggers`/`settings`/`metadata` MUST be omitted (host generates per-expansion); `variables` is replaced by the chain's top-level `parameters`. See workflow-chain-packs.md §WorkflowDefinitionFragment.",
132
+ "properties": {
133
+ "nodes": {
134
+ "type": "array",
135
+ "minItems": 1,
136
+ "items": { "$ref": "#/$defs/FragmentNode" },
137
+ "description": "Nodes in the fragment. Every node's `typeId` MUST reference a published node-pack typeId or a reserved `core.*` typeId."
138
+ },
139
+ "edges": {
140
+ "type": "array",
141
+ "items": { "$ref": "#/$defs/FragmentEdge" },
142
+ "description": "Edges between fragment nodes. Required when `nodes.length > 1`."
143
+ }
144
+ },
145
+ "additionalProperties": false
146
+ },
147
+ "FragmentNode": {
148
+ "type": "object",
149
+ "description": "Mirror of `workflow-definition.schema.json#/$defs/WorkflowNode` with relaxed `required[]` (chain authors MAY omit `name`/`position`/`config`/`inputs` for trivial pass-through nodes). Maintenance note: when fields are added to `WorkflowNode` in `workflow-definition.schema.json`, mirror the addition here so chain packs can express the same shapes. Drift here means chain-pack authors can't use new node features.",
150
+ "required": ["id", "typeId"],
151
+ "properties": {
152
+ "id": {
153
+ "type": "string",
154
+ "minLength": 1,
155
+ "description": "Node id, unique within the fragment. Hosts MUST rewrite these to globally-unique ids at expansion time."
156
+ },
157
+ "typeId": {
158
+ "type": "string",
159
+ "pattern": "^[a-z][a-zA-Z0-9._-]*$",
160
+ "minLength": 1,
161
+ "maxLength": 256
162
+ },
163
+ "name": { "type": "string" },
164
+ "position": {
165
+ "type": "object",
166
+ "properties": {
167
+ "x": { "type": "number" },
168
+ "y": { "type": "number" }
169
+ },
170
+ "additionalProperties": false
171
+ },
172
+ "config": {
173
+ "type": "object",
174
+ "description": "Node config — host-validated against the referenced typeId's config schema. String fields MAY contain `{{params.<name>}}` placeholders that the host MUST substitute at expansion time.",
175
+ "additionalProperties": true,
176
+ "$comment": "Open by design — node config shapes are per-typeId and only known to the host at expansion time when the referenced typeId's config schema is resolved. Cross-typeId enforcement happens at the expansion step, not at manifest-validation time."
177
+ },
178
+ "inputs": {
179
+ "type": "object",
180
+ "description": "Per-port input wiring. String values MAY contain `{{params.<name>}}` placeholders.",
181
+ "additionalProperties": true,
182
+ "$comment": "Open by design — port shapes are per-typeId, same reasoning as `config` above."
183
+ }
184
+ },
185
+ "additionalProperties": false
186
+ },
187
+ "FragmentEdge": {
188
+ "type": "object",
189
+ "required": ["from", "to"],
190
+ "properties": {
191
+ "from": {
192
+ "type": "string",
193
+ "description": "Source node id (must reference a node in `nodes[]`). MAY use `nodeId.outputPort` syntax to bind a specific output port."
194
+ },
195
+ "to": {
196
+ "type": "string",
197
+ "description": "Target node id (must reference a node in `nodes[]`). MAY use `nodeId.inputPort` syntax."
198
+ },
199
+ "condition": {
200
+ "type": "string",
201
+ "description": "Optional edge condition expression — same shape as a top-level workflow edge's condition."
202
+ }
203
+ },
204
+ "additionalProperties": false
205
+ },
206
+ "Signing": {
207
+ "type": "object",
208
+ "description": "Optional signing metadata. Reuses node-packs.md §signing unchanged.",
209
+ "properties": {
210
+ "publicKeyRef": {
211
+ "type": "string",
212
+ "description": "Path inside the tarball to the Ed25519 public key (PEM-encoded)."
213
+ },
214
+ "signatureRef": {
215
+ "type": "string",
216
+ "description": "Path to the detached signature over `pack.json`."
217
+ },
218
+ "method": {
219
+ "type": "string",
220
+ "enum": ["manual", "sigstore"]
221
+ }
222
+ },
223
+ "additionalProperties": false
224
+ }
225
+ }
226
+ }
package/src/lib/driver.ts CHANGED
@@ -78,6 +78,21 @@ class OpenWOPDriver {
78
78
  return this.request('POST', path, { ...init, body });
79
79
  }
80
80
 
81
+ /** PUT helper. The body is JSON-stringified by default; pass a string
82
+ * Content-Type header for raw-body PUTs (e.g. tarball uploads).
83
+ * Production hosts that accept tarball PUTs on /v1/packs/* expect
84
+ * `Content-Type: application/octet-stream`; callers MUST set the
85
+ * header explicitly when uploading non-JSON. */
86
+ put(path: string, body: unknown, init: OpenWOPRequestInit = {}): Promise<OpenWOPResponse> {
87
+ return this.request('PUT', path, { ...init, body });
88
+ }
89
+
90
+ /** DELETE alias for the canonical name. Keeps the call-site shorter
91
+ * for scenarios that delete via `driver.del(...)`. */
92
+ del(path: string, init: OpenWOPRequestInit = {}): Promise<OpenWOPResponse> {
93
+ return this.request('DELETE', path, init);
94
+ }
95
+
81
96
  delete(path: string, init: OpenWOPRequestInit = {}): Promise<OpenWOPResponse> {
82
97
  return this.request('DELETE', path, init);
83
98
  }
package/src/lib/env.ts CHANGED
@@ -25,6 +25,28 @@
25
25
  * hosts go strict-mode green without falsifying capability claims.
26
26
  * Example for SQLite:
27
27
  * OPENWOP_OPTED_OUT_PROFILES=openwop-production,openwop-auth-mtls
28
+ *
29
+ * OPENWOP_OPTED_OUT_FIXTURES — comma-separated fixture ids (or
30
+ * trailing-`*` globs) the host operator has DELIBERATELY chosen
31
+ * not to honor. Applied in `lib/fixtures.ts` by filtering matching
32
+ * entries out of the cached advertised-fixture set, so any
33
+ * scenario gated via `isFixtureAdvertised(...)` skips cleanly.
34
+ * Use when a host auto-loads every `conformance-*.json` on disk
35
+ * (so the fixture id IS in the discovery doc) but the host doesn't
36
+ * implement the gated feature. Symmetric to `OPENWOP_OPTED_OUT_
37
+ * PROFILES` for the fixture-id axis. Example for SQLite:
38
+ * OPENWOP_OPTED_OUT_FIXTURES=conformance-dispatch-*,conformance-subworkflow-input-mapping*
39
+ *
40
+ * OPENWOP_OPTED_OUT_SCENARIOS — comma-separated scenario ids that
41
+ * individual tests consult to skip themselves where neither
42
+ * profile-opt-out nor fixture-opt-out is fine-grained enough
43
+ * (e.g., OTel trace-inheritance across `core.subWorkflow` —
44
+ * `conformance-subworkflow-parent` is correctly advertised because
45
+ * non-OTel subworkflow scenarios pass, but the host doesn't
46
+ * propagate traceparent across the dispatch boundary). Use
47
+ * `isScenarioOptedOut(scenarioId)` from `env.ts` in the test's
48
+ * skip predicate. Reserved for cases where the suite-wide
49
+ * skip mechanisms can't carry the granularity.
28
50
  */
29
51
 
30
52
  export interface ConformanceEnv {
@@ -84,3 +106,32 @@ export function loadEnv(): ConformanceEnv {
84
106
  };
85
107
  return cached;
86
108
  }
109
+
110
+ /**
111
+ * Returns true when the operator has listed `scenarioId` in
112
+ * `OPENWOP_OPTED_OUT_SCENARIOS`. Use inside a test's `describe.skipIf`
113
+ * predicate when neither profile-opt-out nor fixture-opt-out is
114
+ * granular enough. Logs the skip reason via the caller — this helper
115
+ * is silent so callers can format their own message.
116
+ *
117
+ * Re-reads `process.env` on every call (single env access + split, no
118
+ * cache). Symmetric with `lib/fixtures.ts:loadOptedOutPredicate` which
119
+ * re-reads on every `setAdvertisedFixtures(...)` call — so unit tests
120
+ * can mutate `process.env.OPENWOP_OPTED_OUT_SCENARIOS` between cases
121
+ * without having to invalidate a memoization.
122
+ */
123
+ export function isScenarioOptedOut(scenarioId: string): boolean {
124
+ const raw = process.env.OPENWOP_OPTED_OUT_SCENARIOS?.trim() ?? '';
125
+ if (raw.length === 0) return false;
126
+ for (const entry of raw.split(',')) {
127
+ if (entry.trim() === scenarioId) return true;
128
+ }
129
+ return false;
130
+ }
131
+
132
+ /** Test-only: clear the `loadEnv()` memoization so subsequent calls
133
+ * re-read `process.env`. Required for any test that mutates the env
134
+ * vars consumed by `loadEnv()` mid-suite. */
135
+ export function __resetEnvCacheForTests(): void {
136
+ cached = null;
137
+ }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Driver helpers for the test-only event-log query seam
3
+ * (`GET /v1/host/sample/test/runs/:runId/events`).
4
+ *
5
+ * Used by aiEnvelope engine-projection scenarios that verify the
6
+ * spec-prescribed events the host MUST emit on each envelope outcome
7
+ * (per RFC 0021 §A point 1-7 + interrupt.md + capabilities.md
8
+ * §"cap.breached"). All operations soft-skip on HTTP 404 — hosts
9
+ * without the seam keep the existing advertisement-shape coverage.
10
+ *
11
+ * Reset semantics: callers SHOULD `resetTestSeam()` in their test's
12
+ * `afterEach` (or scope each test to a unique runId) to keep state
13
+ * from leaking across scenarios.
14
+ */
15
+
16
+ import { driver } from './driver.js';
17
+
18
+ export interface TestEvent {
19
+ readonly eventId: string;
20
+ readonly runId: string;
21
+ readonly type: string;
22
+ readonly payload: Record<string, unknown>;
23
+ readonly timestamp: string;
24
+ readonly sequence: number;
25
+ readonly causationId?: string;
26
+ readonly nodeId?: string;
27
+ readonly contentTrust?: 'trusted' | 'untrusted';
28
+ }
29
+
30
+ export type QueryOutcome =
31
+ | { ok: true; events: TestEvent[] }
32
+ | { ok: false; reason: 'seam_unavailable' }
33
+ | { ok: false; reason: 'http_error'; status: number };
34
+
35
+ /** Query the test-only event log for a run, with optional filters. */
36
+ export async function queryTestEvents(
37
+ runId: string,
38
+ filter: { type?: string; correlationId?: string; causationId?: string; nodeId?: string } = {},
39
+ ): Promise<QueryOutcome> {
40
+ const qs = new URLSearchParams();
41
+ if (filter.type) qs.set('type', filter.type);
42
+ if (filter.correlationId) qs.set('correlationId', filter.correlationId);
43
+ if (filter.causationId) qs.set('causationId', filter.causationId);
44
+ if (filter.nodeId) qs.set('nodeId', filter.nodeId);
45
+ const url = `/v1/host/sample/test/runs/${encodeURIComponent(runId)}/events${qs.toString() ? '?' + qs.toString() : ''}`;
46
+ const res = await driver.get(url);
47
+ if (res.status === 404) return { ok: false, reason: 'seam_unavailable' };
48
+ if (res.status !== 200) return { ok: false, reason: 'http_error', status: res.status };
49
+ const body = res.json as { events?: TestEvent[] };
50
+ return { ok: true, events: body.events ?? [] };
51
+ }
52
+
53
+ /** Reset the test-only event log + capability overlay (suite teardown). */
54
+ export async function resetTestSeam(): Promise<void> {
55
+ await driver.post('/v1/host/sample/test/reset', {});
56
+ }
57
+
58
+ /** Probe whether the seam is exposed. Use to soft-skip early. */
59
+ export async function isEventLogSeamAvailable(): Promise<boolean> {
60
+ const res = await queryTestEvents('__probe__');
61
+ return res.ok;
62
+ }
@@ -26,6 +26,16 @@
26
26
  * This module is sync. The async fetch lives in `setup.ts` which calls
27
27
  * `setAdvertisedFixtures(...)` from a top-level `await`.
28
28
  *
29
+ * Honest opt-out (symmetric to `OPENWOP_OPTED_OUT_PROFILES`):
30
+ * `OPENWOP_OPTED_OUT_FIXTURES` (CSV, supports trailing `*` glob)
31
+ * subtracts matching fixture-ids from the cached set even when the
32
+ * host advertises them. Operators use this when the host happens to
33
+ * carry a fixture file (e.g., it auto-loads every `conformance-*.json`
34
+ * on disk) but does NOT implement the underlying feature — so the
35
+ * gated scenario should skip instead of running and failing. The
36
+ * subtraction happens at cache-population time, so the predicate
37
+ * remains a single sync set lookup at scenario-evaluation time.
38
+ *
29
39
  * @see spec/v1/capabilities.md §`fixtures`
30
40
  * @see spec/v1/profiles.md §`openwop-fixtures`
31
41
  * @see RFCS/0003-fixture-gating.md
@@ -35,19 +45,46 @@ import type { DiscoveryPayload } from './profiles.js';
35
45
 
36
46
  let _advertisedFixtures: ReadonlySet<string> | null = null;
37
47
 
48
+ /**
49
+ * Parse `OPENWOP_OPTED_OUT_FIXTURES` into a match predicate. Each entry
50
+ * is either an exact id or a glob with a trailing `*`. Returns a
51
+ * function that answers "is this fixture-id opted out?" — empty / unset
52
+ * env reduces to "always false."
53
+ */
54
+ function loadOptedOutPredicate(): (id: string) => boolean {
55
+ const raw = process.env.OPENWOP_OPTED_OUT_FIXTURES?.trim() ?? '';
56
+ if (raw.length === 0) return () => false;
57
+ const exact = new Set<string>();
58
+ const prefixes: string[] = [];
59
+ for (const entry of raw.split(',').map((s) => s.trim()).filter((s) => s.length > 0)) {
60
+ if (entry.endsWith('*')) {
61
+ prefixes.push(entry.slice(0, -1));
62
+ } else {
63
+ exact.add(entry);
64
+ }
65
+ }
66
+ return (id) => exact.has(id) || prefixes.some((p) => id.startsWith(p));
67
+ }
68
+
38
69
  /**
39
70
  * Populate the cache from a discovery-doc payload. The function is
40
71
  * tolerant of malformed inputs — anything other than a string array
41
72
  * collapses to "no fixtures advertised" rather than throwing, so the
42
73
  * suite remains resilient against host bugs in the discovery surface.
74
+ *
75
+ * Applies `OPENWOP_OPTED_OUT_FIXTURES` at this step: opted-out ids are
76
+ * filtered out of the cache before storage so downstream lookups can
77
+ * stay a single sync set-membership test.
43
78
  */
44
79
  export function setAdvertisedFixtures(c: DiscoveryPayload | null | undefined): void {
45
80
  if (c == null || !Array.isArray(c.fixtures)) {
46
81
  _advertisedFixtures = new Set();
47
82
  return;
48
83
  }
84
+ const isOptedOut = loadOptedOutPredicate();
49
85
  const ids = c.fixtures.filter(
50
- (entry): entry is string => typeof entry === 'string' && entry.length > 0,
86
+ (entry): entry is string =>
87
+ typeof entry === 'string' && entry.length > 0 && !isOptedOut(entry),
51
88
  );
52
89
  _advertisedFixtures = new Set(ids);
53
90
  }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Capability-toggle harness primitive — driver helper for the
3
+ * env-gated test-seam endpoint at
4
+ * `POST /v1/host/sample/test/capability-toggle`.
5
+ *
6
+ * Lets refusal-case scenarios (RFC 0022 §C HVMAP-1a-refusal,
7
+ * HVMAP-2-refusal, etc.) flip a capability flag off temporarily,
8
+ * exercise the host's refusal path, then restore the default.
9
+ *
10
+ * All operations soft-skip on HTTP 404 — hosts that don't expose the
11
+ * seam keep the existing advertisement-shape coverage intact.
12
+ *
13
+ * Reset semantics: callers MUST `resetHostCapabilities()` in their
14
+ * test's `afterEach` (or equivalent) to keep state from leaking
15
+ * across scenarios.
16
+ */
17
+
18
+ import { driver } from './driver.js';
19
+
20
+ export type ToggleOutcome =
21
+ | { ok: true; overlay: Record<string, boolean> }
22
+ | { ok: false; reason: 'seam_unavailable' }
23
+ | { ok: false; reason: 'http_error'; status: number };
24
+
25
+ /** Set a capability flag's overlay value. `value: null` removes the
26
+ * overlay entry (restoring the host's hard-coded default). */
27
+ export async function setHostCapability(
28
+ name: string,
29
+ value: boolean | null,
30
+ ): Promise<ToggleOutcome> {
31
+ const res = await driver.post('/v1/host/sample/test/capability-toggle', { name, value });
32
+ if (res.status === 404) return { ok: false, reason: 'seam_unavailable' };
33
+ if (res.status !== 200) return { ok: false, reason: 'http_error', status: res.status };
34
+ const body = res.json as { overlay?: Record<string, boolean> };
35
+ return { ok: true, overlay: body.overlay ?? {} };
36
+ }
37
+
38
+ /** Clear ALL capability overlay entries on the host. */
39
+ export async function resetHostCapabilities(): Promise<ToggleOutcome> {
40
+ const res = await driver.post('/v1/host/sample/test/capability-toggle', { reset: true });
41
+ if (res.status === 404) return { ok: false, reason: 'seam_unavailable' };
42
+ if (res.status !== 200) return { ok: false, reason: 'http_error', status: res.status };
43
+ const body = res.json as { overlay?: Record<string, boolean> };
44
+ return { ok: true, overlay: body.overlay ?? {} };
45
+ }
46
+
47
+ /** Probe whether the host exposes the capability-toggle seam at all.
48
+ * Use this to soft-skip a scenario early when the host lacks the
49
+ * toggle (the refusal contract is still spec-normative; the test just
50
+ * can't drive it from outside). */
51
+ export async function isToggleAvailable(): Promise<boolean> {
52
+ const probe = await setHostCapability('__probe__', null);
53
+ return probe.ok;
54
+ }
@@ -37,6 +37,9 @@ interface AgentCaps {
37
37
  | {
38
38
  verbosity: 'summary' | 'full' | 'off' | undefined;
39
39
  tokenLimit: number | undefined;
40
+ /** RFC 0024. When true, host may emit `agent.reasoning.delta`
41
+ * events in addition to the closing `agent.reasoned`. */
42
+ streaming: boolean;
40
43
  }
41
44
  | undefined;
42
45
  }
@@ -84,6 +87,7 @@ export function setMultiAgentCapabilities(c: DiscoveryPayload | null | undefined
84
87
  typeof (reasoningRaw as Record<string, unknown>).tokenLimit === 'number'
85
88
  ? ((reasoningRaw as Record<string, unknown>).tokenLimit as number)
86
89
  : undefined,
90
+ streaming: asBoolean((reasoningRaw as Record<string, unknown>).streaming),
87
91
  }
88
92
  : undefined;
89
93
  _agentCaps = {
@@ -113,6 +117,12 @@ export function getReasoningVerbosity(): 'summary' | 'full' | 'off' | undefined
113
117
  return _agentCaps?.reasoning?.verbosity;
114
118
  }
115
119
 
120
+ /** RFC 0024 — host emits incremental `agent.reasoning.delta` events
121
+ * while a reasoning block is still open. */
122
+ export function isReasoningStreamingSupported(): boolean {
123
+ return _agentCaps?.reasoning?.streaming === true;
124
+ }
125
+
116
126
  /** Phase 2 — host supports the named modelClass. */
117
127
  export function hasModelClass(modelClass: string): boolean {
118
128
  return _agentCaps?.modelClasses.has(modelClass) === true;