@openwop/spec-artifacts 2.0.4 → 2.0.6
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.
- package/CORPUS-STAMP.json +49 -11
- package/api/seams-v2.yaml +1 -1
- package/api/v2/asyncapi.yaml +1 -1
- package/api/v2/openapi.yaml +1 -1
- package/package.json +1 -1
- package/schemas/v2/certification-bundle.schema.json +5 -0
- package/spec/v1/core-standard-manifest.json +2 -2
- package/spec/v2/README.md +15 -0
- package/spec/v2/core/capabilities.md +404 -0
- package/spec/v2/core/conformance.md +66 -0
- package/spec/v2/core/connection-packs.md +32 -0
- package/spec/v2/core/errors.md +131 -0
- package/spec/v2/core/events.md +109 -0
- package/spec/v2/core/form-content-packs.md +32 -0
- package/spec/v2/core/headers.md +40 -0
- package/spec/v2/core/idempotency.md +45 -0
- package/spec/v2/core/identity.md +141 -0
- package/spec/v2/core/interop.md +65 -0
- package/spec/v2/core/interrupt.md +83 -0
- package/spec/v2/core/overview.md +58 -0
- package/spec/v2/core/packs.md +72 -0
- package/spec/v2/core/persistence.md +168 -0
- package/spec/v2/core/replay.md +104 -0
- package/spec/v2/core/runs.md +109 -0
- package/spec/v2/core/security-defaults.md +108 -0
- package/spec/v2/core/versioning.md +116 -0
- package/spec/v2/core/webhooks.md +64 -0
- package/spec/v2/core/workflow-chain-packs.md +36 -0
- package/spec/v2/declaration.json +8 -0
- package/spec/v2/declaration.schema.json +34 -2
- package/spec/v2/ext/a2uiSurface/README.md +20 -0
- package/spec/v2/ext/brand/README.md +20 -0
- package/spec/v2/ext/canvas/README.md +20 -0
- package/spec/v2/ext/chat/README.md +20 -0
- package/spec/v2/ext/coordination/README.md +20 -0
- package/spec/v2/ext/dataIntegration/README.md +20 -0
- package/spec/v2/ext/entities/README.md +20 -0
- package/spec/v2/ext/grpc-transport/README.md +13 -0
- package/spec/v2/ext/kanban/README.md +20 -0
- package/spec/v2/ext/knowledge/README.md +20 -0
- package/spec/v2/ext/launchStudio/README.md +20 -0
- package/spec/v2/ext/messaging/README.md +20 -0
- package/spec/v2/ext/portability/README.md +11 -0
- package/spec/v2/ext/provider-idempotency/README.md +11 -0
- package/spec/v2/ext/restTransport/README.md +20 -0
- package/spec/v2/ext/sandbox-runtime-notes/README.md +11 -0
- package/spec/v2/ext/webResearch/README.md +20 -0
- package/spec/v2/release.json +2 -2
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# Conformance
|
|
2
|
+
|
|
3
|
+
> **Status: Draft · v2.0.0-rc (2026-09-03) · RFC 0168.**
|
|
4
|
+
|
|
5
|
+
## Why this exists
|
|
6
|
+
|
|
7
|
+
v1 could say a host passed and could not say what it witnessed: test ids were derived from titles, the witness class was recorded on extensions but not requirements, nine test-seam operations sat in the canonical API, and the certification bundle had an open root and no signature. This document is the v2 evidence contract: how a requirement is asserted, how every requirement declares what can witness it, how the seams are mounted, what the suite ships, and what a bundle proves. Profiles are in overview.md; the capability vocabulary the suite gates on is capabilities.md.
|
|
8
|
+
|
|
9
|
+
## Requirement ids
|
|
10
|
+
|
|
11
|
+
`expect(x, req('openwop.<area>.<slug>', '<doc> §<section>', '<requirement>'))` is the only assertion form. A scenario assertion without a requirement id MUST fail the suite's lint. Ids are minted in `conformance/requirements.json`; every test declares its id explicitly. A title reword without a corresponding `requirement-aliases.json` row MUST fail CI, because published bundles cite ids and an orphaned id orphans every bundle that cited it.
|
|
12
|
+
|
|
13
|
+
The ledger records per `it`, and a bundle's `results.requirements[]` is the per-assertion list. A post-assertion soft-skip MUST record `skipped` for every id not reached and MUST NOT record `pass`.
|
|
14
|
+
|
|
15
|
+
## Witness class
|
|
16
|
+
|
|
17
|
+
Every family in `spec/v2/declaration.json`, every requirement in `conformance/requirements.json`, and every row of `SECURITY/invariants.yaml` MUST carry `witness` from the closed set:
|
|
18
|
+
|
|
19
|
+
| Class | Meaning |
|
|
20
|
+
| --- | --- |
|
|
21
|
+
| `witnessable-unaided` | the suite observes it on any host with no advertisement |
|
|
22
|
+
| `witnessable-gated` | observed when the host advertises the gating capability |
|
|
23
|
+
| `seam-gated` | observed only through the seams profile |
|
|
24
|
+
| `claims-check` | the host's own claim is checked for shape, not behavior |
|
|
25
|
+
| `negative-existence` | the suite asserts a thing is absent |
|
|
26
|
+
| `unwitnessable` | no observation path exists; `rationale` REQUIRED |
|
|
27
|
+
|
|
28
|
+
A protocol-tier invariant marked `unwitnessable` MUST fail the corpus gate. `tests: []` is expressible only as `unwitnessable`. A bundle disposition `blocked` does not exist as a witness class: what v1 called blocked is `seam-gated` or `unwitnessable`.
|
|
29
|
+
|
|
30
|
+
The six v1 certification admissions map to one class each: the shape-versus-behavior dual grade is `witnessable-gated` on the behavioral leg, never two grades; "blocked as unobservable" is `seam-gated` or `unwitnessable`; install-time-only extension opacity is `claims-check`; corpus-structural legs run in the spec repo's CI and have no host class; gRPC end-to-end is `unwitnessable` (interop.md); a negative-existence claim is `negative-existence`.
|
|
31
|
+
|
|
32
|
+
A MUST whose only witness is `seam-gated` MUST either mint a normative observation path before the cut or be demoted to SHOULD. The seam count in `docs/witness-baseline.json` is a ratchet and MUST NOT rise.
|
|
33
|
+
|
|
34
|
+
## The seams profile
|
|
35
|
+
|
|
36
|
+
Test seams are the profile `openwop-conformance-seams-v2` (`spec/v2/profiles.json`), described by `api/seams-v2.yaml` with schemas under `schemas/v2/seams/`, in the path space `/conformance/seams/…`. The seam schemas `$ref` the canonical error and event schemas with no tolerance path. A host that mounts the seams MUST advertise the profile in `profiles[]`; a `testSeams` capability flag does not exist and MUST NOT be advertised. `api/v2/openapi.yaml` and `spec/v2/path-manifest.json` MUST contain no seam or sample-host operation; an SDK generated from the canonical document has no seam method. The profile is versioned with the suite (`seams-v2` for 2.x).
|
|
37
|
+
|
|
38
|
+
## Two products, two ledgers
|
|
39
|
+
|
|
40
|
+
Corpus-coherence checks run in the spec repo's CI (`scripts/check-spec-coherence.mjs`) and MUST NOT appear in a host bundle; the bundle schema forbids their ids. `--offline` is a declared property of a scenario, not a runtime discovery.
|
|
41
|
+
|
|
42
|
+
`@openwop/openwop-conformance@2.0.0` ships `dist`, `fixtures`, and `vectors` only. The corpus — `api/`, `schemas/`, the `spec/v2/*.json` registries, `CORPUS-STAMP.json` — is `@openwop/spec-artifacts@2.0.0`, an exact-pinned peer dependency the suite MUST digest-check at start and MUST refuse to run against on a mismatch. The suite is one package: `--target-major 1|2` selects the target (default: the host's `preferredVersion`), scenario ids share one namespace across majors, and the 1.x target is removed at v1 end-of-support.
|
|
43
|
+
|
|
44
|
+
## Bundle v3
|
|
45
|
+
|
|
46
|
+
A certification bundle validates against `schemas/v2/certification-bundle.schema.json`: closed root, `bundleVersion: "3"`.
|
|
47
|
+
|
|
48
|
+
| Field | Rule |
|
|
49
|
+
| --- | --- |
|
|
50
|
+
| `suite` | `name`, `version`, `targetMajor`, `specArtifactsVersion` REQUIRED |
|
|
51
|
+
| `host` | `name`, `version`, `build.{kind, id}` REQUIRED; `kind` is `image-digest`, `commit`, or `artifact-sha256` |
|
|
52
|
+
| `discovery` | `url`, `sha256`, `protocolVersions`, `preferredVersion` REQUIRED |
|
|
53
|
+
| `claimedProfiles[]` | `id`, `evidenceTier` (`self` \| `steward` \| `independent`), `witnessCount`, `certified` REQUIRED |
|
|
54
|
+
| `results` | `totals` and the per-requirement list REQUIRED |
|
|
55
|
+
| `witnessSha256` | REQUIRED; covers the reporter record |
|
|
56
|
+
| `assertionCount` | REQUIRED, ≥ 1 |
|
|
57
|
+
| `detail.nonPass[]` | REQUIRED when any total other than `executedPass` is non-zero |
|
|
58
|
+
| `signature` | REQUIRED |
|
|
59
|
+
|
|
60
|
+
`signature` is an Ed25519 attestation over the canonical JSON of `{ witnessSha256, host.build, suite.version, discovery.sha256 }`; `over` MUST list exactly those four members. A host that signs bundles MUST publish the corresponding public keys as `signingKeys[]` in its discovery document, and `signature.keyId` MUST name one of them. A verifier MUST resolve `keyId` there — in the discovery document of the host the bundle is *about* — and MUST verify the attestation under the published key.
|
|
61
|
+
|
|
62
|
+
A signature that cannot be resolved to a published key attests **integrity only**: it proves the bundle was not altered after signing, and proves nothing about who signed it, because a signer can mint a keypair and a key id at will. Such a bundle MUST NOT be read as attributable evidence, and a gate MUST distinguish three outcomes that a presence check collapses into one — *no discovery document was read*, *read and the key is not published*, and *the attestation does not verify*. A retired key MUST stay listed, because removing it silently invalidates every bundle it already signed. `evidenceTier: independent` MUST carry a `verifierKeyId` distinct from the host's signing key; the verifier MUST refuse, not warn, on a missing or self-signed independent claim. A bundle with `totals.blocked > 0` does not certify. A profile that carries an operator relaxation (`host.relaxations[]`) cannot certify. v1 and v2 bundles are never upgraded to v3; a bundle is evidence at its own version.
|
|
63
|
+
|
|
64
|
+
## Corpus-gate evidence
|
|
65
|
+
|
|
66
|
+
An RFC whose acceptance criteria are corpus gates rather than host scenarios records the evidence label **corpus gate — no host tier** in its `Updated` line; the accepted-predicate check reads `(corpus)` rows from `evidence/corpus-ledger.json` for such an RFC and MUST NOT require a host bundle for it.
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# Connection Packs
|
|
2
|
+
|
|
3
|
+
> **Status: Draft · v2.0.0-rc (2026-09-03) · RFC 0177, RFC 0095.**
|
|
4
|
+
|
|
5
|
+
## Why this exists
|
|
6
|
+
|
|
7
|
+
A connection pack is a signed provider definition — the endpoints, scope catalog, and reach a connector's `auth.provider` string resolves against. v1 let two definitions claim one `provider.id` and picked between them silently by version. v2 makes the id unique per host and makes a collision fail closed. The manifest is `schemas/v2/connection-pack-manifest.schema.json`; installation and signing follow packs.md.
|
|
8
|
+
|
|
9
|
+
## Provider identity
|
|
10
|
+
|
|
11
|
+
A `provider.id` MUST be unique per host. Built-in provider definitions are the host's own pack for every rule in this document.
|
|
12
|
+
|
|
13
|
+
| Situation | Host behavior |
|
|
14
|
+
| --- | --- |
|
|
15
|
+
| Exactly one definition of bare id `P` | `P` resolves to it |
|
|
16
|
+
| An installed pack and a built-in both define `P` | the later registration MUST be refused with `connection_provider_conflict` |
|
|
17
|
+
| Two installed packs both define `P` | the later registration MUST be refused with `connection_provider_conflict` |
|
|
18
|
+
| No definition of `P` | the dependent connector or pack MUST be refused with `connection_provider_unresolved` |
|
|
19
|
+
|
|
20
|
+
A host MUST NOT choose between two claimants by comparing versions.
|
|
21
|
+
|
|
22
|
+
## The qualified form
|
|
23
|
+
|
|
24
|
+
A connector MAY name a provider by its qualified form `<packName>#<id>`. A qualified reference resolves only to the named pack's definition. A bare id resolves only when exactly one definition exists on the host.
|
|
25
|
+
|
|
26
|
+
## Resolution
|
|
27
|
+
|
|
28
|
+
A host advertising `connections.packsSupported` MUST resolve a connector's `auth: { type: "oauth2", provider: P }` (and any `host.oauth` invocation for `P`) against the definition selected above to obtain its endpoints and scope catalog. An unresolvable provider MUST be refused when the dependent connector or pack is registered. On a publish-path host, resolution MUST run after the idempotency short-circuit: a byte-identical re-publish of an installed pack MUST succeed even when resolution inputs have since changed.
|
|
29
|
+
|
|
30
|
+
## Errors
|
|
31
|
+
|
|
32
|
+
Both codes are in `spec/v2/errors.json`: `connection_provider_conflict` (two claimants for one bare id) and `connection_provider_unresolved` (no definition for the referenced id).
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# Errors
|
|
2
|
+
|
|
3
|
+
> **Status: Draft · v2.0.0-rc (2026-09-03) · RFC 0171 §B.**
|
|
4
|
+
|
|
5
|
+
## Why this exists
|
|
6
|
+
|
|
7
|
+
Every error a v2 host returns is a row in one registry. A client routes on `error`, never on `message`, and a code that is not registered is not a protocol error. The registry is the single source for the envelope schema, the HTTP status, and retriability.
|
|
8
|
+
|
|
9
|
+
## The registry
|
|
10
|
+
|
|
11
|
+
`spec/v2/errors.json` holds one row per code: `{ code, httpStatus, retriable, details, since, deprecated? }` plus the provenance fields `statusSource` and `source`. It registers **96** codes. `schemas/v2/error-envelope.schema.json` is GENERATED from it and MUST NOT be edited by hand.
|
|
12
|
+
|
|
13
|
+
A host MUST return a registered code, or a vendor code, in every error response. A vendor code MUST match `^(?!openwop\.)[a-z][a-z0-9]*(-[a-z0-9]+)*\.[a-z][a-z0-9_]*$` with its first segment an org registered in `spec/v2/declaration.json`; `openwop.` is reserved. The registry grows by the closed-enum rule in overview.md §0: a producer MUST NOT emit an unregistered member, and a consumer MUST accept an unknown registered member and MUST NOT act on it.
|
|
14
|
+
|
|
15
|
+
## The envelope
|
|
16
|
+
|
|
17
|
+
Every error response body MUST be `{ error, message, details? }` and nothing else (`additionalProperties: false`). `error` is the registered code or a vendor code; `message` is a non-empty string; `details` is an object whose shape is the row's `details` schema. A row whose `details` is `null` accepts any object; when a row registers a schema, the generated envelope becomes a `oneOf` discriminated on `error`. Contextual data (conflict refs, trace ids, validation paths) MUST live under `details`, never at a new top level. The same envelope is the per-id `error` of `bulkCancelRuns` (runs.md). When present, `details.correlationId` MUST be a non-empty string.
|
|
18
|
+
|
|
19
|
+
`x-openwop-http-status` and `x-openwop-retriable` in the generated schema mirror the registry; a host MUST answer with the registered status.
|
|
20
|
+
|
|
21
|
+
## Retry timing
|
|
22
|
+
|
|
23
|
+
Retry timing lives in the `Retry-After` header only. `details.retryAfter`, `details.retryAfterMs` and `details.retryAfterSeconds` are not part of v2 and a host MUST NOT emit them. A `429 rate_limited` response MUST set `Retry-After`. The retriable rows are `residency_unavailable`, `rate_limited`, `internal_error`, `pack_registry_unreachable`, `runner_unavailable`.
|
|
24
|
+
|
|
25
|
+
## One code per state
|
|
26
|
+
|
|
27
|
+
An interrupt has one code per state: a token or run-scoped resolve against an interrupt that is already resolved, or whose run is cancelled or completed, MUST return `409 interrupt_already_resolved`; a signed token past its `expiresAt` MUST return `410 interrupt_expired`; a token whose `alg` or `kid` the host does not accept MUST return `401 interrupt_token_invalid` (see interrupt.md, identity.md). The idempotency mismatch code is `idempotency_key_mismatch` only (idempotency.md).
|
|
28
|
+
|
|
29
|
+
## Codes by HTTP status
|
|
30
|
+
|
|
31
|
+
Generated from `spec/v2/errors.json` (97 codes; `retriable` and `statusSource` are in the registry).
|
|
32
|
+
|
|
33
|
+
Code | Status
|
|
34
|
+
--- | ---
|
|
35
|
+
`connection_provider_unresolved` | 400
|
|
36
|
+
`connector_action_unresolved` | 400
|
|
37
|
+
`credential_scope_unsupported` | 400
|
|
38
|
+
`delegation_chain_cyclic` | 400
|
|
39
|
+
`delegation_chain_too_long` | 400
|
|
40
|
+
`idempotency_key_invalid` | 400
|
|
41
|
+
`interop_version_unsupported` | 400
|
|
42
|
+
`oauth_provider_unsupported` | 400
|
|
43
|
+
`oauth_scope_unsupported` | 400
|
|
44
|
+
`pack_dependency_cycle` | 400
|
|
45
|
+
`pack_engine_unsupported` | 400
|
|
46
|
+
`pack_integrity_failure` | 400
|
|
47
|
+
`pack_kind_invalid` | 400
|
|
48
|
+
`pack_lockfile_incomplete` | 400
|
|
49
|
+
`pack_peer_dependency_missing` | 400
|
|
50
|
+
`pack_peer_dependency_undefined` | 400
|
|
51
|
+
`pack_signature_invalid` | 400
|
|
52
|
+
`pack_validation_failed` | 400
|
|
53
|
+
`protocol_version_mismatch` | 400
|
|
54
|
+
`schedule_horizon_exceeded` | 400
|
|
55
|
+
`sub_chain_cycle` | 400
|
|
56
|
+
`sub_chain_depth_exceeded` | 400
|
|
57
|
+
`unsupported_stream_mode` | 400
|
|
58
|
+
`until_in_past` | 400
|
|
59
|
+
`validation_error` | 400
|
|
60
|
+
`webhook_url_rejected` | 400
|
|
61
|
+
`audience_mismatch` | 401
|
|
62
|
+
`connector_auth_expired` | 401
|
|
63
|
+
`credential_revoked` | 401
|
|
64
|
+
`delegation_expired` | 401
|
|
65
|
+
`identity_unresolvable` | 401
|
|
66
|
+
`identity_unverified` | 401
|
|
67
|
+
`interrupt_token_invalid` | 401
|
|
68
|
+
`key_revoked` | 401
|
|
69
|
+
`sender_constraint_missing` | 401
|
|
70
|
+
`unauthenticated` | 401
|
|
71
|
+
`credential_forbidden` | 403
|
|
72
|
+
`delegation_scope_amplified` | 403
|
|
73
|
+
`forbidden` | 403
|
|
74
|
+
`force_engine_version_forbidden` | 403
|
|
75
|
+
`id_tenant_mismatch` | 403
|
|
76
|
+
`mock_provider_forbidden` | 403
|
|
77
|
+
`pack_namespace_unauthorized` | 403
|
|
78
|
+
`run_forbidden` | 403
|
|
79
|
+
`sandbox_capability_denied` | 403
|
|
80
|
+
`sandbox_escape_attempt` | 403
|
|
81
|
+
`workspace_membership_required` | 403
|
|
82
|
+
`credential_not_found` | 404
|
|
83
|
+
`interrupt_not_found` | 404
|
|
84
|
+
`not_found` | 404
|
|
85
|
+
`pack_version_not_found` | 404
|
|
86
|
+
`replay_source_missing` | 404
|
|
87
|
+
`signature_not_available` | 404
|
|
88
|
+
`protocol_version_unsupported` | 406
|
|
89
|
+
`connection_provider_conflict` | 409
|
|
90
|
+
`envelope_correlation_conflict` | 409
|
|
91
|
+
`idempotency_in_flight` | 409
|
|
92
|
+
`idempotency_key_mismatch` | 409
|
|
93
|
+
`interrupt_already_resolved` | 409
|
|
94
|
+
`pack_dependency_conflict` | 409
|
|
95
|
+
`pack_integrity_mismatch` | 409
|
|
96
|
+
`replay_diverged_at_refusal` | 409
|
|
97
|
+
`replay_memory_snapshot_unavailable` | 409
|
|
98
|
+
`run_already_active` | 409
|
|
99
|
+
`run_terminal` | 409
|
|
100
|
+
`run_state_conflict` | 409
|
|
101
|
+
`version_conflict` | 409
|
|
102
|
+
`workspace_conflict` | 409
|
|
103
|
+
`interrupt_cancelled` | 410
|
|
104
|
+
`interrupt_expired` | 410
|
|
105
|
+
`workspace_too_large` | 413
|
|
106
|
+
`payload_too_large` | 413
|
|
107
|
+
`unsupported_media_type` | 415
|
|
108
|
+
`capability_not_provided` | 422
|
|
109
|
+
`capability_required` | 422
|
|
110
|
+
`credential_required` | 422
|
|
111
|
+
`envelope_invalid` | 422
|
|
112
|
+
`envelope_refusal` | 422
|
|
113
|
+
`fork_point_invalid` | 422
|
|
114
|
+
`envelope_truncation_unrecoverable` | 422
|
|
115
|
+
`loop_limit_exceeded` | 422
|
|
116
|
+
`mcp_mrtr_rounds_exceeded` | 422
|
|
117
|
+
`pack_runtime_requirement_unmet` | 422
|
|
118
|
+
`recursion_limit_exceeded` | 422
|
|
119
|
+
`residency_unavailable` | 422
|
|
120
|
+
`run_timeout` | 422
|
|
121
|
+
`sandbox_memory_exceeded` | 422
|
|
122
|
+
`sandbox_timeout` | 422
|
|
123
|
+
`token_budget_exceeded` | 422
|
|
124
|
+
`client_version_unsupported` | 426
|
|
125
|
+
`rate_limited` | 429
|
|
126
|
+
`event_type_unmapped` | 500
|
|
127
|
+
`internal_error` | 500
|
|
128
|
+
`pack_load_failure` | 500
|
|
129
|
+
`credential_unavailable` | 501
|
|
130
|
+
`pack_registry_unreachable` | 503
|
|
131
|
+
`runner_unavailable` | 503
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# Events
|
|
2
|
+
|
|
3
|
+
> **Status: Draft · v2.0.0-rc (2026-09-03) · RFC 0171 §A, §E; RFC 0176 §A.**
|
|
4
|
+
|
|
5
|
+
## Why this exists
|
|
6
|
+
|
|
7
|
+
A run is its append-only event log; every snapshot, stream, poll, fork and diff is a projection of it. v2 has one event envelope, one closed type registry with one naming rule, one payload registry, one ordering field, one events channel and one poll cursor, so that a typo is a validation failure and not a silently ignored event.
|
|
8
|
+
|
|
9
|
+
## The envelope
|
|
10
|
+
|
|
11
|
+
`schemas/v2/run-event.schema.json` (`RunEventDoc`) is closed. `eventId`, `runId`, `type`, `payload`, `timestamp`, `sequence` and `schemaVersion` are REQUIRED; `nodeId`, `engineVersion` and `causationId` are OPTIONAL. Every id field `$ref`s its grammar in `schemas/v2/ids.schema.json` (identity.md).
|
|
12
|
+
|
|
13
|
+
| Field | Rule |
|
|
14
|
+
| --- | --- |
|
|
15
|
+
| `sequence` | The one ordering field: integer ≥ 0, first event `0`, strictly increasing per run. Persisted logs are never renumbered. |
|
|
16
|
+
| `schemaVersion` | Per-event schema version, integer ≥ 1, first-class (RFC 0172 §B axis 5). |
|
|
17
|
+
| `engineVersion` | Integer ≥ 0 everywhere (RFC 0172 §B axis 3). |
|
|
18
|
+
| `eventId` | Host-minted, opaque; consumers MUST treat it as a string. |
|
|
19
|
+
| `causationId` | The `eventId`, or AI-envelope `correlationId`, that caused this event. |
|
|
20
|
+
| `timestamp` | ISO 8601. |
|
|
21
|
+
|
|
22
|
+
A consumer MUST NOT throw on an event whose `type` it does not know; it folds what it understands and ignores the rest.
|
|
23
|
+
|
|
24
|
+
## Types
|
|
25
|
+
|
|
26
|
+
`type` is `oneOf` a closed enum of registered protocol types and a vendor pattern. The enum is GENERATED from `spec/v2/event-codemap.json` (117 rows, every row `decided`) and MUST NOT be edited by hand. The vendor branch is exactly:
|
|
27
|
+
|
|
28
|
+
```text
|
|
29
|
+
^(?!openwop\.)[a-z][a-z0-9]*(-[a-z0-9]+)*\.[a-z][a-z0-9]*(-[a-z0-9]+)*(\.[a-z][a-z0-9]*(-[a-z0-9]+)*)?$
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
| Rule | Requirement |
|
|
33
|
+
| --- | --- |
|
|
34
|
+
| Naming | A protocol type is `domain.verb-ed`: kebab-case, exactly two segments, past tense for a transition (`run.started`, `node.suspend-failed`, `run.resume-started`). `domain.noun` is permitted only for an emitted artifact (`output.chunk`, `provider.usage`, `channel.presence`, `agent.handoff`, `envelope.refusal`, `agent.reasoning-delta`, `voice.synthesis-chunk`, `voice.endpoint-candidate`); each exception is recorded in the codemap and checked by the corpus gate. |
|
|
35
|
+
| Reserved prefix | `openwop.` is the only reserved prefix. `core.`, `community.`, `vendor.`, `private.` and `local.` are pack namespaces, not event namespaces, and a type under them is invalid. |
|
|
36
|
+
| Vendor events | A vendor type's first segment MUST be an org registered in the `extensions` object of `spec/v2/declaration.json` — the ORG REGISTRY, not the `extensions` metadata key a host publishes in its own discovery payload; an unregistered org fails validation. An org named in `reservedOrgs` is forbidden, never registered, and `extensionsKeyPattern` is the shape a vendor type must have, not a permission to use it. The registry is normally small: `example` is held by the protocol for documentation and conformance and is never assignable to a vendor. |
|
|
37
|
+
| Growth | The registry grows by the closed-enum rule in overview.md §0: a producer MUST NOT emit an unregistered protocol type; a consumer MUST accept an unknown registered member and MUST NOT act on it. |
|
|
38
|
+
|
|
39
|
+
## Payloads
|
|
40
|
+
|
|
41
|
+
`schemas/v2/run-event-payloads.schema.json` holds one `$defs` entry per payload, every entry `additionalProperties: false`, and `_typeIndex`: the NORMATIVE map from v2 type to `$defs` key, GENERATED from `spec/v2/event-codemap.json`. A host MUST emit a payload that validates against the entry `_typeIndex` names for its `type`. Sub-typing is `$ref` composition, never duplication: `approval.*` and `clarification.*` resolve to `interruptRequested` / `interruptResolved` (interrupt.md); `lease.acquired`, `lease.renewed` and `lease.lost` share `leaseLifecycle`.
|
|
42
|
+
|
|
43
|
+
The CloudEvents mapping and the webhook delivery envelope are GENERATED from the same definition (one source, three renderings): the event's `type`, `eventId`, `sequence` and `payload` are byte-identical across the run stream, a CloudEvents rendering and a webhook delivery.
|
|
44
|
+
|
|
45
|
+
`run.started` carries `owner { tenant, workspace?, subject }`, the same closed block as `RunSnapshot.owner` with `subject` REQUIRED (runs.md, identity.md). `run.cancelled` carries `reason`, `cancelledBy`, `durationMs` and `parentRunId`. `run.completed` MUST carry `outputs` as an object — an empty object is a valid value; an absent key is not. A client cannot tell "no outputs" from "outputs not rendered" when the key is missing, and until 2026-09-04 no schema in either major required it: v1 named the property, required nothing and left the object open, so a host emitted the singular `output` for its whole life and validated every time. Closing the object in v2 caught the extra key; only this sentence and its witness (`v2-run-completed-outputs`) catch an absent one.
|
|
46
|
+
|
|
47
|
+
## AI envelopes: E1–E5
|
|
48
|
+
|
|
49
|
+
`schemas/v2/ai-envelope.schema.json` is the shape an LLM emits; the engine records its acceptance as one or more `RunEventDoc`s. In v2 `correlationId` and `meta.source` are REQUIRED on every envelope, and an engine MUST reject an envelope that omits either; nothing is synthesized. An envelope kind MUST be namespaced under the same `<org>.` rule as events, universal kinds excepted.
|
|
50
|
+
|
|
51
|
+
| Gap | Contract |
|
|
52
|
+
| --- | --- |
|
|
53
|
+
| E1 partial reassembly | Every chunk of one partial emission carries the same `correlationId`; the events that record them are ordered by `sequence`; the emission is complete at the first recorded chunk with `partial: false`. A consumer MAY render progressively but MUST NOT enable any action before that event. |
|
|
54
|
+
| E2 multi-turn correlation | Each turn is an envelope with its own `correlationId`; every event it produces carries `causationId = correlationId`. A re-emission with a `correlationId` already recorded in the run MUST return the cached outcome and MUST NOT emit new events. |
|
|
55
|
+
| E3 vendor kinds | The registry of vendor kinds is `spec/v2/declaration.json`; a kind whose org is not registered is invalid. |
|
|
56
|
+
| E4 sub-typing | `$ref` composition, as in the payload registry above. |
|
|
57
|
+
| E5 refusal × retry | `configurable.ai.maxRefusals` (runs.md) is the ceiling on `envelope.refusal` events a run records. A host MUST NOT retry the emission that produced a refusal. |
|
|
58
|
+
|
|
59
|
+
Worked example (E5), `maxRefusals: 2`:
|
|
60
|
+
|
|
61
|
+
```text
|
|
62
|
+
seq 7 envelope.refusal nodeId n1 (refusal 1; the run's retry policy re-dispatches n1)
|
|
63
|
+
seq 9 envelope.refusal nodeId n1 (refusal 2 = ceiling)
|
|
64
|
+
seq 10 node.failed error.code envelope_refusal; n1 is not re-dispatched
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## The events channel
|
|
68
|
+
|
|
69
|
+
`api/v2/asyncapi.yaml` declares one channel, `runEvents`, at `/runs/{runId}/events`, and `api/v2/openapi.yaml` declares the same path (`streamRunEvents`); the two MUST resolve to the same absolute path (RFC 0172 §C.2). The `streamMode` query parameter is one pattern, not four enums:
|
|
70
|
+
|
|
71
|
+
```text
|
|
72
|
+
^(values|(updates|messages|debug)(,(updates|messages|debug))*)$
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
The default is `updates`. A host MUST implement `updates` and SHOULD implement all four. A value outside the pattern, or a mode the host does not implement, MUST return `400 unsupported_stream_mode` with `details.supported` listing each individual mode the host serves; combinations are not listed. Validation MUST run before any content negotiation.
|
|
76
|
+
|
|
77
|
+
| Mode | Emits | Combines |
|
|
78
|
+
| --- | --- | --- |
|
|
79
|
+
| `updates` | Run transitions, terminal node transitions, suspensions, `node.dispatched`, interrupt events, `artifact.created`, `eval.*`, `deployment.*`, `workspace.updated`; each payload is a delta | yes |
|
|
80
|
+
| `values` | One synthesized `state.snapshot` (`schemas/v2/run-snapshot.schema.json`) after each `updates`-tier transition | never |
|
|
81
|
+
| `messages` | `ai.message.chunk` (`outputChunk` payload) from streaming AI nodes only; a host MUST populate a Tier 1 `meta` slot whenever it has the data | yes |
|
|
82
|
+
| `debug` | Every event in the log, including `log.appended`, `variable.changed`, `version.pinned`, `lease.*`, `node.retried` and every vendor event | yes |
|
|
83
|
+
|
|
84
|
+
Vendor events appear in `debug` only. In a mixed mode the host emits the union of the filters in log order and MUST NOT reorder; each frame SHOULD carry `event:` naming the mode that admitted it, and a consumer MUST tolerate an event admitted by more than one mode.
|
|
85
|
+
|
|
86
|
+
### SSE frames
|
|
87
|
+
|
|
88
|
+
Each frame carries `id:`, `event:` and `data:`: `id:` is the `sequence`, `event:` is the v2 `type` (single mode), `data:` is the `RunEventDoc`. Two frame names are not types and are not in the `type` enum: `state.snapshot` (the `values` frame, whose `data:` is a `RunSnapshot`) and `batch` (the `bufferMs` frame, whose `data:` is an array of `RunEventDoc`). A host MUST set `Content-Type: text/event-stream`, MUST emit a keep-alive comment at least every 30 seconds, and MUST close the connection after the run's terminal event (`run.completed`, `run.failed`, `run.cancelled`).
|
|
89
|
+
|
|
90
|
+
`Last-Event-ID` resumes every mode: the host MUST look up the event with that sequence, MUST begin at the next sequence, and MUST NOT re-emit the resumption point. In `values` mode resumption MUST emit a `state.snapshot` first. With `bufferMs` (0..5000) the host accumulates events into one `event: batch` frame whose `data:` is an array of `RunEventDoc`; it MUST flush on a terminal event, on `node.suspended`, and on close; the batch's `id:` SHOULD be its highest `sequence` and `Last-Event-ID` MUST honor that id. A consumer MUST tolerate both a one-element batch and an unbatched frame. A host MUST NOT limit subscribers per run except for resource protection, and then MUST answer `429 rate_limited` with `Retry-After` rather than drop silently.
|
|
91
|
+
|
|
92
|
+
### Host events
|
|
93
|
+
|
|
94
|
+
`hostEvents` carries the heartbeat messages (`schemas/v2/heartbeat-evaluated.schema.json`, `schemas/v2/heartbeat-state-changed.schema.json`) at `/host/events` (`streamHostEvents`), the documented default; a host MAY declare another address under `heartbeat.deliveryChannel` (capabilities.md). The channel is content-free of run data. There is no channel without an address.
|
|
95
|
+
|
|
96
|
+
## Poll
|
|
97
|
+
|
|
98
|
+
`GET /runs/{runId}/events/poll` (`pollRunEvents`) is the long-poll fallback.
|
|
99
|
+
|
|
100
|
+
| Parameter | Rule |
|
|
101
|
+
| --- | --- |
|
|
102
|
+
| `afterSequence` | Integer ≥ 0; the response carries events with `sequence > afterSequence`. Omission means "from the first event" (sequence 0). `lastSequence` and `since` are not parameters. |
|
|
103
|
+
| `timeout` | Seconds to wait for new events, 1..60, default 30. |
|
|
104
|
+
|
|
105
|
+
The response is `{ runId, events, lastSequence, status, isTerminal }` (closed): `lastSequence` is the highest sequence in the log at the time of the response, `-1` when the log is empty, and has no other meaning; `status` is the snapshot status; `isTerminal` is whether the run is terminal. A cursor past the end of the log MUST return `200` with an empty `events` array. The shape is declared here and generated into `api/v2/openapi.yaml` from one definition.
|
|
106
|
+
|
|
107
|
+
## Era-2 logs
|
|
108
|
+
|
|
109
|
+
A run whose `eventLogSchemaVersion` is `2` was written by a v1 host. Every reader (poll, stream, fork, diff, debug bundle) MUST translate each event through `spec/v2/event-codemap.json` at the storage boundary: `type` is mapped, the payload is projected, `sequence` (including `0`), `eventId`, `timestamp` and `causationId` pass through. A type the codemap does not name and that carries no vendor org MUST fail the read with `500 event_type_unmapped`. A host MUST NOT carry a private mapping and MUST NOT rewrite era-2 rows in place. Fork and replay over an era-2 parent are in replay.md.
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# Form Content Packs
|
|
2
|
+
|
|
3
|
+
> **Status: Draft · v2.0.0-rc (2026-09-03) · RFC 0177, RFC 0137.**
|
|
4
|
+
|
|
5
|
+
## Why this exists
|
|
6
|
+
|
|
7
|
+
A form-content pack ships declarative form templates a host renders in its own chrome. v1 templates had no conditional visibility, no localization, and no validation beyond `required` and `format`. v2 adds all three by reusing constructs the corpus already defines. The manifest is `schemas/v2/form-content-pack-manifest.schema.json`; installation and signing follow packs.md.
|
|
8
|
+
|
|
9
|
+
## Conditional visibility
|
|
10
|
+
|
|
11
|
+
A field MAY carry `when: <EdgeCondition>`. The grammar is the `WorkflowEdge.condition` object `{ type, left, right }` of `schemas/v2/workflow-definition.schema.json`, with the operator set of workflow-chain-packs.md §"Edge conditions": `type` is one of `expression`, `equals`, `notEquals`, `contains`, `regex`, `truthy`, `falsy`; `truthy` and `falsy` take `left` only. A host MUST evaluate `when` with its edge-condition semantics and MUST NOT accept any other expression language for visibility.
|
|
12
|
+
|
|
13
|
+
```jsonc
|
|
14
|
+
{ "id": "region", "type": "select", "label": "Region",
|
|
15
|
+
"when": { "type": "equals", "left": "fields.shipping", "right": "international" } }
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Localized strings
|
|
19
|
+
|
|
20
|
+
`label`, `title`, and `description` are localized strings. A host MUST select the rendered language by the locale-selection and fallback rules of i18n.md, and MUST treat every rendered string as untrusted: escaped for the target surface, never interpreted as markup, script, or a template directive.
|
|
21
|
+
|
|
22
|
+
## Validation
|
|
23
|
+
|
|
24
|
+
| Constraint | Applies to | Rule |
|
|
25
|
+
| --- | --- | --- |
|
|
26
|
+
| `required` | any type | the value MUST be present |
|
|
27
|
+
| `format` | `text`, `longtext` | one of `email`, `uri`, `date`, `date-time`, `time`, or `x-<format>` |
|
|
28
|
+
| `minLength` | `text`, `longtext` | a host MUST reject a shorter value |
|
|
29
|
+
| `min`, `max` | `number` | a host MUST reject a value outside the closed range |
|
|
30
|
+
| `pattern` | `text`, `longtext` | a host MUST reject a non-matching value |
|
|
31
|
+
|
|
32
|
+
The five spec-reserved `format` values are the core set. A host that recognizes a format SHOULD apply it; one that does not MUST ignore it and accept plain text. A host MUST ignore `format` on any type other than `text` or `longtext`.
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# Headers
|
|
2
|
+
|
|
3
|
+
> **Status: Draft · v2.0.0-rc (2026-09-03) · RFC 0171 §C.1, RFC 0172 §A.3–§A.4.** GENERATED by `scripts/derive-v2-api.py` from `api/v2/openapi.yaml`; do not edit.
|
|
4
|
+
|
|
5
|
+
## Why this exists
|
|
6
|
+
|
|
7
|
+
RFC 0171 §C.1: every non-standard header is `OpenWOP-<Name>` and every header is declared in OpenAPI, so this table — generated from the same declaration — enumerates all of them. A header that is not in this table is not part of the protocol. Standard headers keep their standard names (`Idempotency-Key`, `ETag`, `If-None-Match`, `Last-Event-ID`, `Retry-After`, `Authorization`).
|
|
8
|
+
|
|
9
|
+
## Request headers
|
|
10
|
+
|
|
11
|
+
| Header | Operations | Meaning |
|
|
12
|
+
| --- | --- | --- |
|
|
13
|
+
| `Accept-Language` | 1 | BCP-47 preference list; authoritative for locale selection (i18n.md). A malformed value MUST NOT 400. |
|
|
14
|
+
| `Idempotency-Key` | 14 | Per-mutation idempotency token (see `idempotency.md` Layer 1). |
|
|
15
|
+
| `If-None-Match` | 2 | RFC 0165 §C.2; runs.md §Snapshot. Standard conditional request against any resource that carries an `ETag` — the discovery document (capabilities.md §1) and the run snapshot (runs.md §Snapshot): a matching value MUST yield `304 Not Modified` with no body, and the 304 carries `OpenWOP-Version` like every response (versioning.md §1.4). |
|
|
16
|
+
| `Last-Event-ID` | 1 | Resume from sequence after this ID. |
|
|
17
|
+
| `OpenWOP-Dedup` | 1 | When set, server cross-host claim system rejects duplicate `(tenantId, scopeId)` pairs with `409 Conflict`. |
|
|
18
|
+
| `OpenWOP-Force-Engine-Version` | 1 | **Test-keys-only.** When set, the server emits events for this run AS IF it |
|
|
19
|
+
| `OpenWOP-Version` | 51 | RFC 0172 §A.3 — selects a listed major.minor; absent ⇒ the host's `preferredVersion`; unlisted ⇒ 406 protocol_version_unsupported. |
|
|
20
|
+
|
|
21
|
+
## Response headers
|
|
22
|
+
|
|
23
|
+
| Header | Operations | Meaning |
|
|
24
|
+
| --- | --- | --- |
|
|
25
|
+
| `Cache-Control` | 2 | |
|
|
26
|
+
| `Content-Encoding` | 1 | RFC 0115. Present only when the host negotiated compression |
|
|
27
|
+
| `Content-Language` | 1 | The BCP-47 locale actually used (equals the response `locale`). |
|
|
28
|
+
| `ETag` | 3 | Optional probe handle for mid-session capability change detection. Deprecated toward v2 (RFC 0165 §C.2). |
|
|
29
|
+
| `Location` | 1 | Canonical URI of the new template. |
|
|
30
|
+
| `OpenWOP-Idempotent-Replay` | 1 | Set when the response was served from the idempotency cache. |
|
|
31
|
+
| `OpenWOP-Version` | 51 | RFC 0172 §A.4 — the contract that produced this response; MUST equal the one used. |
|
|
32
|
+
| `Retry-After` | 1 | Seconds until the active claim is stale-eligible. |
|
|
33
|
+
|
|
34
|
+
## Webhook delivery headers
|
|
35
|
+
|
|
36
|
+
Declared in `webhooks.md`, not in OpenAPI (the host is the client): `OpenWOP-Webhook-Id`, `OpenWOP-Event-Type`, `OpenWOP-Timestamp`, `OpenWOP-Signature`, `OpenWOP-Signature-Algorithm` (RFC 0165 §C.1). The `X-openwop-*` family is emitted beside them through the overlap and removed at v1 end-of-support (`spec/v1/deprecations.json` `webhook-x-header-family`).
|
|
37
|
+
|
|
38
|
+
## Removed in v2
|
|
39
|
+
|
|
40
|
+
`Capabilities-Etag` (the standard `ETag`/`If-None-Match` pair applies to the discovery document), `X-Dedup`, `X-Force-Engine-Version`, `X-Pack-Sha256`, `X-Pack-Signing-Method` (renamed under the one scheme), `X-openwop-*` (webhooks), `openwop-Webhook-Signature` (SDK-only). Each has a `spec/v1/deprecations.json` row with a removal trigger.
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# Idempotency
|
|
2
|
+
|
|
3
|
+
> **Status: Draft · v2.0.0-rc (2026-09-03) · RFC 0170 §D.3, RFC 0171 §B.2, RFC 0173 §B.**
|
|
4
|
+
|
|
5
|
+
## Why this exists
|
|
6
|
+
|
|
7
|
+
A retried request MUST NOT create a second run, and a retried node MUST NOT issue a second external effect. A host MUST implement Layer 1 for every mutating endpoint and Layer 2 for every node executor that performs an external side effect.
|
|
8
|
+
|
|
9
|
+
## Layer 1: `Idempotency-Key`
|
|
10
|
+
|
|
11
|
+
The header keeps its standard name (RFC 0171 §C.1) and applies to every mutating operation in `api/v2/openapi.yaml`; `GET` operations MUST NOT honor it.
|
|
12
|
+
|
|
13
|
+
| Rule | Requirement |
|
|
14
|
+
| --- | --- |
|
|
15
|
+
| Grammar | The value MUST match `^[A-Za-z0-9._~-]{22,128}$` and MUST carry at least 128 bits of entropy (a UUIDv4 in canonical or base64url form satisfies it). A host MUST reject a value outside the grammar with `400 idempotency_key_invalid`. |
|
|
16
|
+
| Record key | A record MUST be keyed by `(authenticatedTenantId, canonicalEndpointId, callerIdempotencyKey)`; the tenant MUST come from the credential, never the body. |
|
|
17
|
+
| Final outcomes | A host MUST cache `2xx` and non-retryable `4xx` responses (status, headers, body) and MUST return the cached response to a same-key duplicate. |
|
|
18
|
+
| Retryable outcomes | `429` and `5xx` MUST NOT be replayed from cache; a same-key retry MUST re-execute, and a later final outcome replaces the record. |
|
|
19
|
+
| Not cached | `400 idempotency_key_invalid`, `400 validation_error`, `401` and `403` MUST NOT be cached. |
|
|
20
|
+
| Digest mismatch | A different request digest under the same record key MUST fail with `409 idempotency_key_mismatch` and MUST NOT return the cached body. This is the only mismatch code. |
|
|
21
|
+
| Concurrency | Of two concurrent same-key requests a host MUST process exactly one to completion and MUST NOT process both. Retry timing travels in `Retry-After` only. |
|
|
22
|
+
| Replay marker | A response served from cache MUST carry `OpenWOP-Idempotent-Replay: true`. |
|
|
23
|
+
| Retention | A record MUST be retained for at least 24 hours. |
|
|
24
|
+
| Keyspace | Host-minted identifiers MUST NOT share the caller idempotency store. Logs and spans MUST NOT expose keys. |
|
|
25
|
+
|
|
26
|
+
## Layer 2: effect identity
|
|
27
|
+
|
|
28
|
+
Layer 2 is bound by advertising `idempotency` (security-defaults.md). Its unit is the **effect**, identified once and stable across every transport or provider retry.
|
|
29
|
+
|
|
30
|
+
| Rule | Requirement |
|
|
31
|
+
| --- | --- |
|
|
32
|
+
| Keying | An effect MUST be keyed on its business identity (`keying: business-identity`): derived from the business operation, stable across every entry point, containing no `runId`, `nodeId` or ordinal. The activity recipe (`keying: activity-recipe`: tenant, run, node, ordinal, `providerKey`) is the fallback for a provider with no business key. |
|
|
33
|
+
| Attempts | The retry counter MUST NOT participate in the identity. Two distinct logical invocations MUST receive different identities. |
|
|
34
|
+
| Claim | The persist that guards the effect MUST be an atomic claim (compare-and-set or insert-if-absent) that at most one executor can win, so at most one concurrent duplicate performs the effect. |
|
|
35
|
+
| Provider key | When the provider accepts an idempotency key, the host MUST inject the effect identity (or a documented deterministic derivative), stable across retries. A host that cannot use the provider's convention MUST still persist the outcome. |
|
|
36
|
+
| Streaming | A streamed body MUST NOT be cached in the ledger; the host SHOULD record the request and its final outcome. |
|
|
37
|
+
| Retention | An effect record MUST be retained for at least 14 days. |
|
|
38
|
+
|
|
39
|
+
### Witness: `GET /runs/{runId}/effects`
|
|
40
|
+
|
|
41
|
+
A host that advertises `idempotency` MUST serve `schemas/v2/effect-ledger-projection.schema.json` at `GET /runs/{runId}/effects` (`getRunEffects`): `{ runId, effects[] }`, each record carrying `effectId` (tenant-bound, `schemas/v2/ids.schema.json`), `nodeId`, `attempt`, `keying`, `state` (`claimed` | `completed` | `released` | `escaped`), `at`, optional `invocationId` and a redaction-safe `providerKey`. The projection MUST be content-free of provider payloads and credential material.
|
|
42
|
+
|
|
43
|
+
## Composition
|
|
44
|
+
|
|
45
|
+
Layer 1 deduplicates the caller's request; Layer 2 deduplicates the run's effects. A retried provider call inside a run MUST resolve to the same effect record. Effects under replay and fork are in replay.md; identifier grammars are in identity.md.
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
# Identity
|
|
2
|
+
|
|
3
|
+
> **Status: Draft · v2.0.0-rc (2026-09-03) · RFC 0170, 0165, 0176.**
|
|
4
|
+
|
|
5
|
+
## Why this exists
|
|
6
|
+
|
|
7
|
+
v1 carried a `principal` beside an optional Subject, a legacy rule that was advisory, a `SubjectLink` with no schema, and resume tokens with no scheme. v2 makes the Subject the owner of every run, binds every lane to a trust root and a revocation rule, gives the link and every id a grammar, and prefixes tokens so a host can rotate them. Idempotency-key grammar is `idempotency.md`.
|
|
8
|
+
|
|
9
|
+
## 1. The Subject is the owner (RFC 0170 §A)
|
|
10
|
+
|
|
11
|
+
### 1.1 Shape (`schemas/v2/subject.schema.json`)
|
|
12
|
+
|
|
13
|
+
`RunSnapshot.owner` is `{ tenant, workspace?, subject }` with `subject` REQUIRED; `principal` and `principalKind` are removed (`subject.subjectId` and `subject.kind` carry them). `run.started` MUST echo the same block (`runs.md`, `events.md`). The Subject is closed (`additionalProperties: false`):
|
|
14
|
+
|
|
15
|
+
| Field | Rule |
|
|
16
|
+
| --- | --- |
|
|
17
|
+
| `issuer` | REQUIRED; the lane's trust root (§2.2); `^\S+$`, 1–1024 |
|
|
18
|
+
| `subjectId` | REQUIRED; `ids.schema.json#/$defs/subjectId` — issuer-scoped, stable, opaque, never PII |
|
|
19
|
+
| `tenant` | REQUIRED; `tenantId` |
|
|
20
|
+
| `lane` | REQUIRED; `api-key \| oauth2 \| oidc \| mtls \| saml \| scim \| ldap \| workload \| session \| anonymous` |
|
|
21
|
+
| `kind` | REQUIRED; `user \| agent \| anonymous \| workload` |
|
|
22
|
+
| `keyClass` | `opaque-idp \| configured-immutable`; MUST be present iff `lane ∈ {saml, scim}` |
|
|
23
|
+
| `actor` | OPTIONAL; a nested Subject that acts on this subject's behalf; depth bounded at four |
|
|
24
|
+
|
|
25
|
+
`kind: anonymous` REQUIRES `lane: anonymous` and `lane: anonymous` REQUIRES `kind: anonymous`. The `actor` depth bound (4) is a four-level `$ref` chain (`actor1`…`actor4`) rather than a recursive `$ref`; a fifth level MUST fail validation. `session` is a host-native credential the host itself issued (a durable login session, a local password); `anonymous` is an RFC 0132 public surface. The lane enum grows only under `overview.md` §0.
|
|
26
|
+
|
|
27
|
+
### 1.2 The legacy subject rule (RFC 0170 §A.3)
|
|
28
|
+
|
|
29
|
+
On every read of a run created before the host began emitting subjects, the host MUST stamp `issuer: "urn:openwop:legacy"`, `lane` as attested else `api-key`, `kind` as recorded else `user`. A host MUST stamp the legacy subject at first read and MUST NOT rewrite it later. A legacy subject MUST NOT participate in a link (§3), an actor chain, or a delegation decision.
|
|
30
|
+
|
|
31
|
+
### 1.3 Fork (RFC 0170 §A.4)
|
|
32
|
+
|
|
33
|
+
On fork the host MUST copy `owner` verbatim onto the child: `tenant`, `workspace`, and `subject`. There is no `principal` asymmetry.
|
|
34
|
+
|
|
35
|
+
### 1.4 A2A anonymous end users (RFC 0170 §A.5)
|
|
36
|
+
|
|
37
|
+
An end user reaching the host through an A2A peer is `kind: anonymous`, `lane: anonymous`, with the forwarding peer's subject as `actor`; such a subject MUST NOT be linked.
|
|
38
|
+
|
|
39
|
+
## 2. One binding pipeline, every lane (RFC 0170 §B)
|
|
40
|
+
|
|
41
|
+
### 2.1 The pipeline (§B.1)
|
|
42
|
+
|
|
43
|
+
Every lane MUST: verify the credential against the lane's trust root; bind the verified identity to the request, never to an asserted header; check audience; resolve to a Subject before any authorization decision; and fail closed. The closed reason vocabulary is the family-wide error set in §6. Every lane is advertised as one member of the `auth.lanes[]` facet (`spec/v2/facets/auth.schema.json`):
|
|
44
|
+
|
|
45
|
+
```json
|
|
46
|
+
{ "lane": "oidc", "issuers": ["https://idp.example"], "revocation": "exp-and-recheck",
|
|
47
|
+
"revocationWindowSeconds": 300, "minimumAssurance": "sender-constrained",
|
|
48
|
+
"delegationProofs": ["dpop"] }
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
`lane`, `issuers[]` (min 1), `revocation`, and `minimumAssurance` are REQUIRED on each member. `auth.lanes[].issuers[]` is the realm; the v1 `auth.profiles` facet is replaced by it (`capabilities.md`).
|
|
52
|
+
|
|
53
|
+
### 2.2 Trust roots and revocation (§B.2, §B.3)
|
|
54
|
+
|
|
55
|
+
Every lane MUST name its trust root as `subject.issuer` and MUST advertise it in `issuers[]`. Revocation exists for every lane; the `revocation` value names the rule.
|
|
56
|
+
|
|
57
|
+
| Lane | `subject.issuer` (trust root) | Revocation MUST | `revocation` |
|
|
58
|
+
| --- | --- | --- | --- |
|
|
59
|
+
| `api-key` | the key realm (`urn:<host>:api-key` or a host-chosen URI) | refuse a revoked key on the next request (`credential_revoked`) | `next-request` |
|
|
60
|
+
| `oauth2` | the token issuer | honor `exp`; re-check the issuer within the advertised `revocationWindowSeconds` | `exp-and-recheck` |
|
|
61
|
+
| `oidc` | `iss` | as `oauth2` | `exp-and-recheck` |
|
|
62
|
+
| `mtls` | the CA subject | check CRL or OCSP, or issue certificates whose lifetime is at most the advertised window | `crl \| ocsp \| short-lived` |
|
|
63
|
+
| `saml` | the IdP entityID (`<saml:Issuer>`) | honor `NotOnOrAfter`; consult the SCIM link deny-set when both lanes are advertised (§3) | `not-on-or-after` |
|
|
64
|
+
| `scim` | the SCIM connection id bound at configuration to one IdP entityID | bind each client credential to one IdP entityID; refuse an unbound request | `bound-connection` |
|
|
65
|
+
| `ldap` | the directory base DN | re-bind on each request or advertise a session window | `rebind` |
|
|
66
|
+
| `workload` | the scheme's trust root | enforce `delegation_expired` | `delegation-expiry` |
|
|
67
|
+
| `session` | `urn:<host>:session` | refuse a revoked session on the next request (`credential_revoked`) | `next-request` |
|
|
68
|
+
| `anonymous` | `urn:<host>:anon-surface` | — | — |
|
|
69
|
+
|
|
70
|
+
`revocationWindowSeconds` (integer ≥ 1) MUST be advertised wherever the rule names a window (`exp-and-recheck`, `short-lived`, `rebind`).
|
|
71
|
+
|
|
72
|
+
### 2.3 Minimum assurance (§B.4)
|
|
73
|
+
|
|
74
|
+
Each lane MUST advertise `minimumAssurance: bearer | sender-constrained | key-bound`. A request below the lane's floor MUST be refused with `sender_constraint_missing`. An audit fact MUST record the assurance actually used. A bearer fallback MUST NOT inherit a sender-constrained label (invariant `sender-constraint-no-bearer-downgrade`, `SECURITY/invariants.yaml`).
|
|
75
|
+
|
|
76
|
+
### 2.4 Delegation proofs (§B.5)
|
|
77
|
+
|
|
78
|
+
The proof format is lane-scoped: mTLS key binding or DPoP for the two JWT lanes (`oauth2`, `oidc`), SVID chains for `workload`. A host MUST advertise the proofs it accepts under `auth.lanes[].delegationProofs[]` (`mtls-key-binding | dpop | svid-chain`). A chain with no acceptable proof MUST be refused as `identity_unverified`. The chain rules keep their codes: a chain longer than the bound is `delegation_chain_too_long`, a cyclic chain is `delegation_chain_cyclic`, and a link that widens scope is `delegation_scope_amplified` (invariants `delegation-chain-bounded-acyclic`, `delegation-no-scope-amplification`, `delegation-provenance-not-authorization`).
|
|
79
|
+
|
|
80
|
+
## 3. The link is a record (RFC 0170 §C; `schemas/v2/subject-link.schema.json`)
|
|
81
|
+
|
|
82
|
+
```json
|
|
83
|
+
{ "a": { "issuer": "…", "subjectId": "…" }, "b": { "issuer": "…", "subjectId": "…" },
|
|
84
|
+
"keyClass": "opaque-idp" | "configured-immutable", "issuer": "<IdP entityID>",
|
|
85
|
+
"tenant": "<tenantId>", "formedAt": "<date-time>", "deniedAt"?: "<date-time>" }
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
The record and both `SubjectRef`s are closed; `a`, `b`, `keyClass`, `issuer`, `tenant`, `formedAt` are REQUIRED. A link MUST be tenant-scoped, MUST join exactly two subjects whose `issuer` values are bound to one IdP entityID (`issuer` on the record), and MUST NOT include a legacy (`urn:openwop:legacy` is schema-rejected) or anonymous subject. Deactivation sets `deniedAt`; the SAML decision path MUST consult it (the leaver contract). The link is a reference, not a merge: nothing rewrites a subject already stamped on a run.
|
|
89
|
+
|
|
90
|
+
`auth.subjectLinking` is removed (`capabilities.md` row `C2.5`): advertising both `saml` and `scim` lanes implies the contract. Lanes stay separate facets; there is no single "enterprise identity" profile. The `auth.subjectLinkKey` facet (`opaque-idp | configured-immutable`) names the key class the host forms links under. Invariant `subject-link-record-shape` is registered with its scenario.
|
|
91
|
+
|
|
92
|
+
## 4. Resume tokens (RFC 0170 §E.1; RFC 0176 §B.2)
|
|
93
|
+
|
|
94
|
+
An interrupt resume token is `ow2.<alg>.<kid>.<payload>.<mac>`: `alg ∈ {hs256}` at the cut (`interrupt.tokenAlgs[]` advertises it), `kid` (`keyId` grammar) selects the verification secret, `payload` and `mac` as in v1. A host MUST refuse a token whose `alg` it does not advertise or whose `kid` it does not hold with `401` `interrupt_token_invalid`. The `{token}` path parameter carries the grammar (`api/v2/openapi.yaml`).
|
|
95
|
+
|
|
96
|
+
An issued v1 two-segment token MUST remain resolvable under `kid: legacy` until its `expiresAt`; a run suspended on an interrupt at the cut continues under `persistence.md` and its outstanding token resolves the same way. Interrupt semantics are `interrupt.md`.
|
|
97
|
+
|
|
98
|
+
## 5. Identifier grammars (`schemas/v2/ids.schema.json`)
|
|
99
|
+
|
|
100
|
+
Every id field in every v2 schema and every `api/v2/openapi.yaml` parameter and response body MUST `$ref` its kind. `x-openwop-minted` records who mints the id: `host` (opaque and checkable), `author` (chosen in a workflow or pack; the v1 grammar stands), or `registry`.
|
|
101
|
+
|
|
102
|
+
| Kinds | Grammar | Minted |
|
|
103
|
+
| --- | --- | --- |
|
|
104
|
+
| `runId`, `interruptId`, `subscriptionId`, `deliveryId`, `effectId` | tenant-bound `<tenantId>/<opaque>`: `^[A-Za-z0-9._~-]{1,128}/[A-Za-z0-9._~-]{16,128}$` | host |
|
|
105
|
+
| `eventId` | `^[A-Za-z0-9._~-]{16,128}$` | host |
|
|
106
|
+
| `tenantId`, `workspaceId` | `^[A-Za-z0-9._~-]{1,128}$` | host |
|
|
107
|
+
| `subjectId` | `^[^\s/]{1,256}$` (the issuer's grammar) | host |
|
|
108
|
+
| `traceId`, `spanId` | W3C `^[0-9a-f]{32}$`, `^[0-9a-f]{16}$` | host |
|
|
109
|
+
| `keyId` | `^[A-Za-z0-9._~-]{1,128}$` (signing keys, resume-token `kid`, bundle signatures) | registry |
|
|
110
|
+
| `nodeId`, `workflowId`, `agentId`, `chainId`, `pluginId`, `templateId`, `libraryId` | `^[A-Za-z0-9._~:-]{1,128}$` | author |
|
|
111
|
+
| `typeId` | `^[a-z][a-z0-9-]*(\.[a-z][a-zA-Z0-9-]*)+$` | author |
|
|
112
|
+
|
|
113
|
+
This obligation is enforced by `scripts/check-id-kinds-bound.mjs` against `spec/v2/id-field-bindings.json`, which places every `*Id` property in a v2 schema into one of two sets: it **is** one of the kinds above (and MUST `$ref` it), or nothing here governs it (with the reason recorded). A property in neither **fails**, so a new id field cannot be added without someone deciding which it is. The map exists rather than a name-matching rule because only 20 of the 88 `*Id` properties share a name with a kind: `childRunId` sat as `{type: string, minLength: 1}` in the same file where `parentRunId` was correctly bound, and a check keyed on names would have reported green over it. The rule above says *every id field*, not *every field whose name matches*.
|
|
114
|
+
|
|
115
|
+
The `typeId` grammar admits `_` because `node-pack-manifest.schema.json`'s `name` pattern does and a pack's node type ids are derived from its name — a pack legally named `vendor.acme.my_tools` MUST be able to declare `vendor.acme.my_tools.echo`. A kind that rejects an id a legal name generates is a constraint that cannot express a legitimate value.
|
|
116
|
+
|
|
117
|
+
A host MUST reject a tenant-bound id whose tenant segment is not the caller's with `403` `id_tenant_mismatch`. A host-minted opaque segment MUST match `^[A-Za-z0-9._~-]{16,128}$`: no `@`, no whitespace, no `/`. Handle grammars (`memoryRef`, workspace `path`/`etag`, the plugin version token) and their `resolvability` class are specified where each handle is used; an importer MUST re-mint every `host`-scoped handle (`spec/v2/ext/portability/`).
|
|
118
|
+
|
|
119
|
+
## 6. Identity error codes (`spec/v2/errors.json`)
|
|
120
|
+
|
|
121
|
+
Every code below is a row with `since: "2.0"`, `retriable: false`, and no `details` contract; the envelope is `errors.md`.
|
|
122
|
+
|
|
123
|
+
| Code | HTTP | Raised when |
|
|
124
|
+
| --- | --- | --- |
|
|
125
|
+
| `identity_unverified` | 401 | the credential fails verification against the lane's trust root, or a delegation chain has no acceptable proof |
|
|
126
|
+
| `identity_unresolvable` | 401 | a verified identity resolves to no Subject |
|
|
127
|
+
| `audience_mismatch` | 401 | the credential's audience is not this host |
|
|
128
|
+
| `credential_revoked` | 401 | a revoked key or session is presented (§2.2) |
|
|
129
|
+
| `delegation_expired` | 401 | a delegation or workload credential is past its lifetime |
|
|
130
|
+
| `sender_constraint_missing` | 401 | the request is below the lane's `minimumAssurance` (§2.3) |
|
|
131
|
+
| `delegation_chain_too_long` | 400 | the actor chain exceeds depth 4 |
|
|
132
|
+
| `delegation_chain_cyclic` | 400 | the actor chain repeats a subject |
|
|
133
|
+
| `delegation_scope_amplified` | 403 | a delegated link claims more scope than its delegator |
|
|
134
|
+
| `id_tenant_mismatch` | 403 | a tenant-bound id's tenant segment is not the caller's (§5) |
|
|
135
|
+
| `interrupt_token_invalid` | 401 | an unadvertised `alg` or an unheld `kid` (§4) |
|
|
136
|
+
|
|
137
|
+
`unauthenticated` and `run_forbidden` keep their v1 rows. Every code is a registry member under `overview.md` §0.
|
|
138
|
+
|
|
139
|
+
## 7. Invariants (RFC 0170 §E.2)
|
|
140
|
+
|
|
141
|
+
`workload-identity-cryptographically-bound`, `delegation-provenance-not-authorization`, `delegation-no-scope-amplification`, `delegation-chain-bounded-acyclic`, `sender-constraint-no-bearer-downgrade`, `provenance-attestation-digest-bound`, `subject-link-record-shape`, and `subject-required-on-owner` are registered in `SECURITY/invariants.yaml` with their scenarios; an invariant that reaches the cut without a witness is demoted from `protocol` tier and recorded.
|