@openwop/openwop-conformance 1.18.1 → 1.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,160 @@
1
+ /**
2
+ * Layout-aware path resolver for the offline subset.
3
+ *
4
+ * The same suite source runs in two layouts:
5
+ *
6
+ * 1. Repo checkout — `openwop/conformance/src/scenarios/X.test.ts`. Schemas,
7
+ * api/, and prose docs live one level above the conformance package
8
+ * at the repo root.
9
+ *
10
+ * 2. Published tarball — `node_modules/@openwop/openwop-conformance/src/...`.
11
+ * The `prepack` script vendors `api/` and `schemas/` INTO the package,
12
+ * so they resolve relative to the package root instead of a parent.
13
+ * Spec prose (`spec/v1/*.md`) is NOT bundled — those tests skip.
14
+ *
15
+ * Earlier offline scenarios computed `__dirname/../../..` to find
16
+ * the repo root. That works in a checkout but lands in `node_modules/@openwop/`
17
+ * after npx-style install, breaking `npx -y @openwop/openwop-conformance --offline`
18
+ * with `ENOENT: ... node_modules/@openwop/schemas/workflow-definition.schema.json`.
19
+ *
20
+ * This module centralises the resolution. Strategy:
21
+ *
22
+ * - If `OPENWOP_CONFORMANCE_ROOT` is set, treat its value as the layout root
23
+ * (the directory that contains `schemas/`, `api/`, and either
24
+ * `conformance/fixtures/` (repo) or `fixtures/` directly (vendored)).
25
+ * Used by integrators who put the suite in an unusual location.
26
+ *
27
+ * - Otherwise compute the package root from `import.meta.url` (= the
28
+ * directory containing `package.json`) and probe whether the schemas
29
+ * are vendored at the package root (published) or at the parent (repo).
30
+ *
31
+ * Exported paths are non-null for the materials always present in both
32
+ * layouts; the prose-doc and fixtures.md catalog dirs may resolve to
33
+ * `null` under the published layout, in which case the corresponding
34
+ * scenarios skip cleanly (see `spec-corpus-validity.test.ts`).
35
+ */
36
+ import { existsSync } from 'node:fs';
37
+ import { fileURLToPath } from 'node:url';
38
+ import { dirname, join, resolve as pathResolve } from 'node:path';
39
+ // `dirname(fileURLToPath(import.meta.url))` for an ESM module compiled or
40
+ // run from `src/lib/paths.ts` returns `<pkg>/src/lib/`. The conformance
41
+ // package root is therefore two directories above this file in BOTH the
42
+ // repo checkout and the published tarball — the source layout is
43
+ // identical between the two; only the parent of `<pkg>` differs.
44
+ const HERE = dirname(fileURLToPath(import.meta.url));
45
+ const PKG_ROOT = pathResolve(HERE, '..', '..');
46
+ function resolveFromRoot(root, layout) {
47
+ // Two on-disk shapes for the layout root:
48
+ // - Repo: <root>/schemas, <root>/api, <root>/conformance/fixtures,
49
+ // <root>/conformance/{fixtures.md,coverage.md}, <root>/spec/v1/*.md
50
+ // (Where `<root>` = the repo root, e.g. `openwop/`.)
51
+ // - Vendored / published: <root>/schemas, <root>/api, <root>/fixtures,
52
+ // <root>/fixtures.md (when bundled), no spec/v1.
53
+ // Probe by checking whether `schemas/` lives at the conformance pkg root
54
+ // (vendored) vs one level up (repo).
55
+ const schemasDir = join(root, 'schemas');
56
+ const apiDir = join(root, 'api');
57
+ const repoFixturesDir = join(root, 'conformance', 'fixtures');
58
+ const vendoredFixturesDir = join(root, 'fixtures');
59
+ const fixturesDir = existsSync(repoFixturesDir) ? repoFixturesDir : vendoredFixturesDir;
60
+ const repoScenariosDir = join(root, 'conformance', 'src', 'scenarios');
61
+ const vendoredScenariosDir = join(PKG_ROOT, 'src', 'scenarios');
62
+ const scenariosDir = existsSync(repoScenariosDir)
63
+ ? repoScenariosDir
64
+ : existsSync(vendoredScenariosDir)
65
+ ? vendoredScenariosDir
66
+ : null;
67
+ const repoConformanceReadme = join(root, 'conformance', 'README.md');
68
+ const vendoredConformanceReadme = join(PKG_ROOT, 'README.md');
69
+ const conformanceReadmePath = existsSync(repoConformanceReadme)
70
+ ? repoConformanceReadme
71
+ : existsSync(vendoredConformanceReadme)
72
+ ? vendoredConformanceReadme
73
+ : null;
74
+ const repoFixturesDoc = join(root, 'conformance', 'fixtures.md');
75
+ const vendoredFixturesDoc = join(root, 'fixtures.md');
76
+ const fixturesDocPath = existsSync(repoFixturesDoc)
77
+ ? repoFixturesDoc
78
+ : existsSync(vendoredFixturesDoc)
79
+ ? vendoredFixturesDoc
80
+ : null;
81
+ const repoCoverageDoc = join(root, 'conformance', 'coverage.md');
82
+ const vendoredCoverageDoc = join(root, 'coverage.md');
83
+ const coverageDocPath = existsSync(repoCoverageDoc)
84
+ ? repoCoverageDoc
85
+ : existsSync(vendoredCoverageDoc)
86
+ ? vendoredCoverageDoc
87
+ : null;
88
+ const v1Probe = join(root, 'spec', 'v1');
89
+ const v1Dir = existsSync(v1Probe) ? v1Probe : null;
90
+ const readmeProbe = join(root, 'README.md');
91
+ const readmePath = existsSync(readmeProbe) ? readmeProbe : null;
92
+ const typescriptRunHelpersProbe = join(root, 'sdk', 'typescript', 'src', 'run-helpers.ts');
93
+ const typescriptRunHelpersPath = existsSync(typescriptRunHelpersProbe) ? typescriptRunHelpersProbe : null;
94
+ const pythonTypesProbe = join(root, 'sdk', 'python', 'src', 'openwop_client', 'types.py');
95
+ const pythonTypesPath = existsSync(pythonTypesProbe) ? pythonTypesProbe : null;
96
+ const goTypesProbe = join(root, 'sdk', 'go', 'types.go');
97
+ const goTypesPath = existsSync(goTypesProbe) ? goTypesProbe : null;
98
+ return {
99
+ pkgRoot: PKG_ROOT,
100
+ schemasDir,
101
+ apiDir,
102
+ fixturesDir,
103
+ scenariosDir,
104
+ conformanceReadmePath,
105
+ fixturesDocPath,
106
+ coverageDocPath,
107
+ v1Dir,
108
+ readmePath,
109
+ typescriptRunHelpersPath,
110
+ pythonTypesPath,
111
+ goTypesPath,
112
+ layout,
113
+ };
114
+ }
115
+ function resolveLayout() {
116
+ const override = process.env.OPENWOP_CONFORMANCE_ROOT?.trim();
117
+ if (override && override.length > 0) {
118
+ return resolveFromRoot(pathResolve(override), 'env-override');
119
+ }
120
+ // Vendored / published-tarball layout: `prepack` copies `schemas/` +
121
+ // `api/` to the package root. Repo layout: schemas live one level
122
+ // above the conformance package.
123
+ //
124
+ // Edge case: a developer running `npm pack` locally without a
125
+ // postpack cleanup leaves schemas/ in BOTH places transiently. When
126
+ // both exist, prefer the parent (repo layout) so prose-doc tests
127
+ // continue to run — the parent is the canonical source.
128
+ const parent = pathResolve(PKG_ROOT, '..');
129
+ const parentHasSchemas = existsSync(join(parent, 'schemas'));
130
+ const pkgHasSchemas = existsSync(join(PKG_ROOT, 'schemas'));
131
+ if (parentHasSchemas) {
132
+ return resolveFromRoot(parent, 'repo');
133
+ }
134
+ if (pkgHasSchemas) {
135
+ return resolveFromRoot(PKG_ROOT, 'published');
136
+ }
137
+ // Neither — return the published-style resolution rooted at PKG_ROOT
138
+ // so error messages name a concrete directory rather than a
139
+ // computed-from-undefined path.
140
+ return resolveFromRoot(PKG_ROOT, 'published');
141
+ }
142
+ const _layout = resolveLayout();
143
+ export const PKG_ROOT_PATH = _layout.pkgRoot;
144
+ export const SCHEMAS_DIR = _layout.schemasDir;
145
+ export const API_DIR = _layout.apiDir;
146
+ export const FIXTURES_DIR = _layout.fixturesDir;
147
+ export const SCENARIOS_DIR = _layout.scenariosDir;
148
+ export const CONFORMANCE_README_PATH = _layout.conformanceReadmePath;
149
+ export const FIXTURES_DOC_PATH = _layout.fixturesDocPath;
150
+ export const COVERAGE_DOC_PATH = _layout.coverageDocPath;
151
+ export const V1_DIR = _layout.v1Dir;
152
+ export const README_PATH = _layout.readmePath;
153
+ export const TYPESCRIPT_RUN_HELPERS_PATH = _layout.typescriptRunHelpersPath;
154
+ export const PYTHON_TYPES_PATH = _layout.pythonTypesPath;
155
+ export const GO_TYPES_PATH = _layout.goTypesPath;
156
+ export const LAYOUT = _layout.layout;
157
+ /** Test-only — re-resolve in case env var or filesystem changed. */
158
+ export function __resolveLayoutForTests() {
159
+ return resolveLayout();
160
+ }
@@ -0,0 +1,461 @@
1
+ /**
2
+ * Compatibility-profile derivation for openwop v1.x.
3
+ *
4
+ * Profiles are a named set of capability requirements. A host's profile
5
+ * set is derived from the `/.well-known/openwop` discovery payload — never
6
+ * declared as a separate wire field. See `spec/v1/profiles.md` for the
7
+ * normative predicate definitions.
8
+ *
9
+ * This module is the single canonical implementation of profile membership.
10
+ * Conformance scenarios use it to gate profile-specific assertions; SDKs
11
+ * MAY re-export the derivation helper to give clients a way to ask
12
+ * "does this host satisfy `openwop-secrets`?" without re-implementing the
13
+ * predicates.
14
+ *
15
+ * **Derivation is deterministic and pure.** Same payload, same profile
16
+ * set. No time-of-day, host-specific state, or hidden inputs.
17
+ */
18
+ /**
19
+ * Closed v1.x catalog. Adding a profile requires an RFC per
20
+ * `RFCS/0001-rfc-process.md`.
21
+ */
22
+ export const PROFILE_NAMES = [
23
+ 'openwop-core',
24
+ 'openwop-interrupts',
25
+ 'openwop-stream-sse',
26
+ 'openwop-stream-poll',
27
+ 'openwop-secrets',
28
+ 'openwop-provider-policy',
29
+ 'openwop-node-packs',
30
+ 'openwop-replay-fork',
31
+ 'openwop-fixtures',
32
+ 'openwop-memory',
33
+ 'openwop-trigger-bridge',
34
+ ];
35
+ function isStringArray(value) {
36
+ return Array.isArray(value) && value.every((entry) => typeof entry === 'string');
37
+ }
38
+ function isNonNegativeInteger(value) {
39
+ return typeof value === 'number' && Number.isInteger(value) && value >= 0;
40
+ }
41
+ /**
42
+ * `openwop-core` predicate. Every other profile implies `openwop-core`. A host
43
+ * that fails this predicate is not openwop-compatible.
44
+ *
45
+ * @see spec/v1/profiles.md §`openwop-core`
46
+ */
47
+ export function isCore(c) {
48
+ if (typeof c.protocolVersion !== 'string')
49
+ return false;
50
+ if (!c.protocolVersion.startsWith('1.'))
51
+ return false;
52
+ if (!Array.isArray(c.supportedEnvelopes))
53
+ return false;
54
+ if (!c.supportedEnvelopes.every((entry) => typeof entry === 'string'))
55
+ return false;
56
+ if (typeof c.schemaVersions !== 'object' || c.schemaVersions === null)
57
+ return false;
58
+ if (typeof c.limits !== 'object' || c.limits === null)
59
+ return false;
60
+ if (!isNonNegativeInteger(c.limits.clarificationRounds))
61
+ return false;
62
+ if (!isNonNegativeInteger(c.limits.schemaRounds))
63
+ return false;
64
+ if (!isNonNegativeInteger(c.limits.envelopesPerTurn))
65
+ return false;
66
+ return true;
67
+ }
68
+ /**
69
+ * `openwop-interrupts` predicate.
70
+ *
71
+ * @see spec/v1/profiles.md §`openwop-interrupts`
72
+ */
73
+ export function isInterrupts(c) {
74
+ if (!isCore(c))
75
+ return false;
76
+ if (!isStringArray(c.supportedEnvelopes))
77
+ return false;
78
+ return c.supportedEnvelopes.includes('clarification.request');
79
+ }
80
+ /**
81
+ * `openwop-stream-sse` predicate (discovery-payload only — runtime SSE
82
+ * behavior is verified by `stream-modes*.test.ts`).
83
+ *
84
+ * @see spec/v1/profiles.md §`openwop-stream-sse`
85
+ */
86
+ export function isStreamSse(c) {
87
+ if (!isCore(c))
88
+ return false;
89
+ if (c.supportedTransports == null)
90
+ return true;
91
+ if (!isStringArray(c.supportedTransports))
92
+ return false;
93
+ return c.supportedTransports.includes('rest');
94
+ }
95
+ /**
96
+ * `openwop-stream-poll` predicate (discovery-payload only — runtime polling
97
+ * behavior is verified by `stream-modes.test.ts`).
98
+ *
99
+ * @see spec/v1/profiles.md §`openwop-stream-poll`
100
+ */
101
+ export function isStreamPoll(c) {
102
+ if (!isCore(c))
103
+ return false;
104
+ if (c.supportedTransports == null)
105
+ return true;
106
+ if (!isStringArray(c.supportedTransports))
107
+ return false;
108
+ return c.supportedTransports.includes('rest');
109
+ }
110
+ /**
111
+ * `openwop-secrets` predicate.
112
+ *
113
+ * @see spec/v1/profiles.md §`openwop-secrets`
114
+ */
115
+ export function isSecrets(c) {
116
+ if (!isCore(c))
117
+ return false;
118
+ if (c.secrets == null || typeof c.secrets !== 'object')
119
+ return false;
120
+ if (c.secrets.supported !== true)
121
+ return false;
122
+ if (!isStringArray(c.secrets.scopes))
123
+ return false;
124
+ return c.secrets.scopes.includes('user');
125
+ }
126
+ /**
127
+ * `openwop-provider-policy` predicate.
128
+ *
129
+ * @see spec/v1/profiles.md §`openwop-provider-policy`
130
+ */
131
+ export function isProviderPolicy(c) {
132
+ if (!isCore(c))
133
+ return false;
134
+ if (c.aiProviders == null || typeof c.aiProviders !== 'object')
135
+ return false;
136
+ const policies = c.aiProviders.policies;
137
+ if (policies == null || typeof policies !== 'object')
138
+ return false;
139
+ if (!isStringArray(policies.modes))
140
+ return false;
141
+ if (policies.modes.length === 0)
142
+ return false;
143
+ return policies.modes.includes('optional');
144
+ }
145
+ /**
146
+ * `openwop-node-packs` discovery-only predicate. Runtime registry behavior
147
+ * is verified by `pack-registry*.test.ts`. Discovery alone cannot tell
148
+ * whether GET /v1/packs returns a list-shaped body.
149
+ *
150
+ * @see spec/v1/profiles.md §`openwop-node-packs`
151
+ */
152
+ export function isNodePacksDiscovery(c) {
153
+ return isCore(c);
154
+ }
155
+ /**
156
+ * `openwop-replay-fork` predicate. Host advertises `replay.supported: true`
157
+ * with at least one entry in `replay.modes`. Runtime determinism /
158
+ * branch behavior is verified by `replayDeterminism.test.ts` and
159
+ * `replay-fork.test.ts`.
160
+ *
161
+ * @see spec/v1/profiles.md §`openwop-replay-fork`
162
+ * @see spec/v1/replay.md
163
+ */
164
+ export function isReplayFork(c) {
165
+ if (!isCore(c))
166
+ return false;
167
+ if (c.replay == null || typeof c.replay !== 'object')
168
+ return false;
169
+ if (c.replay.supported !== true)
170
+ return false;
171
+ if (!isStringArray(c.replay.modes))
172
+ return false;
173
+ return c.replay.modes.length > 0;
174
+ }
175
+ /**
176
+ * `openwop-fixtures` predicate (RFC 0003). Host advertises `fixtures` as a
177
+ * non-empty array of non-empty strings — fixture-workflow IDs the host
178
+ * has seeded. Per-fixture skip decisions are made by the suite via
179
+ * `lib/fixtures.ts`; the profile predicate is the all-up "any-advertised"
180
+ * check.
181
+ *
182
+ * @see spec/v1/profiles.md §`openwop-fixtures`
183
+ * @see spec/v1/capabilities.md §`fixtures`
184
+ * @see RFCS/0003-fixture-gating.md
185
+ */
186
+ export function isFixtures(c) {
187
+ if (!isCore(c))
188
+ return false;
189
+ if (!Array.isArray(c.fixtures))
190
+ return false;
191
+ if (c.fixtures.length === 0)
192
+ return false;
193
+ return c.fixtures.every((id) => typeof id === 'string' && id.length > 0);
194
+ }
195
+ /**
196
+ * `openwop-memory` predicate (RFC 0080). Host implements the reconciled
197
+ * memory-capability model at the core tier: a read/write `MemoryAdapter`
198
+ * (`memory.supported: true` and `memory.writable !== false`) plus a cross-run
199
+ * durable store (`agents.memoryBackends` includes `'long-term'`). Capability
200
+ * families are document-root properties of the discovery payload (RFC 0073),
201
+ * so this reads `c.memory` / `c.agents`, matching `isReplayFork`.
202
+ *
203
+ * @see spec/v1/profiles.md §`openwop-memory`
204
+ * @see spec/v1/agent-memory.md §"Memory capability model"
205
+ */
206
+ export function isMemory(c) {
207
+ if (!isCore(c))
208
+ return false;
209
+ const memory = c.memory;
210
+ if (memory == null || typeof memory !== 'object')
211
+ return false;
212
+ if (memory.supported !== true)
213
+ return false;
214
+ if (memory.writable === false)
215
+ return false;
216
+ const agents = c.agents;
217
+ if (agents == null || !isStringArray(agents.memoryBackends))
218
+ return false;
219
+ return agents.memoryBackends.includes('long-term');
220
+ }
221
+ /**
222
+ * `openwop-trigger-bridge` predicate (RFC 0083). Host composes the durable
223
+ * inbound-work contract: advertises the `triggerBridge`, has a `deadLetter`
224
+ * sink for exhausted deliveries, and has at least one durable inbound source
225
+ * (queue bus, durable webhooks, or scheduling). Capability families are
226
+ * document-root properties (RFC 0073), so this reads `c.triggerBridge` /
227
+ * `c.deadLetter` / `c.queueBus` / `c.webhooks` / `c.scheduling`.
228
+ *
229
+ * @see spec/v1/profiles.md §`openwop-trigger-bridge`
230
+ * @see spec/v1/trigger-bridge.md
231
+ */
232
+ export function isTriggerBridge(c) {
233
+ if (!isCore(c))
234
+ return false;
235
+ const supported = (v) => v != null && typeof v === 'object' && v.supported === true;
236
+ if (!supported(c.triggerBridge))
237
+ return false;
238
+ if (!supported(c.deadLetter))
239
+ return false;
240
+ const webhooks = c.webhooks;
241
+ const durableSource = supported(c.queueBus) ||
242
+ supported(c.scheduling) ||
243
+ (webhooks != null && typeof webhooks === 'object' && webhooks.durable === true);
244
+ return durableSource;
245
+ }
246
+ // ─────────────────────────────────────────────────────────────────────────────
247
+ // Operational annex: openwop-agent-platform (RFC 0085).
248
+ //
249
+ // NOT part of the closed `profiles.md` predicate catalog (PROFILE_NAMES /
250
+ // deriveProfiles above) — it is an operational ANNEX (the production-profile.md /
251
+ // auth-profiles.md pattern) combining a discovery predicate with required runtime
252
+ // conformance evidence + documentation + a badge. These helpers compute only the
253
+ // discovery-PREDICATE part; the live aggregate-evidence assertion (does every
254
+ // constituent scenario actually pass?) lives in agent-platform-profile.test.ts.
255
+ //
256
+ // @see spec/v1/agent-platform-profile.md
257
+ // ─────────────────────────────────────────────────────────────────────────────
258
+ /** Narrow helper: a capability sub-block with `supported === true`. */
259
+ function blockSupported(v) {
260
+ return v != null && typeof v === 'object' && v.supported === true;
261
+ }
262
+ /** The `openwop-agent-platform` FLOOR (`partial`) discovery predicate — RFC 0085 §B. */
263
+ export function isAgentPlatformPartial(c) {
264
+ if (!isCore(c))
265
+ return false;
266
+ const agents = c.agents;
267
+ const httpClient = c.httpClient;
268
+ const replay = c.replay;
269
+ const nondet = c.nondeterminismPolicy;
270
+ return (blockSupported(agents?.manifestRuntime) &&
271
+ blockSupported(agents?.liveRuntime) &&
272
+ blockSupported(c.toolCatalog) &&
273
+ blockSupported(c.toolHooks) &&
274
+ blockSupported(httpClient?.safeFetch) &&
275
+ blockSupported(c.providerUsage) &&
276
+ blockSupported(c.prompts) &&
277
+ blockSupported(c.memory) &&
278
+ blockSupported(c.feedback) &&
279
+ (replay?.supported === true || nondet?.declared === true));
280
+ }
281
+ /** The `openwop-agent-platform` `full` discovery predicate (floor + governance tier) — RFC 0085 §B. */
282
+ export function isAgentPlatformFull(c) {
283
+ if (!isAgentPlatformPartial(c))
284
+ return false;
285
+ const agents = c.agents;
286
+ const memory = c.memory;
287
+ // Debug bundle is advertised at `capabilities.debugBundle.supported` (debug-bundle.md /
288
+ // RFC 0009), NOT under `production.*` — the production block only adds stricter truncation MUSTs.
289
+ const httpClient = c.httpClient;
290
+ return (blockSupported(c.authorization) &&
291
+ agents?.manifestRuntime?.installScope === 'tenant' &&
292
+ blockSupported(memory?.attribution) &&
293
+ blockSupported(c.debugBundle) &&
294
+ blockSupported(c.triggerBridge) &&
295
+ blockSupported(httpClient?.egressPolicy));
296
+ }
297
+ /** The host-reported annex status: `full` ⊃ `partial` ⊃ `none` (discovery-predicate only). */
298
+ export function agentPlatformStatus(c) {
299
+ if (isAgentPlatformFull(c))
300
+ return 'full';
301
+ if (isAgentPlatformPartial(c))
302
+ return 'partial';
303
+ return 'none';
304
+ }
305
+ /**
306
+ * The per-term satisfaction breakdown (RFC 0085 §D) — the richer interop signal
307
+ * alongside the flat `none`/`partial`/`full` ladder. Adoption is NON-CONTIGUOUS:
308
+ * a real host built feature-by-feature can satisfy `full`-tier terms (RBAC,
309
+ * memory-attribution, tenant-scoping) while still failing `floor` terms, so the
310
+ * flat status would understate it (reads identical to a do-nothing host). This
311
+ * returns exactly the term ids a host satisfies, so a `none` host honoring 6/16
312
+ * terms is distinguishable from one honoring 0/16.
313
+ */
314
+ export function agentPlatformSatisfiedTerms(c) {
315
+ const agents = c.agents;
316
+ const httpClient = c.httpClient;
317
+ const memory = c.memory;
318
+ const replay = c.replay;
319
+ const nondet = c.nondeterminismPolicy;
320
+ const checks = [
321
+ // floor
322
+ ['floor:agents.manifestRuntime', blockSupported(agents?.manifestRuntime)],
323
+ ['floor:agents.liveRuntime', blockSupported(agents?.liveRuntime)],
324
+ ['floor:toolCatalog', blockSupported(c.toolCatalog)],
325
+ ['floor:toolHooks', blockSupported(c.toolHooks)],
326
+ ['floor:httpClient.safeFetch', blockSupported(httpClient?.safeFetch)],
327
+ ['floor:providerUsage', blockSupported(c.providerUsage)],
328
+ ['floor:prompts', blockSupported(c.prompts)],
329
+ ['floor:memory', blockSupported(c.memory)],
330
+ ['floor:feedback', blockSupported(c.feedback)],
331
+ ['floor:replay-or-nondeterminism', replay?.supported === true || nondet?.declared === true],
332
+ // full (governance)
333
+ ['full:authorization', blockSupported(c.authorization)],
334
+ ['full:tenant-installScope', agents?.manifestRuntime?.installScope === 'tenant'],
335
+ ['full:memory.attribution', blockSupported(memory?.attribution)],
336
+ ['full:debugBundle', blockSupported(c.debugBundle)],
337
+ ['full:triggerBridge', blockSupported(c.triggerBridge)],
338
+ ['full:egressPolicy', blockSupported(httpClient?.egressPolicy)],
339
+ ];
340
+ return checks.filter(([, ok]) => ok).map(([id]) => id);
341
+ }
342
+ // ─────────────────────────────────────────────────────────────────────────────
343
+ // `openwop-core-standard` operational-annex predicate (RFC 0088). Like the
344
+ // agent-platform annex above, this is NOT a closed-catalog profile (so it is
345
+ // absent from deriveProfiles) — it is an operational ANNEX whose claim is backed
346
+ // by the §C floor scenarios passing black-box. This helper computes only the §B
347
+ // discovery predicate (the floor of MUSTs with black-box production-path proof).
348
+ //
349
+ // @see spec/v1/core-standard-profile.md
350
+ // ─────────────────────────────────────────────────────────────────────────────
351
+ /** The `openwop-core-standard` floor discovery predicate — RFC 0088 §B. */
352
+ export function isCoreStandard(c) {
353
+ return isCore(c) && isInterrupts(c) && (isStreamSse(c) || isStreamPoll(c));
354
+ }
355
+ /**
356
+ * Derive the full profile set from a discovery payload.
357
+ *
358
+ * Returns a set sorted by `PROFILE_NAMES` order so output is stable
359
+ * across calls and across implementations.
360
+ */
361
+ export function deriveProfiles(c) {
362
+ const result = [];
363
+ if (isCore(c))
364
+ result.push('openwop-core');
365
+ if (isInterrupts(c))
366
+ result.push('openwop-interrupts');
367
+ if (isStreamSse(c))
368
+ result.push('openwop-stream-sse');
369
+ if (isStreamPoll(c))
370
+ result.push('openwop-stream-poll');
371
+ if (isSecrets(c))
372
+ result.push('openwop-secrets');
373
+ if (isProviderPolicy(c))
374
+ result.push('openwop-provider-policy');
375
+ if (isNodePacksDiscovery(c))
376
+ result.push('openwop-node-packs');
377
+ if (isReplayFork(c))
378
+ result.push('openwop-replay-fork');
379
+ if (isFixtures(c))
380
+ result.push('openwop-fixtures');
381
+ if (isMemory(c))
382
+ result.push('openwop-memory');
383
+ if (isTriggerBridge(c))
384
+ result.push('openwop-trigger-bridge');
385
+ return result;
386
+ }
387
+ /**
388
+ * One-shot membership check.
389
+ */
390
+ export function hasProfile(c, profile) {
391
+ switch (profile) {
392
+ case 'openwop-core':
393
+ return isCore(c);
394
+ case 'openwop-interrupts':
395
+ return isInterrupts(c);
396
+ case 'openwop-stream-sse':
397
+ return isStreamSse(c);
398
+ case 'openwop-stream-poll':
399
+ return isStreamPoll(c);
400
+ case 'openwop-secrets':
401
+ return isSecrets(c);
402
+ case 'openwop-provider-policy':
403
+ return isProviderPolicy(c);
404
+ case 'openwop-node-packs':
405
+ return isNodePacksDiscovery(c);
406
+ case 'openwop-replay-fork':
407
+ return isReplayFork(c);
408
+ case 'openwop-fixtures':
409
+ return isFixtures(c);
410
+ case 'openwop-memory':
411
+ return isMemory(c);
412
+ case 'openwop-trigger-bridge':
413
+ return isTriggerBridge(c);
414
+ }
415
+ }
416
+ export const PROFILE_FLOOR_SCENARIOS = {
417
+ 'openwop-core-standard': {
418
+ required: [
419
+ 'runs-lifecycle.test.ts',
420
+ 'discovery.test.ts',
421
+ 'auth.test.ts',
422
+ 'eventOrdering.test.ts',
423
+ 'failure-path.test.ts',
424
+ 'idempotency.test.ts',
425
+ 'idempotency-key-determinism.test.ts',
426
+ 'webhook-negative.test.ts',
427
+ 'audit-log-verification.test.ts',
428
+ ],
429
+ requiredAnyPrefix: ['interrupt-'],
430
+ },
431
+ };
432
+ /** Is `profile` derivable from a discovery document? Maps a profile name to its predicate (RFC 0089 §B(1)). */
433
+ export function profileDerivable(c, profile) {
434
+ if (profile === 'openwop-core-standard')
435
+ return isCoreStandard(c);
436
+ if (profile === 'openwop-agent-platform')
437
+ return agentPlatformStatus(c) !== 'none';
438
+ if (PROFILE_NAMES.includes(profile)) {
439
+ return deriveProfiles(c).includes(profile);
440
+ }
441
+ return false;
442
+ }
443
+ const scenarioBasename = (id) => id.split('/').pop() ?? id;
444
+ /**
445
+ * Verify a bundle's claim for one profile per RFC 0089 §B. A consumer MUST
446
+ * re-derive (this function) rather than trust `claimedProfiles` verbatim.
447
+ */
448
+ export function verifyBundleProfile(bundle, profile) {
449
+ const derivable = profileDerivable(bundle.discovery.document, profile);
450
+ const floor = PROFILE_FLOOR_SCENARIOS[profile];
451
+ const passed = new Set(bundle.results.passed.map(scenarioBasename));
452
+ const missingFloor = floor ? floor.required.filter((r) => !passed.has(scenarioBasename(r))) : [];
453
+ const prefixOk = (floor?.requiredAnyPrefix ?? []).every((p) => [...passed].some((s) => s.startsWith(p)));
454
+ const floorProven = missingFloor.length === 0 && prefixOk;
455
+ return { profile, derivable, floorProven, valid: derivable && floorProven, missingFloor };
456
+ }
457
+ /** Verify every profile in `bundle.claimedProfiles`; the bundle is valid iff all claims are valid. */
458
+ export function verifyBundle(bundle) {
459
+ const verdicts = bundle.claimedProfiles.map((p) => verifyBundleProfile(bundle, p));
460
+ return { valid: verdicts.every((v) => v.valid), verdicts };
461
+ }
@@ -0,0 +1,27 @@
1
+ {
2
+ "id": "conformance-agent-channel-dispatch",
3
+ "name": "Conformance: Agent Channel Dispatch",
4
+ "version": "1.0",
5
+ "description": "RFC 0082 §B. Single-node run whose `agent` binding pins a deployment CHANNEL (`channel: \"stable\"`) instead of an exact `version`. A host advertising `agents.deployment.supported:true` MUST resolve the channel to a concrete version at first resolution and record it as `resolvedChannel` + `resolvedAgentVersion` on `agent.invocation.started` (RFC 0077); a `:fork {mode:\"replay\"}` MUST re-read that recorded version and MUST NOT re-resolve a since-moved channel. Hosts that omit `agents.deployment` MUST reject this channel-bearing ref with `validation_error` (agent-ref.schema.json) and so cannot seed it. See agent-channel-dispatch.test.ts.",
6
+ "nodes": [
7
+ {
8
+ "id": "resolve",
9
+ "typeId": "core.identity",
10
+ "name": "Channel-bound agent dispatch",
11
+ "position": { "x": 0, "y": 0 },
12
+ "config": {},
13
+ "inputs": {},
14
+ "agent": {
15
+ "agentId": "core.conformance.channel-agent",
16
+ "channel": "stable"
17
+ }
18
+ }
19
+ ],
20
+ "edges": [],
21
+ "triggers": [
22
+ { "id": "manual", "type": "manual", "enabled": true }
23
+ ],
24
+ "variables": [],
25
+ "metadata": { "tags": ["conformance", "multi-agent", "deployment", "channel", "rfc-0082"] },
26
+ "settings": { "timeout": 10000 }
27
+ }
package/fixtures.md CHANGED
@@ -412,6 +412,21 @@ Hosts that don't ship a BYOK SecretResolver MAY return `404` / `422` on the star
412
412
 
413
413
  ---
414
414
 
415
+ ## `conformance-agent-channel-dispatch` (RFC 0082 §B — production-path channel pin)
416
+
417
+ > **Status: capability-gated (RFC 0082 `agents.deployment`).** Only a host advertising `agents.deployment.supported:true` can seed this fixture — a host that omits `agents.deployment` MUST reject the channel-bearing `agent` ref with `validation_error` (`agent-ref.schema.json`). Exercised by `src/scenarios/agent-channel-dispatch.test.ts`.
418
+
419
+ - **Purpose**: prove the RFC 0082 §B channel resolve-and-pin contract from a real run graph (complementing `agent-deployment-lifecycle.test.ts` Leg 4, which uses the host-sample seam). A node binds a deployment CHANNEL (`agent.channel: "stable"`) instead of an exact `version`.
420
+ - **Topology**: a single `core.identity` node whose `agent` binding is `{ "agentId": "core.conformance.channel-agent", "channel": "stable" }` (no `version`). The host MUST have an active deployment of `core.conformance.channel-agent` on the `stable` channel.
421
+ - **Inputs**: none. Trigger `manual`.
422
+ - **Conformance test driver**:
423
+ 1. POST `/v1/runs` with `{workflowId: "conformance-agent-channel-dispatch"}`; poll until terminal.
424
+ 2. **Assert** the first `agent.invocation.started` carries `resolvedChannel: "stable"` and a concrete non-empty `resolvedAgentVersion` (the recorded fact, RFC 0077).
425
+ 3. **Replay** via `POST /v1/runs/{runId}:fork {mode:"replay"}`; **assert** the fork's `agent.invocation.started` re-reads the SAME `resolvedAgentVersion`.
426
+ 4. **(Seam-guarded)** Move the `stable` channel via the deployment seam; **assert** a replay of the original run STILL carries the original pin — never re-resolving the moved channel.
427
+
428
+ ---
429
+
415
430
  ## NodeModule registration
416
431
 
417
432
  The fixtures reference these typeIds:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openwop/openwop-conformance",
3
- "version": "1.18.1",
3
+ "version": "1.19.0",
4
4
  "description": "Production-ready black-box conformance suite for OpenWOP v1.0 compliant servers.",
5
5
  "repository": {
6
6
  "type": "git",