@openwop/spec-artifacts 2.0.4 → 2.0.5

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 (48) hide show
  1. package/CORPUS-STAMP.json +49 -11
  2. package/api/seams-v2.yaml +1 -1
  3. package/api/v2/asyncapi.yaml +1 -1
  4. package/api/v2/openapi.yaml +1 -1
  5. package/package.json +1 -1
  6. package/schemas/v2/certification-bundle.schema.json +5 -0
  7. package/spec/v1/core-standard-manifest.json +2 -2
  8. package/spec/v2/README.md +15 -0
  9. package/spec/v2/core/capabilities.md +404 -0
  10. package/spec/v2/core/conformance.md +66 -0
  11. package/spec/v2/core/connection-packs.md +32 -0
  12. package/spec/v2/core/errors.md +131 -0
  13. package/spec/v2/core/events.md +109 -0
  14. package/spec/v2/core/form-content-packs.md +32 -0
  15. package/spec/v2/core/headers.md +40 -0
  16. package/spec/v2/core/idempotency.md +45 -0
  17. package/spec/v2/core/identity.md +141 -0
  18. package/spec/v2/core/interop.md +65 -0
  19. package/spec/v2/core/interrupt.md +83 -0
  20. package/spec/v2/core/overview.md +58 -0
  21. package/spec/v2/core/packs.md +72 -0
  22. package/spec/v2/core/persistence.md +168 -0
  23. package/spec/v2/core/replay.md +104 -0
  24. package/spec/v2/core/runs.md +109 -0
  25. package/spec/v2/core/security-defaults.md +108 -0
  26. package/spec/v2/core/versioning.md +116 -0
  27. package/spec/v2/core/webhooks.md +64 -0
  28. package/spec/v2/core/workflow-chain-packs.md +36 -0
  29. package/spec/v2/declaration.json +8 -0
  30. package/spec/v2/declaration.schema.json +34 -2
  31. package/spec/v2/ext/a2uiSurface/README.md +20 -0
  32. package/spec/v2/ext/brand/README.md +20 -0
  33. package/spec/v2/ext/canvas/README.md +20 -0
  34. package/spec/v2/ext/chat/README.md +20 -0
  35. package/spec/v2/ext/coordination/README.md +20 -0
  36. package/spec/v2/ext/dataIntegration/README.md +20 -0
  37. package/spec/v2/ext/entities/README.md +20 -0
  38. package/spec/v2/ext/grpc-transport/README.md +13 -0
  39. package/spec/v2/ext/kanban/README.md +20 -0
  40. package/spec/v2/ext/knowledge/README.md +20 -0
  41. package/spec/v2/ext/launchStudio/README.md +20 -0
  42. package/spec/v2/ext/messaging/README.md +20 -0
  43. package/spec/v2/ext/portability/README.md +11 -0
  44. package/spec/v2/ext/provider-idempotency/README.md +11 -0
  45. package/spec/v2/ext/restTransport/README.md +20 -0
  46. package/spec/v2/ext/sandbox-runtime-notes/README.md +11 -0
  47. package/spec/v2/ext/webResearch/README.md +20 -0
  48. package/spec/v2/release.json +2 -2
@@ -0,0 +1,109 @@
1
+ # Runs
2
+
3
+ > **Status: Draft · v2.0.0-rc (2026-09-03) · RFC 0170 §A, §D.1; RFC 0171 §D; RFC 0176 §B.1.**
4
+
5
+ ## Why this exists
6
+
7
+ A run is the unit of execution, ownership and observation. This document is the run surface of `api/v2/openapi.yaml`: how a run is created, read, streamed, cancelled, paused, forked and diffed, and the one snapshot shape every host projects from the same event log (events.md).
8
+
9
+ ## Identity
10
+
11
+ Every id `$ref`s `schemas/v2/ids.schema.json` (identity.md). A `runId` is tenant-bound, `<tenantId>/<opaque>`, host-minted; a host MUST reject a `runId` whose tenant segment is not the caller's with `403 id_tenant_mismatch` and MUST NOT disclose whether the run exists. A caller MUST treat every id as opaque.
12
+
13
+ ## Surface
14
+
15
+ Every operation accepts `OpenWOP-Version` (overview.md); every mutating operation accepts `Idempotency-Key` (idempotency.md); every response carries `OpenWOP-Version`. Scopes are the `auth.md` vocabulary.
16
+
17
+ | Operation | Method and path | Scope | Gate |
18
+ | --- | --- | --- | --- |
19
+ | `createRun` | `POST /runs` | `runs:create` | — |
20
+ | `getRun` | `GET /runs/{runId}` | `runs:read` | — |
21
+ | `streamRunEvents` | `GET /runs/{runId}/events` | `runs:read` | events.md |
22
+ | `pollRunEvents` | `GET /runs/{runId}/events/poll` | `runs:read` | events.md |
23
+ | `cancelRun` | `POST /runs/{runId}/cancel` | `runs:cancel` | — |
24
+ | `bulkCancelRuns` | `POST /runs:bulk-cancel` | `runs:cancel` | — |
25
+ | `pauseRun` | `POST /runs/{runId}:pause` | `runs:cancel` | — |
26
+ | `resumeRun` | `POST /runs/{runId}:resume` | `runs:cancel` | — |
27
+ | `forkRun` | `POST /runs/{runId}:fork` | `runs:create` + `runs:read` | `replay` (replay.md) |
28
+ | `diffRun` | `GET /runs/{runId}:diff?against=` | `runs:read` on both | OPTIONAL; `404` when absent |
29
+ | `getRunAncestry` | `GET /runs/{runId}/ancestry` | `runs:read` | `multiAgent.executionModel.crossHostCausation.ancestryEndpointSupported`; `404` when unadvertised |
30
+ | `createAnnotation` / `listAnnotations` | `POST` / `GET /runs/{runId}/annotations` | `runs:annotate` / `runs:read` | `feedback`; `404 not_found` when unadvertised (as `getEvalSummary` and `getRunAncestry`; the registry has no 501 for an unadvertised surface) |
31
+ | `getArtifact` | `GET /runs/{runId}/artifacts/{artifactId}` | `artifacts:read` | — |
32
+ | `getEvalSummary` | `GET /runs/{runId}/eval-summary` | `runs:read` | `agents.evalSuite`; `404` when unadvertised |
33
+ | `getRunCompensation` | `GET /runs/{runId}/compensation` | `runs:read` | `compensation` (security-defaults.md) |
34
+ | `getRunEffects` | `GET /runs/{runId}/effects` | `runs:read` | `idempotency` (idempotency.md) |
35
+
36
+ ## Create
37
+
38
+ The `createRun` body is closed at the composition (`unevaluatedProperties: false`): `workflowId` (REQUIRED unless `mode: eval`), `inputs`, `residency`, `tenantId`, `scopeId`, `callbackUrl` (the signed-token callback, interrupt.md), `mode`, `evalSuiteRef`, `agentId`, and the `RunOptions` fields `configurable`, `tags`, `metadata`. A body without `RunOptions` MUST be accepted as if it were `{}`.
39
+
40
+ | Header | Rule |
41
+ | --- | --- |
42
+ | `Idempotency-Key` | RECOMMENDED; a replayed create MUST NOT create a second run and carries `OpenWOP-Idempotent-Replay: true`. |
43
+ | `OpenWOP-Dedup: enforce` | The host MUST reject a duplicate `(tenantId, scopeId)` with `409 run_already_active` and `Retry-After`. |
44
+ | `OpenWOP-Force-Engine-Version` | Test keys only; the seams profile. A host MUST reject it on a production credential with `403`. |
45
+
46
+ The `201` response is `{ runId, status, eventsUrl, statusUrl? }`, `status` one of `pending`, `running`, `waiting-approval`, `waiting-input`, `waiting-external`. `eventsUrl` and `statusUrl` MUST resolve under the origin the request was made to — a relative path, or an absolute URL on the same origin — and MUST NOT downgrade the scheme; a link that names a different host (a backing service behind the origin) or `http://` on an `https://` origin is non-conformant. The base that minted `runId` MUST resolve it: `GET /runs/{runId}` and `GET /runs/{runId}/events/poll` at that base MUST answer `200` for the id the `201` returned, percent-encoded per identity.md §5 (a front door that decodes `%2F` before routing makes every tenant-bound id unreachable). `mode: eval` (with `evalSuiteRef` and `agentId` REQUIRED) starts an eval-suite projection that emits the content-free `eval.*` family and terminates with an `EvalSummary`; a host that does not advertise `agents.evalSuite` MUST reject it. A `residency.region` the host does not advertise MUST be rejected with `422 residency_unavailable` and no run created. A workflow that references a capability-gated reserved node type on a host that does not advertise the capability MUST be rejected with `422 capability_required`.
47
+
48
+ `run.started` (events.md) MUST echo the run's `owner` block exactly as `RunSnapshot.owner` carries it (RFC 0170 §A.1); `transport` records `rest`, `mcp`, `a2a` or `ui`.
49
+
50
+ ## Run options
51
+
52
+ `schemas/v2/run-options.schema.json` is `{ configurable?, tags?, metadata? }`.
53
+
54
+ `configurable` is `schemas/v2/configurable.schema.json`: closed, nested and versioned (RFC 0171 §D.1). The request body `$ref`s it directly; there is no `allOf`-merge of an open map. `version` is REQUIRED and is `1`.
55
+
56
+ | Section | Keys | Rule |
57
+ | --- | --- | --- |
58
+ | `run` | `recursionLimit`, `runTimeoutMs`, `maxLoopIterations`, `escalationThreshold` | `recursionLimit` is clamped to `limits.maxNodeExecutions`. `runTimeoutMs` resolves to `min(runTimeoutMs, limits.maxRunDurationMs)`; an out-of-range value MUST return `400 validation_error` at create, and a breach MUST emit `cap.breached { kind: 'run-duration' }` and terminate the run `failed` with `run_timeout`. `maxLoopIterations` resolves against `limits.maxLoopIterations`; a breach MUST emit `cap.breached { kind: 'loop-iterations' }` and fail with `loop_limit_exceeded`. `escalationThreshold` is the `low-confidence` threshold (interrupt.md). |
59
+ | `ai` | `provider`, `model`, `temperature` (0..2), `maxTokens`, `credentialRef`, `promptOverrides`, `mockProvider`, `reasoningVerbosity` (`none` \| `summary` \| `full`), `maxRefusals` | `provider` MUST be in `aiProviders.supported`, else `400 validation_error`. `credentialRef` MUST reference a provider in `aiProviders.byok`, else `403 credential_forbidden`; it never carries key material. `mockProvider` is test-keys-only: a host MUST refuse it on a production credential with `403`. `maxRefusals` is the refusal ceiling (events.md E5). |
60
+ | `distillation` | `tokenBudget` | Resolves to `min(tokenBudget, memory.distillation.maxTokenBudget)`; a run that cannot distill within it MUST fail atomically with `token_budget_exceeded`. |
61
+ | `budget` | `schemas/v2/budget-policy.schema.json` | The run's budget policy. |
62
+ | `extensions` | `<org>: {…}` | A vendor key lives under its registered org and nowhere else. |
63
+
64
+ An unknown root key, an unknown key inside a section, or a dotted key (`ai.provider` as a string key) MUST be rejected with `400 validation_error`. A host MUST persist `RunOptions` on the run at creation, MUST surface the same `configurable` to every attempt of a node, and MUST NOT allow `configurable` to change after creation. A workflow's `configurableSchema` MUST be validated against at create time and MUST be surfaced on `getWorkflow`.
65
+
66
+ `tags` is an opaque string array (at most 100 entries, each at most 256 characters, valid UTF-8); a host MUST NOT reject a tag on format and MUST return `400 validation_error` over the limits. `metadata` is a free-form JSON object the engine MUST NOT consume for any execution decision; a host MUST persist it. Both surface unchanged on `RunSnapshot`.
67
+
68
+ ## Snapshot
69
+
70
+ `getRun` returns `schemas/v2/run-snapshot.schema.json`, the fold of the event log through the run projection. `runId`, `workflowId`, `status`, `owner` and `eventLogSchemaVersion` are REQUIRED; the object is closed.
71
+
72
+ | Field | Rule |
73
+ | --- | --- |
74
+ | `owner` | `{ tenant, workspace?, subject }`, closed, `subject` REQUIRED (`schemas/v2/subject.schema.json`). `principal` and `principalKind` do not exist. A run created before the host emitted subjects reads with the legacy subject rule (identity.md), stamped at first read and never rewritten. |
75
+ | `status` | `pending`, `running`, `paused`, `waiting-approval`, `waiting-input`, `waiting-external`, `completed`, `failed`, `cancelling`, `cancelled`. `waiting-external` MUST be used when the suspended interrupt's `kind` is `external-event`. `cancelling` is the state between an accepted cancel and the terminal `cancelled`. The vocabulary grows by overview.md §0. |
76
+ | `eventLogSchemaVersion` | The era key, integer ≥ 2. A v2 host MUST stamp `3` on every run it creates; a v1-era run reads as `2` (events.md). |
77
+ | `engineVersion` | Integer. |
78
+ | `compensationStatus` | `none`, `pending`, `running`, `completed`, `partial`, `failed`, `manual`. A host that does not advertise `compensation` MUST omit it; a host that does MUST include it on every snapshot, `none` when never requested. |
79
+ | `currentNodeId` | Set while suspended; names the node holding the interrupt. |
80
+ | `error` | `{ code, message, details? }` on terminal `failed`. |
81
+ | `configurable`, `tags`, `metadata` | The persisted `RunOptions`. |
82
+ | `agent`, `runOrchestrator` | `schemas/v2/agent-ref.schema.json`; `runOrchestrator` MUST NOT change for the run's lifetime. |
83
+ | `metrics.openwopCost` | `{ usd, tokens { input, output }, model, provider, duration_ms }`; absence is not zero. |
84
+
85
+ The `200` SHOULD carry a strong `ETag` derived from the latest persisted `sequence`; when present it MUST change on every observable transition and be stable otherwise. A request whose `If-None-Match` matches MUST receive `304` with no body. A host MAY compress (`gzip` baseline; `br`, `zstd` only where advertised under `restTransport.contentEncodings`) and MUST then set `Content-Encoding` and `Vary: Accept-Encoding`; the decoded body is byte-identical.
86
+
87
+ ## Cancel
88
+
89
+ `cancelRun` accepts `{ reason? }` and answers `200 { runId, status }` with `status` `cancelling` or `cancelled`; the cascade MAY be asynchronous and the run emits `run.cancelled` when it completes. A cancel on a run that is already terminal (`completed`, `failed`, `cancelled`) MUST be refused `409 run_terminal`; a `200` whose `status` echoes the terminal state is outside this grammar. Cancelling a parent MUST NOT silently abandon an active compensation (security-defaults.md). `run.cancelled.parentRunId` with `reason: parent-cancelled` records a cascade from a parent.
90
+
91
+ A non-terminal run a v2 host inherits from v1 whose `version.pinned` change ids the host still implements MUST continue under the era-2 reader; the pin is never rewritten. When any pinned change id is no longer implemented the host MUST cancel the run with `run.cancelled { reason: 'v1_pin_unsupported', cancelledBy: 'v2-cutover' }` and MUST NOT follow code it no longer has (RFC 0176 §B.1). A run suspended on an interrupt at the cut continues, its token resolvable under `kid: legacy` until `expiresAt` (interrupt.md).
92
+
93
+ `bulkCancelRuns` accepts `{ runIds[1..100], reason? }`; over the host's cap (RECOMMENDED 100) it MUST return `400 validation_error` with `details.maxRunIds`. The host MUST process each id independently, MUST return `200 { results[] }` in request order even when every id failed, and MUST enforce authorization per id: a run the caller cannot see yields `ok: false` with an error envelope in that entry, never a top-level `403` — `id_tenant_mismatch` (or `not_found` where existence is not leaked) when the id's tenant segment is not the caller's (identity.md §5 applies inside an entry exactly as on a path), `run_forbidden` for a run in the caller's tenant the caller may not cancel, `run_terminal` for a run already terminal. `ok: true` carries `status` `cancelling` or `cancelled`; `ok: false` carries the error envelope (errors.md).
94
+
95
+ ## Pause and resume
96
+
97
+ `pauseRun` accepts `{ reason?, drainPolicy? }` with `drainPolicy` `immediate` (snapshot between events) or `drain-current-node` (default; the executing node reaches a terminal first) and answers `202 { runId, status: 'paused', pausedAt? }`; the transition emits `run.paused`, whose payload echoes the request's `drainPolicy` word. With `immediate`, the attempt that was executing is cut between events: it has no terminal node event, a host MUST NOT record `node.failed` (or any terminal node event) for it, and the resumed run's `node.started` begins a fresh attempt — a `node.failed` here would make a replay fold a failure the source never had (replay.md). The record of the interruption is `run.paused` itself; its payload MAY carry `interruptedNodeId` and `interruptedAttempt` so a `debug` consumer can see which attempt was cut. A run already paused, terminal, or otherwise unpausable MUST receive `409`: `run_terminal` when the run is terminal, else `run_state_conflict` with `details.runStatus` naming the status that refused it. `resumeRun` accepts `{ reason? }`, answers `202 { runId, status: 'running', resumedAt? }`, emits `run.resumed`, and MUST return `409` when the run is not paused — `run_terminal` or `run_state_conflict` by the same rule. Pause is operator-driven and distinct from cancel (terminal) and from an interrupt (`waiting-*`); only `resumeRun` or a cancel exits `paused`. A replay MUST fold `run.paused` and `run.resumed` as no-ops for projected state.
98
+
99
+ ## Fork
100
+
101
+ `forkRun` accepts `{ mode: replay | branch, fromSeq?, runOptionsOverlay? }`: events with `sequence < fromSeq` are fixed history and events `≥ fromSeq` re-execute. `fromSeq` is REQUIRED for `branch` and defaults to `0` for `replay`; `runOptionsOverlay` is `branch`-only, and a `replay` with a non-empty overlay MUST be rejected with `400`. A `fromSeq` not in the source log MUST be rejected with `422 fork_point_invalid`. The `201` response is `{ runId, sourceRunId, fromSeq?, mode, status, eventsUrl }`. The child's `owner` is copied verbatim from the parent (RFC 0170 §A.4). Determinism, side-effect suppression, and forking an era-2 parent are in replay.md.
102
+
103
+ ## Diff and ancestry
104
+
105
+ `diffRun` returns `schemas/v2/run-diff-response.schema.json`: `divergedAtSeq`, ordered `eventDiffs[]`, `stateDiff`, optional `truncated`. The diff MUST be a pure function of the two logs: identical logs MUST yield `divergedAtSeq: null` and empty `eventDiffs`; `eventId`, `runId`, `timestamp` and other run-scoped fields MUST be excluded from comparison. A host that diffs an in-flight prefix MUST set `truncated: true`. A caller lacking `runs:read` on either run MUST receive `403`. `getRunAncestry` returns `schemas/v2/run-ancestry-response.schema.json` (`runId`, `hostId`, `parent` or `null`); a client walks the chain one hop at a time via `parent.wellKnownUrl`.
106
+
107
+ ## Annotations, artifacts, eval summary
108
+
109
+ `createAnnotation` accepts `schemas/v2/annotation-create.schema.json` and returns `201` with `schemas/v2/annotation.schema.json`; `listAnnotations` returns `{ annotations[] }`. An annotation is a live notification (`run.annotated`), never a run event: it MUST NOT enter the event log and MUST be excluded from fork, replay and diff. `getArtifact` returns the artifact as an implementation-defined JSON object. `getEvalSummary` returns `schemas/v2/eval-summary.schema.json` for a terminal eval run, `409` while it is running, `404` when the run is not an eval run; the summary MUST be content-free of task output, rubric prose and credentials.
@@ -0,0 +1,108 @@
1
+ # Security Defaults
2
+
3
+ > **Status: Draft · v2.0.0-rc (2026-09-03) · RFC 0173 (§A–§E), 0164 §22, 0170 §B.**
4
+
5
+ ## Why this exists
6
+
7
+ v1 protected a tenant only when a host volunteered a boolean: fourteen auth-family flags, `replay.sideEffectSuppression`, `webhooks.durable`, `interrupt.approverRouting`, and `sandbox.supported` each gated a MUST. RFC 0164 §22 named the pattern — opt-in security is the pattern the corpus keeps regretting — and RFC 0173 applies that ruling to the whole corpus. This document is the obligation table: which surface binds which behavior, the invariant, and the witness.
8
+
9
+ ## The rule
10
+
11
+ A security-load-bearing behavior is an obligation of the surface that needs it (RFC 0173 §A.1). Advertising the surface binds the behavior; no discovery field gates it. Every obligation in `core/` MUST name a surface, an invariant, and a witness class other than `unwitnessable`; a row that cannot is not in `core/` (§D.1).
12
+
13
+ A host MUST NOT advertise a surface whose obligation it has relaxed (§A.2).
14
+
15
+ ## The obligation table
16
+
17
+ | Surface advertised | Obligation (v1 flag it replaces) | Witness | Invariant |
18
+ | --- | --- | --- | --- |
19
+ | any lane in `auth.lanes[]` | the lane obligations of identity.md: the verify → bind → audience → resolve → fail-closed pipeline (RFC 0170 §B.1); a named trust root as `subject.issuer` (§B.2); revocation for the lane (§B.3); the advertised `minimumAssurance` floor, `mtls.required` becoming `key-bound` (§B.4); lane-scoped delegation proof (§B.5). Replaces the fourteen `auth.*` gates. | unaided or seam-gated per lane | `sender-constraint-no-bearer-downgrade`; per-lane rows (RFC 0170 §E) |
20
+ | both `saml` and `scim` lanes | the leaver contract (RFC 0164; already mandatory) | seam-gated (RFC 0163 seams) | `subject-link-leaver-deny`, `subject-link-mandatory-when-both-advertised` |
21
+ | `replay` (any mode) | side-effect suppression with `recorded-outcome` semantics as the only conforming behavior; `none` is not a value (replay.md) | witnessable-gated via the effect-seam manifest | `replay-fanout-no-refire`; effect-seam rows registered at Accepted |
22
+ | `webhooks` | durable delivery: retries per the advertised policy with backoff, dead-letter on exhaustion, at-least-once; best-effort is not a conforming delivery mode (replaces `webhooks.durable`) | witnessable-gated (`webhook-signed-delivery` + dead-letter leg) | registered at Accepted (RFC 0173) |
23
+ | `interrupt` with `approversList` or `refKinds` | enforcement: a resolver not in the list, group, or role MUST be refused (replaces the `approverRouting` gate; `refKinds[]` stays a facet) | witnessable-gated | registered at Accepted (RFC 0173) |
24
+ | `packs` (pack execution) | isolation: the eight `node-pack-sandbox-*` invariants bind for pack code; `sandbox.isolationModel` names the mechanism and never relaxes the property (replaces `sandbox.supported`) | witnessable-gated (eight `sandbox-*` scenarios) | `node-pack-sandbox-*` |
25
+ | `compensation` | the plan, attempt, and inverse-action obligations with the read projection `GET /runs/{runId}/compensation` and the operator action family as canonical wire (replaces `compensation.supported` with seam-only evidence) | witnessable-gated (reads) + seam-gated (operator actions) | `compensation-replay-no-refire`, `compensation-effect-id-retry-stable` |
26
+ | `idempotency` | Layer-2 effect identity keyed on business identity; the activity recipe is the fallback; `GET /runs/{runId}/effects` is the read | witnessable-gated (fixture provider) | `logical-effect-id-retry-stable` |
27
+
28
+ ### Auth lanes
29
+
30
+ The fourteen gate fields are removed from `schemas/v2/capabilities.schema.json`; `auth.lanes[]` carries `{ lane, issuers[], revocation, minimumAssurance, delegationProofs[] }` as facets. The obligations are stated once in identity.md and bind on advertisement.
31
+
32
+ ### Replay suppression
33
+
34
+ A host that advertises `replay` MUST suppress external effects during a `replay` fork and MUST publish the effect-seam manifest at `GET /host/effect-seams` (`schemas/v2/effect-seam-manifest.schema.json`, RFC 0173 §C.1). The `replay-side-effect-suppression` scenario asserts every manifest row is suppressed and drives one seam of each kind to observe no re-fire. A host that cannot suppress MUST NOT advertise `replay`. The manifest is a self-declaration: a seam omitted is invisible to the suite, and its completeness is recorded as negative-existence, found by audit rather than witnessed.
35
+
36
+ ### Webhook durability
37
+
38
+ A host that advertises `webhooks` MUST retry a failed delivery per its advertised `retryPolicy` (`maxAttempts`, `backoff`), MUST route an exhausted delivery to the dead-letter sink, and MUST deliver at least once; subscribers dedup on `(OpenWOP-Webhook-Id, runId, sequence)` (webhooks.md). The `webhook-durable-delivery` scenario observes retry then dead-letter.
39
+
40
+ ### Approver enforcement
41
+
42
+ A host that surfaces `approversList`, or advertises `refKinds` including `group` or `role`, MUST refuse a resolution from a principal outside the list, group, or role at resolve time. Membership MUST be resolved at decision time and MUST NOT be re-resolved during replay (replay.md). The `approver-enforced` scenario submits a non-listed resolver and observes the refusal.
43
+
44
+ ### Sandbox isolation
45
+
46
+ A host that executes third-party packs MUST enforce the eight `node-pack-sandbox-*` invariants of `SECURITY/invariants.yaml` (`no-process`, `network-gated`, `fs-gated`, `no-env`, `timeout`, `memory-cap`, `isolated-context`, `no-eval`) and MUST advertise `sandbox.isolationModel ∈ wasm | process | container | vm` (`spec/v2/facets/sandbox.schema.json`). `node:vm` is not a value. A host that cannot isolate MUST NOT execute third-party packs; it MAY register and validate them. The `no-eval` row stays reference-impl in `ext/sandbox-runtime-notes` (§D.1). The `pack-isolation` scenario drives the eight legs.
47
+
48
+ ### Compensation
49
+
50
+ A host that advertises `compensation` MUST serve `GET /runs/{runId}/compensation` (`schemas/v2/compensation-projection.schema.json`): `{ runId, status, plan[], attempts[] }`, the plan carrying `{ nodeId, order, policy?, irreversibleEffect? }` and each attempt `{ nodeId, attempt, outcome, at, reason? }`, keyed on the node and attempt the operator family uses. The trichotomy of §D.1 resolves to core obligation with a declared witness; a host that does not advertise `compensation` has no obligation.
51
+
52
+ ### Layer-2 effect identity
53
+
54
+ A host that advertises `idempotency` MUST assign a logical effect id once per effect, stable across transport retries, and MUST inject it as the provider's idempotency key (RFC 0150 §B). Where a provider exposes no business key, the v1 activity recipe is the documented fallback. `GET /runs/{runId}/effects` (`schemas/v2/effect-ledger-projection.schema.json`) serves `{ runId, effects[] }`, each `{ effectId, nodeId, attempt, invocationId?, keying: business-identity | activity-recipe, providerKey?, state: claimed | completed | released | escaped, at }`, content-free of provider payloads. Layer-2 retention MUST be at least 14 days (RFC 0170 §D.3). No deployed history holds a v1 recipe key, so no dual-read migration exists (RFC 0147 UQ2).
55
+
56
+ ## Relaxations
57
+
58
+ A relaxation, where one is legitimate — a development deployment, a single-tenant appliance — is an operator setting, never a discovery field (RFC 0173 §A.2). Every relaxation a host runs under MUST be recorded in its certification bundle as `host.relaxations[]` (`schemas/v2/certification-bundle.schema.json`): `{ obligation, durability, reason }`, `durability ∈ session | deployment | permanent`.
59
+
60
+ | Durability | Meaning |
61
+ | --- | --- |
62
+ | `session` | Lost on restart. |
63
+ | `deployment` | Set at deploy time. |
64
+ | `permanent` | Survives restarts and is auditable. |
65
+
66
+ A bundle that records a relaxation MUST NOT certify the profile the relaxed obligation belongs to; the `relaxation-recorded` scenario verifies it unaided (conformance.md). RFC 0158's ladder is the model: evidence lives in the bundle, and a field that let a host assert a property with nothing behind it is the failure the ladder prevents.
67
+
68
+ ## Three dispositions
69
+
70
+ Every security obligation in `core/` is exactly one of (RFC 0173 §D.1):
71
+
72
+ | Disposition | Where | Requirement |
73
+ | --- | --- | --- |
74
+ | core obligation with a declared witness | this table | MUST name surface, invariant, witness. |
75
+ | extension | `spec/v2/ext/` | MUST declare a witness class and both maturity axes. |
76
+ | removed | — | No text survives. |
77
+
78
+ There is no unimplemented MUST. Compensation and Layer-2 effect identity are core obligations at filing; either MUST move to `ext/` at the cut if its witness does not land. RFC 0150's sub-decisions: operation ids in the declaration file are canonical and aliases are register rows; the provider semantic-option registry is `spec/v2/ext/provider-idempotency/registry.json` with a witness per provider; the qualification test is a fixture provider that rejects a changed key (§D.2).
79
+
80
+ ### RFC 0035
81
+
82
+ RFC 0035 (Parked) is resolved by the `packs` row: its §B probes become the `packs` obligation, and the RFC flips `Superseded` by RFC 0173 at the cut in the same PR (RFC 0174 §A.1). Its tripwire — a non-steward host fencing untrusted packs — becomes the `adoption: independent` axis, not a status gate.
83
+
84
+ ## Threat models
85
+
86
+ RFC 0173 §E requires three threat-model artifacts before its dependents flip Accepted:
87
+
88
+ | Artifact | Requirement |
89
+ | --- | --- |
90
+ | `SECURITY/threat-model-replay.md` §6 Residual risks | MUST record branch re-fires, seams outside the manifest, and the manifest as a self-declaration. |
91
+ | `SECURITY/threat-model-replay.md` §7 Verification, §8 References | MUST name the manifest scenario and `fork-a-v1-run`; a threat model missing a sibling section fails the template gate. |
92
+ | `SECURITY/threat-model-interop.md` | MUST exist before RFC 0175 flips Accepted (written by RFC 0175's cut). |
93
+
94
+ ## Migration
95
+
96
+ | Row | v1 | v2 |
97
+ | --- | --- | --- |
98
+ | `C6.1` | fourteen `auth.*` gate flags | obligations of the lane; `auth.lanes[]` facets |
99
+ | `C6.2` | `replay.sideEffectSuppression: none \| recorded-outcome` | suppression is the only replay behavior; the manifest is the witness |
100
+ | `C6.3` | `webhooks.durable` opt-in | durable delivery binds with `webhooks`; undelivered best-effort deliveries are not translated |
101
+ | `C6.4` | `interrupt.approverRouting` gate | enforcement binds with the fields |
102
+ | `C6.5` | `sandbox.supported` gate | isolation binds with pack execution; `node:vm` not a value |
103
+ | `C6.6` | `compensation.supported` with seam-only evidence | core obligation with the read projection; persisted plans and attempts unchanged |
104
+ | `C6.7` | unimplemented activity recipe | business-identity keying; `GET /runs/{runId}/effects` |
105
+ | `C6.8` | none | `host.relaxations[]` in bundle v3 |
106
+ | `C6.9` | five-section replay threat model; no interop model | sibling sections; `threat-model-interop.md` |
107
+
108
+ See also: identity.md, replay.md, webhooks.md, capabilities.md, conformance.md.
@@ -0,0 +1,116 @@
1
+ # Versioning and Release
2
+
3
+ > **Status: Draft · v2.0.0-rc (2026-09-03) · RFC 0172, 0179, 0176.**
4
+
5
+ ## Why this exists
6
+
7
+ v1 negotiated on one scalar, could not advertise two majors, split `engineVersion` across two types, and presumed a `/v2/` path space that the `/v1/v1` defect already showed is the wrong model. This document is the one place a v2 host reads to learn how a major is selected, what each version axis means, and what a release is.
8
+
9
+ ## 1. Major negotiation (RFC 0172 §A)
10
+
11
+ ### 1.1 Advertisement
12
+
13
+ A v2 host MUST advertise `protocolVersions[]` (grammar `^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$` per member) containing every `<major>.<minor>` it serves, and a root `preferredVersion` that MUST be a member of `protocolVersions[]`. Both are REQUIRED root metadata in `schemas/v2/capabilities.schema.json` (see `capabilities.md`). Through the overlap a host serves `["1.<n>", "2.<m>"]`; after v1 end-of-support it serves `["2.<m>"]`.
14
+
15
+ **Through the overlap `preferredVersion` MUST name a 1.x member.** A header-less request is a v1 client's request: `capabilities.md` §1 makes the header-less representation the v1 document, and §1.3 makes the header-less default `preferredVersion`'s major, so on a host whose `protocolVersions[]` contains any `1.x` member the two rules agree only when `preferredVersion` is that `1.x`. A host that drops v1 from `protocolVersions[]` advertises a `2.x` `preferredVersion` and its header-less representation becomes the closed v2 root. On a host serving a single major, `preferredVersion` MUST equal `protocolVersion` (RFC 0179 §A.1). A v2 consumer reads `preferredVersion` as the header-less default; when it is absent on a v1 document the consumer's default is `max(protocolVersions[])`, else `protocolVersion` (RFC 0179 §A.2). The suite's `--target-major` defaults from it (RFC 0168 §D.3).
16
+
17
+ ### 1.2 Paths
18
+
19
+ v1 operations keep their `/v1/…` path keys unchanged through the overlap. v2 operations are unversioned path keys on a bare origin (`servers[].url = https://{host}`): `/runs`, `/runs/{runId}`, `/.well-known/openwop`. There is no `/v2/` path space. An unversioned path is the v2 surface; the v1 MUST that servers answer `400` for unversioned roots is retracted for v2.
20
+
21
+ A host that advertises a major in `protocolVersions[]` MUST reach, under that major, every operation it serves under the other. Advertising a major is a claim about the **path space**, not about `/.well-known/openwop` alone — that resource's representation is *selected* by the request header (§1.3), so it answers correctly for a host that has mounted nothing else, and every discovery-level probe of the advertisement passes with it. Concretely: if `/v1/<op>` answers and the unversioned `/<op>` returns `404` under the advertised major, the advertisement overstates what the host serves and the host MUST NOT advertise that major until the surface is reachable. The pairing is normative because a lone `404` cannot distinguish *"this host does not serve that operation"* from *"this host serves it and did not mount it under this major"*, and only the second is a defect.
22
+
23
+ `spec/v2/path-manifest.json` (generated) carries operations and channels with a `resolvedPath` on a bare origin: exactly one version segment for v1 rows and none for v2 rows. OpenAPI (`api/v2/openapi.yaml`), AsyncAPI (`api/v2/asyncapi.yaml`), and any kept proto MUST resolve to identical absolute paths for the shared event stream (`scripts/check-path-parity.mjs`); the canonical OpenAPI MUST contain no seam or test-mode operation (those live in the seams profile, see `conformance.md`).
24
+
25
+ ### 1.3 The request header
26
+
27
+ A request on an unversioned path MAY carry `OpenWOP-Version: <major>` or `OpenWOP-Version: <major>.<minor>` — `2` and `2.0` select the same major and a host MUST accept both. Only the major selects; a minor in the header is informational, and what pins a minor is `minClientVersion` plus the additive rules.
28
+
29
+ | Condition | Host behavior |
30
+ | --- | --- |
31
+ | Header names a major in `protocolVersions[]` | MUST serve that major |
32
+ | Header names a major not in `protocolVersions[]` | MUST answer `406` `protocol_version_unsupported` with `details.protocolVersions[]` echoing the list |
33
+ | Header absent on an unversioned path | MUST serve `preferredVersion`'s major |
34
+ | `/v1/…` path with `OpenWOP-Version` other than `1` | MUST answer `400` `protocol_version_mismatch` |
35
+
36
+ A request on a `/v1/…` path key MUST NOT carry `OpenWOP-Version` with a value other than `1`. All three codes are rows in `spec/v2/errors.json` (see `errors.md`).
37
+
38
+ ### 1.4 The response header
39
+
40
+ A response on any path MUST carry `OpenWOP-Version: <major>.<minor>` naming the contract that produced it. Reporting a version other than the one used is a silent downgrade and non-conformant; the `dual-stack-negotiation` scenario falsifies it. Emitting the header on `/v1/` responses is additive in v1.x and REQUIRED in v2.
41
+
42
+ ### 1.5 Client precedence and `minClientVersion`
43
+
44
+ When both majors are advertised, a v2 client MUST select the highest major it implements that the host lists; a v1 client (no header, `/v1/` paths) is unaffected. `minClientVersion` (axis 15, grammar as axis 1) is a MUST: a host MAY refuse a client below it with `426` `client_version_unsupported`.
45
+
46
+ `OpenWOP-Version` on a request selects by MAJOR; the `<major>.<minor>` spelling is accepted because `protocolVersions[]` members are `<major>.<minor>` and a client echoing one back is the obvious thing to do — the conformance driver does exactly that. A minor pin is what `minClientVersion` and the additive rules cover (RFC 0172 UQ1, recommended disposition; the integer-only reading was corrected in Phase 4 after it contradicted the suite that tests it).
47
+
48
+ ## 2. The 18 version axes (RFC 0172 §B; RFC 0167 §E.1)
49
+
50
+ `unify` = one type and grammar with a codemod; `first-class` = own schema-enforced grammar and negotiation rule; `retire` = absorbed into the capability record's `{status, since, until?}`; `delete` = removed with a register row.
51
+
52
+ | # | Axis | Disposition | v2 grammar | Owner |
53
+ | --- | --- | --- | --- | --- |
54
+ | 1 | `protocolVersion` | first-class; kept as `preferredVersion`'s twin for v1 readers through the overlap, removed after | `^(0\|[1-9][0-9]*)\.(0\|[1-9][0-9]*)$` | this document |
55
+ | 2 | `protocolVersions[]` + `preferredVersion` | first-class, negotiation input | as #1 | this document |
56
+ | 3 | `engineVersion` | unify: integer everywhere; codemod `openwop.codemod.engine-version-unify` | `integer, minimum 0` | this document |
57
+ | 4 | `eventLogSchemaVersion` | first-class, the era key | integer; v2 writes `3` | `persistence.md` |
58
+ | 5 | per-event `schemaVersion` | first-class; §0 growth rule | integer | `events.md` |
59
+ | 6 | `schemaVersions` map | first-class; keys = envelope-kind grammar | `additionalProperties: false` over declared kinds | `events.md` |
60
+ | 7 | `version.pinned` | first-class; the v1-pinned-run disposition | integer min/max | `persistence.md` |
61
+ | 8 | `contractProvenance` | delete | — | `capabilities.md` |
62
+ | 9 | `minimumSuiteVersion` | retire into `spec/v2/declaration.json` | semver | `capabilities.md` |
63
+ | 10 | `bundleVersion` | unify to one `const` family: certification v3 `"3"`, export `"2"`, debug `"2"` | string const | `conformance.md` |
64
+ | 11 | A2A `versions[]` / `preferredVersion` | first-class facet of `a2a` | `^[0-9]+\.[0-9]+$` | `interop.md` |
65
+ | 12 | MCP `revisions[]` / `preferredVersion` | first-class facet of `mcp` | date | `interop.md` |
66
+ | 13 | `multiAgent.executionModel.version` | first-class | integer with a schema `maximum` the suite reads | `events.md` |
67
+ | 14 | OpenAPI / AsyncAPI `info.version` | generated from the corpus tag | semver | this document |
68
+ | 15 | `minClientVersion` | first-class MUST (§1.5) | as #1 | this document |
69
+ | 16 | channel `schemaVersion` / `compatibleWith` | first-class | integer / range | `events.md` |
70
+ | 17 | webhook signature scheme | retire into `deprecations.json` | — | `webhooks.md` |
71
+ | 18 | pack `engines.openwop` + `registryVersion` | first-class with the absent-ceiling rule | semver range / semver | `packs.md` |
72
+
73
+ One grammar covers protocol, envelope-kind, and pack axes wherever a version is `<major>.<minor>` (#1, #2, #11, #15). `typeId@<semver>` is a pack axis (`packs.md`); the `2` in `typeId@2.0.0` never means `OpenWOP-Version: 2`. `docs/PROTOCOL-STATUS.md` carries one row per axis (RFC 0172 §D.2).
74
+
75
+ ### 2.1 `engineVersion` (axis 3)
76
+
77
+ `engineVersion` MUST be an integer (`minimum 0`) at the discovery root and on every per-event carrier. A persisted v1 run document that carries the string form is legacy-stamped: the reader MUST normalise it to an integer and MUST NOT rewrite the stored document. The codemod `openwop.codemod.engine-version-unify` MUST refuse any value not matching `^(0|[1-9][0-9]*)$`.
78
+
79
+ ### 2.2 `eventLogSchemaVersion` (axis 4; RFC 0176 §A.2)
80
+
81
+ `eventLogSchemaVersion` is the era key. A v2 host MUST stamp `3` on every run it creates. A run document without the field on a store that has ever been written by a v1 host MUST read as `2` (v1 era). The v1 rule for `< 2` (snapshot fallback, no projection write-through) is unchanged. Discovery advertises the value the host writes for new runs and nothing else; the schema floor is `minimum 2`. The reader contract is `persistence.md`.
82
+
83
+ ## 3. Where v2 lives (RFC 0172 §C)
84
+
85
+ `spec/v2/core/` and `spec/v2/ext/<key>/` hold the prose; `schemas/v2/` holds every v2 schema with `$id` under `https://openwop.dev/spec/v2/`; the site publishes them at `/spec/v2/`. The flat `schemas/` tree (v1 `$id`s) is read-only from the cut; v1 `$id` values are immutable identifiers, and a domain move is answered by a redirect, never a rewrite. AsyncAPI `servers.production.pathname` is empty and every channel address carries its own path, exactly as OpenAPI path keys do.
86
+
87
+ ## 4. One release identity (RFC 0172 §D)
88
+
89
+ The corpus tag `v2.<minor>.<patch>` (release candidates `v2.0.0-rc.<n>`) is the only release event; suite, SDKs, registry, and site derive from it. `spec/v2/release.json` carries the next tag as `version` and is bumped only by the release PR that cuts the tag. Every human-surface version (README banner, `docs/PROTOCOL-STATUS.md`, OpenAPI and AsyncAPI `info.version`, `conformance/package.json`) MUST be generated from it and checked with `--check` in the merge gate; the published tarball digest MUST equal the tree's as a release precondition. The identity and advertised-versions checks keep their three-outcome discipline (`conformance.md`).
90
+
91
+ A consumer that vendors any file from `schemas/`, `api/`, or `spec/` MUST pin to a published tag, record it, and refuse a sync from any other ref; a v1.x consumer MUST NOT vendor `schemas/v2/` (RFC 0176 §E.1).
92
+
93
+ ## 5. The overlap (RFC 0167 §B.5; RFC 0176)
94
+
95
+ Through the overlap a host MUST advertise both majors (§1.1), MUST emit `OpenWOP-Version` on every response (§1.4), and MUST serve `/.well-known/openwop` as one resource whose representation the request header selects (`capabilities.md`). The dual-stack scenario creates one run through `/v1/runs` with no header and reads it through `/runs` with `OpenWOP-Version: 2`; the response headers name the contract used.
96
+
97
+ **A run minted under major 1 and read under major 2 MUST be named by its tenant-bound projection** `<tenantId>/<the v1 id>` (`identity.md` §5). A host MUST NOT return the bare v1 id in a major-2 response body. This paragraph is normative because its absence was a real defect: until 2026-09-04 §5 described the overlap's shape and said nothing about the identifier, so a conformance check asserted byte-equality with the v1 id, a host implemented `identity.md` §5 instead, and the two could not both hold. Neither reading was wrong about §5 — §5 had no reading.
98
+
99
+ The projection is mandatory rather than optional for a reason that is not stylistic. A tenant-bound id carries the tenant segment that §5's `403 id_tenant_mismatch` check reads. **A bare, unprefixed id has no tenant segment, so the mandatory cross-tenant refusal cannot run on it at all.** Admitting a legacy unprefixed form under major 2 would therefore create a class of identifiers — exactly the long-lived ones, carried over from v1 — on which major 2's tenant-isolation check is structurally inapplicable. The grammar in `ids.schema.json` has no legacy branch, and it MUST NOT acquire one.
100
+
101
+ The overlap ends at v1 end-of-support (`overview.md`), when `protocolVersions[]` drops the `1.<n>` member and every alias carrying the `v1-end-of-support` trigger is removed.
102
+
103
+ ## 6. Migration rows (RFC 0172)
104
+
105
+ | Row | v1 | v2 |
106
+ | --- | --- | --- |
107
+ | `C5.1` | `engineVersion` integer at root, string on five carriers | integer everywhere; codemod `engine-version-unify` |
108
+ | `C5.3` | — | root `preferredVersion` |
109
+ | `C5.4` | — | `OpenWOP-Version` request/response header; three error codes |
110
+ | `C5.5` | `/v1/<op>` path keys | unversioned `/<op>` keys (v1 keys retained through the overlap) |
111
+ | `C5.6` | `400` for unversioned roots | unversioned roots are the v2 surface |
112
+ | `C5.7` | `$id` base `/spec/v1/` | `/spec/v2/` (new files; v1 `$id`s immutable) |
113
+ | `C5.8` | `minClientVersion` advisory | MUST (§1.5) |
114
+ | `C5.9` | `info.version` hand-maintained | generated from the corpus tag |
115
+
116
+ Row `C5.2` (channel state-key prefixes → typed channels) is owned by `events.md`. Every row is a `spec/v1/migrations.json` entry; the persisted-data disposition for each is `not-persisted` except `C5.1` (legacy-stamped) and `C5.7` (never-upgraded).
@@ -0,0 +1,64 @@
1
+ # Webhooks
2
+
3
+ > **Status: Draft · v2.0.0-rc (2026-09-03) · RFC 0165 §C.1, 0173 §B, 0176 §D.2, 0171 §A.4.**
4
+
5
+ ## Why this exists
6
+
7
+ Polling a run for progress is inefficient, and SSE cannot reach systems that need server-to-server delivery. A client registers a URL and an event filter once; the host POSTs matching events, signed, as they happen. In v2 durable delivery binds with the surface — a signed event that may be dropped is not a delivery contract.
8
+
9
+ ## Surfaces
10
+
11
+ A host that advertises `webhooks` (capabilities.md) serves `registerWebhook` (`POST /webhooks`) and `unregisterWebhook` (`DELETE /webhooks/{webhookId}`) from `api/v2/openapi.yaml`. The facet (`spec/v2/facets/webhooks.schema.json`) is `{ signatureAlgorithms[] }`, which MUST list `"v1"`; there is no `durable` field.
12
+
13
+ | Operation | Request | Response |
14
+ | --- | --- | --- |
15
+ | `registerWebhook` | `{ url, events[], secret?, tags? }`; `url` MUST be `https://`; `events[]` MUST be non-empty v2 event type names (events.md) | `201 { webhookId }` |
16
+ | `unregisterWebhook` | path `webhookId` | `204`; `404` when unknown; `403` when the caller is outside the subscription's tenant |
17
+
18
+ A subscription MUST receive only events from runs within its tenant scope; cross-tenant delivery is a protocol violation whatever the filter says (invariant `webhook-cross-tenant-isolation`). `tags` narrows delivery to runs whose options carry an overlapping tag.
19
+
20
+ ## Delivery
21
+
22
+ The delivery envelope is generated from the same payload definition as the event itself and the CloudEvents mapping — one source, three renderings (RFC 0171 §A.4). The body is `{ runId, workspaceId?, event }` where `event` is the verbatim run event (events.md), and it MUST validate against `schemas/v2/webhook-delivery.schema.json`. `workspaceId` is present exactly when `RunSnapshot.owner.workspace` is (`identity.md` §1) — a host MUST NOT substitute its tenant id for an absent workspace.
23
+
24
+ The envelope's `runId` is tenant-bound (`identity.md` §5), like every other rendering of a v2 `runId`. An outbound emission is not a response to a versioned request, so nothing in the request cycle supplies the form — the grammar does. **A host that projects on responses and not on emissions hands the subscriber an identifier the client has never seen**, and the failure is silent: the subscriber's correlation matches nothing, with no error, no `4xx` and no log line. Until 2026-09-04 the nested `event.runId` was bound by `run-event.schema.json` while the envelope's own was carried by this paragraph alone, which is how a real host shipped the split.
25
+
26
+ ### Headers
27
+
28
+ | Header | Value |
29
+ | --- | --- |
30
+ | `OpenWOP-Webhook-Id` | the subscription id |
31
+ | `OpenWOP-Event-Type` | the v2 event type |
32
+ | `OpenWOP-Timestamp` | Unix seconds at signing |
33
+ | `OpenWOP-Signature` | `sha256={hex}`, HMAC-SHA256 over the signed bytes |
34
+ | `OpenWOP-Signature-Algorithm` | `v1` |
35
+
36
+ A host MUST send all five on every delivery. The signed bytes are `{timestamp}.{rawBody}`, where `rawBody` is the exact bytes delivered. Scheme `v1` is HMAC-SHA256 with the subscription secret (`hs256`).
37
+
38
+ ### Verification
39
+
40
+ A subscriber MUST verify before acting: reject a timestamp more than ±5 minutes from its clock; compute `HMAC-SHA256({timestamp}.{rawBody}, secret)`; compare in constant time. A subscriber MUST reject an unrecognized `OpenWOP-Signature-Algorithm` value. Subscribers SHOULD track `(OpenWOP-Webhook-Id, runId, sequence)` for at-least-once deduplication. A host MUST NOT log the secret.
41
+
42
+ ### Dual emission through the overlap
43
+
44
+ A host advertising both majors MUST send, on every delivery, the `X-openwop-*` family alongside the `OpenWOP-*` family with identical values (RFC 0165 §C.1, RFC 0176 §D.2). A v2 receiver MUST accept a delivery carrying only the `X-openwop-*` family under scheme `v1`, verifying the same bytes. This adds no signature scheme. Per-subscription secrets are unchanged across the cut; deliveries queued before the cut are drained under their own retry policy with the payload they were serialized with (persistence.md). The `X-openwop-*` family is removed on its register date.
45
+
46
+ ## Durability
47
+
48
+ Durable delivery is an obligation of the `webhooks` surface (RFC 0173 §B; security-defaults.md). A host MUST:
49
+
50
+ - retry a failed attempt per its advertised `retryPolicy` (`maxAttempts`, `backoff ∈ none | fixed | exponential`) with backoff between attempts;
51
+ - route a delivery whose retries are exhausted to the dead-letter sink, inspectable for `retentionDays`, rather than drop it;
52
+ - deliver each matching event at least once; a receiver MAY observe the same event more than once.
53
+
54
+ Best-effort delivery is not a conforming mode. A `3xx` response is a delivery failure and is retried under the same policy. The `webhook-durable-delivery` scenario observes retry then dead-letter (conformance.md).
55
+
56
+ ## Replay
57
+
58
+ A host MUST NOT deliver events a `replay` fork re-emits as fixed history; replay-ness is read from the run, never from the event type (replay.md). A `branch` fork's events are new facts and are delivered.
59
+
60
+ ## Egress
61
+
62
+ At registration a host MUST reject (`400 webhook_url_rejected`) non-`https://` URLs, RFC 1918 and loopback and link-local ranges, IPv6 ULA, cloud metadata hosts, and `localhost`. At delivery time a host MUST re-resolve the hostname, validate every resolved address against the same denied ranges plus its own denylist, connect to the validated address without re-resolving, and refuse to follow redirects (invariant `webhook-delivery-egress-revalidation`, reference-impl tier).
63
+
64
+ See also: events.md, replay.md, persistence.md, security-defaults.md.
@@ -0,0 +1,36 @@
1
+ # Workflow Chain Packs
2
+
3
+ > **Status: Draft · v2.0.0-rc (2026-09-03) · RFC 0177, RFC 0133.**
4
+
5
+ ## Why this exists
6
+
7
+ A workflow-chain pack ships a reusable fragment a host expands into a concrete definition, and may compose other chains as co-registered children. v1 left three lifecycle questions open: which version a chain reference binds to, who owns a shared child, and whether `{{params.*}}` may survive into a persisted definition. v2 decides all three. The manifest is `schemas/v2/workflow-chain-pack-manifest.schema.json`; installation and signing follow packs.md.
8
+
9
+ ## Exact pins
10
+
11
+ Every reference a chain makes to a node type or an external chain MUST pin an exact version per referenced `typeId` (`core.ai.callPrompt@1.0.0`). A host MUST refuse to register a chain whose reference carries a range or no version. Ranges are a v2.x additive follow-up and do not exist in v2.0.
12
+
13
+ ## Co-registered children
14
+
15
+ When a parent chain expands a `subChainRef`, the child is registered as its own workflow under a deterministic child id. A host MUST:
16
+
17
+ | Rule | Behavior |
18
+ | --- | --- |
19
+ | Identity | register the child under a deterministic id, so two parents composing the same child share one registration |
20
+ | Reference count | count each parent that references the child; deleting a parent decrements the count |
21
+ | Deletion | delete the child only when its last parent is deleted |
22
+ | Ownership record | persist the resolved child version in the parent's ownership record, so a re-instantiation or `:fork` reproduces the same child |
23
+
24
+ The ownership record is a persistence.md store.
25
+
26
+ ## Parameter substitution
27
+
28
+ `{{params.<name>}}` tokens are substituted at expansion time, when the author drops the tile. A persisted definition MUST NOT contain a `{{params.*}}` token, and a host MUST NOT defer substitution to dispatch time. A portable per-run deferral — materializing chain parameters into workflow variables bound through PromptTemplate `{{varName}}` slots and variable-sourced PortValues — is a named v2.x additive RFC and is not part of v2.0.
29
+
30
+ ## Composition depth
31
+
32
+ A host MUST bound sub-chain nesting by `workflowChainPacks.subChains.maxDepth` (capabilities.md), default 8. A composition that exceeds the depth, or that transitively composes itself, MUST fail closed with `sub_chain_cycle`; the depth check and the cycle check compose as one guard.
33
+
34
+ ## Edge conditions
35
+
36
+ Fragment edges carry the same `condition` and `triggerRule` shapes as a top-level definition (`schemas/v2/workflow-definition.schema.json`). `EdgeCondition.type` is one of `expression`, `equals`, `notEquals`, `contains`, `regex`, `truthy`, `falsy`; `truthy` and `falsy` take `left` and no `right`. A host MUST carry both fields through expansion verbatim and MUST honor them on expanded edges as on authored ones. form-content-packs.md reuses this operator set for field visibility.
@@ -15,6 +15,14 @@
15
15
  "openwop",
16
16
  "vendor"
17
17
  ],
18
+ "extensions": {
19
+ "example": {
20
+ "name": "Reserved for documentation, examples and conformance",
21
+ "registered": "2026-09-06",
22
+ "reserved": true,
23
+ "note": "Never assignable to a real vendor (RFC 2606 precedent). A conformance scenario needs one registered org whose events no host owns, so the positive half of the vendor rule is drivable; without it the registry could only ever say no."
24
+ }
25
+ },
18
26
  "metadata": [
19
27
  {
20
28
  "key": "protocolVersion",
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://json-schema.org/draft/2020-12/schema",
3
3
  "$id": "https://openwop.dev/spec/v2/declaration.schema.json",
4
- "title": "OpenWOP v2 capability declaration file (RFC 0169 §B)",
4
+ "title": "OpenWOP v2 capability declaration file (RFC 0169 \u00a7B)",
5
5
  "type": "object",
6
6
  "additionalProperties": false,
7
7
  "required": [
@@ -11,6 +11,7 @@
11
11
  "witnessClasses",
12
12
  "extensionsKeyPattern",
13
13
  "reservedOrgs",
14
+ "extensions",
14
15
  "metadata",
15
16
  "families",
16
17
  "profiles"
@@ -52,6 +53,37 @@
52
53
  "type": "string"
53
54
  }
54
55
  },
56
+ "extensions": {
57
+ "type": "object",
58
+ "description": "The vendor-org REGISTRY (events.md \u00a7Rules, RFC 0171 \u00a7A.1): a vendor event type's first segment MUST be a key here, and an unregistered org fails validation. Distinct from the `extensions` metadata key in the discovery payload (metadata[] row `extensions`, schemas/v2/capabilities.schema.json), which is a HOST's own extension map. An org in reservedOrgs is forbidden, never registered. An empty object is a well-formed registry that admits no vendor type \u2014 it is not a licence to skip the check.",
59
+ "propertyNames": {
60
+ "pattern": "^[a-z][a-z0-9]*(-[a-z0-9]+)*$"
61
+ },
62
+ "additionalProperties": {
63
+ "type": "object",
64
+ "additionalProperties": false,
65
+ "required": [
66
+ "name",
67
+ "registered"
68
+ ],
69
+ "properties": {
70
+ "name": {
71
+ "type": "string"
72
+ },
73
+ "registered": {
74
+ "type": "string",
75
+ "format": "date"
76
+ },
77
+ "reserved": {
78
+ "type": "boolean",
79
+ "description": "true when the org is held by the protocol and never assignable to a vendor."
80
+ },
81
+ "note": {
82
+ "type": "string"
83
+ }
84
+ }
85
+ }
86
+ },
55
87
  "metadata": {
56
88
  "type": "array",
57
89
  "items": {
@@ -299,7 +331,7 @@
299
331
  }
300
332
  }
301
333
  },
302
- "description": "Hand-decided alias rows for registry peer-dependency keys the mechanical rules (host.<family>, openwop.<family>.<facet>, <family>.<facet>) cannot explain (RFC 0177 §B.2)."
334
+ "description": "Hand-decided alias rows for registry peer-dependency keys the mechanical rules (host.<family>, openwop.<family>.<facet>, <family>.<facet>) cannot explain (RFC 0177 \u00a7B.2)."
303
335
  }
304
336
  }
305
337
  }
@@ -0,0 +1,20 @@
1
+ # `a2uiSurface` — extension
2
+
3
+ | Field | Value |
4
+ | --- | --- |
5
+ | **witness:** | `claims-check` |
6
+ | **technical:** | `experimental` |
7
+ | **adoption:** | `none` |
8
+ | **peer-dependency id** | `a2uiSurface` |
9
+ | **advertised as** | `extensions.<org>.a2uiSurface` (RFC 0169 §A.4) — never a root key |
10
+ | **owning RFC** | RFC 0114 |
11
+
12
+ > **Status: Draft · v2.0.0-rc (2026-09-03).** Extension document (RFC 0169 §B.3; RFC 0167 Axiom 1: a family that cannot be witnessed unaided lives here and is not a core obligation).
13
+
14
+ ## What it is
15
+
16
+ RFC 0169 §C.5 (deltaTransport claims-check; ext/ unless a behavioral witness lands) The v1 prose that defines the surface is `spec/v1/capabilities.md` (root key `a2uiSurface`, RFC 0114); it stands as the definition until this document carries its own normative text (P3-E and the Phase 4 host legs). A host that serves this surface advertises it under `extensions.<org>.a2uiSurface` with the RFC 0169 record shape; a pack that requires it names `a2uiSurface` in `peerDependencies` (RFC 0177 §B.1).
17
+
18
+ ## Witness
19
+
20
+ `claims-check`: the suite can read the claim from discovery but has no behavioral probe; adoption is measured by the INTEROP-MATRIX bundle evidence.
@@ -0,0 +1,20 @@
1
+ # `brand` — extension
2
+
3
+ | Field | Value |
4
+ | --- | --- |
5
+ | **witness:** | `claims-check` |
6
+ | **technical:** | `experimental` |
7
+ | **adoption:** | `single-witness` |
8
+ | **peer-dependency id** | `brand` |
9
+ | **advertised as** | `extensions.<org>.brand` (RFC 0169 §A.4) — never a root key |
10
+ | **owning RFC** | RFC 0144 |
11
+
12
+ > **Status: Draft · v2.0.0-rc (2026-09-03).** Extension document (RFC 0169 §B.3; RFC 0167 Axiom 1: a family that cannot be witnessed unaided lives here and is not a core obligation).
13
+
14
+ ## What it is
15
+
16
+ RFC 0169 §B.3 (RFC 0144 extension class; prose-only §host.* section in v1; served under extensions.openwop-app.* by the one host that has it) The v1 prose that defines the surface is `spec/v1/host-capabilities.md` §host.brand (RFC 0144 extension class); it stands as the definition until this document carries its own normative text (P3-E and the Phase 4 host legs). A host that serves this surface advertises it under `extensions.<org>.brand` with the RFC 0169 record shape; a pack that requires it names `brand` in `peerDependencies` (RFC 0177 §B.1).
17
+
18
+ ## Witness
19
+
20
+ `claims-check`: the suite can read the claim from discovery but has no behavioral probe; adoption is measured by the INTEROP-MATRIX bundle evidence.
@@ -0,0 +1,20 @@
1
+ # `canvas` — extension
2
+
3
+ | Field | Value |
4
+ | --- | --- |
5
+ | **witness:** | `claims-check` |
6
+ | **technical:** | `experimental` |
7
+ | **adoption:** | `single-witness` |
8
+ | **peer-dependency id** | `canvas` |
9
+ | **advertised as** | `extensions.<org>.canvas` (RFC 0169 §A.4) — never a root key |
10
+ | **owning RFC** | RFC 0144 |
11
+
12
+ > **Status: Draft · v2.0.0-rc (2026-09-03).** Extension document (RFC 0169 §B.3; RFC 0167 Axiom 1: a family that cannot be witnessed unaided lives here and is not a core obligation).
13
+
14
+ ## What it is
15
+
16
+ RFC 0169 §B.3 (RFC 0144 extension class; prose-only §host.* section in v1; served under extensions.openwop-app.* by the one host that has it) The v1 prose that defines the surface is `spec/v1/host-capabilities.md` §host.canvas (RFC 0144 extension class); it stands as the definition until this document carries its own normative text (P3-E and the Phase 4 host legs). A host that serves this surface advertises it under `extensions.<org>.canvas` with the RFC 0169 record shape; a pack that requires it names `canvas` in `peerDependencies` (RFC 0177 §B.1).
17
+
18
+ ## Witness
19
+
20
+ `claims-check`: the suite can read the claim from discovery but has no behavioral probe; adoption is measured by the INTEROP-MATRIX bundle evidence.