@openwop/openwop-conformance 1.0.0 → 1.1.1

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 (86) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +31 -6
  3. package/api/grpc/openwop.proto +251 -0
  4. package/api/openapi.yaml +109 -3
  5. package/coverage.md +48 -9
  6. package/fixtures/conformance-configurable-schema.json +39 -0
  7. package/fixtures/conformance-subworkflow-parent.json +1 -1
  8. package/fixtures/conformance-wasm-pack-memory-cap-breach.json +23 -0
  9. package/fixtures/openwop-smoke-byok-roundtrip.json +25 -0
  10. package/fixtures.md +21 -0
  11. package/package.json +3 -1
  12. package/schemas/README.md +4 -0
  13. package/schemas/audit-verify-result.schema.json +90 -0
  14. package/schemas/capabilities.schema.json +342 -1
  15. package/schemas/node-pack-manifest.schema.json +4 -4
  16. package/schemas/pack-lockfile.schema.json +92 -0
  17. package/schemas/registry-version-manifest.schema.json +145 -0
  18. package/schemas/run-event-payloads.schema.json +20 -4
  19. package/schemas/run-event.schema.json +2 -1
  20. package/schemas/security-advisory.schema.json +109 -0
  21. package/src/lib/a2a-fake-peer.ts +143 -56
  22. package/src/lib/behavior-gate.ts +107 -0
  23. package/src/lib/env.ts +37 -0
  24. package/src/lib/grpc-framing.test.ts +96 -0
  25. package/src/lib/grpc-framing.ts +76 -0
  26. package/src/lib/oidc-issuer.test.ts +328 -0
  27. package/src/lib/oidc-issuer.ts +241 -0
  28. package/src/lib/otel-collector-grpc.test.ts +191 -0
  29. package/src/lib/otel-collector.test.ts +303 -0
  30. package/src/lib/otel-collector.ts +318 -14
  31. package/src/lib/otlp-protobuf.test.ts +461 -0
  32. package/src/lib/otlp-protobuf.ts +529 -0
  33. package/src/scenarios/a2a-task-roundtrip.test.ts +147 -28
  34. package/src/scenarios/agentConfidenceEscalation.test.ts +1 -0
  35. package/src/scenarios/agentMemoryCrossTenantIsolation.test.ts +1 -0
  36. package/src/scenarios/agentMemoryRedactionContract.test.ts +1 -0
  37. package/src/scenarios/agentMemoryRoundTrip.test.ts +1 -0
  38. package/src/scenarios/agentMemoryTtlExpiry.test.ts +1 -0
  39. package/src/scenarios/agentMessageReducer.test.ts +1 -0
  40. package/src/scenarios/agentMetadata.test.ts +1 -0
  41. package/src/scenarios/agentPackExport.test.ts +1 -0
  42. package/src/scenarios/agentPackInstall.test.ts +1 -0
  43. package/src/scenarios/agentPackProvenance.test.ts +1 -0
  44. package/src/scenarios/audit-log-integrity.test.ts +3 -6
  45. package/src/scenarios/auth-api-key-rotation.test.ts +182 -0
  46. package/src/scenarios/auth-mtls.test.ts +274 -0
  47. package/src/scenarios/auth-oauth2-client-credentials.test.ts +259 -0
  48. package/src/scenarios/auth-oidc-user-bearer.test.ts +361 -0
  49. package/src/scenarios/bulk-cancel.test.ts +111 -0
  50. package/src/scenarios/configurable-schema.test.ts +48 -0
  51. package/src/scenarios/conversationCapabilityNegotiation.test.ts +1 -0
  52. package/src/scenarios/conversationLifecycle.test.ts +1 -0
  53. package/src/scenarios/conversationReplayDeterminism.test.ts +1 -0
  54. package/src/scenarios/conversationVsLegacySuspend.test.ts +1 -0
  55. package/src/scenarios/debug-bundle-truncation.test.ts +95 -0
  56. package/src/scenarios/discovery.test.ts +183 -0
  57. package/src/scenarios/http-client-ssrf.test.ts +71 -0
  58. package/src/scenarios/idempotency.test.ts +6 -0
  59. package/src/scenarios/idempotencyRetry.test.ts +3 -0
  60. package/src/scenarios/mcp-tool-roundtrip.test.ts +205 -34
  61. package/src/scenarios/mcp-toolcall-redaction.test.ts +66 -0
  62. package/src/scenarios/memory-compaction-event-emitted.test.ts +121 -0
  63. package/src/scenarios/memory-compaction-provenance-tag.test.ts +116 -0
  64. package/src/scenarios/memory-compaction-sr1-carry-forward.test.ts +127 -0
  65. package/src/scenarios/metric-emission.test.ts +113 -0
  66. package/src/scenarios/multi-region-idempotency.test.ts +39 -4
  67. package/src/scenarios/orchestratorConservativePath.test.ts +1 -0
  68. package/src/scenarios/orchestratorDispatch.test.ts +1 -0
  69. package/src/scenarios/orchestratorTermination.test.ts +1 -0
  70. package/src/scenarios/otel-emission-grpc.test.ts +98 -0
  71. package/src/scenarios/otel-trace-propagation-subworkflow.test.ts +139 -0
  72. package/src/scenarios/pause-resume.test.ts +119 -0
  73. package/src/scenarios/production-backpressure.test.ts +342 -0
  74. package/src/scenarios/production-retention-expiry.test.ts +164 -0
  75. package/src/scenarios/registry-public.test.ts +222 -0
  76. package/src/scenarios/replay-llm-cache-key.test.ts +35 -0
  77. package/src/scenarios/replay-retention-expiry.test.ts +178 -0
  78. package/src/scenarios/restart-during-run.test.ts +177 -0
  79. package/src/scenarios/spec-corpus-validity.test.ts +59 -26
  80. package/src/scenarios/staleClaim.test.ts +3 -0
  81. package/src/scenarios/wasm-pack-abi-version-rejection.test.ts +67 -10
  82. package/src/scenarios/wasm-pack-memory-cap.test.ts +64 -9
  83. package/src/scenarios/webhook-negative.test.ts +90 -0
  84. package/src/scenarios/webhook-signed-delivery.test.ts +178 -0
  85. package/src/setup.ts +25 -1
  86. package/vitest.config.ts +5 -1
@@ -0,0 +1,121 @@
1
+ /**
2
+ * RFC 0012 §B — `memory.compacted` event emission shape.
3
+ *
4
+ * Verifies that hosts advertising
5
+ * `capabilities.memory.compaction.supported: true` emit a canonical
6
+ * `memory.compacted` event payload per `run-event-payloads.schema.json`
7
+ * §`memoryCompacted` whenever a compaction run completes.
8
+ *
9
+ * Required fields per the schema:
10
+ * - `memoryRef` (string, non-empty)
11
+ * - `outputId` (string, non-empty)
12
+ * - `sourceCount` (integer ≥ 1)
13
+ * - `trigger` (closed enum: `host-managed | client-requested | both`)
14
+ * - `byteSize` (integer ≥ 0)
15
+ *
16
+ * Optional:
17
+ * - `sourceIds` (array of non-empty strings; exhaustive within the
18
+ * array — no "and N more" semantics — when present)
19
+ *
20
+ * Gating identical to `memory-compaction-sr1-carry-forward.test.ts`:
21
+ * capability advertisement + test seam reachable.
22
+ *
23
+ * @see RFCS/0012-memory-compaction-profile.md §B
24
+ * @see schemas/run-event-payloads.schema.json §memoryCompacted
25
+ */
26
+
27
+ import { describe, it, expect } from 'vitest';
28
+ import { driver } from '../lib/driver.js';
29
+
30
+ const MEMORY_REF = 'mem_tenant:default_agent:conformance-rfc0012-event_longTerm';
31
+
32
+ interface MemoryCaps {
33
+ compaction?: { supported?: boolean };
34
+ }
35
+
36
+ async function isCompactionAdvertised(): Promise<boolean> {
37
+ const disco = await driver.get('/.well-known/openwop');
38
+ const memory = (disco.json as { capabilities?: { memory?: MemoryCaps } }).capabilities?.memory;
39
+ return memory?.compaction?.supported === true;
40
+ }
41
+
42
+ async function isTestSeamReachable(): Promise<boolean> {
43
+ const r = await driver.post('/v1/test/memory/compact', {});
44
+ return r.status !== 404;
45
+ }
46
+
47
+ describe('memory-compaction-event-emitted: canonical memory.compacted payload shape', () => {
48
+ it('compaction run returns a canonical memoryCompacted payload', async () => {
49
+ if (!(await isCompactionAdvertised())) {
50
+ // eslint-disable-next-line no-console
51
+ console.warn('[rfc0012-event] capabilities.memory.compaction.supported not advertised; skipping');
52
+ return;
53
+ }
54
+ if (!(await isTestSeamReachable())) {
55
+ // eslint-disable-next-line no-console
56
+ console.warn('[rfc0012-event] test seam unreachable; skipping');
57
+ return;
58
+ }
59
+
60
+ const seed = await driver.post('/v1/test/memory/seed', {
61
+ memoryRef: MEMORY_REF,
62
+ entries: [
63
+ { id: `event-src-${Date.now()}-a`, content: 'First memory entry.' },
64
+ { id: `event-src-${Date.now()}-b`, content: 'Second memory entry.' },
65
+ { id: `event-src-${Date.now()}-c`, content: 'Third memory entry.' },
66
+ ],
67
+ });
68
+ expect(seed.status).toBe(201);
69
+
70
+ const compactRes = await driver.post('/v1/test/memory/compact', {
71
+ memoryRef: MEMORY_REF,
72
+ });
73
+ expect(compactRes.status).toBe(200);
74
+
75
+ const event = compactRes.json as {
76
+ type?: string;
77
+ payload?: Record<string, unknown>;
78
+ };
79
+
80
+ expect(event.type, driver.describe(
81
+ 'observability.md §"Canonical run lifecycle event names"',
82
+ 'event MUST be type=memory.compacted',
83
+ )).toBe('memory.compacted');
84
+
85
+ const payload = event.payload ?? {};
86
+
87
+ expect(typeof payload.memoryRef, driver.describe(
88
+ 'RFC 0012 §B / run-event-payloads.schema.json §memoryCompacted',
89
+ 'memoryRef MUST be a non-empty string',
90
+ )).toBe('string');
91
+ expect((payload.memoryRef as string).length).toBeGreaterThan(0);
92
+
93
+ expect(typeof payload.outputId, driver.describe(
94
+ 'RFC 0012 §B',
95
+ 'outputId MUST be a string identifying the distilled entry',
96
+ )).toBe('string');
97
+ expect((payload.outputId as string).length).toBeGreaterThan(0);
98
+
99
+ expect(Number.isInteger(payload.sourceCount), driver.describe(
100
+ 'RFC 0012 §B',
101
+ 'sourceCount MUST be an integer',
102
+ )).toBe(true);
103
+ expect(payload.sourceCount as number).toBeGreaterThanOrEqual(1);
104
+
105
+ expect(['host-managed', 'client-requested', 'both']).toContain(payload.trigger);
106
+
107
+ expect(Number.isInteger(payload.byteSize), driver.describe(
108
+ 'RFC 0012 §B',
109
+ 'byteSize MUST be an integer',
110
+ )).toBe(true);
111
+ expect(payload.byteSize as number).toBeGreaterThanOrEqual(0);
112
+
113
+ if (payload.sourceIds !== undefined) {
114
+ expect(Array.isArray(payload.sourceIds)).toBe(true);
115
+ for (const id of payload.sourceIds as unknown[]) {
116
+ expect(typeof id, 'sourceIds entries MUST be strings').toBe('string');
117
+ expect((id as string).length).toBeGreaterThan(0);
118
+ }
119
+ }
120
+ });
121
+ });
@@ -0,0 +1,116 @@
1
+ /**
2
+ * RFC 0012 §C — `compacted-from:<id>` provenance tag convention.
3
+ *
4
+ * The distilled entry SHOULD (not MUST) carry a tag of the form
5
+ * `compacted-from:<compactionRunId>` where `<compactionRunId>` is a
6
+ * host-issued opaque identifier. This lets `MemoryAdapter.list`
7
+ * consumers detect compacted entries without needing access to the
8
+ * `memory.compacted` event stream.
9
+ *
10
+ * SOFT ASSERTION: log-and-warn if absent, fail only if a present tag
11
+ * is malformed. Hosts with structurally-constrained tag-spaces (legacy
12
+ * tag-prefix discipline, fixed-vocabulary tagging) MAY omit this —
13
+ * the `memory.compacted` event itself remains the canonical provenance
14
+ * signal.
15
+ *
16
+ * Gating identical to the other RFC 0012 scenarios.
17
+ *
18
+ * @see RFCS/0012-memory-compaction-profile.md §C
19
+ */
20
+
21
+ import { describe, it, expect } from 'vitest';
22
+ import { driver } from '../lib/driver.js';
23
+
24
+ const MEMORY_REF = 'mem_tenant:default_agent:conformance-rfc0012-tag_longTerm';
25
+ const COMPACTED_FROM_RE = /^compacted-from:[^\s:][^\s]*$/;
26
+
27
+ interface MemoryCaps {
28
+ compaction?: { supported?: boolean };
29
+ }
30
+
31
+ interface MemoryListResponse {
32
+ entries?: Array<{ id?: string; tags?: string[] }>;
33
+ }
34
+
35
+ async function isCompactionAdvertised(): Promise<boolean> {
36
+ const disco = await driver.get('/.well-known/openwop');
37
+ const memory = (disco.json as { capabilities?: { memory?: MemoryCaps } }).capabilities?.memory;
38
+ return memory?.compaction?.supported === true;
39
+ }
40
+
41
+ async function isTestSeamReachable(): Promise<boolean> {
42
+ const r = await driver.post('/v1/test/memory/compact', {});
43
+ return r.status !== 404;
44
+ }
45
+
46
+ describe('memory-compaction-provenance-tag: compacted-from:<id> tag follows §C convention', () => {
47
+ it('compacted entry carries a well-formed compacted-from tag, OR omits it cleanly (no malformed tags)', async () => {
48
+ if (!(await isCompactionAdvertised())) {
49
+ // eslint-disable-next-line no-console
50
+ console.warn('[rfc0012-tag] capabilities.memory.compaction.supported not advertised; skipping');
51
+ return;
52
+ }
53
+ if (!(await isTestSeamReachable())) {
54
+ // eslint-disable-next-line no-console
55
+ console.warn('[rfc0012-tag] test seam unreachable; skipping');
56
+ return;
57
+ }
58
+
59
+ // Seed + compact.
60
+ const seedStamp = Date.now();
61
+ const seed = await driver.post('/v1/test/memory/seed', {
62
+ memoryRef: MEMORY_REF,
63
+ entries: [
64
+ { id: `tag-src-${seedStamp}-1`, content: 'Source content alpha.' },
65
+ { id: `tag-src-${seedStamp}-2`, content: 'Source content beta.' },
66
+ ],
67
+ });
68
+ expect(seed.status).toBe(201);
69
+
70
+ const compactRes = await driver.post('/v1/test/memory/compact', {
71
+ memoryRef: MEMORY_REF,
72
+ });
73
+ expect(compactRes.status).toBe(200);
74
+
75
+ const event = compactRes.json as { payload?: { outputId?: string } };
76
+ const outputId = event.payload?.outputId;
77
+ expect(typeof outputId).toBe('string');
78
+
79
+ // Resolve the entry via the wire MemoryAdapter list surface (no
80
+ // direct get-by-id wire endpoint; we filter list results).
81
+ // Hosts that don't expose memory:list on the wire skip — this is
82
+ // a `MemoryAdapter.list` surface check, which the canonical
83
+ // capabilities.memory.supported claim already covers.
84
+ const listRes = await driver.get(
85
+ `/v1/memory/${encodeURIComponent(MEMORY_REF)}?limit=50`,
86
+ );
87
+ if (listRes.status === 404) {
88
+ // eslint-disable-next-line no-console
89
+ console.warn('[rfc0012-tag] host does not expose memory:list at /v1/memory/{ref}; skipping tag inspection (canonical provenance signal remains the memory.compacted event itself)');
90
+ return;
91
+ }
92
+ expect(listRes.status, 'memory:list MUST return 200 when reachable').toBe(200);
93
+
94
+ const body = (listRes.json as MemoryListResponse) ?? {};
95
+ const entries = body.entries ?? [];
96
+ const output = entries.find((e) => e.id === outputId);
97
+ if (!output) {
98
+ // eslint-disable-next-line no-console
99
+ console.warn(`[rfc0012-tag] outputId ${outputId} not visible via memory:list; cannot inspect tags`);
100
+ return;
101
+ }
102
+ const tags = output.tags ?? [];
103
+
104
+ // RFC 0012 §C: SHOULD-tag, soft assertion.
105
+ const provenance = tags.find((t) => t.startsWith('compacted-from:'));
106
+ if (provenance === undefined) {
107
+ // eslint-disable-next-line no-console
108
+ console.warn('[rfc0012-tag] output entry has no compacted-from:<id> tag — RFC 0012 §C is SHOULD, not MUST; pass with warning');
109
+ return;
110
+ }
111
+ expect(provenance, driver.describe(
112
+ 'RFC 0012 §C',
113
+ 'compacted-from tag MUST match `compacted-from:<id>` shape (non-empty id, no whitespace) when present',
114
+ )).toMatch(COMPACTED_FROM_RE);
115
+ });
116
+ });
@@ -0,0 +1,127 @@
1
+ /**
2
+ * RFC 0012 §D — SR-1 carry-forward through memory compaction.
3
+ *
4
+ * Verifies the load-bearing security claim of RFC 0012 (Memory
5
+ * Compaction Profile, `Active` 2026-05-13 — comment window closes
6
+ * 2026-05-20): when a host advertising
7
+ * `capabilities.memory.compaction.supported: true` produces a
8
+ * compacted `MemoryEntry`, the derived content MUST pass the
9
+ * same BYOK redaction harness as a fresh `put`. The fact that
10
+ * source entries were SR-1-compliant at original `put` time is
11
+ * NOT evidence to skip redaction on derived content — summarization
12
+ * models can introduce secret-shaped substrings (hallucinated
13
+ * tokens, format-leaks from in-context examples) not present in
14
+ * any source.
15
+ *
16
+ * Gating:
17
+ * - `capabilities.memory.compaction.supported` MUST be `true`.
18
+ * - Host MUST expose the test seam at `POST /v1/test/memory/{seed,
19
+ * compact}` — gated on the host's `OPENWOP_TEST_TRIGGER_COMPACTION`
20
+ * env var. Without it the scenario can't synchronously drive
21
+ * compaction (RFC 0012 normates only `trigger: 'host-managed'`).
22
+ * The seam itself is host-implementation-specific; the conformance
23
+ * suite skips when the seam isn't reachable.
24
+ *
25
+ * @see RFCS/0012-memory-compaction-profile.md §D
26
+ * @see SECURITY/invariants.yaml `memory-compaction-sr-1-carry-forward`
27
+ */
28
+
29
+ import { describe, it, expect } from 'vitest';
30
+ import { driver } from '../lib/driver.js';
31
+
32
+ const MEMORY_REF = 'mem_tenant:default_agent:conformance-rfc0012-sr1_longTerm';
33
+
34
+ interface MemoryCaps {
35
+ compaction?: { supported?: boolean };
36
+ }
37
+
38
+ async function isCompactionAdvertised(): Promise<boolean> {
39
+ const disco = await driver.get('/.well-known/openwop');
40
+ const memory = (disco.json as { capabilities?: { memory?: MemoryCaps } }).capabilities?.memory;
41
+ return memory?.compaction?.supported === true;
42
+ }
43
+
44
+ async function isTestSeamReachable(): Promise<boolean> {
45
+ // Probe the seam with an empty body — expects 400 if reachable
46
+ // (validation_error on missing memoryRef), 404 when disabled.
47
+ const r = await driver.post('/v1/test/memory/compact', {});
48
+ return r.status !== 404;
49
+ }
50
+
51
+ describe('memory-compaction-sr1-carry-forward: derived content passes the BYOK redaction harness', () => {
52
+ it('compacted MemoryEntry content MUST NOT carry source-side form-leak signatures', async () => {
53
+ if (!(await isCompactionAdvertised())) {
54
+ // eslint-disable-next-line no-console
55
+ console.warn('[rfc0012-sr1] capabilities.memory.compaction.supported not advertised; skipping');
56
+ return;
57
+ }
58
+ if (!(await isTestSeamReachable())) {
59
+ // eslint-disable-next-line no-console
60
+ console.warn('[rfc0012-sr1] test seam /v1/test/memory/compact unreachable; skipping (set host\'s OPENWOP_TEST_TRIGGER_COMPACTION=true)');
61
+ return;
62
+ }
63
+
64
+ // 1. Seed source entries containing:
65
+ // - The canonical `[BYOK:...]` form-leak signature (placeholder
66
+ // surfaces verbatim — should be caught by SR-1 carry-forward).
67
+ // - A non-canonical `<REDACTED:...>` marker that the host's
68
+ // redaction harness should re-canonicalize.
69
+ // - Plain, non-sensitive prose.
70
+ const seed = await driver.post('/v1/test/memory/seed', {
71
+ memoryRef: MEMORY_REF,
72
+ entries: [
73
+ { id: `sr1-src-${Date.now()}-1`, content: 'User confirmed: [BYOK:hk_live_canary_42]' },
74
+ { id: `sr1-src-${Date.now()}-2`, content: 'Resolved <REDACTED:db-prod-creds> outage.' },
75
+ { id: `sr1-src-${Date.now()}-3`, content: 'Customer asked about pricing tiers.' },
76
+ ],
77
+ });
78
+ expect(seed.status, 'seed endpoint MUST return 201 when reachable').toBe(201);
79
+
80
+ // 2. Drive compaction synchronously.
81
+ const compactRes = await driver.post('/v1/test/memory/compact', {
82
+ memoryRef: MEMORY_REF,
83
+ });
84
+ expect(compactRes.status, 'compact MUST return 200 with ≥2 source entries').toBe(200);
85
+
86
+ const event = compactRes.json as {
87
+ type?: string;
88
+ payload?: { outputId?: string; memoryRef?: string };
89
+ // Out-of-band field from the test seam carrying the persisted
90
+ // entry bytes; the wire-level `memory.compacted` event does NOT
91
+ // carry content. Required for SR-1 verification — the canonical
92
+ // event payload is shape-only and would pass this scenario
93
+ // trivially without it.
94
+ outputContent?: string;
95
+ };
96
+ expect(event.type, 'event payload MUST be type=memory.compacted').toBe('memory.compacted');
97
+
98
+ if (typeof event.outputContent !== 'string') {
99
+ // eslint-disable-next-line no-console
100
+ console.warn('[rfc0012-sr1] test seam did not return outputContent; the wire-level memory.compacted shape does not surface content so without a host-side seam we cannot verify §D end-to-end. Skipping.');
101
+ return;
102
+ }
103
+
104
+ // The load-bearing assertion: the PERSISTED entry content (what
105
+ // future MemoryAdapter.get / list consumers would see) MUST NOT
106
+ // carry source-side form-leak signatures. A host that skips its
107
+ // BYOK redaction pass on derived content fails here.
108
+ expect(event.outputContent.includes('[BYOK:hk_live_canary_42]'), driver.describe(
109
+ 'RFC 0012 §D',
110
+ 'derived MemoryEntry.content MUST NOT carry source-side [BYOK:...] form-leak signatures (SR-1 carry-forward)',
111
+ )).toBe(false);
112
+ expect(event.outputContent.includes('<REDACTED:db-prod-creds>'), driver.describe(
113
+ 'RFC 0012 §D',
114
+ 'derived MemoryEntry.content MUST NOT echo non-canonical <REDACTED:...> markers from sources',
115
+ )).toBe(false);
116
+
117
+ // Positive: the canonical `[REDACTED:...]` placeholder MUST be
118
+ // present where SR-1 carry-forward re-substituted a source-side
119
+ // leak. Pinning this prevents a host from "passing" by simply
120
+ // stripping source content rather than redacting it (which would
121
+ // also lose audit signal).
122
+ expect(event.outputContent, driver.describe(
123
+ 'RFC 0012 §D',
124
+ 'derived MemoryEntry.content MUST carry canonical [REDACTED:...] placeholders where source-side leaks were re-substituted',
125
+ )).toMatch(/\[REDACTED:[^\]]+\]/);
126
+ });
127
+ });
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Track 11: metric-emission verification.
3
+ *
4
+ * Verifies that hosts claiming `capabilities.observability.metrics`
5
+ * emit the canonical `openwop.run.backlog`, `openwop.queue.depth`, and
6
+ * (after at least one completed run) `openwop.run.duration` metrics
7
+ * documented in `spec/v1/observability.md`.
8
+ *
9
+ * Operator contract (same as `otel-emission.test.ts`):
10
+ * 1. Start the conformance suite with `OPENWOP_OTEL_COLLECTOR=true`
11
+ * and `OPENWOP_OTEL_COLLECTOR_PORT=<port>`.
12
+ * 2. Boot the host with `OTEL_EXPORTER_OTLP_ENDPOINT=http://127.0.0.1:<port>`.
13
+ *
14
+ * Skip conditions:
15
+ * - Collector disabled (`OPENWOP_OTEL_COLLECTOR` unset / false).
16
+ * - Host doesn't advertise `capabilities.observability.metrics.supported`.
17
+ *
18
+ * @see spec/v1/observability.md §"Metrics"
19
+ */
20
+
21
+ import { describe, it, expect } from 'vitest';
22
+ import { driver } from '../lib/driver.js';
23
+ import { pollUntilTerminal } from '../lib/polling.js';
24
+ import { isFixtureAdvertised } from '../lib/fixtures.js';
25
+ import { getCollector } from '../lib/otel-collector.js';
26
+
27
+ const FIXTURE = 'conformance-noop';
28
+
29
+ interface MetricsCaps {
30
+ supported?: boolean;
31
+ names?: ReadonlyArray<string>;
32
+ }
33
+
34
+ async function metricsAdvertised(): Promise<MetricsCaps | null> {
35
+ try {
36
+ const disco = await driver.get('/.well-known/openwop');
37
+ const caps = (disco.json as {
38
+ capabilities?: { observability?: { metrics?: MetricsCaps } };
39
+ }).capabilities;
40
+ return caps?.observability?.metrics ?? null;
41
+ } catch {
42
+ return null;
43
+ }
44
+ }
45
+
46
+ async function waitForMetric(name: string, timeoutMs = 5_000): Promise<boolean> {
47
+ const collector = getCollector();
48
+ if (!collector) return false;
49
+ const deadline = Date.now() + timeoutMs;
50
+ while (Date.now() < deadline) {
51
+ if (collector.metricByName(name)) return true;
52
+ await new Promise((r) => setTimeout(r, 100));
53
+ }
54
+ return false;
55
+ }
56
+
57
+ describe('metric-emission: canonical openwop.* metrics arrive at the collector', () => {
58
+ it('host emits openwop.run.backlog, openwop.queue.depth, and openwop.run.duration', async () => {
59
+ if (!getCollector()) {
60
+ // eslint-disable-next-line no-console
61
+ console.warn(
62
+ '[metric-emission] collector not started; set OPENWOP_OTEL_COLLECTOR=true to run',
63
+ );
64
+ return;
65
+ }
66
+ const metricsCaps = await metricsAdvertised();
67
+ if (!metricsCaps?.supported) {
68
+ // eslint-disable-next-line no-console
69
+ console.warn(
70
+ '[metric-emission] host does not advertise observability.metrics.supported; skipping',
71
+ );
72
+ return;
73
+ }
74
+ if (!isFixtureAdvertised(FIXTURE)) {
75
+ // eslint-disable-next-line no-console
76
+ console.warn(`[metric-emission] ${FIXTURE} not advertised; skipping`);
77
+ return;
78
+ }
79
+
80
+ const collector = getCollector()!;
81
+ collector.reset();
82
+
83
+ // Drive at least one completed run so openwop.run.duration has a sample.
84
+ const create = await driver.post('/v1/runs', { workflowId: FIXTURE });
85
+ expect(create.status).toBe(201);
86
+ const runId = (create.json as { runId: string }).runId;
87
+ await pollUntilTerminal(runId, { timeoutMs: 10_000 });
88
+
89
+ // Wait for the host's metric-emit tick to land at the collector.
90
+ const sawBacklog = await waitForMetric('openwop.run.backlog', 5_000);
91
+ expect(sawBacklog, driver.describe(
92
+ 'observability.md §"Metrics"',
93
+ 'host claiming metrics MUST emit openwop.run.backlog',
94
+ )).toBe(true);
95
+
96
+ const sawQueueDepth = await waitForMetric('openwop.queue.depth', 5_000);
97
+ expect(sawQueueDepth, driver.describe(
98
+ 'observability.md §"Metrics"',
99
+ 'host claiming metrics MUST emit openwop.queue.depth',
100
+ )).toBe(true);
101
+
102
+ const sawDuration = await waitForMetric('openwop.run.duration', 5_000);
103
+ expect(sawDuration, driver.describe(
104
+ 'observability.md §"Metrics"',
105
+ 'host claiming metrics MUST emit openwop.run.duration after a completed run',
106
+ )).toBe(true);
107
+
108
+ // Shape spot-check: backlog gauge data point has a numeric value.
109
+ const backlog = collector.metricByName('openwop.run.backlog')!;
110
+ expect(backlog.kind).toBe('gauge');
111
+ expect(typeof backlog.dataPoint.value).toBe('number');
112
+ });
113
+ });
@@ -2,18 +2,29 @@
2
2
  * Track 13: multi-region idempotency capability shape (idempotency.md v1.1).
3
3
  *
4
4
  * Verifies that hosts advertising the multi-region idempotency annex
5
- * surface a valid `capabilities.idempotency.crossRegion` value. The
6
- * end-to-end partition behavior cannot be exercised black-box; this
7
- * scenario validates the discovery-document shape so clients can rely
8
- * on the capability for routing decisions.
5
+ * surface a valid `capabilities.idempotency.crossRegion` value AND, when
6
+ * claiming `'best-effort'` or `'strict'`, expose the operator-tier
7
+ * metric names per `idempotency.md` §"Operator surface".
8
+ *
9
+ * The annex's partition-replay convergence rule cannot be exercised
10
+ * black-box (it requires multi-region host deployment under a real
11
+ * partition); the algorithm itself is verified in-process via the
12
+ * Postgres host's `multi-region-idempotency.test.ts` smoke against
13
+ * the canonical resolver. This scenario validates the discovery-
14
+ * document shape so clients can rely on the capability for routing
15
+ * decisions.
9
16
  *
10
17
  * @see spec/v1/idempotency.md §"Multi-region idempotency"
18
+ * @see examples/hosts/postgres/src/multi-region.ts (canonical resolver)
11
19
  */
12
20
 
13
21
  import { describe, it, expect } from 'vitest';
14
22
  import { driver } from '../lib/driver.js';
15
23
 
16
24
  const ALLOWED = new Set(['single-region', 'best-effort', 'strict']);
25
+ const REQUIRED_METRICS_WHEN_MULTI_REGION = [
26
+ 'openwop.idempotency.cross_region_conflicts_total',
27
+ ];
17
28
 
18
29
  interface IdempotencyCaps {
19
30
  supported?: boolean;
@@ -22,6 +33,10 @@ interface IdempotencyCaps {
22
33
  crossRegion?: string;
23
34
  }
24
35
 
36
+ interface ObservabilityCaps {
37
+ metrics?: { names?: string[] };
38
+ }
39
+
25
40
  describe('multi-region-idempotency: capability shape', () => {
26
41
  it('idempotency.crossRegion (when advertised) MUST be one of the closed enum', async () => {
27
42
  const disco = await driver.get('/.well-known/openwop');
@@ -49,4 +64,24 @@ describe('multi-region-idempotency: capability shape', () => {
49
64
  expect(idem.layer2RetentionSeconds).toBeGreaterThan(0);
50
65
  }
51
66
  });
67
+
68
+ it('multi-region hosts SHOULD expose the cross-region conflict counter per §"Operator surface"', async () => {
69
+ const disco = await driver.get('/.well-known/openwop');
70
+ const caps = (disco.json as { capabilities?: { idempotency?: IdempotencyCaps; observability?: ObservabilityCaps } })
71
+ .capabilities;
72
+ const crossRegion = caps?.idempotency?.crossRegion;
73
+
74
+ if (crossRegion !== 'best-effort' && crossRegion !== 'strict') {
75
+ // Single-region hosts have no conflicts to count — skip.
76
+ return;
77
+ }
78
+
79
+ const advertised = new Set(caps?.observability?.metrics?.names ?? []);
80
+ for (const name of REQUIRED_METRICS_WHEN_MULTI_REGION) {
81
+ expect(advertised.has(name), driver.describe(
82
+ 'idempotency.md §"Operator surface"',
83
+ `multi-region hosts SHOULD advertise metric "${name}" so operators can monitor conflict frequency`,
84
+ )).toBe(true);
85
+ }
86
+ });
52
87
  });
@@ -1,5 +1,6 @@
1
1
  /**
2
2
  * Multi-Agent Shift Phase 5 — CP-1 conservative-path orchestrator suspend.
3
+ * Normative reference: RFCS/0006-orchestrator.md
3
4
  *
4
5
  * Verifies the CP-1 invariant: when a `core.orchestrator.supervisor`
5
6
  * would emit a decision with `confidence < escalationThreshold`, the
@@ -1,5 +1,6 @@
1
1
  /**
2
2
  * Multi-Agent Shift Phase 5 — orchestrator → dispatch → next-worker round-trip.
3
+ * Normative reference: RFCS/0006-orchestrator.md
3
4
  *
4
5
  * Verifies that a workflow with `core.orchestrator.supervisor` →
5
6
  * `core.dispatch` topology emits the canonical event sequence:
@@ -1,5 +1,6 @@
1
1
  /**
2
2
  * Multi-Agent Shift Phase 5 — orchestrator terminate decision (CO-3).
3
+ * Normative reference: RFCS/0006-orchestrator.md
3
4
  *
4
5
  * Verifies that when an `core.orchestrator.supervisor` emits a decision
5
6
  * with `kind: 'terminate'`:
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Track 11: OTel span emission over OTLP/gRPC.
3
+ *
4
+ * Verifies that hosts advertising `capabilities.observability.otel.exportProtocols`
5
+ * including `"grpc"` can emit `openwop.*` spans over OTLP/gRPC to the
6
+ * in-suite collector and that the gRPC framing path captures the same
7
+ * `openwop.run` + `openwop.node.*` shape as the HTTP-JSON path.
8
+ *
9
+ * The gRPC collector is the parallel HTTP/2 server inside
10
+ * `OtelCollector` (started via `startGrpc()` in setup.ts when the
11
+ * `OPENWOP_OTEL_COLLECTOR=true` flag is set). The host points its
12
+ * exporter at the printed `:<grpcPort>` (h2c) with
13
+ * `OTEL_EXPORTER_OTLP_PROTOCOL=grpc`. Spans captured over gRPC land
14
+ * in the same store as HTTP — `getCollector().spans()` returns the
15
+ * union.
16
+ *
17
+ * Skip conditions:
18
+ * - Collector disabled (`OPENWOP_OTEL_COLLECTOR` unset / false).
19
+ * - Host does not advertise `capabilities.observability.otel.exportProtocols`
20
+ * including `"grpc"` (presumed not configured for gRPC emission).
21
+ * - Required fixture (`conformance-noop`) not advertised.
22
+ *
23
+ * @see spec/v1/observability.md §"Export protocols"
24
+ * @see conformance/src/lib/otel-collector.ts §_handleGrpcStream
25
+ * @see conformance/src/lib/grpc-framing.ts
26
+ */
27
+
28
+ import { describe, it, expect } from 'vitest';
29
+ import { driver } from '../lib/driver.js';
30
+ import { pollUntilTerminal } from '../lib/polling.js';
31
+ import { isFixtureAdvertised } from '../lib/fixtures.js';
32
+ import { getCollector, waitForRunSpans } from '../lib/otel-collector.js';
33
+
34
+ const FIXTURE = 'conformance-noop';
35
+
36
+ async function advertisesGrpcExport(): Promise<boolean> {
37
+ try {
38
+ const disco = await driver.get('/.well-known/openwop');
39
+ const caps = (disco.json as {
40
+ capabilities?: {
41
+ observability?: { otel?: { exportProtocols?: unknown } };
42
+ };
43
+ }).capabilities;
44
+ const protocols = caps?.observability?.otel?.exportProtocols;
45
+ return Array.isArray(protocols) && protocols.includes('grpc');
46
+ } catch {
47
+ return false;
48
+ }
49
+ }
50
+
51
+ describe('otel-emission-grpc: OTLP/gRPC export path', () => {
52
+ it('host emits openwop.run spans over OTLP/gRPC; collector captures them via the shared store', async () => {
53
+ if (!getCollector()) {
54
+ // eslint-disable-next-line no-console
55
+ console.warn('[otel-emission-grpc] collector not started; set OPENWOP_OTEL_COLLECTOR=true to run');
56
+ return;
57
+ }
58
+ if (!isFixtureAdvertised(FIXTURE)) {
59
+ // eslint-disable-next-line no-console
60
+ console.warn(`[otel-emission-grpc] fixture ${FIXTURE} not advertised; skipping`);
61
+ return;
62
+ }
63
+ if (!(await advertisesGrpcExport())) {
64
+ // eslint-disable-next-line no-console
65
+ console.warn(
66
+ '[otel-emission-grpc] host does not advertise capabilities.observability.otel.exportProtocols including "grpc"; skipping. ' +
67
+ 'Hosts MAY opt into gRPC export by emitting OTLP via the OTLP/gRPC transport and adding `"grpc"` to the array.',
68
+ );
69
+ return;
70
+ }
71
+
72
+ const collector = getCollector()!;
73
+ collector.reset();
74
+
75
+ const create = await driver.post('/v1/runs', { workflowId: FIXTURE });
76
+ expect(create.status).toBe(201);
77
+ const runId = (create.json as { runId: string }).runId;
78
+
79
+ await pollUntilTerminal(runId, { timeoutMs: 15_000 });
80
+
81
+ // gRPC and HTTP-JSON spans both land in `_spans`, so the existing
82
+ // span-query helpers work transparently. If the host emits over
83
+ // both transports, we capture both; the assertion only requires
84
+ // at least one openwop.run span correlated by runId.
85
+ const runSpans = await waitForRunSpans(runId, { timeoutMs: 5_000, minCount: 1 });
86
+
87
+ expect(runSpans.length, driver.describe(
88
+ 'observability.md §"Export protocols" + RFC 0008/0009 Track 11',
89
+ 'host advertising exportProtocols ∋ "grpc" MUST emit openwop.* spans over OTLP/gRPC',
90
+ )).toBeGreaterThan(0);
91
+
92
+ const runSpan = runSpans.find((s) => s.name === 'openwop.run');
93
+ expect(runSpan?.attributes.get('openwop.run_id'), driver.describe(
94
+ 'observability.md §"Run-level attributes"',
95
+ 'openwop.run span MUST carry openwop.run_id attribute',
96
+ )).toBe(runId);
97
+ });
98
+ });