@openwop/openwop-conformance 1.1.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/README.md +2 -2
  3. package/coverage.md +29 -17
  4. package/fixtures/conformance-agent-low-confidence.json +7 -4
  5. package/fixtures/conformance-agent-pack-handoff-schema-validation.json +30 -0
  6. package/fixtures/conformance-agent-reasoning.json +23 -4
  7. package/fixtures/conformance-dispatch-cross-worker-handoff-child-a.json +27 -0
  8. package/fixtures/conformance-dispatch-cross-worker-handoff-child-b.json +25 -0
  9. package/fixtures/conformance-dispatch-cross-worker-handoff.json +60 -0
  10. package/fixtures/conformance-dispatch-input-mapping-child.json +25 -0
  11. package/fixtures/conformance-dispatch-input-mapping.json +49 -0
  12. package/fixtures/conformance-dispatch-output-mapping-child.json +27 -0
  13. package/fixtures/conformance-dispatch-output-mapping.json +49 -0
  14. package/fixtures/conformance-subworkflow-input-mapping-child.json +27 -0
  15. package/fixtures/conformance-subworkflow-input-mapping.json +33 -0
  16. package/fixtures.md +12 -2
  17. package/package.json +1 -1
  18. package/schemas/README.md +7 -0
  19. package/schemas/agent-ref.schema.json +1 -1
  20. package/schemas/ai-envelope.schema.json +106 -0
  21. package/schemas/capabilities.schema.json +300 -3
  22. package/schemas/core-conformance-mock-agent-config.schema.json +147 -0
  23. package/schemas/dispatch-config.schema.json +26 -0
  24. package/schemas/envelopes/clarification.request.schema.json +43 -0
  25. package/schemas/envelopes/error.schema.json +26 -0
  26. package/schemas/envelopes/schema.request.schema.json +22 -0
  27. package/schemas/envelopes/schema.response.schema.json +22 -0
  28. package/schemas/node-pack-manifest.schema.json +5 -0
  29. package/schemas/pack-lockfile.schema.json +16 -0
  30. package/schemas/run-event-payloads.schema.json +18 -2
  31. package/schemas/run-event.schema.json +2 -1
  32. package/schemas/workflow-chain-pack-manifest.schema.json +226 -0
  33. package/src/lib/behavior-gate.ts +44 -5
  34. package/src/lib/env.ts +27 -0
  35. package/src/lib/webhook-receiver.ts +137 -0
  36. package/src/lib/workflow-chain-expansion.ts +213 -0
  37. package/src/scenarios/agentPackCatalog.test.ts +216 -0
  38. package/src/scenarios/agentPackHandoffSchemaValidation.test.ts +146 -0
  39. package/src/scenarios/agentReasoningEvents.test.ts +58 -7
  40. package/src/scenarios/agents-run-tool-allowlist.test.ts +182 -0
  41. package/src/scenarios/ai-envelope-shape.test.ts +362 -0
  42. package/src/scenarios/aiEnvelope.capBreached.test.ts +173 -0
  43. package/src/scenarios/aiEnvelope.contractRefusal.test.ts +150 -0
  44. package/src/scenarios/aiEnvelope.correlationReplay.test.ts +69 -0
  45. package/src/scenarios/aiEnvelope.redaction.test.ts +73 -0
  46. package/src/scenarios/aiEnvelope.schemaDrift.test.ts +87 -0
  47. package/src/scenarios/aiEnvelope.trustBoundaryPropagation.test.ts +143 -0
  48. package/src/scenarios/aiEnvelope.universalKinds.test.ts +176 -0
  49. package/src/scenarios/append-ordering.test.ts +44 -0
  50. package/src/scenarios/artifact-auth.test.ts +58 -0
  51. package/src/scenarios/blob-cross-tenant-isolation.test.ts +66 -0
  52. package/src/scenarios/blob-presign-expiry.test.ts +66 -0
  53. package/src/scenarios/blob-roundtrip.test.ts +48 -0
  54. package/src/scenarios/cache-cross-tenant-isolation.test.ts +61 -0
  55. package/src/scenarios/cache-ttl-expiry.test.ts +47 -0
  56. package/src/scenarios/dispatch-cross-worker-handoff.test.ts +98 -0
  57. package/src/scenarios/dispatch-input-mapping.test.ts +94 -0
  58. package/src/scenarios/dispatch-output-mapping.test.ts +65 -0
  59. package/src/scenarios/fs-path-traversal.test.ts +124 -0
  60. package/src/scenarios/idempotency-key-determinism.test.ts +230 -0
  61. package/src/scenarios/interrupt-token-matrix.test.ts +126 -0
  62. package/src/scenarios/kv-atomic-increment.test.ts +74 -0
  63. package/src/scenarios/kv-cas.test.ts +75 -0
  64. package/src/scenarios/kv-cross-tenant-isolation.test.ts +85 -0
  65. package/src/scenarios/kv-ttl-expiry.test.ts +47 -0
  66. package/src/scenarios/mcp-server-elicitation-bridge.test.ts +92 -0
  67. package/src/scenarios/mcp-server-prompt-roundtrip.test.ts +80 -0
  68. package/src/scenarios/mcp-server-resource-roundtrip.test.ts +82 -0
  69. package/src/scenarios/mcp-server-sampling-bridge.test.ts +84 -0
  70. package/src/scenarios/mcp-server-tool-roundtrip.test.ts +107 -0
  71. package/src/scenarios/mcp-server-untrusted-args.test.ts +105 -0
  72. package/src/scenarios/mcp-tool-roundtrip.test.ts +13 -6
  73. package/src/scenarios/memory-compaction-event-emitted.test.ts +121 -0
  74. package/src/scenarios/memory-compaction-provenance-tag.test.ts +116 -0
  75. package/src/scenarios/memory-compaction-sr1-carry-forward.test.ts +127 -0
  76. package/src/scenarios/multi-region-idempotency.test.ts +39 -4
  77. package/src/scenarios/otel-trace-propagation-subworkflow.test.ts +139 -0
  78. package/src/scenarios/pause-resume.test.ts +43 -0
  79. package/src/scenarios/queue-ack-nack-dlq.test.ts +67 -0
  80. package/src/scenarios/queue-cross-tenant-isolation.test.ts +66 -0
  81. package/src/scenarios/queue-publish-consume-roundtrip.test.ts +48 -0
  82. package/src/scenarios/registry-public.test.ts +91 -0
  83. package/src/scenarios/search-bm25-roundtrip.test.ts +47 -0
  84. package/src/scenarios/spec-corpus-validity.test.ts +28 -7
  85. package/src/scenarios/sql-injection-rejection.test.ts +84 -0
  86. package/src/scenarios/sql-transaction-atomicity.test.ts +66 -0
  87. package/src/scenarios/stream-subscribe-from-beginning.test.ts +66 -0
  88. package/src/scenarios/subworkflow-input-mapping.test.ts +100 -0
  89. package/src/scenarios/table-cross-tenant-isolation.test.ts +65 -0
  90. package/src/scenarios/table-cursor-pagination.test.ts +47 -0
  91. package/src/scenarios/table-schema-enforcement.test.ts +47 -0
  92. package/src/scenarios/vector-knn-roundtrip.test.ts +48 -0
  93. package/src/scenarios/webhook-receiver-adversarial.test.ts +210 -0
  94. package/src/scenarios/workflow-chain-expansion.test.ts +366 -0
  95. package/src/scenarios/workflow-chain-pack-manifest-validation.test.ts +232 -0
  96. package/src/scenarios/workflow-chain-pack-signature-verification.test.ts +138 -0
  97. package/src/scenarios/workflow-chain-unresolvable-typeid.test.ts +170 -0
@@ -0,0 +1,105 @@
1
+ /**
2
+ * mcp-server-untrusted-args — RFC 0020 §D + SECURITY/invariants.yaml
3
+ * `mcp-server-untrusted-args`.
4
+ *
5
+ * Status: ACTIVE (advertisement + behavioral). Asserts that tools/call
6
+ * with arguments violating the registered inputSchema is rejected with
7
+ * JSON-RPC `-32602 invalid params` BEFORE any workflow side-effects.
8
+ *
9
+ * @see RFCS/0020-host-mcp-server-composition.md
10
+ * @see SECURITY/invariants.yaml — mcp-server-untrusted-args
11
+ */
12
+
13
+ import { describe, it, expect } from 'vitest';
14
+ import { driver } from '../lib/driver.js';
15
+
16
+ interface DiscoveryDoc {
17
+ capabilities?: Record<string, unknown>;
18
+ }
19
+
20
+ async function readCap(): Promise<Record<string, unknown> | null> {
21
+ const res = await driver.get('/.well-known/openwop');
22
+ const body = res.json as DiscoveryDoc | undefined;
23
+ const top = body?.capabilities as Record<string, unknown> | undefined;
24
+ const cur = (top && typeof top === 'object') ? (top as Record<string, unknown>)["mcp"] : undefined;
25
+ const final = (cur && typeof cur === 'object') ? (cur as Record<string, unknown>)["serverMount"] : undefined;
26
+ return (final && typeof final === 'object' ? (final as Record<string, unknown>) : null);
27
+ }
28
+
29
+ async function rpc(method: string, params?: Record<string, unknown>) {
30
+ const id = Math.floor(Math.random() * 1e6);
31
+ const req: Record<string, unknown> = { jsonrpc: '2.0', id, method };
32
+ if (params !== undefined) req.params = params;
33
+ const res = await driver.post('/v1/host/sample/mcp', req);
34
+ return { status: res.status, body: res.json as { result?: unknown; error?: { code: number; message: string; data?: unknown } } };
35
+ }
36
+
37
+ const TEST_TOOL_NAME = `inj_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;
38
+
39
+ async function registerStrictWorkflow(): Promise<boolean> {
40
+ const res = await driver.post('/v1/host/sample/workflows', {
41
+ workflowId: `mcp.untrusted.${Date.now()}`,
42
+ nodes: [
43
+ {
44
+ nodeId: 'expose',
45
+ typeId: 'core.openwop.mcp.expose-tool',
46
+ config: {
47
+ name: TEST_TOOL_NAME,
48
+ description: 'Strict-schema tool',
49
+ inputSchema: {
50
+ type: 'object',
51
+ properties: { text: { type: 'string' } },
52
+ required: ['text'],
53
+ additionalProperties: false,
54
+ },
55
+ },
56
+ },
57
+ ],
58
+ });
59
+ return res.status === 200 || res.status === 201;
60
+ }
61
+
62
+ describe('mcp-server-untrusted-args: advertisement shape (RFC 0020)', () => {
63
+ it('capabilities.mcp.serverMount is well-formed when present', async () => {
64
+ const cap = await readCap();
65
+ if (cap === null) return;
66
+ expect(typeof cap.supported).toBe('boolean');
67
+ });
68
+ });
69
+
70
+ describe('mcp-server-untrusted-args: behavioral (RFC 0020 §D)', () => {
71
+ it('tools/call with malformed arguments is rejected with JSON-RPC -32602 BEFORE workflow start', async () => {
72
+ const cap = await readCap();
73
+ if (!cap || cap.supported !== true) return;
74
+ if (!(await registerStrictWorkflow())) return;
75
+
76
+ const r = await rpc('tools/call', {
77
+ name: TEST_TOOL_NAME,
78
+ arguments: { wrongField: 'no' },
79
+ });
80
+ if (r.status === 404) return;
81
+ expect(r.status, 'JSON-RPC envelope MUST 200').toBe(200);
82
+ expect(
83
+ r.body.error?.code,
84
+ driver.describe(
85
+ 'SECURITY/invariants.yaml mcp-server-untrusted-args',
86
+ 'malformed arguments MUST be rejected with -32602 invalid params before workflow start',
87
+ ),
88
+ ).toBe(-32602);
89
+ expect(r.body.error?.data, 'error.data MUST carry validation violations').toBeDefined();
90
+ });
91
+
92
+ it('tools/call with valid arguments is accepted', async () => {
93
+ const cap = await readCap();
94
+ if (!cap || cap.supported !== true) return;
95
+ const r = await rpc('tools/call', {
96
+ name: TEST_TOOL_NAME,
97
+ arguments: { text: 'hello' },
98
+ });
99
+ if (r.status === 404) return;
100
+ expect(r.status).toBe(200);
101
+ if (r.body.error) {
102
+ expect(r.body.error.code, 'valid args MUST NOT trigger -32602').not.toBe(-32602);
103
+ }
104
+ });
105
+ });
@@ -32,15 +32,22 @@
32
32
  * probe at a real MCP server. Auto-detects the transport from the
33
33
  * server's `Content-Type` response header:
34
34
  * - `application/json` → single-JSON response, parsed as one
35
- * JSON-RPC frame.
35
+ * JSON-RPC frame. Verified end-to-end against
36
+ * `@modelcontextprotocol/sdk@1.29.0` `enableJsonResponse: true`
37
+ * streamable-http servers (2026-05-12).
36
38
  * - `text/event-stream` → streamable-http+SSE; the probe reads
37
39
  * SSE frames until it finds one whose `data:` payload matches
38
- * the JSON-RPC `id` we sent, then returns that frame.
40
+ * the JSON-RPC `id` we sent, then returns that frame. Verified
41
+ * end-to-end against `@modelcontextprotocol/sdk@1.29.0`
42
+ * streamable-http servers WITHOUT `enableJsonResponse`
43
+ * (2026-05-13).
39
44
  * The stdio transport (default for `modelcontextprotocol/servers`
40
- * reference servers) is still out of scope — those run as a child
41
- * process speaking JSON-RPC over stdin/stdout, no HTTP endpoint to
42
- * point env vars at. Operators wanting interop evidence against
43
- * stdio servers run them under a `mcp-bridge` HTTP adapter.
45
+ * reference servers) is HTTP-incompatible by design — those run
46
+ * as a child process speaking JSON-RPC over stdin/stdout, no HTTP
47
+ * endpoint. Operators collecting interop evidence against stdio
48
+ * servers run them under the documented HTTP-to-stdio bridge at
49
+ * `examples/mcp-stdio-bridge/` (verified end-to-end 2026-05-13;
50
+ * probe + bridge + `echo-stdio-server.mjs` round-trip passes 2/2).
44
51
  * Assertions stay shape-only: tools/list returns ≥1 tool, a
45
52
  * tools/call returns valid MCP content (a `result.content` array,
46
53
  * possibly `isError: true` — both are spec-conformant).
@@ -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
+ });
@@ -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
  });